` + `aria-hidden` — ARIA string attribute
+
+ARIA attributes are string-valued, but Glimmer's bare-mustache form applies the same falsy-coercion as for boolean HTML attributes (omit on `false`/`null`/`undefined`). Bare-string and concat forms render the value literally — concat does **not** coerce to a boolean here. The "At runtime (per ARIA spec)" column derives whether the element is hidden from assistive tech: `aria-hidden="true"` is hidden; `aria-hidden="false"` is visible; `aria-hidden=""` (and the implied default) is contested.
+
+| ID | Source | outerHTML | IDL `ariaHidden` | hasAttr | At runtime (ARIA) |
+| --- | --------------------------------------- | --------------------------------- | ---------------- | ------- | ---------------------------------------------- |
+| h1 | `
` | `
` | `""` | `true` | **contested** (empty value) |
+| h2 | `
` | `
` | `""` | `true` | **contested** |
+| h3 | `
` | `
` | `"true"` | `true` | **hidden** |
+| h4 | `
` | `
` | `"false"` | `true` | **visible** |
+| h5 | `
` | `
` | `""` | `true` | **contested** (rendered empty, _not_ `"true"`) |
+| h6 | `
` | `
` | `null` | `false` | **visible** (default) |
+| h7 | `
` | `
` | `"true"` | `true` | **hidden** |
+| h8 | `
` | `
` | `"false"` | `true` | **visible** |
+| h9 | `
` | `
` | `null` | `false` | **visible** |
+| h10 | `
` | `
` | `null` | `false` | **visible** |
+| h11 | `
` | `
` | `""` | `true` | **contested** |
+| h12 | `
` | `
` | `"true"` | `true` | **hidden** |
+| h13 | `
` | `
` | `"false"` | `true` | **visible** |
+| h14 | `
` | `
` | `"true"` | `true` | **hidden** |
+| h15 | `
` | `
` | `"false"` | `true` | **visible** |
+
+**Lint truth for `aria-hidden`:** the rule depends on the value, not just presence. Notable differences from boolean attrs: bare `{{true}}` renders as `aria-hidden=""` (contested, not `"true"`); concat `="{{false}}"` renders as `aria-hidden="false"` (visible — _not_ IDL-coerced like boolean attrs).
+
+### `
` + `tabindex` — numeric attribute
+
+Falsy-coercion (`false`/`null`) omits, like boolean attrs. Numeric and string-numeric values render the literal. IDL `tabIndex` returns `-1` when no attribute is set (the default for non-focusable elements), so `hasAttr` is the cleaner signal for "tabindex is set" than checking `tabIndex`.
+
+| ID | Source | outerHTML | IDL `tabIndex` | hasAttr | Effective |
+| --- | -------------------------------- | --------------------------- | -------------- | ------- | ----------- |
+| t1 | `
` | `
` | `0` | `true` | tabindex 0 |
+| t2 | `
` | `
` | `-1` | `true` | tabindex -1 |
+| t3 | `
` | `
` | `1` | `true` | tabindex 1 |
+| t4 | `
` | `
` | `0` | `true` | tabindex 0 |
+| t5 | `
` | `
` | `0` | `true` | tabindex 0 |
+| t6 | `
` | `
` | `-1` (default) | `false` | not set |
+| t7 | `
` | `
` | `-1` (default) | `false` | not set |
+
+**Lint truth for `tabindex`:** rules that care about the value should extract the literal from the AST (the value is preserved as-written across all bare and concat literal forms). Rules that care about presence should check that the source is not bare `{{false}}` / `{{null}}` (and by inference `{{undefined}}`, not tested).
+
+### `
` + `autocomplete` — string attribute (not boolean-coerced)
+
+A regular string attribute. Glimmer's bare-mustache **does not** apply falsy-coercion here — `autocomplete={{false}}` renders as `autocomplete="false"` (kept). This is the key difference from boolean HTML attrs and from `aria-*`. The IDL `el.autocomplete` canonicalizes the attribute value (returns `""` for invalid tokens), so it differs from `getAttribute('autocomplete')` for non-spec values.
+
+| ID | Source | outerHTML | IDL `autocomplete` | hasAttr | attrValue |
+| --- | ------------------------------------ | ------------------------------ | -------------------- | ------- | --------- |
+| i1 | `
` | `
` | `"off"` | `true` | `"off"` |
+| i2 | `
` | `
` | `"off"` | `true` | `"off"` |
+| i3 | `
` | `
` | `"off"` | `true` | `"off"` |
+| i4 | `
` | `
` | `""` (canonicalized) | `true` | `"false"` |
+| i5 | `
` | `
` | `""` (canonicalized) | `true` | `"false"` |
+
+**Lint truth for `autocomplete`:** rules should check `getAttribute('autocomplete')` (or its AST equivalent) against the spec's valid token list, not the IDL property. The bare-mustache `{{false}}` form will give a literal `"false"` value — almost certainly an authoring bug worth flagging.
+
+### Cross-attribute observations
+
+- **Glimmer's bare-mustache "boolean coercion" list.** For `muted` (HTML boolean), `disabled` (HTML boolean), `aria-hidden` (ARIA string), and `tabindex` (numeric), bare `{{false}}` / `{{null}}` / `{{undefined}}` (and `{{0}}` for `muted`) cause the attribute to be **omitted**. For `autocomplete` (plain string), bare `{{false}}` renders as `autocomplete="false"`. So Glimmer applies boolean-coercion to a known set — at minimum HTML boolean attrs, ARIA attrs, and numeric attrs. Plain string attrs do not get coerced.
+- **Bare-mustache string literals never coerce.** `attr={{"false"}}`, `attr={{"true"}}`, `attr={{""}}` always render as `attr="
"` for every attribute kind tested. The literal `"false"` is JS-truthy and gets passed through.
+- **Bare-mustache numeric `0` is in the falsy set for `muted`.** Verified for `muted` (`{{0}}` → omitted). Not yet tested for `disabled` / `aria-hidden` / `autocomplete`.
+- **Concat-mustache forks by attribute kind.** For HTML boolean attrs (`muted`, `disabled`), any concat — including `"{{false}}"`, `"{{'false'}}"`, `"x{{false}}"` — sets the IDL property to `true`, regardless of the literal value inside. For ARIA / string attrs (`aria-hidden`, `autocomplete`), concat renders the stringified value as the attribute value (no boolean coercion); `aria-hidden="{{false}}"` becomes `aria-hidden="false"` (visible).
+- **Concat is never falsy.** Across all attribute kinds tested, no concat form produces an absent attribute. Rules treating `attr="{{false}}"` as "off" are wrong for boolean attrs (it's IDL-true) and wrong for string attrs (the rendered value is `"false"`, attribute present).
+
+## Reading attribute values in rules
+
+Rule authors who classify attribute values must consume the reference table above through one of these AST shapes — each maps to a known rendering verdict:
+
+| AST shape | Source examples | Verdict |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `attr.value === null` (no value) | `` | Attribute is **present** with empty value (rendered as `attr=""`) — see d1, h1 |
+| `attr.value.type === 'GlimmerTextNode'` | `attr="literal text"` | Attribute is **present** with the literal `chars` string — see m1–m4, h2–h4, d1, t-static, i1 |
+| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerBooleanLiteral'` and `path.value === true` | `attr={{true}}` | **Reflecting boolean attrs**: present (e.g., `disabled=""`). **Non-reflecting boolean attrs** (`muted`, `autoplay`, etc.): IDL property set true, HTML attribute omitted. **ARIA string attrs**: present as `attr=""`. **Numeric attrs**: untested. — see m5, d2, h5 |
+| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerBooleanLiteral'` and `path.value === false` (or `GlimmerNullLiteral` / `GlimmerUndefinedLiteral`) | `attr={{false}}` / `{{null}}` / `{{undefined}}` | Attribute is **omitted** at runtime — see m6, m9, m10, d3, d6, h6, h9, h10, t6, t7. **Exception:** plain string attrs (e.g., `autocomplete`) do _not_ falsy-coerce; bare `{{false}}` renders as `attr="false"` (i4). |
+| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerStringLiteral'` | `attr={{"value"}}` | Attribute is **present** with the literal `path.value` string — see m7, m8, h7, h8, d4, d5, i2 |
+| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerNumberLiteral'` (verified for `tabindex` only) | `attr={{0}}` | **Numeric attrs**: present with stringified number (t1, t2, t3). **`muted={{0}}` is in the falsy-omit set** (m12); not yet tested for other kinds. |
+| `attr.value.type === 'GlimmerMustacheStatement'` with dynamic path | `attr={{this.x}}` | **Unknown** at lint time. |
+| `attr.value.type === 'GlimmerConcatStatement'` with all-literal parts | `attr="{{X}}"` (single literal-mustache) / `attr="text{{X}}"` | **Boolean HTML attrs** (`muted`, `disabled`, etc.): IDL true regardless of inner literal — m13–m19, d7–d10. **ARIA / string attrs** (`aria-hidden`, `autocomplete`): renders the stringified value literally — h12–h15, i3, i5. |
+| `attr.value.type === 'GlimmerConcatStatement'` with any dynamic part | `attr="{{this.x}}"` / `attr="x{{this.y}}"` | **Concat is never falsy** — present at runtime, but the value is generally **unknown** at lint time. |
+
+### Common mistakes to avoid
+
+1. **Don't use `findAttr(node, 'foo')` (AST-presence) as a proxy for "attribute set at runtime."** It's wrong for bare `{{false}}` / `{{null}}` / `{{undefined}}` on boolean-coerced attrs (m6/m9/m10/d3/d6/h6/h9/h10/t6/t7) — those forms still create an `AttrNode` in the AST but are omitted at runtime.
+2. **Don't lump `BooleanLiteral(false)` with `StringLiteral("false")`.** Bare `{{"false"}}` is a JS-truthy string; it renders the literal `"false"` value (i4, h8, d4, m8). Treating them together as "off" is the most common audit footgun in this codebase.
+3. **Don't treat single-mustache concat as the inner literal.** `attr="{{X}}"` is **never** falsy. For boolean HTML attrs the IDL property is set true regardless of `X`'s literal value (m14 verified: `