@surea11y/core 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
@@ -0,0 +1,301 @@
1
+ # Design challenges
2
+
3
+ A running log of engine design decisions worth re-examining: cases where an existing choice turned out to conflict with a ground-truth source (usually the W3C ACT rules test corpus), or just looks questionable on a second look. Not all of these are bugs; some are tradeoffs that deserve a second opinion before being confirmed or overturned. Each entry has the decision as it stands, why it's being questioned, and its current status. Settled entries move to [Decided](#decided) at the bottom, with the reasoning kept, since a decision is only useful later if the argument behind it survives with it.
4
+
5
+ ## Open
6
+
7
+ ### `label-in-name` compares against accessibility-tree text, where ACT uses *visible* inner text
8
+
9
+ **Decision as it stands:** `label-in-name.js` builds the element's visible label from text nodes whose parent is accessibility-tree eligible, which drops `aria-hidden` subtrees and keeps text that CSS has hidden visually. The `aria-hidden` half is reasoned in the rule's own comment: an `<i aria-hidden="true">` icon-font ligature renders as a glyph, not as the literal word in the DOM, so counting its text would flag every icon-only button named by `aria-label`.
10
+
11
+ **Why it's being questioned:** ACT `2ee8b8` is explicit that its "visible inner text" is a rendering property, not an accessibility-tree one, and three of its examples turn on the difference: `<a aria-label="Download specification">Download <span aria-hidden="true">gizmo</span> specification</a>` fails ACT (the word "gizmo" is on screen, whatever `aria-hidden` says) and passes here; a `clip-path: inset(50%)` visually-hidden span passes ACT (nothing is rendered) and fails here; and `<div style="display: inline">` children concatenate into one word for ACT ("ACT" from three inline divs) while we read them apart. All three point the same way: the label a sighted user speaks comes from what is painted, not from what the accessibility tree carries. The icon-font case the current behavior was built for is real, but `aria-hidden` is a coarse stand-in for it, since the attribute says "don't expose this," not "this doesn't render as words."
12
+
13
+ **Why this hasn't been changed yet:** "visible inner text" needs a real rendering model: per-node `display` resolution for the concatenation rule, plus the visually-hidden detection (`clip-path`, `clip`, 1px boxes, off-screen positioning) the engine's offscreen heuristic only partly covers. It's a shared-helper change with reach beyond this rule.
14
+
15
+ **Caveat added 2026-08-19:** re-fetching ACT `2ee8b8`'s live rule page while closing the icon-font entry below turned up only 13 published examples, none of them the `aria-hidden`/`clip-path`/inline-concatenation shapes described above. Those specific claims don't currently reproduce against the live corpus (same stale-local-checkout pattern as the `bc4a75` applicability question, now in Decided). The architectural critique (accessibility-tree text vs. true rendered text) may still be sound, but it is currently untested by ACT ground truth rather than confirmed by it.
16
+
17
+ **Status:** open, unresolved as of 2026-08-19. Deprioritized pending a live ACT example that actually exercises it.
18
+
19
+ ### Five ARIA roles that *require* an accessible name have no rule covering them at all
20
+
21
+ **Decision as it stands:** with `aria-role-name-present` now scoped to WAI-ARIA's "Accessible Name Required: True" roles (see the entry in [Decided](#decided) below), the engine's naming coverage is principled but incomplete. Deriving the full set from `aria-query` (name-required, and name-from-author-only, so subtree text cannot rescue it) turns up five roles no rule in this repo evaluates: `table`, `tabpanel`, `treegrid`, `application` and `marquee`. The DPUB and Graphics module roles in the same bucket (`doc-biblioentry`, `doc-pagebreak`, `doc-part`, `graphics-document`, `graphics-symbol`) are also uncovered for naming, though `graphics-*` are covered for text alternatives by `svg-image-text-alternative-present`.
22
+
23
+ **Why it's being questioned:** `tabpanel` is the pointed one. The rule was fixed by dropping `tablist`, which the spec does *not* require a name for, while `tabpanel`, which it does, goes unchecked. The same predicate that justified the removal argues for the addition, and `table` is common enough in real markup that the gap is not theoretical.
24
+
25
+ **Why this hasn't been changed yet:** each added role is new `serious` failures on scans that are green today, which is a different kind of change from removing false positives; it needs its own changelog entry and a baseline conversation with integrators, not a quiet ride-along. `application` and `marquee` are also rare enough, and odd enough, to deserve their own look rather than a bulk add; `treegrid` overlaps `grid`, which is already covered.
26
+
27
+ **Status:** open as of 2026-08-21, deferred, not forgotten. The exclusion list and its reasoning live in `scripts/generate-aria-tables.js`, next to the generated set.
28
+
29
+ ## Decided
30
+
31
+ ### `contrast-minimum`/`contrast-enhanced` treated symbol-only text as real text needing a contrast ratio, fixed
32
+
33
+ **Decision as it stands (before the fix):** the shared text scan's applicability gate (`isNonEmptyText` in `getTextScan()`, `src/core/contrast-helpers.js`) only checked for non-whitespace characters. A text node made entirely of punctuation/symbol glyphs (`----=====+++...±±±±@@@@@@@@`) counted the same as real words.
34
+
35
+ **Why it was questioned:** ACT `afw4f7`/`09o5cg`'s own applicability is scoped to text that "expresses something in human language," and their own passed example for the "environment-dependent" bucket this was filed under is exactly a paragraph of pure symbols. Nothing environment-dependent about it: a plain applicability gap.
36
+
37
+ **Decision (2026-08-19):** `isNonEmptyText` now also requires at least one Unicode letter or number (`\p{L}`/`\p{N}`) somewhere in the text node, applied at both the text-node walk and the `<input type=submit|button|reset>` value-attribute path that shares the same gate. Digits-only text (`"42"`) still counts and is still checked; only text with zero letters or digits at all is exempt.
38
+
39
+ **Status:** resolved 2026-08-19. `afw4f7` drops from 6 to 5 mismatches, `09o5cg` from 5 to 4.
40
+
41
+ ### `css-orientation-lock` required an EXACT 90/270-degree rotation, when its own comments already claimed "approximately," fixed
42
+
43
+ **Decision as it stands (before the fix):** `isLockingRotation`'s comment claimed to flag "~90/~270 degrees," but the actual code did `Math.abs(abs - 90) % 90 <= 0`. Modulo of a non-negative number is `<= 0` only when it's exactly `0`, so this only ever matched a rotation that was an exact multiple of 90. The 3 live mismatches this produced were filed as a single "env/harness limit: jsdom's CSS parser drops `@media(orientation)` rules using `rad` units or `matrix3d()`."
44
+
45
+ **Why it was questioned:** directly parsing both cited stylesheets with jsdom (outside this engine entirely) showed jsdom parses `rotate(1.5708rad)` and `matrix3d(...)` correctly; `style.transform` comes back with both intact. Neither is a jsdom limitation. What's actually happening: `1.5708rad` converts to `90.0000210...` degrees (floating-point remainder from the radian conversion), and ACT's own failed example uses an inexact `92.5deg`. Both are "approximately 90" in every practical sense, but neither is `=== 90`, so the exact-modulo check silently passed both.
46
+
47
+ **Decision (2026-08-19):** replaced the exact-equality check with a real tolerance window (±5 degrees) around the normalized position, fixing both the floating-point case and the inexact one (the "not a jsdom limitation" 2 of the original 3 mismatches). The third, `matrix3d()`, genuinely is a documented scope limit (this rule doesn't decompose transform matrices into an equivalent angle) and stays as-is; that reasoning was already correct, just miscategorized as "jsdom" alongside the two that weren't.
48
+
49
+ **Status:** resolved 2026-08-19. `b33eff` drops from 3 to 1 mismatch (the `matrix3d()` case, accepted at the time; later closed too, see below).
50
+
51
+ ### `css-orientation-lock` deferred `matrix()`/`matrix3d()`/`rotate3d()` entirely, though the remaining case is an unambiguous pure rotation, fixed
52
+
53
+ **Decision as it stood:** the entry above accepted `matrix3d()` as a genuine scope limit: decomposing a general 3D transform matrix into an equivalent rotation angle was filed alongside `table-th-has-data-cells`'s narrower positional-header algorithm as "higher-complexity/lower-value" work.
54
+
55
+ **Why it was questioned:** the one live mismatch this produced, ACT's own `transform: matrix3d(0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)`, isn't a general 3D matrix at all: its 3rd/4th columns match the identity (no translation, no perspective, no rotation around another axis) and its top-left 2x2 block is a unit-length, orthogonal rotation. That combination has exactly one equivalent angle, computable with `atan2`, not a decomposition that could be ambiguous or require a guess.
56
+
57
+ **Decision (2026-08-19):** `rotateDegreesFromTransform` now also parses `rotate3d(x, y, z, angle)` directly (trivial when x/y are ~0 and z is ~±1) and `matrix()`/`matrix3d()` via a 2x2 rotation check (`decomposeMatrix2dRotation`) and, for `matrix3d`, an identity check on the other two columns first (`decomposeMatrix3dZRotation`). Anything that isn't a pure Z rotation (scale, skew, translation, perspective, a rotation combined with another axis) contributes 0 degrees, same as an unrecognized value; the rule still never guesses at an angle a general transform doesn't uniquely have.
58
+
59
+ **Status:** resolved 2026-08-19. `b33eff` runs clean (0/10).
60
+
61
+ ### `iframe-focusable-content` never resolved a `srcdoc` iframe's content, and never exempted tiny "tracking pixel" iframes, both fixed
62
+
63
+ **Decision as it stands (before the fix):** the rule's `contentDoc = el.contentDocument || null` resolution was the only path to an embedded document; it was documented as an "env/harness limit: jsdom doesn't populate `iframe.contentDocument` from a `srcdoc` attribute; a real browser does," implying nothing could be done about it in this codebase specifically.
64
+
65
+ **Why it was questioned:** that framing only holds for the test-fetcher's own jsdom instance. This library's own Node/jsdom integration path (`docs/INTEGRATION.md` Pattern 1) is a real, documented way consumers run this engine, and jsdom's `srcdoc` gap affects them identically, not just this repo's test harness. `el.getAttribute('srcdoc')` is a plain string this engine can read and parse itself, independent of whatever the host DOM implementation does with it.
66
+
67
+ **Decision (2026-08-19):** two changes, both scoped to this rule. (1) When the live `contentDocument` looks empty and a `srcdoc` attribute is present, its HTML string is parsed via `DOMParser` as a static fallback; no rendering pipeline needed, and every downstream helper in this file (`isRenderedInDoc`, `probeImmediateFocusRedirect`) already degrades gracefully when handed a detached, `defaultView`-less document. (2) Fixing that surfaced a second, previously-masked gap: ACT akn7bn's own Expectation requires the focusable content to also be *visible*, and a `width`/`height` ≤ 2px iframe (the "tracking pixel" pattern, ACT's own passed example) can't render anything perceptible. That exemption is now checked via the iframe's own HTML attributes, a static signal that doesn't need real layout.
68
+
69
+ **Status:** resolved 2026-08-19. `akn7bn` runs clean (0/6).
70
+
71
+ ### `bypass-blocks-present`'s heading mechanism credited a screen-reader-only heading, when ACT requires it to be visible, fixed
72
+
73
+ **Decision as it stands (before the fix):** `hasHeading()` credited any `<h1>`-`<h6>`/`[role="heading"]` that was included in the accessibility tree; an off-screen-positioned, clipped, opacity:0, or zero-size-overflow-hidden heading counted the same as a fully visible one. This mismatch was never individually triaged; it sat inside `ye5d6e`/`047fe0`'s combined "1, 2" mismatch count, both filed under one blanket "deliberate leniency" reason that only actually described a *different* shape (a heading positioned inside the repeated content it's supposed to be an escape from).
74
+
75
+ **Why it was questioned:** re-fetching `047fe0`'s live corpus surfaced a case that reason doesn't cover at all: `<h1 class="off-screen">` inside `<div id="main">`, correctly positioned *after* the repeated nav, still expected **failed** by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" *and* "[visible](#visible)." A screen-reader-only heading gives sighted keyboard users no equivalent way to locate the start of non-repeated content, which is exactly the gap `047fe0` is checking for.
76
+
77
+ **Decision (2026-08-19):** `hasHeading()` now also requires the heading to carry no CSS-hiding hint (`helpers.getVisibilityHintsInfo`: off-screen, clipped, opacity:0, zero-size-overflow-hidden). The sibling `hasMainLandmark()`/`hasWorkingAnchorLink()` checks are left untouched; `cf77f2`'s own live text doesn't carry the same visibility requirement for a `<main>` landmark, confirmed by an existing regression test that pins a clipped-but-accessible `<main>` as still credited.
78
+
79
+ **Status:** resolved 2026-08-19, verified directly (`tests/engine-checks/manual/bypass-blocks-present.test.js` pins the case with the CSS inlined, since the real ACT test case defines it via an external stylesheet jsdom's test fetcher can't load, an env/harness limit of the same class as `oj04fd`). The corpus checker's own count for `047fe0` stays at 2 (the fixed case can't register as clean through the harness limitation; the other, unrelated mismatch is the genuine, still-open positional-judgment gap).
80
+
81
+ ### `iframe-name-present` exempted every `role="none"`/`"presentation"` iframe from needing a name, regardless of focusability, fixed
82
+
83
+ **Decision as it stands (before the fix):** the rule's own header comment already claimed this "matches ACT cae760," and the doc's mismatch table filed the one remaining `cae760` case entirely under a *different*, correctly-reasoned divergence (a `tabindex="-1"` iframe with no role, which ACT considers unreachable and out of scope, but this engine still evaluates). That framing missed a second, distinct mismatch hiding in the same ACT id.
84
+
85
+ **Why it was questioned:** re-fetching `cae760`'s live corpus surfaced `<iframe title=" " role="none" src="...">` (no `tabindex` at all) expected **failed**, which this rule returned `notApplicable` for. ACT's own reasoning: "because iframe elements are part of sequential focus navigation, the explicit semantic role of none will be ignored, due to Presentational Roles Conflict Resolution." Unlike most elements, `<iframe>`/`<frame>` are natively focusable by default, no `tabindex` needed, so `role="none"` alone never actually removes one from the tab order, and the "decorative" marking doesn't stick.
86
+
87
+ **Decision (2026-08-19):** the role="none"/"presentation" exemption now only applies when the iframe is also out of the tab order (an explicit negative `tabindex`). `helpers.getFocusableInfo` has no native-focusability entry for `iframe`/`frame` at all (it's an unusual element in that respect: most things need an explicit `tabindex` or a native-interactive tag), so focusability is computed locally in the rule instead of through the shared helper.
88
+
89
+ **Status:** resolved 2026-08-19. `cae760` drops from 2 to 1 mismatch; the one remaining case is the pre-existing, correctly-reasoned `tabindex="-1"`-without-role divergence, left as-is.
90
+
91
+ ### `aria-valid-attr-value` treated an explicitly-empty non-idref value as invalid, and always required `aria-errormessage`'s target to exist, both fixed
92
+
93
+ **Decision as it stands (before the fix):** `validateAttrValue`'s empty-value exemption (`allowEmpty`) only applied to `idref`/`idref-list` types; a bare boolean/tristate/number/token-list attribute with no value (`aria-checked` alone, `aria-relevant=""`) was validated against its type's value set and failed. Existence-checking on single-idref attributes applied uniformly to both `aria-activedescendant` and `aria-errormessage`. Confirmed against ACT `6a7281`'s live corpus (2/23 mismatches).
94
+
95
+ **Why it was questioned:** the doc previously filed this under "deliberate scope: our idref-type validation extends beyond ACT's syntax-only check... more useful, not a bug," covering only the `aria-errormessage` half. But ACT `6a7281`'s own Applicability text is a blanket rule for *every* value type: "any WAI-ARIA state or property that is **not empty**." An empty value (including a bare attribute) is out of scope entirely, not specific to idrefs. And its own Background text names `aria-errormessage` specifically as a non-required property whose target "may be created in response to an event that may or may not happen," a documented ACT exception, not something our stricter check improves on.
96
+
97
+ **Decision (2026-08-19):** `validateAttrValue` now short-circuits to `{valid: true}` for any empty value before the type-specific switch, and the `idref` case exempts `aria-errormessage` (but not `aria-activedescendant`, which ACT gives no such carve-out) from existence-checking.
98
+
99
+ **Status:** resolved 2026-08-19. `6a7281` runs clean (0/23).
100
+
101
+ ### `contrast-minimum`/`contrast-enhanced` treated exact foreground/background color matches as a real (1:1) contrast failure, fixed
102
+
103
+ **Decision as it stands (before the fix):** the docs described this as an accepted, permanent divergence: "ACT treats text whose color exactly matches its background as invisible; the contrast rules report the 1:1 ratio, since nothing in the markup separates 'invisible on purpose' from 'invisible by accident'."
104
+
105
+ **Why it was questioned:** that framing conflates two different questions. Detecting authorial *intent* (was this meant to be hidden?) is undecidable from markup, and the engine is right not to guess at it. But ACT's own rule doesn't ask that question at all; its stated reasoning for the inapplicable case is purely factual: "this text is not visible because the foreground color is the same as the background color." Whether two fully-resolved, fully-opaque colors are bit-for-bit identical is a plain equality check, deterministic and intent-free, not a judgment call: the same class of fact as any other computability gate this engine already applies (gradients, filters, opacity).
106
+
107
+ **Decision (2026-08-19):** added an `isSameColorAsBackground` gate to the shared text scan (`getTextScan` in `src/core/contrast-helpers.js`) that all three contrast rules (`contrast-computable`, `contrast-minimum`, `contrast-enhanced`) already use for applicability. It fires only when both the effective foreground and effective background resolve cleanly to a fully opaque color and those colors are identical; any gradient, image, or partial-opacity background is left untouched and still reaches the existing computability/ratio logic normally. A near-miss (e.g. `#fefefe` on `#ffffff`) is unaffected and still fails on ratio, guarded by a regression test.
108
+
109
+ **Status:** resolved 2026-08-19. `afw4f7` and `09o5cg` both drop one mismatch each (7→6, 6→5).
110
+
111
+ ### `valid-lang`'s applicability didn't resolve which text actually inherits a given `lang` attribute, fixed
112
+
113
+ **Decision as it stands (before the fix):** `valid-lang.js` applied to any non-root element carrying a non-empty `lang` attribute with *any* non-whitespace text anywhere in its subtree, unconditionally, and validated that attribute's value as a language tag. Confirmed against ACT `de46e4`'s live corpus (3/19 mismatches, exactly the three shapes below).
114
+
115
+ **Why it was questioned:** ACT `de46e4`'s applicability is narrower and more precise: it applies only when "there is some text inheriting its programmatic language from the element which is neither empty nor only whitespace." Three concrete gaps: (a) an invalid `lang` on an element whose entire text content is re-scoped by a nested descendant's own (valid) `lang` attribute has no text actually governed by the invalid value, so it should pass, not fail; (b) `alt` text on a descendant counts as governed text too; (c) text that's present but not rendered (`display:none`) doesn't count as governed text, but `aria-hidden` and offscreen positioning do *not* exempt text either, confirmed by ACT's own failed examples for both.
116
+
117
+ **Decision (2026-08-19):** rewrote applicability around a `hasGovernedText` walk: recurses through the element's subtree, stopping at (not recursing into) any descendant that carries its own non-empty `lang` (that subtree governs itself, not the outer element), and counts a non-empty `alt` on `img`/`area`/`input[type=image]` the same as a text node. Only actual non-rendering (CSS `display:none`, the `hidden` attribute, via the existing `targetSet: 'dom'` eligibility check) excludes a node; `aria-hidden` and offscreen positioning are left alone, matching ACT's own failed examples for both.
118
+
119
+ **Status:** resolved 2026-08-19. `de46e4` runs clean (0/19).
120
+
121
+ ### No rule covered `role="graphics-symbol"`/`"graphics-document"` on an SVG descendant, only the `<svg>` root itself, fixed
122
+
123
+ **Decision as it stands (before the fix):** `svg-text-alternative-present.js`'s own header comment stated: "Does NOT extend to arbitrary `role="graphics-symbol"` descendants nested inside an `<svg>`; this check's scope is the `<svg>` root only." `role-img-text-alternative-present.js` was scoped to `role="img"` only, not `graphics-symbol`/`graphics-document`. Confirmed against ACT `7d6734`'s live corpus (1/10 mismatch, its own failed example: `<svg><circle role="graphics-symbol" .../></svg>`, a root `<svg>` with no role at all but a descendant circle carrying the role).
124
+
125
+ **Why it was questioned:** ACT `7d6734`'s applicability is "any SVG element with an explicit role of img, graphics-document, or graphics-symbol," where "SVG element" means any element in the SVG namespace, not just the root `<svg>` tag; `graphics-symbol` is specifically meant for descendant shapes. Neither existing rule reached this.
126
+
127
+ **Decision (2026-08-19):** `role-img-text-alternative-present.js`'s selector widened from `[role="img" i]:not(img)` to also match `[role="graphics-symbol" i]`/`[role="graphics-document" i]` anywhere in the document; this already covers nested SVG shapes, since the rule was never scoped to a particular tag. Its existing aria-label/aria-labelledby/title checks needed no change; its SVG-first-child-`<title>` naming check (previously gated to `tag === 'svg'` only) was widened to any SVG-namespace element via `namespaceURI`, since SVG-AAM's title-child naming mechanism isn't root-specific either. `svg-text-alternative-present.js` keeps sole ownership of the `<svg>` root case (its own header comment updated to point at the sibling rule for descendants). The two rules already had accepted, pre-existing double-coverage on a `role="img"`/`"graphics-document"` `<svg>` root (both fire on the same unnamed element), which this change doesn't newly introduce, only extends consistently to `graphics-document` alongside the existing `img` overlap.
128
+
129
+ **Status:** resolved 2026-08-19. `7d6734` runs clean (0/10).
130
+
131
+ ### `img-alt-decorative` only considered `<img alt="">`, missing `aria-hidden`/`role=none` images and every svg/canvas case, fixed
132
+
133
+ **Decision as it stands (before the fix):** `src/checks/manual/img-alt-decorative-manual.js` was hard-scoped to a CSS selector matching only `img[alt=""]`/`img[alt^=" "]`/`img[alt$=" "]`, i.e. an `<img>` already marked (or nearly marked) decorative via `alt`. Confirmed against ACT `e88epe`'s live corpus (4/20 mismatches, all four of its own failed examples: `aria-hidden="true"` with a non-empty `alt`, `role="none"` with a non-empty `alt`, an unlabeled decorative `<svg>`, an unlabeled `<canvas>` drawn via script).
134
+
135
+ **Why it was questioned:** ACT `e88epe`'s own applicability, fetched directly, is much broader: "any `img`, `canvas` or `svg` element that is visible and" excluded from the accessibility tree by *any* mechanism: `aria-hidden`, `role="none"`/`"presentation"`, an `svg` with an implicit/explicit `graphics-document` role and an empty name, or a `canvas` with no explicit role and an empty name.
136
+
137
+ **Decision (2026-08-19):** rewritten around the direction ACT actually asks for. Instead of a narrow "already-marked-decorative" selector, the rule now selects visible `img`/`canvas`/`svg` elements and asks whether each is *excluded* from the accessibility tree (`isIncludedInAccessibilityTree` for aria-hidden/inert; an explicit `role="none"`/`"presentation"` or `<img alt="">` not overridden by focusability, matching the same conflict-resolution convention already used by `svg-text-alternative-present.js`; an unlabeled `<svg>`'s implicit `graphics-document` role reusing that same file's `hasIntent` boundary; an unlabeled `<canvas>` with no explicit role). The noise-reduction concern is handled by ACT's own exception: an element is skipped entirely when any ancestor already has an author-supplied name (`aria-label`/`aria-labelledby`/`title`/`<label>`), the common real case of an icon-only button already named via `aria-label`, where whether the icon itself is decorative is moot. Two of ACT's own applicability carve-outs (an `<img>` mid-load/broken, a fully-transparent `<canvas>`) aren't decidable from a static scan and are accepted as a documented limitation (`docs/LIMITATIONS.md`) rather than chased further, a rare, low-cost false positive a reviewer dismisses at a glance.
138
+
139
+ **Status:** resolved 2026-08-19. `e88epe` runs clean (0/20).
140
+
141
+ ### `label-in-name` had no exemption for icon-font glyphs or icon-standing-in characters, fixed
142
+
143
+ **Decision as it stands (before the fix):** `label-in-name.js` did a literal text-containment check between an element's visible text and its accessible name, with no exemption for visible text that is actually rendering as an icon rather than readable text.
144
+
145
+ **Why it was questioned:** ACT `2ee8b8`'s own expectation text includes an explicit carve-out: visible text must be contained in the accessible name "except for characters in the text nodes used to express non-text content." Its own two failed-example fixes: `<button aria-label="close">X</button>` (a visible "X" glyph standing in for a close icon; ACT: "the 'x' text node is non-text content") and `<button aria-label="Find">search</button>` styled with an icon font (`font-family: 'Material Icons'`) that remaps the literal word "search" to render as a magnifying-glass glyph.
146
+
147
+ **Decision (2026-08-19):** fixed with two narrow heuristics. (a) A curated `font-family` name list (Material Icons/Symbols, Font Awesome, Ionicons, Glyphicons, IcoMoon, Bootstrap Icons, Feather, ...) catches the icon-font-remap shape, the same curated-list tradeoff as `link-name-quality`'s phrase list. (b) A whole visible label of exactly one character that doesn't even appear inside the accessible name catches the "X" shape, scoped to "doesn't appear in the name at all" specifically so a real word-boundary mismatch (visible `"1"` against `aria-label="1a"`, where "1" *is* a substring of the name) still fails outright rather than being swept into the exemption; an existing test guards exactly this distinction. ACT does not itself define an algorithmic test for "non-text content" (confirmed by fetching the rule's own Background/Assumptions text), so both heuristics report `cantTell` rather than a silent pass, surfacing the case for a human look instead of asserting or hiding a possible defect.
148
+
149
+ **Status:** resolved 2026-08-19. `2ee8b8` runs clean (0/13).
150
+
151
+ ### The ARIA structure rules only evaluate containers that carry an explicit `role`, not a bug
152
+
153
+ **Decision as it stands:** `aria-required-children`, `aria-prohibited-children` and `aria-required-parent` all key their applicability off `getExplicitRole(el)`; an element with no `role=""` attribute is never evaluated as a container, however clear its native semantics.
154
+
155
+ **Why it was questioned:** an earlier pass, reading ACT `bc4a75` from a local checkout of `act-rules/act-rules.github.io`, took the rule's failed-example 10 to be `<ul><div></div><div></div></ul>` (an implicit `list` owning `generic` children) and concluded our explicit-role-only applicability was a gap.
156
+
157
+ **Decision (2026-08-19):** not a bug. Fetching `bc4a75`'s live page directly (act-rules.github.io) shows its Applicability text reads *"has a WAI-ARIA 1.1 explicit semantic role with required owned elements"*, explicit is load-bearing, and its own **Inapplicable Example 2** is exactly `<ul><li>Item 1</li></ul>`, plain native markup with no role anywhere. ACT itself excludes bare native containers from this rule; the local-checkout reading that prompted this entry didn't match the current published rule. `getExplicitRole`-only applicability is correct as written and needs no change.
158
+
159
+ **Status:** resolved 2026-08-19, no code change; confirmed against the live ACT rule text.
160
+
161
+ ### `aria-prohibited-children` gated `group`/`rowgroup` transparency behind the container's own required-owned set, a real bug, now fixed
162
+
163
+ **Decision as it stands (before the fix):** a `role="group"`/`role="rowgroup"` owned child was only treated as a transparent wrapper (recursed through) when the *container's own* required-owned-roles set happened to include `group`/`rowgroup`, true for `menu`/`menubar`/`tree`, false for `list`, `listbox`, `table`, `radiogroup`, `tablist`.
164
+
165
+ **Why it was questioned:** running the live `bc4a75` corpus surfaced a passed example this engine failed: `<div role="list"><span role="listitem">Item 1</span><div role="group"><span role="listitem">Item 2</span><span role="listitem">Item 3</span></div></div>`. `list`'s required-owned set is `listitem` only, so the `group` wrapper was treated as a real, non-transparent owned entry with role `group`, not in the required set, and flagged. ACT's failed example 6 (`role="list"` wrapping a `role="group"` that owns `role="tab"` children, expected to fail) confirms the same: `group` is transparent under `list` regardless of `list`'s own required-owned set, so what determines the outcome is the *group's own children*, not the group role itself.
166
+
167
+ **Decision:** `group`/`rowgroup` are universally transparent intermediary containers for owned-element matching, for any container role, not conditional on the container's own required-owned set naming `group`/`rowgroup` as an acceptable leaf role. Fixed in `collectOwnedRoles` (`src/checks/automatic/aria-prohibited-children.js`).
168
+
169
+ **Status:** resolved 2026-08-19. `bc4a75` runs clean (0/19 mismatches).
170
+
171
+ ### Page-wide duplicate-`id` checking was skipped once, now built, version-scoped
172
+
173
+ **Decision as it stands:** `src/checks/automatic/duplicate-id-aria.js` only flags a duplicate `id` when it's referenced by an ARIA ID-reference attribute. Its header comment: "Scoped to ids referenced by ARIA, not the broader/deprecated page-wide duplicate-id check (see ROADMAP.md's 'Skip' list)." That `ROADMAP.md` no longer exists in the repo (not found in the working tree or as a tracked file in `git log`, likely a local planning doc that was never committed), so the original reasoning behind "skip" isn't recoverable verbatim, only the pointer to it.
174
+
175
+ **Why it's being questioned:** while mining ACT's gap list (per the user's request to find gaps worth turning into new rules), `3ea0c8` "Id attribute value is unique" is exactly this broader page-wide check, and it's detectable with a simple, deterministic document-wide scan. Checked ACT's own SC mapping for it: `3ea0c8` maps to **WCAG 4.1.1 Parsing**, which the Working Group formally **removed in WCAG 2.2** (browsers/AT no longer depend on strict-parsing conformance the way they did when that SC was written), and axe-core deprecated its own equivalent broad `duplicate-id` check around the same time, for the same reason. So the original "skip" call was well-founded *for WCAG 2.2 conformance scoring specifically*.
176
+
177
+ That said, duplicate IDs are still a real, practical bug independent of which SC currently covers them: they break `<label for>` association, fragment navigation, and any `getElementById`/`querySelector('#...')` call, not just ARIA references. This engine already supports WCAG-version-scoped tagging (`wcag2a`/`wcag21a`/`wcag22aa`-style tags, see `docs/ENGINE_OPTIONS.md`'s WCAG-version filtering). A page-wide duplicate-id rule could be added and tagged as WCAG 2.0/2.1-only (`wcag411`-style, excluded from WCAG 2.2 tag sets) rather than either fully skipped or wrongly counted against 2.2 conformance, the two options the original either/or "skip" decision didn't have room for.
178
+
179
+ **Decision (2026-08-19):** build it, version-scoped. The new `duplicate-id` rule maps to SC 4.1.1 and carries `wcag2a` (its 2.0/2.1 origin) plus a new `wcag22-removed` tag; a consumer targeting WCAG 2.2 drops it with `excludeTags: ['wcag22-removed']`, one targeting 2.0 or 2.1 keeps a real 4.1.1 result. That is the third option the original either/or "skip" call did not have room for: the defect is real regardless of which SC covers it (`<label for>`, fragment navigation and `getElementById` all resolve to the first match), while the conformance arithmetic stays honest for every version. `src/coverage/wcag-version-map.js` gained `WCAG22_REMOVED_SCS`/`removedInVersion` so the removal is recorded next to the additions rather than living only in a rule comment.
180
+
181
+ **Status:** resolved 2026-08-19. `duplicate-id` ships, clean against all 10 of ACT `3ea0c8`'s examples.
182
+
183
+ ### `<label for>`/wrapping association is applied to elements that aren't natively labelable, contradicting ACT
184
+
185
+ **Decision as it stands:** the shared accessible-name helper (`getAccessibleNameInfo` in `src/core/dom-helpers.js`, ~line 2984) falls back to a `label[for]`/wrapping-`<label>` lookup by element `id` for *any* element, not just genuinely labelable native ones (`input`/`textarea`/`select`/`button`/`output`/`meter`/`progress`). Its own comment describes this as intentional: "fallback for elements where `.labels` isn't natively available, e.g. a non-native-labelable element like `<div role="button" id="x">` still explicitly pointed at by `<label for="x">`." The same pattern is duplicated in `textbox-name-present.js`, `combobox-name-present.js`, `listbox-name-present.js`, `searchbox-name-present.js`, `slider-name-present.js`, and `spinbutton-name-present.js`.
186
+
187
+ **Why it's being questioned:** ACT `e086e5`'s own test corpus fails a `<label>first name<div role="textbox"></div></label>` (and the `label[for]` equivalent). A `<div role="textbox">` isn't a native HTML label target, and per HTML, `<label>` only creates a real accessible-name association with labelable elements. `textbox-name-present.js`'s own header comment even states the opposite of what the shared helper does: `role="textbox"` is "name-from-author-only... must NOT fall back to subtree content." The intent was clearly to be strict here, but the `<label>` fallback undermines it.
188
+
189
+ **Decision (2026-08-19):** keep the leniency. The question was whether this engine follows the spec or follows what assistive technology actually does, and the answer here is what users experience: where a screen reader announces a `<label for>` pointed at a non-labelable ARIA widget, an engine that calls that name absent would report a missing name the user can hear perfectly well, a false positive, and the worst kind, since it sends an author to "fix" working markup. Reporting a name that some AT ignores is the safer error: it under-reports a real problem rather than inventing one, and the widget's own naming rules (`aria-label`/`aria-labelledby`) still apply on top.
190
+
191
+ Two consequences, both accepted: the shared helper and its six rule-local copies stay as they are, and ACT `e086e5`'s two `<label>`-on-`role="textbox"` failed examples stay permanent mismatches, reclassified in `docs/ACT_RULE_MAPPING.md` from an open question to a deliberate divergence.
192
+
193
+ **Status:** resolved 2026-08-19, no code change; behaviour confirmed as intended.
194
+
195
+ ### `aria-allowed-attr` only checks elements with an *explicit* `role` attribute, the entry was written from a stale comment
196
+
197
+ **Decision as it stood:** `src/checks/automatic/aria-allowed-attr.js`'s header comment said the rule was scoped to elements carrying an explicit `role="..."`, and this entry took it at its word.
198
+
199
+ **What was actually true:** the comment was out of date when the entry was written. An implicit-role path had already landed on 2026-08-13 (`cdf9a13`), with a generated `IMPLICIT_ROLE_BY_ELEMENT` table gated on elements whose role is the same in every context, and the rule had been judging `<p aria-level="2">` and friends ever since. The entry described the documentation, not the code, a reminder that a header comment is evidence of intent, not of behaviour, and that the check is one `runa11yCoreOnHtml` call away.
200
+
201
+ **What the real remaining gap was:** elements HTML-AAM maps to *no* role at all. ACT `5c01ea`'s failed example 2 is `<audio controls aria-orientation="horizontal">`: `audio` has no role, so no role-specific attribute is supported on it, and the rule skipped it because the implicit-role lookup came back empty, indistinguishable, in the old code, from "a role this table does not model."
202
+
203
+ **Decision (2026-08-19):** separate the two. A generated `ROLELESS_ELEMENTS` set (`audio`, `video`) makes "no role in any context" an answer rather than a shrug, and every non-global ARIA attribute on one of those is reported. `div`/`span` joined the context-free table as `generic`, whose supported set is empty, so `<div aria-expanded="true">` is now reported too; the attribute announces nothing there, a real defect rather than a spec technicality. Context-dependent elements (`<a>`, `<section>`, `<td>`, ...) are still skipped rather than guessed at; that restraint is what the second entry above is about.
204
+
205
+ **Status:** resolved 2026-08-19. `5c01ea` now runs clean against all 17 of ACT's examples, and the rule's header comment describes what it does.
206
+
207
+ ### `aria-required-children` uses "at least one acceptable owned role," where ACT requires every owned role to be acceptable
208
+
209
+ **Decision as it stood:** `aria-required-children` is satisfied by finding any single matching descendant, and its header comment called that a recall-over-precision trade-off. This entry read ACT `bc4a75`'s exclusive expectation against it and concluded the engine would miss any container mixing valid and invalid owned children, a `role="list"` holding one real `listitem` and a stray `role="button"`.
210
+
211
+ **What was actually true:** the exclusive check exists, in `aria-prohibited-children`. This repo splits ACT's single rule into two atomic decisions: "does a required child exist" and "is every owned child allowed," and the second one already walks the owned graph exclusively, with `group`/`rowgroup` transparency and boundary handling. ACT's failed example 6, the nested `group` owning `treeitem`s that this entry quoted in full, fails today; so does the mixed list. The entry compared ACT's rule against one half of the pair.
212
+
213
+ **Decision (2026-08-19):** no algorithm rewrite. The defect was in the mapping, which pointed `bc4a75` at `aria-required-children` alone, so the corpus run measured half the coverage and reported the other half as missing. `bc4a75` is now a `family` match over both rules, and `aria-required-children`'s header says which half it owns, so the next reader does not repeat the inference.
214
+
215
+ **Status:** resolved 2026-08-19. `bc4a75` went from 4 mismatches to 1, the remainder being the implicit-container applicability entry below.
216
+
217
+ ### `NATIVE_CONTAINMENT_ROLE_BY_ELEMENT` gave several native tags an unconditional implicit role, ignoring HTML-AAM's context requirement
218
+
219
+ **Decision as it stood:** `getContainmentRole` mapped `li → listitem`, `option → option`, `tr → row`, `td → cell`, `th → columnheader` and the row groups unconditionally, whatever contained them.
220
+
221
+ **Why it was wrong:** HTML-AAM makes those roles conditional. An `<li>` is a `listitem` only as a child of `<ul>`, `<ol>` or `<menu>`; an `<option>` only inside `select`/`datalist`/`optgroup`; the table family only inside a real table. ACT `bc4a75` tests it directly: `<div role="list"><li>Item 1</li><span role="link">Item 2</span></div>` must fail, because with the `<li>` carrying no role the list owns nothing valid at all, and the engine passed it.
222
+
223
+ **Decision (2026-08-19):** fixed. A `NATIVE_CONTAINMENT_CONTEXT` table records the containing tags each conditional role needs, split between HTML-AAM's "child of" conditions (`li`, `option`, the row groups) and its "descendant of a table" ones (`tr`, `td`, `th`), which sit inside a rowgroup in most real tables. The common CSS-reset shape `<ul role="list"><li>…</li></ul>` is untouched, since the `<li>`'s parent really is a `<ul>`.
224
+
225
+ One existing test changed meaning with it: a bare `<option>` under `role="listbox"`, outside any `<select>`, used to count as the listbox's owned child. It no longer carries a role, so it is transparent and a focusable element inside it becomes the listbox's own roleless owned entry. That is the same conditional ACT applies to `<li>`, so applying it to `<option>` too is the consistent reading; the alternative would have been to accept `role="listbox"` as native context for `<option>` while ACT explicitly refuses `role="list"` as context for `<li>`.
226
+
227
+ **Status:** resolved 2026-08-19.
228
+
229
+ ### `contrast-computable` never treated `text-shadow` as a blocker, though ACT's own examples rely on one to rescue otherwise-failing contrast, fixed
230
+
231
+ **Decision as it stood:** `getComputabilityBlocker` walked ancestors checking mix-blend-mode, filter/backdrop-filter, background-image/gradient, and ancestor opacity, but never looked at `text-shadow` on the text element itself.
232
+
233
+ **Why it was questioned:** ACT `afw4f7`'s own passed example is `color: #AAA` text over a `#EEE`-ish background with a strong contrasting `text-shadow` outline, text that would normally fail the ratio test but passes because the shadow supplies enough perceptible contrast around each glyph. This engine has no glyph-rendering model to compute how a shadow affects the ratio, so asserting a confident `fail` there (as it previously did) contradicts a real browser's rendering; the correct answer is the same "defer to manual review" shape already used for every other computability blocker.
234
+
235
+ **Decision (2026-08-19):** added a `text-shadow` check to `getComputabilityBlocker`, scoped to the text element itself (a foreground property already resolved by inheritance, unlike the ancestor-walked background properties). A declared shadow with non-zero alpha now reports `cantTell` with `reasonCode: 'TEXT_SHADOW'` instead of asserting pass/fail. Implementing this surfaced a confirmed jsdom (29.1.1) bug: reading computed `text-shadow` a second time on the same element silently returns a different, wrong value, worked around by reading it exactly once per element and caching the result (see `docs/LIMITATIONS.md`).
236
+
237
+ **Status:** resolved 2026-08-19. `afw4f7` drops from 5 to 4 mismatches, `09o5cg` from 5 to 4 (the remaining mismatches on both are the unrelated gradient-background and shadow-DOM-via-script cases, see `docs/ACT_RULE_MAPPING.md`).
238
+
239
+ ### `identical-links-same-purpose` was filed as a permanent structural gap for `role="link"` elements, actually fixable, now fixed
240
+
241
+ **Decision as it stood:** the mapping doc (`docs/ACT_RULE_MAPPING.md`) filed ACT `fd3a94`/`b20e66`'s remaining mismatch as a genuine structural limit: the rule's `a[href]`-only selector "cannot cover a `role="link"` element whose target lives inside a JS string, not markup," filed in the same family as this engine's documented "dynamic/post-interaction state" limitation (`docs/LIMITATIONS.md`), implying nothing could be done short of executing script.
242
+
243
+ **Why it was questioned:** a re-audit against the live ACT rule pages found the actual failing snippets are `<span role="link" tabindex="0" onclick="location='/about/contact.html'">`; the destination is a literal string sitting in the `onclick` attribute's value, readable via `el.getAttribute('onclick')` without executing anything. It only *looks* script-dependent because a real browser resolves it by running the handler; the string itself is already static markup. `link-name-quality` had already independently widened its own selector to `a[href], area[href], [role="link"]` for the same "any semantic link" applicability reasoning (see the "Real rule bugs found and fixed" list above); this rule just hadn't received the same treatment.
244
+
245
+ **Decision (2026-08-19):** widened the applicability selector to `a[href], [role="link"]`, and added a regex fallback (`resolveOnclickLocation`) that extracts a destination from a `location='...'`/`location.href='...'`/`location.assign('...')`/`location.replace('...')` pattern in the element's `onclick` attribute when it has no real `href`. This rule is `cantTell`-capped (never asserts `fail`), so an `onclick` shape the regex doesn't recognize simply isn't resolved, a recall cost, not a false-fail risk.
246
+
247
+ **Status:** resolved 2026-08-19. `fd3a94` and `b20e66` both run clean against the live ACT corpus (0/19, 0/21).
248
+
249
+ ### `iframe-name-present`'s focusability exemption was filed as a deliberate broader-than-ACT scope choice, actually an implementation accident, now fixed
250
+
251
+ **Decision as it stood:** the mapping doc filed the remaining `cae760` mismatch as deliberate: "`iframe-name-present` doesn't exempt a `tabindex="-1"` iframe the way ACT's focus-reachability precondition does; arguably more useful for AT rotor/frame-list navigation, not just Tab order," framed as an intentional, considered choice to go beyond ACT's own scope.
252
+
253
+ **Why it was questioned:** re-reading ACT cae760's own Applicability text directly: "This rule applies to `iframe` elements that are included in the accessibility tree **and** that can be accessed by sequential focus navigation," two independent, unconditional AND conditions, not a role-scoped exception. The codebase's own `isFrameFocusable()` exemption was written nested *inside* the `role="none"`/`"presentation"` branch (added specifically to fix the earlier presentational-conflict-resolution case), so it only ever fired for a decorative-marked iframe; a plain `<iframe tabindex="-1">` with no role at all, which is cae760's own passed/inapplicable example, fell straight through and still got flagged. The "arguably more useful for AT rotor navigation" rationale reads like a justification invented after the gap was noticed, not a decision made on its own merits before shipping.
254
+
255
+ **Decision (2026-08-19):** the focusability check now gates applicability directly (`if (!isFrameFocusable(el)) continue;`), independent of role, matching cae760's own unconditional AND. A focusable `role="none"` iframe still needs a name (Presentational Roles Conflict Resolution correctly keeps applying there); a non-focusable iframe of any role, or no role, is now out of scope, matching the live rule exactly.
256
+
257
+ **Status:** resolved 2026-08-19. `cae760` runs clean against the live ACT corpus (0/10).
258
+
259
+ ### `aria-role-name-present` failed five roles WAI-ARIA never required a name for, fixed
260
+
261
+ **Decision as it stood:** the rule carried a hand-written allowlist of ten roles: `scrollbar`, `toolbar`, `tablist`, `radiogroup`, `tree`, `grid`, `menu`, `menubar`, `meter`, `progressbar`, and reported any unnamed one as a `serious`, `high`-confidence WCAG 4.1.2 Level A failure. The rule's header comment defended the *name computation* at length (name-from-author-only, so descendant text is never accepted, or a labelled child would pass its unnamed container) but said nothing about how the list itself was chosen, beyond that it was "a frozen allowlist rather than every role WAI-ARIA lets an author name."
262
+
263
+ **Why it was questioned:** "lets an author name" is the wrong predicate. WAI-ARIA records two separate characteristics per role, *Name From* (where a name may come from) and *Accessible Name Required* (whether one must exist), and the list collapsed them. Checked against `aria-query`, the same source `CHANGELOG.md` records this repo using to reconcile `aria-allowed-attr` against ARIA 1.2, five of the ten roles are `nameRequired: false`: `tablist`, `toolbar`, `menu`, `menubar` and `scrollbar`. For those, no normative route to a 4.1.2 failure exists. WCAG 4.1.2 governs user interface components; in a tab widget the operable components are the `tab`s, which name themselves from their own contents, and the `tablist` is a container that manages them. The contrast with `radiogroup` shows what the spec is encoding: a radiogroup's name usually carries the question itself ("Shipping method"), without which the options are semantically stranded, which is why ARIA marks that one required. Naming a `tablist` is a WAI-ARIA Authoring Practices recommendation, and the project's own policy model is explicit that advisory findings must not produce `fail` (`docs/POLICY.md`, and the design-doc quote in `landmark-banner-is-top-level-manual.js`). The rule also had no ACT counterpart (`docs/ACT_RULE_MAPPING.md` files it under "Extra coverage beyond ACT"), so nothing in `scripts/act-testcase-check.js` ever validated the applicability set. The practical cost was a mainstream, perfectly usable pattern (one tab widget under a visible heading, no `aria-label` on the tablist) reported as a serious Level A failure at `high` confidence.
264
+
265
+ **Options weighed:** a `cantTell` tier for the not-required roles was designed in full before being dropped. The engine supports it (`resolveTieredOutcome` in `src/core/dom-helpers.js` exists precisely for a fail/cantTell split within one automatic rule), but the value did not survive scrutiny. Unconditionally, it flags every unnamed `tablist` on every page, and the reviewer answers "fine" nearly every time; that is not review, it is the same false positive at a lower severity. Conditioned on two or more indistinguishable same-role containers (where the name actually does work), it needs a page-level count that becomes ambiguous under `root`-scoped scans. Either way it costs i18n keys in four locales and, per `docs/WCAG_CONFORMANCE.md`, flips the `wcag-4.1.2-name-role-value` composite from `pass` to `cantTell` for something the spec does not require. The asymmetry decided it: adding an advisory rule later is cheap, and un-shipping a noisy `cantTell` after integrators have built baselines around it is not.
266
+
267
+ **Decision (2026-08-21):** the five name-not-required roles are simply out of applicability, not passing, not reviewed, out of scope, which is the accurate statement. The rule now evaluates `grid`, `meter`, `progressbar`, `radiogroup` and `tree`, and its `4.1.2` / Level A / `serious` / `high` metadata is finally true of every finding it emits. The set is generated from `aria-query` by `scripts/generate-aria-tables.js` into a marked block, with the exclusions and their reasons recorded beside it, and a test in the rule's own suite re-derives it from `aria-query` so a future hand-edit back to a hand-picked allowlist fails CI rather than shipping. `meter` and `progressbar` were kept despite having dedicated rules: those map to SC 1.1.1, so this rule is what gives the two roles any 4.1.2 coverage at all; dropping them as "redundant" would have punched a quiet hole in the rollup. (That the dedicated rules map a naming failure to 1.1.1 rather than 4.1.2 looks wrong on its own terms, and is worth a separate look.)
268
+
269
+ **Status:** resolved 2026-08-21. The roles ARIA actually requires a name for still fail; the ones it does not are no longer reported. The converse gap this exposed is tracked in [Open](#open) above.
270
+
271
+ ### `aria-prohibited-children` attributed an item's own content to the container, six levels up, fixed
272
+
273
+ **Decision as it stood:** `collectOwnedRoles` treated any child with no containment role as fully transparent and recursed through it, up to `MAX_DEPTH = 40`, stopping only at the first element carrying a real role. "Owned child of the container" therefore meant "the first role-bearing element found down each branch, at any depth."
274
+
275
+ **Why it was questioned:** a real Angular Material scan reported `role="separator"` on a `mat-divider` as a prohibited child of an enclosing `role="radiogroup"`. The divider sits at `mat-radio-group > div > div > avq-card > div > avq-card-content > div > mat-divider`, six levels down, inside one card's body, dividing two columns of that card's content. In the accessibility tree its parent is a generic container inside the card; it is not a child of the radiogroup by any reading. The transparency that produced this is not itself wrong, it is forced, because the card wrapper carries no role and the radio it holds is buried at `avq-card-header > div > mat-radio-button > div > div > input[type=radio]`; a walk that refuses to descend reports every card-based radio group as owning no radios at all. The defect was that the leniency ran in both directions: descending to *find* the item also swept up everything else in the item's subtree and judged it against the container. Reproduced across the container roles: a scroll `button` beside the tabs in a roleless tab-strip wrapper (`tablist`), a `role="img"` icon beside the option in a row wrapper (`listbox`), a `role="status"` in a body wrapper (`grid`), a `role="tooltip"` beside a treeitem (`tree`).
276
+
277
+ **Why "only direct children" was not the fix:** under a strict accessibility-tree reading, the generic wrapper is *itself* an owned child whose role (`generic`) is not in the required set, so a literal direct-children rule fails the same markup, just blaming a different element, and also fails the very common `<div role="list"><div><div role="listitem">`. Roleless elements are not removed from the tree the way `role="none"`/`"presentation"` elements are; they are exposed as generic nodes. The engine's descent is a leniency on top of that, and the fix had to preserve it.
278
+
279
+ **Decision (2026-08-21):** when the walk enters a roleless wrapper, it now checks whether that wrapper's subtree yields any role from the container's required set. If it does, the wrapper is an item wrapper: only the items are collected, and the rest of its subtree is the item's own content, outside this rule's question. If it does not, the wrapper is interposed content and everything found inside it is still reported. Scoped to roleless wrappers only: `role="none"`/`"presentation"` keeps its existing behaviour, because a presentational element genuinely is removed from the accessibility tree with its children promoted to the container, and `group`/`rowgroup` keep the unconditional transparency ACT `bc4a75` confirmed. A genuinely stray direct child of the container is still reported, which is the rule's real value and is covered by regression tests alongside each fixed shape.
280
+
281
+ **Cost of the suppression, measured:** the first write-up of this entry called the suppression a straight recall trade: "a stray `role="button"` next to a `role="listitem"` inside one wrapper is no longer reported." Measuring it showed that framing was too pessimistic. Placing every candidate stray role beside a valid item inside one roleless wrapper, across `list`/`radiogroup`/`tablist`/`listbox`/`tree`, splits cleanly in two. Every role that could plausibly be a *misplaced item* (`option`, `tab`, `treeitem`, `menuitem`, `row`, `gridcell`, `listitem`) is still reported, by `aria-required-parent`: those roles carry a Required Context Role in ARIA, a roleless `div` does not satisfy it, and that rule works from the child's side, untouched by this change. What actually falls through is only roles ARIA places no context requirement on at all (`button`, `separator`, `status`, `tooltip`, `region`), and for those there is no normative basis to call them misplaced in the first place; they are content, which is exactly what the reported `mat-divider` was. So the suppression costs no finding that any rule could justify making. A `role="group"` wrapper is not roleless and stays fully strict: `list > group > [listitem, option]` still fails here as well as in `aria-required-parent`.
282
+
283
+ **Two refinements considered and rejected:** reporting item-ish strays inside item wrappers anyway would duplicate `aria-required-parent` exactly, same defect, two rule IDs, two occurrences per element, against this repo's one-rule-one-decision principle. Flagging strays that are direct *siblings* of the item would catch the `listitem` + `button` shape, but also `<span role="listitem">Invoice</span><span role="img" aria-label="paid">`, a row with a status icon beside its item: the same pattern class this entry fixes, re-broken on a heuristic with no spec text behind it.
284
+
285
+ **Note on ACT:** `docs/ACT_RULE_MAPPING.md` lists `bc4a75` as running clean before this change, so its published examples never exercised the roleless-wrapper shape; passing that corpus was not evidence the behaviour was right. Re-running `scripts/act-testcase-check.js` to confirm the corpus is still clean afterwards needs network access to the ACT rule pages, which this environment's egress proxy blocks; it should be re-run wherever that is available.
286
+
287
+ **Status:** resolved 2026-08-21. The reported false positive and four more of the same shape across `tablist`, `listbox`, `grid` and `tree` now pass; the stray-direct-child cases still fail. The related strictness question is tracked in [Open](#open) above.
288
+
289
+ ### `aria-prohibited-children` treated "required owned elements" as the exhaustive list of *permitted* children, fixed
290
+
291
+ **Decision as it stood:** the rule's header stated it outright: "the 'allowed owned roles' set is exactly REQUIRED_OWNED_ROLES, not a separately authored, broader list: an owned element is allowed only if its role is literally in the container's required set." A `role="menu"` could own `menuitem`, `menuitemcheckbox`, `menuitemradio` and `group`, and nothing else.
292
+
293
+ **Why it was questioned:** WAI-ARIA's "Required Owned Elements" answers what a container MUST contain. It is not a permitted-children list, and using it as one produced two false positives on markup the spec itself describes. A `role="separator"` between menu items was reported, though ARIA defines that role as "a divider that separates and distinguishes sections of content or groups of menuitems" and the Authoring Practices menu and menubar patterns use separators throughout. Worse, a `role="caption"` on a `role="table"`/`role="grid"` was reported, while the engine's own `REQUIRED_CONTEXT_ROLE` table says a caption must be inside a figure, grid or table; the two tables contradicted each other, and this rule lost.
294
+
295
+ **Decision (2026-08-21):** a second table, `ALLOWED_EXTRA_OWNED_ROLES`, carries the difference between "must contain" and "may contain," and the verdict is taken against required ∪ allowed. It is small on purpose, and an entry needs one of two sources: ARIA gives the child role a Required Context Role naming this container (`caption` in `table`/`grid`, mechanically checkable, since prohibiting a child ARIA says belongs there is self-contradiction), or the child role's own spec definition places it there (`separator` in `menu`/`menubar`, `separator` has no Required Context Role at all, so no derivation can express this and it is listed by hand). Generated by `scripts/generate-aria-tables.js`, which records the source for each entry and rejects one that satisfies neither: it throws on a role already required by that container, and on a role whose declared context roles do not include it.
296
+
297
+ **Not added, each rejected by that validator or by HTML:** `treegrid: caption`, `caption`'s context is figure/grid/table, and although `treegrid` subclasses `grid`, extending it would be this repo's judgement rather than ARIA's. `rowgroup: rowheader`, aria-query lists it, but HTML has no counterpart (a `<th>` must live in a `<tr>`) and the spec's own `rowheader` context is `row`; per `generate-aria-tables.js`'s header, the spec wins over the package, so it waits until it can be checked against the spec directly. `list: separator`, `<ul>`/`<ol>` admit only `<li>` plus script-supporting elements, and no spec text extends the menuitem carve-out to lists.
298
+
299
+ **Interaction with the item-wrapper fix above:** the two sets are used for different questions, on purpose. Only a *required* role makes a roleless wrapper an item wrapper, so a wrapper holding nothing but a separator is still interposed content and is still reported under a container that prohibits separators. The allowed set decides only the final verdict.
300
+
301
+ **Status:** resolved 2026-08-21. Separators in menus/menubars and captions on tables/grids pass; separators under `list`/`listbox`/`tablist`, captions under `treegrid`, and any stray role with no source behind it still fail. The reported allowed-roles list in the failure message now names the full allowed set rather than only the required roles.
@@ -1,6 +1,6 @@
1
1
  # Engine options reference
2
2
 
3
- Every runner (`runDomRulesInPage`, `runa11yCoreInPage`) takes the same four arguments: `(pageUrl, contextSelector, engineOptions, runOnly)`. This page documents `engineOptions` and `runOnly` in full — the real, current surface, verified against `src/core/dom-runner.js` and `scripts/build-core.js`, not the historical `docs/README.md`.
3
+ Every runner (`runDomRulesInPage`, `runa11yCoreInPage`) takes the same four arguments: `(pageUrl, contextSelector, engineOptions, runOnly)`. This page documents `engineOptions` and `runOnly` in full, verified against `src/core/dom-runner.js` and `scripts/build-core.js`.
4
4
 
5
5
  ## Selecting which rules run
6
6
 
@@ -55,6 +55,18 @@ Since versions are cumulative (2.1 = 2.0 + new; 2.2 = 2.0 + 2.1 + new), select a
55
55
  { tags: ['wcag22a', 'wcag22aa', 'wcag22aaa'] }
56
56
  ```
57
57
 
58
+ **One SC goes the other way.** WCAG 2.2 removed SC 4.1.1 Parsing — the only criterion ever dropped rather than added. A rule mapped to it carries its 2.0-origin tag (`wcag2a`) like any other baseline rule, plus `wcag22-removed`, and the version tag sets above therefore include it under a 2.2 target, where it does not belong. Exclude it explicitly:
59
+
60
+ ```js
61
+ // WCAG 2.2 AA conformance, without the criterion 2.2 removed:
62
+ {
63
+ tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'],
64
+ excludeTags: ['wcag22-removed']
65
+ }
66
+ ```
67
+
68
+ `duplicate-id` is the only rule carrying that tag today. Left in, it still reports something real — a duplicate id breaks `<label for>`, fragment links and `getElementById` whatever the standard says — it just is not a 2.2 conformance failure.
69
+
58
70
  ### Via `engineOptions` (no `runOnly`)
59
71
 
60
72
  Same filtering, expressed as comma-separated strings (or arrays) nested in `engineOptions`:
@@ -129,7 +141,7 @@ const engineOptions = {
129
141
  | `contrast.rootCanvasFallback` | The assumed page background color when it's not computable at all — only matters in `auditorAssist` mode. |
130
142
  | `visibilityMode` | Controls how strict the three contrast rules (`contrast-minimum`, `contrast-enhanced`, `contrast-computable`) are about deciding a text node is actually eligible to check. **Not read by any other rule.** `'styleOnly'` (default): eligibility is CSS-only — `display`, `visibility`, `opacity`, ancestor-hiding, etc. `'styleAndGeometry'`: adds real layout checks (`getClientRects()`/`getBoundingClientRect()`) on top of that — text with no client rects, or zero width/height, is excluded too. Reach for `'styleAndGeometry'` when running under a real browser/Playwright-Puppeteer (`runa11yCoreInPage`) and you want contrast findings to reflect actual rendered layout rather than just computed style; under plain jsdom (`runDomRulesInPage`) there's no real layout engine, so `'styleAndGeometry'` mostly just adds `getBoundingClientRect()` zero-size checks, not true clipping/overflow detection — see [`LIMITATIONS.md`](./LIMITATIONS.md). |
131
143
  | `policyContract` / `policy` | See [`POLICY.md`](./POLICY.md) — controls which outcomes/confidence values are allowed and whether manual rules' would-be `fail`s get coerced to `cantTell`. |
132
- | `output.includeSelector` / `.includeHtml` | Only affects the small number of rules (currently 4 of 125) that rely on the engine's automatic selector/HTML fill-in rather than building their own — most rules set `selector`/`html` themselves inside `runInPage` and are unaffected by this option. Not a reliable way to strip selectors/HTML from all output. |
144
+ | `output.includeSelector` / `.includeHtml` | Suppresses the engine's automatic `selector`/`html` fill-in. Since every rule was migrated to report its element rather than build occurrences by hand (1.5.0), that fill-in is the path almost all of them take: setting `includeSelector: false` strips selectors from 117 of the 130 rules, and `includeHtml: false` strips HTML snippets from 122. The remainder still assemble those fields themselves inside `runInPage` and are unaffected — among them `contrast-minimum`/`contrast-enhanced` (whose findings are text runs, not elements), `page-title-present` and `identical-links-same-purpose`. So this narrows output substantially but is still not a guarantee of *no* selectors or HTML anywhere in the result. |
133
145
  | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule currently reads `ctx.config`** for anything besides `excludeSelectors`. |
134
146
  | `probes` | An optional, JSON-safe evidence object your host application can supply (depth- and size-capped by the engine before rules see it, via `ctx.inputs.probes`) — for future rules that might accept externally-supplied signals (e.g. real layout measurements a static DOM scan can't compute itself). Not consumed by any current rule. |
135
147
  | `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` **additionally** adds a per-rule timing breakdown there. `profileRules` on its own does nothing — `perfStats` is what creates the object the breakdown lives in. Shape is not part of the stable output contract — don't build on it. Note also that `profileRules` is the one option that makes output non-deterministic: counters are stable across identical runs, wall-clock timings are not. Leave it off if you diff results between runs. |
@@ -153,7 +165,7 @@ const engineOptions = {
153
165
  };
154
166
  ```
155
167
 
156
- Why you'd want this: Angular Material's `<mat-select>` builds its internal ARIA structure in a way that trips a false positive on `aria-required-children` specifically, even though the component is otherwise fine. With only the global `excludeSelectors`, the only way to silence that false positive is `excludeSelectors: ['mat-select']` — which also hides `mat-select` from *every other rule*, including `color-contrast` and `aria-allowed-attr`, silently dropping real coverage those checks never had a problem with. The example above keeps `mat-select` fully visible to every rule except the one that misfires on it.
168
+ Why you'd want this: Angular Material's `<mat-select>` builds its internal ARIA structure in a way that trips a false positive on `aria-required-children` specifically, even though the component is otherwise fine. With only the global `excludeSelectors`, the only way to silence that false positive is `excludeSelectors: ['mat-select']` — which also hides `mat-select` from *every other rule*, including `contrast-minimum` and `aria-allowed-attr`, silently dropping real coverage those checks never had a problem with. The example above keeps `mat-select` fully visible to every rule except the one that misfires on it.
157
169
 
158
170
  Effective exclusions for a given rule are the **union** of the global list and that rule's own list — an element matching either is dropped from that rule's candidates. A rule whose only would-be-failing elements are all excluded this way reports `outcome: 'pass'` or `'notApplicable'` (matching that rule's own no-candidates convention), with `occurrences: []` — never `outcome: 'fail'` with an empty `occurrences` array, since that exact shape is reserved elsewhere in the schema to mean "this rule threw" (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)).
159
171
 
@@ -243,7 +255,7 @@ A descriptor has the *same shape as an internal rule module's own export* — if
243
255
  ```
244
256
 
245
257
  - `runInPage`/`applicability` may be a **real function** or a **function-source string** (i.e. `fn.toString()`). Pass a real function when `engineOptions` never leaves the current JS realm (plain Node/jsdom use). Pass a string when it does — e.g. a Playwright `page.evaluate(runa11yCoreInPage, { engineOptions })` call, where `engineOptions` crosses a JSON/structured-clone boundary that cannot carry a live `Function` reference but can carry a string. The engine reconstructs a string via `new Function`, the same mechanism `scripts/build-core.js` already uses to embed every built-in rule's source into the in-page runner.
246
- - `meta` gets identical defaulting/validation to a build-time rule (via the same `normalizeRuleMeta` used for the other 125 rules) — omit anything you don't need; `severity` defaults to `moderate`, `confidence` to `medium`, `type` to `automatic`, etc.
258
+ - `meta` gets identical defaulting/validation to a build-time rule (via the same `normalizeRuleMeta` used for every built-in rule) — omit anything you don't need; `severity` defaults to `moderate`, `confidence` to `medium`, `type` to `automatic`, etc.
247
259
  - A custom rule whose `id` collides with a built-in one **overrides it for that scan**, rather than running both. Since a same-named custom rule is just as likely to be an accidental collision as a deliberate override, every collision is surfaced two ways: a `console.warn` naming the id(s), and a top-level `overriddenBuiltinIds` array on the result (empty when there's no collision) — see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md).
248
260
  - An invalid descriptor (missing/non-string `id`, or a `runInPage` that isn't a function and isn't a reconstructable source string) is silently skipped — the rest of the scan, including every built-in rule, still runs normally. This isn't a validation gap to fix: a custom rule is arbitrary caller-supplied code, so "fail this one entry closed, don't abort the scan" is the safer default, mirroring how a *built-in* rule that throws is contained to a `cantTell` for that rule rather than crashing the run.
249
261
  - Results appear in `checksResults` exactly like any other rule's, including automatic `selector`/`html`/`structuralPath` fill-in for `fail`/`cantTell` occurrences that only attach `{ __node }` (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)).
package/docs/I18N.md CHANGED
@@ -6,10 +6,10 @@ Every rule's title/description, and every occurrence's summary/hint, is localize
6
6
 
7
7
  | Locale | File | Keys | Values still in English |
8
8
  |---|---|---|---|
9
- | `en` (English) | `src/i18n/en.json` | 633 | — (the canonical/fallback set) |
10
- | `fr` (French) | `src/i18n/fr.json` | 633 | 1 |
11
- | `de` (German) | `src/i18n/de.json` | 633 | 0 |
12
- | `es` (Spanish) | `src/i18n/es.json` | 633 | 0 |
9
+ | `en` (English) | `src/i18n/en.json` | 666 | — (the canonical/fallback set) |
10
+ | `fr` (French) | `src/i18n/fr.json` | 666 | 1 |
11
+ | `de` (German) | `src/i18n/de.json` | 666 | 0 |
12
+ | `es` (Spanish) | `src/i18n/es.json` | 666 | 0 |
13
13
 
14
14
  Locale files are plain JSON: a flat map of key to translated string, in the same key order as `en.json`. Nothing else lives in them, so contributing a language means editing text and never touching code.
15
15
 
@@ -192,5 +192,5 @@ A few things worth knowing:
192
192
  - **`engineOptions.pingWaitTime`** (default `500`ms) and **`engineOptions.frameWaitTime`** (default `60000`ms) control how long a child frame gets to answer a ping and a full run request respectively.
193
193
  - **No jsdom/Node equivalent** — this is browser-only. jsdom's window/frame model doesn't meaningfully represent independent-realm cross-origin `postMessage`, and the feature has no purpose in Node anyway.
194
194
  - **Bundler-free, like `runa11yCoreInPage`** — both functions are fully self-contained (their own private copy of the rule catalog and every helper they need), so raw-source injection (a bookmarklet, a content script with no build step) works with zero bundler needed, exactly like `runa11yCoreInPage` already does. If you *do* use a normal bundler/`require`/`import`, that works too, unchanged.
195
- - **Cost of that self-containment**: `src/core.js` grew from ~1.86MB to ~3.1MB, since these two functions each needed their own complete private copy of the rule catalog and shared helpers rather than sharing the outer `RULE_IMPLS`. If this file's size ever becomes a real problem, the fix would be to drop the bundler-free requirement for just these two functions (accepting that cross-frame scanning in "plain script injection" mode needs a real bundler, unlike `runa11yCoreInPage` alone) rather than tripling the embedded catalog again for some future feature.
195
+ - **Cost of that self-containment**: `src/core.js` grew from ~1.86MB to ~3.1MB when these two functions landed, since each needed its own complete private copy of the rule catalog and shared helpers rather than sharing the outer `RULE_IMPLS`; it is ~4.3MB now, and grows with every rule added, three times over. If this file's size ever becomes a real problem, the fix would be to drop the bundler-free requirement for just these two functions (accepting that cross-frame scanning in "plain script injection" mode needs a real bundler, unlike `runa11yCoreInPage` alone) rather than tripling the embedded catalog again for some future feature.
196
196
  - **No origin/identity check on the sender** beyond the message's own namespaced envelope. Running a read-only scan and replying with DOM-derived results isn't a privileged operation; the content involved is no more sensitive than what's already rendered on the page.
@@ -6,7 +6,7 @@ Stated plainly and upfront, not left for you to discover. Every item here is a d
6
6
 
7
7
  surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at one instant, with no ability to simulate user interaction, wait for async state changes, or measure real layout at arbitrary viewport sizes. These aren't missing rules — no rule implementation, however clever, can close them without a fundamentally different architecture (real browser automation driving actual keyboard/pointer events over time):
8
8
 
9
- - **Keyboard-trap detection** (WCAG 2.1.2) — requires simulating actual focus/keydown sequences and observing whether focus can escape. No static markup signal exists for this.
9
+ - **Keyboard-trap detection** (WCAG 2.1.2) — requires driving focus around the page and watching where it lands, which no single read of the DOM can do. A technique that would work has been scoped out (see the keyboard-trap section of [`ACT_RULE_MAPPING.md`](./ACT_RULE_MAPPING.md#keyboard-trap-detection-scoping-notes)), but it would be this engine's first check to mutate the page it is inspecting, so it is not built and would not be part of a normal scan if it were. Nothing in static markup stands in for it in the meantime.
10
10
  - **Reflow / clipping at zoom** (WCAG 1.4.10) — requires real layout measurement (`clientWidth`/`scrollWidth`) at a simulated 320px-equivalent viewport. Unlike some CSS-declaration-based heuristics elsewhere in this engine, there is no static markup proxy for "does content get clipped at 400% zoom" at all.
11
11
  - **Dynamic/post-interaction state** — anything that only exists after a click, hover, or async data load (a modal's contents, a dropdown's options, form-validation error messages) is invisible to a scan of the page's *current* DOM. If your framework renders it eagerly (even off-screen/`hidden`), it's scannable; if it only exists after interaction, it isn't, unless you drive that interaction yourself before scanning (e.g. click the button, *then* scan).
12
12
 
@@ -14,17 +14,19 @@ surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at
14
14
 
15
15
  - **jsdom (Node, no real browser) has no CSS layout engine.** Rules needing real geometry — most notably `target-size-minimum` (WCAG 2.5.8, needs real `getBoundingClientRect()`) — report `notApplicable` under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2) to get real findings from these rules.
16
16
  - **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec), along with any other subtree hidden via `display:none`, `visibility:hidden`, `[hidden]`, or closed `<details>`, are **excluded from rule evaluation by default** — matching the visibility-aware behavior of other established engines. This is a deliberate default (`engineOptions.includeHiddenElements: false`), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set `engineOptions.includeHiddenElements: true` to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#engineoptions--the-rest) for the option and exactly which hiding mechanisms it covers.
17
- - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. A scan running inside an actual loaded browser tab sees the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
17
+ - **jsdom's computed `text-shadow` is unreliable on a second read of the same element.** Confirmed in jsdom 29.1.1: reading a computed `text-shadow` value a second time on the same element — through any accessor, from any freshly-requested `CSSStyleDeclaration` for that element, regardless of caching — silently returns a different, wrong "no shadow" value instead of the real declared one. The first read is always correct. This engine works around it internally by reading each element's `text-shadow` exactly once per run and caching the result (see `__textShadowInfoEl` in `src/core/contrast-helpers.js`), so a single scan is unaffected. It only surfaces if you read `getComputedStyle(el).textShadow` yourself, more than once, against the same jsdom-parsed element — a real browser has no such bug.
18
+ - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. A scan running inside an actual loaded browser tab sees the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with rule correctness. That's why `aria-checked-state-mismatch` is capped at `manual`/`cantTell` rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
18
19
 
19
- ## Deliberately not attempted — judgment calls, not automatable safely
20
+ ## Not attempted: judgment calls that aren't automatable safely
20
21
 
21
22
  These have no comparably safe heuristic at this engine's confidence bar (`fail` must stay reserved for deterministic, high-confidence violations, full stop). Building them anyway would either catch almost nothing (too narrow to be useful) or risk real false positives (too broad to trust):
22
23
 
23
- - **"Is this heading/label text meaningful?"** — real headings and labels are enormously varied and legitimately short ("FAQ," "Name," "Overview" are all fine). Unlike link text (where a small, well-established "always bad" phrase list exists — see `link-name-quality`), there's no equivalent safe list here.
24
+ - **"Is this heading/label text meaningful?"** — real headings and labels are enormously varied and legitimately short ("FAQ," "Name," "Overview" are all fine), so nothing decides from markup whether a heading describes the section under it or a label describes the field beside it. What *is* decidable is that some strings cannot describe anything: `heading-quality` and `form-control-label-quality` flag leftover placeholders, numbered template slots, filenames and URLs against curated exact-match lists, the same precision-over-recall trade-off `link-name-quality` makes. Both are `manual` rules capped at `cantTell` — they raise a candidate for review, they never assert the text is wrong.
24
25
  - **"Does this error message describe the problem?"** — what triggers a validation error and its content are almost always JS/validation-library-driven, invisible to a static scan in the first place; not just a heuristic-design problem.
25
26
  - **Fine-grained time-based-media sub-checks** (WCAG 1.2.x has ~8 distinct ACT-rule-level cases beyond what's built) — audio/video content itself is fundamentally unverifiable from static markup; the two broadest, safest cases are covered (`media-alternative-transcript-evidence`, `video-caption`), the narrower ones are not, by design.
26
27
  - **Images-of-text content analysis** (WCAG 1.4.5/1.4.9) — would need OCR-equivalent image understanding; out of scope for a static-markup engine.
27
28
  - **Motion-actuation controls** (WCAG 2.5.4) — niche, low real-world incidence; not prioritized, not structurally impossible.
29
+ - **`img-alt-decorative`'s two ACT e88epe edge cases** — an `<img>` whose current network request state isn't "completely available" (still loading, or broken), and a `<canvas>` that is fully transparent (nothing actually drawn on it), are both exempt from ACT's own applicability. Neither is decidable from a static DOM scan (no image decoding, no canvas pixel readback), so both are left in scope rather than exempted — a rare false positive a human reviewer dismisses at a glance, safer than silently under-reporting a real excluded/undecorative element.
28
30
 
29
31
  ## What this means in practice
30
32
 
package/docs/REPORT.md CHANGED
@@ -32,4 +32,4 @@ require('fs').writeFileSync('report.html', html);
32
32
 
33
33
  ## Scope
34
34
 
35
- This is a single-scan report — one point-in-time snapshot, not a dashboard tracking results across many scans over time. Multi-run history/trend tracking is a separate, larger concern (see the project roadmap's "Enterprise/compliance features" — historical trend tracking across scans) and isn't part of this tool.
35
+ This is a single-scan report — one point-in-time snapshot, not a dashboard tracking results across many scans over time. Tracking results across many runs is a separate, larger concern and isn't part of this tool: keep the JSON from each scan and diff it yourself, or feed SARIF to a dashboard that already does history ([`SARIF.md`](./SARIF.md)).