@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
package/CHANGELOG.md CHANGED
@@ -4,258 +4,302 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.6.0] - 2026-08-23
8
+
9
+ ### Added
10
+ - `duplicate-id` flags a non-empty `id` repeated within the same document or shadow tree. Tagged `wcag2a` plus `wcag22-removed` since SC 4.1.1 (Parsing) was dropped in WCAG 2.2, so exclude it from a 2.2 conformance run with `excludeTags: ['wcag22-removed']`. See `docs/ENGINE_OPTIONS.md`.
11
+ - `form-control-label-quality` reviews a field's visible label text: a leftover placeholder, a label reused across several fields with nothing telling them apart, or a label split between visible and hidden parts. Sits alongside `form-control-programmatic-label-quality`, which judges the labelling mechanism instead of the text.
12
+ - `css-focus-indicator-suppressed` flags `:focus`/`:focus-visible` rules that remove the outline with no replacement drawn anywhere else.
13
+ - `heading-quality` flags placeholder heading text: generic words, numbered template slots, filenames, URLs.
14
+ - `presentational-children-focusable-absent` catches a focusable descendant left inside a role that makes its own children presentation-only (`button`, `img`, `option`, `tab`, ...): reachable by keyboard, but with no exposed role or name.
15
+
16
+ ### Changed
17
+ - `docs/RULE_CATALOG.md` now includes each rule's applicability and expectation text alongside its summary, generated straight from source.
18
+ - `link-name-quality` weighs nearby context before flagging a link as non-descriptive (an `aria-describedby` target, or the enclosing list item/table cell/paragraph's own text) rather than judging the accessible name on its own. It also catches bare file-format names like "HTML" or "PDF" for the first time.
19
+ - Rule summaries and hints are reworded throughout: same meaning, plainer phrasing. Anything snapshot-testing that text will see a diff.
20
+
21
+ ### Removed
22
+ - `bin/surea11y-core.js` and its `bin` entry, unused since the CLI moved to `@surea11y/cli` in 1.4.0.
23
+
24
+ ### Fixed
25
+ - `aria-prohibited-children` no longer treats a container's *required* owned elements as the exhaustive list of children it may own. WAI-ARIA's "Required Owned Elements" says what a container must contain, not all it may contain. Using the required set as the allowed set also flagged markup the spec itself describes: a `role="separator"` between menu items, and a `role="caption"` on a `role="table"`/`role="grid"`. A second table now carries the difference; an entry needs a source, either an ARIA Required Context Role naming the container or the child role's own definition placing it there. `scripts/generate-aria-tables.js` generates the table and rejects any entry with neither (`treegrid: caption` and `rowgroup: rowheader` are both refused, with the reasoning recorded). Separators under `list`/`listbox`/`tablist`, captions under `treegrid`, and any other unsourced role still fail, and the failure message now lists the full allowed set rather than only the required roles.
26
+ - `aria-prohibited-children` no longer reports an item's own content as a prohibited child of the container. The walk treats a roleless wrapper as transparent, which it has to, since component markup routinely buries the real item several roleless levels down (an Angular Material card whose radio sits at `card > header > mat-radio-button > div > div > input[type=radio]`). But that transparency ran in both directions, so any role-bearing element anywhere in an item's subtree registered as an owned child of the container too far up, e.g. a `role="separator"` dividing two columns inside a radio card was reported as a prohibited child of the enclosing `role="radiogroup"`, six levels up. A roleless wrapper that holds a required item is now treated as an item wrapper: only the items it holds count as the container's owned children, and the rest of that subtree belongs to the item. This touches all twelve container roles in `REQUIRED_OWNED_ROLES`. Unchanged: `role="none"`/`"presentation"` wrappers, which really are removed from the accessibility tree with their children promoted, so a disallowed role inside one is still an owned child; a roleless wrapper holding no item at all, which is interposed content and still reported; and any genuinely stray direct child of the container.
27
+ - `aria-role-name-present` no longer fails `tablist`, `toolbar`, `menu`, `menubar` and `scrollbar` for having no accessible name. Its role list was a hand-written allowlist of roles WAI-ARIA lets an author name, which is the wrong predicate: the spec records *Name From* and *Accessible Name Required* separately, and those five are `nameRequired: false`. A single tab widget under a visible heading, a mainstream and usable pattern, was reported as a `serious` WCAG 4.1.2 Level A failure at `high` confidence, with no ACT counterpart validating the scope. The rule now evaluates only the roles ARIA actually requires a name for (`grid`, `meter`, `progressbar`, `radiogroup`, `tree`), so its Level A mapping is true of every finding it emits; the five dropped roles are out of applicability rather than passing. The set is generated from `aria-query` by `scripts/generate-aria-tables.js` and re-derived in the rule's tests, so it can't drift back to a hand-picked list. `meter`/`progressbar` stay despite their dedicated rules, which map to SC 1.1.1: this rule is their only 4.1.2 coverage. Open questions (a `cantTell`-tier alternative that was considered and rejected, and the five name-*required* roles still uncovered: `table`, `tabpanel`, `treegrid`, `application`, `marquee`) are tracked in `docs/DESIGN_CHALLENGES.md`.
28
+ - `isAccTreeEligible` ignored `content-visibility: hidden`, so content the browser skips entirely (no accessibility tree, no focus, no find-in-page) stayed in every rule's candidate list and could be reported as a failure. It blocks the subtree now, like `display:none`, using the `contentVisibilityHidden` reason `isDomVisibleEligible` already reported. The element carrying the declaration is unaffected: it hides its contents, not itself, so it stays in scope.
29
+ - `contrast-minimum`/`contrast-enhanced` stopped requiring a contrast ratio for text made entirely of punctuation or symbols; digit-only text is still checked.
30
+ - `contrast-computable` treats a declared `text-shadow` as a computability blocker (`cantTell`) instead of asserting pass or fail: a strong shadow can rescue contrast that would otherwise fail.
31
+ - `contrast-minimum`/`contrast-enhanced` stopped reporting a failure when text's foreground color exactly matches its background; that text isn't visible in the first place.
32
+ - `identical-links-same-purpose` covers `[role="link"]` elements too, not just `a[href]`, and can pull a link's destination out of an `onclick` attribute when there's no real `href`.
33
+ - `iframe-name-present` requires an iframe to be reachable by keyboard focus before it needs a name, and stopped flagging one already marked decorative with `role="none"`/`"presentation"`.
34
+ - `css-orientation-lock` uses a ±5-degree tolerance for its 90/270-degree rotation check instead of demanding an exact multiple of 90, and now decomposes `matrix()`/`matrix3d()`/`rotate3d()` rotations as well.
35
+ - `iframe-focusable-content` resolves a `srcdoc` iframe's embedded content, and exempts iframes 2px or smaller (tracking pixels) from needing to be free of focusable content.
36
+ - `bypass-blocks-present`'s heading mechanism requires the heading to actually be visible, not just present in the accessibility tree.
37
+ - `aria-valid-attr-value` stopped failing a bare or empty ARIA attribute value, and no longer requires `aria-errormessage`'s target to exist (`aria-activedescendant` still does).
38
+ - `valid-lang` scopes to text that actually inherits its language from the target element, rather than any text anywhere in its subtree.
39
+ - `role-img-text-alternative-present` covers `role="graphics-symbol"`/`"graphics-document"` too, including nested SVG shapes, and stopped flagging elements inside `aria-hidden="true"`.
40
+ - `img-alt-decorative` reviews any visible `<img>`/`<canvas>`/`<svg>` excluded from the accessibility tree, not just `<img alt="">`.
41
+ - `label-in-name` stopped flagging visible text standing in for an icon: a single character absent from the accessible name, or text rendered through a known icon font.
42
+ - `aria-prohibited-children` treats `role="group"`/`"rowgroup"` wrappers as transparent for every container role, not only the ones that also accept `group`/`rowgroup` as a leaf role themselves.
43
+ - Native containment roles (`<li>` to `listitem`, `<option>` to `option`, the table row/cell roles) are conditional on the element's actual parent now, matching HTML-AAM. Affects `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`.
44
+ - `aria-allowed-attr` judges elements with no ARIA role at all (`<audio>`/`<video>`, and `<div>`/`<span>` as `generic`) instead of skipping them.
45
+ - `table-headers-attr-valid` scopes strictly to `table`/`grid`/`treegrid`; any other explicit role takes a table out of scope entirely.
46
+ - `aria-required-attr` requires `aria-valuenow` on a focusable `role="separator"`.
47
+ - `presentation-role-conflict` stopped flagging an `<img alt="">` that carries an explicit role of its own.
48
+ - Ten German/Spanish/French strings for `input-image-alt-present` had lost diacritics and apostrophes; restored.
49
+ - 19 rules had an English `title`/`description` that disagreed between source and dictionary; corrected, and `validate-rule.js` now asserts the two match.
50
+ - `scripts/generate-aria-tables.js --check` could never pass: it compared unformatted output against the formatted, committed tables.
51
+
7
52
  ## [1.5.0] - 2026-08-16
8
53
 
9
54
  ### Added
10
- - `engine.locale` on the result records which dictionary a run actually used: `{ requested, resolved, reason }`, once per result. Locale fallback is graceful and per-string, so asking for a language the build doesn't carry has always produced fluent English with nothing in the output to say so; `reason` now distinguishes `ok`, `unknown-locale` (no dictionary for that code — a region subtag counts as its own locale) and `partial-dictionary` (dictionary used but missing keys). Purely additive, so `engine.schemaVersion` stays `"1.0.0"`. Documented in `docs/OUTPUT_SCHEMA.md` and `docs/API_STABILITY.md`.
55
+ - `engine.locale` on the result records which dictionary a run actually used: `{ requested, resolved, reason }`, once per result. Locale fallback is graceful and per-string, so asking for a language the build doesn't carry has always produced fluent English with nothing in the output to say so. `reason` now distinguishes `ok`, `unknown-locale` (no dictionary for that code, a region subtag counts as its own locale) and `partial-dictionary` (dictionary used but missing keys). Purely additive, so `engine.schemaVersion` stays `"1.0.0"`. Documented in `docs/OUTPUT_SCHEMA.md` and `docs/API_STABILITY.md`.
11
56
  - `npm run i18n:sync` rewrites every non-English locale file against `en.json`: adds keys that are new (seeded with the English text), drops keys `en.json` no longer has, and leaves existing translations untouched. `npm run i18n:check` performs the same comparison without writing and exits non-zero on drift. Adding a rule string previously meant editing four files by hand, with a missed one falling back to English silently.
12
57
 
13
58
  ### Changed
14
- - The standalone browser bundle carries English only, and every other locale ships beside it as `surea11y.i18n.<locale>.js`, loaded with a second `<script>` tag. The bundle drops from 1580 KB to 1300 KB and stops growing as languages are added — the change of shape matters more than the one-off 280 KB. Asking for a locale whose side file is not loaded returns English with `engine.locale.reason` set to `dictionary-not-loaded`, so it is visible rather than silent. `require('@surea11y/core')` and every binding are unaffected and still carry all locales. `scripts/build-browser.js` now generates its own English-only core in memory instead of reading the shipped `src/core.js`; the in-page runner keeps its table inside the function body, because the bindings serialize that function into the page, so the only way to give the bundle a smaller table is to build it with one.
59
+ - The standalone browser bundle carries English only, and every other locale ships beside it as `surea11y.i18n.<locale>.js`, loaded with a second `<script>` tag. The bundle drops from 1580 KB to 1300 KB and stops growing as languages are added. Asking for a locale whose side file is not loaded returns English with `engine.locale.reason` set to `dictionary-not-loaded`, so it's visible rather than silent. `require('@surea11y/core')` and every binding are unaffected and still carry all locales. `scripts/build-browser.js` now generates its own English-only core in memory instead of reading the shipped `src/core.js`.
15
60
  - `npm run build` removes a `surea11y.i18n.<locale>.js` whose locale no longer exists. `files` matches those by glob, so a dropped language would otherwise have kept shipping.
16
- - New `engineOptions.messages`, a `{ [locale]: { key: text } }` map checked before the built-in tables. It can override individual strings or supply a language the build does not carry, and it is how a locale side file reaches the in-page runner. Omitted keys fall back normally.
17
- - Locale codes are matched case-insensitively, so `pt-br` and `PT-BR` both find `pt-BR.json`. Only an exact spelling matched before, which would have caught the first contributor to add a regional file. A code differing from its dictionary only in case reports `ok` rather than `primary-subtag` — no fallback happened.
18
- - A locale code carrying a subtag now falls back to its base language before falling back to English: `de-DE` and `de-AT` both use `de.json`, matched case-insensitively, and an exact `de-DE.json` still wins if one exists. Previously any subtag resolved straight to English, so a browser-supplied `de-DE` produced English output while `de` produced German. `engine.locale.reason` reports `primary-subtag` for it. A translator now only needs `pt.json` to serve every Portuguese variant; a regional file is for when the wording genuinely differs.
19
- - Locale sources are JSON (`src/i18n/*.json`) rather than CommonJS modules. A translation is now a data change with no executable code in the diff, and a contributor needs no build knowledge. The generated `src/core.js` and `surea11y.browser.js` are byte-identical across the conversion. `src/i18n/*` was already documented as internal and has never been in the published `files` allowlist, so nothing importable changed.
61
+ - New `engineOptions.messages`, a `{ [locale]: { key: text } }` map checked before the built-in tables. It can override individual strings or supply a language the build does not carry, and it's how a locale side file reaches the in-page runner. Omitted keys fall back normally.
62
+ - Locale codes are matched case-insensitively, so `pt-br` and `PT-BR` both find `pt-BR.json`. Only an exact spelling matched before. A code differing from its dictionary only in case reports `ok` rather than `primary-subtag`, since no fallback happened.
63
+ - A locale code carrying a subtag now falls back to its base language before falling back to English: `de-DE` and `de-AT` both use `de.json`, matched case-insensitively, and an exact `de-DE.json` still wins if one exists. Previously any subtag resolved straight to English, so a browser-supplied `de-DE` produced English output while `de` produced German. `engine.locale.reason` reports `primary-subtag` for it. A translator now only needs `pt.json` to serve every Portuguese variant.
64
+ - Locale sources are JSON (`src/i18n/*.json`) rather than CommonJS modules, so a translation is a data change with no executable code in the diff. The generated `src/core.js` and `surea11y.browser.js` are byte-identical across the conversion. `src/i18n/*` was already documented as internal and has never been in the published `files` allowlist.
20
65
  - `npm run i18n:new` writes a locale file mirroring `en.json`'s key order. Its refusal to overwrite an existing locale now points at `i18n:sync`, which updates a file without discarding translations.
21
66
  - Locale completeness is now asserted for every locale rather than for a hand-maintained list of the ones documented as complete; `i18n:sync` makes full key parity the only valid state.
22
67
  - `aria-deprecated-role` occurrences resolve their hint through `ariaDeprecatedRole_guidance_directory`/`_generic`/`_default`, replacing `ariaDeprecatedRole_hint_fail`/`_hint_cantTell`. Reason codes are unchanged, so existing baselines keep matching.
23
68
  - `formControl_programmaticLabelQuality_summary_cantTell` interpolates `{{method}}` in place of `{{methodLabel}}`, which held the same value once an unreachable branch was removed.
24
69
  - `css-orientation-lock` reports a rule whose selector cannot be read through `cssOrientationLock_summary_fail_unknownSelector` instead of substituting a placeholder selector name.
25
70
  - The HTML report's meta bar carries the resolved locale alongside the engine tag and schema version, naming the requested locale too when the two differ. A report generated in a locale the engine does not carry previously read as an ordinary English one. A result from an older engine has no `engine.locale` and gets no chip.
26
- - `target-size-minimum` now reports `cantTell` instead of `fail` when an undersized inline link's only spacing conflict is another inline link in the same run of text (e.g. pipe-separated links in a `<nav>`). The SC 2.5.8 inline exception ("the target is in a sentence or its size is otherwise constrained by the line-height of non-target text") plausibly covers such a link, but whether it applies isn't decidable from geometry — so the outcome shouldn't flip between `fail` and `pass` on the wrapping element's tag alone, which is exactly what happened before: the same links failed inside a `<nav>` yet passed inside a `<p>`, because the strict inline-text exception (`isInlineTextExceptionTarget`) requires a text-block container (`p`, `li`, `dd`, ...). The strict exception (link inside a text block → pass outright) is unchanged; the new middle tier is scoped narrowly by a shared `isInlineLinkTarget` helper (link-like, rendered inline/inline-*, with visible text) and only downgrades when both the target and its conflicting neighbor match, so an inline link crowding a `<button>` or a block-displayed nav link still fails. New `cantTell` locale strings (`targetSizeMinimum_summary_cantTell_inlineLinkRun`/`_hint_`) in all four locales; occurrences carry reason code `undersized-inline-link-run`.
71
+ - `target-size-minimum` now reports `cantTell` instead of `fail` when an undersized inline link's only spacing conflict is another inline link in the same run of text (e.g. pipe-separated links in a `<nav>`). SC 2.5.8's inline exception ("the target is in a sentence or its size is otherwise constrained by the line-height of non-target text") plausibly covers such a link, but whether it applies isn't decidable from geometry alone. Previously the outcome flipped between `fail` and `pass` based on the wrapping element's tag: the same links failed inside a `<nav>` yet passed inside a `<p>`, because the strict inline-text exception (`isInlineTextExceptionTarget`) requires a text-block container (`p`, `li`, `dd`, ...). The strict exception is unchanged; the new middle tier is scoped by a shared `isInlineLinkTarget` helper (link-like, rendered inline/inline-*, with visible text) and only downgrades when both the target and its conflicting neighbor match, so an inline link crowding a `<button>` or a block-displayed nav link still fails. New `cantTell` locale strings (`targetSizeMinimum_summary_cantTell_inlineLinkRun`/`_hint_`) in all four locales; occurrences carry reason code `undersized-inline-link-run`.
27
72
 
28
73
  ### Fixed
29
- - `docs/TROUBLESHOOTING.md` still described two shipped locales and warned that key parity had to be maintained by hand. There are four, and `i18n:sync` with its build check is exactly what removed that risk.
30
- - The README brand tag rendered twice on npm, once per theme. It relied on `#gh-dark-mode-only` and `#gh-light-mode-only`, fragments only GitHub acts on; every other renderer drew both images. A `<picture>` with a `prefers-color-scheme` source picks one variant on GitHub and npm alike. Its paths are absolute, since the tarball ships `docs/**/*.md` but not the SVGs beside them.
31
- - A `fail` resting only on elements the engine could not walk to the root of is reported as `cantTell`. Ancestor walks stop after 200 steps so a malformed tree cannot hang a scan, but past that depth the walk cannot show an element is exposed — and three rules were asserting violations on content nested inside `aria-hidden`, which is the one outcome this engine must not produce. An occurrence alongside one the walk did reach still keeps the `fail`, since a single confirmed element justifies it. Counted as `ancestorsIncludingSelf.truncated` in `perfStats`. This only reaches rules that report their element through `helpers.reportOccurrence`; the rest keep the old behaviour until they are migrated.
32
- - `region` dominated the runtime of any page it fired on, taking roughly four minutes on a thousand unplaced elements and scaling cubically from there. It hand-built its occurrence objects, and an occurrence that arrives without its element makes the engine re-find one with `document.querySelector` to build `structuralPath` — a document-wide query per occurrence. It reports the element now: the same page takes under a second, with identical occurrences. A new `perfStats` counter, `structuralPath.selectorFallback`, makes the expensive path visible, `docs/RULE_AUTHORING.md` §4.3 documents it as a performance contract rather than a convenience, and `tests/structural-path-fallback.test.js` ratchets the count of rules still building occurrences by hand so it can only shrink. Every rule now reports its element: `button-name-present` on 500 unnamed buttons went from 36.9s to 0.3s, and the ancestor-walk downgrade reaches all of them rather than only the 41 that already did. Output is byte-identical across all 130 fixtures throughout — the engine fills `selector` and `html` with the same helpers the rules were calling.
33
- - `contrast-minimum`, `contrast-enhanced` and `contrast-computable` never examined text inside an open shadow root. Their text collection used a `TreeWalker`, which stops at a shadow boundary, and a `querySelectorAll` for value-bearing inputs, which does not cross one either — so low-contrast text in a web component was reported as `notApplicable` rather than checked, and `contrast-computable` did not flag it as unresolvable either. Every open shadow root beneath a scan root is now walked in its own right, nested roots included. `includeShadowDom: false` still skips them, closed roots remain unreachable, and text is counted once whether it is slotted or not. Background resolution already crossed the boundary and is unchanged.
34
- - `aria-roles-valid` and `aria-deprecated-role` missed a hidden shadow host. Their ancestor walk used `parentElement`, which stops at a shadow root, so a host carrying `aria-hidden` was never seen from inside its own shadow content and the rule reported anyway. The walk now follows the composed tree, stepping over the shadow root to reach the host.
35
- - `aria-roles-valid` and `aria-deprecated-role` now treat an `inert` subtree as programmatically hidden, alongside `display:none`, `visibility` and `aria-hidden`. The ACT glossary those two follow predates `inert` and names only the other three, but an inert subtree is out of the accessibility tree entirely, so a role on it reaches nobody. No ACT test case for either rule uses `inert`, so consistency with ACT is unaffected. Rules that judge attribute *syntax* are deliberately unchanged — that is a static-markup property, valid or not regardless of what is visible today.
74
+ - `docs/TROUBLESHOOTING.md` still described two shipped locales and warned that key parity had to be maintained by hand. There are four, and `i18n:sync` with its build check is what removed that risk.
75
+ - The README brand tag rendered twice on npm, once per theme. It relied on `#gh-dark-mode-only` and `#gh-light-mode-only`, fragments only GitHub acts on; every other renderer drew both images. A `<picture>` with a `prefers-color-scheme` source picks one variant on GitHub and npm alike, with absolute paths since the tarball ships `docs/**/*.md` but not the SVGs beside them.
76
+ - A `fail` resting only on elements the engine could not walk to the root of is reported as `cantTell`. Ancestor walks stop after 200 steps so a malformed tree cannot hang a scan, but past that depth the walk cannot show an element is exposed, and three rules were asserting violations on content nested inside `aria-hidden`, which is the one outcome this engine must not produce. An occurrence alongside one the walk did reach still keeps the `fail`, since a single confirmed element justifies it. Counted as `ancestorsIncludingSelf.truncated` in `perfStats`. This only reaches rules that report their element through `helpers.reportOccurrence`; the rest keep the old behaviour until they're migrated.
77
+ - `region` dominated the runtime of any page it fired on, taking roughly four minutes on a thousand unplaced elements and scaling cubically from there. It hand-built its occurrence objects, and an occurrence that arrives without its element makes the engine re-find one with `document.querySelector` to build `structuralPath`, a document-wide query per occurrence. It reports the element now: the same page takes under a second, with identical occurrences. A new `perfStats` counter, `structuralPath.selectorFallback`, makes the expensive path visible; `docs/RULE_AUTHORING.md` §4.3 documents it as a performance contract, and `tests/structural-path-fallback.test.js` ratchets the count of rules still building occurrences by hand so it can only shrink. Every rule now reports its element: `button-name-present` on 500 unnamed buttons went from 36.9s to 0.3s. Output is byte-identical across all 130 fixtures throughout.
78
+ - `contrast-minimum`, `contrast-enhanced` and `contrast-computable` never examined text inside an open shadow root. Their text collection used a `TreeWalker`, which stops at a shadow boundary, and a `querySelectorAll` for value-bearing inputs, which doesn't cross one either, so low-contrast text in a web component was reported as `notApplicable` rather than checked. Every open shadow root beneath a scan root is now walked in its own right, nested roots included. `includeShadowDom: false` still skips them, closed roots remain unreachable, and text is counted once whether it's slotted or not. Background resolution already crossed the boundary and is unchanged.
79
+ - `aria-roles-valid` and `aria-deprecated-role` missed a hidden shadow host. Their ancestor walk used `parentElement`, which stops at a shadow root, so a host carrying `aria-hidden` was never seen from inside its own shadow content. The walk now follows the composed tree, stepping over the shadow root to reach the host.
80
+ - `aria-roles-valid` and `aria-deprecated-role` now treat an `inert` subtree as programmatically hidden, alongside `display:none`, `visibility` and `aria-hidden`. The ACT glossary those two follow predates `inert` and names only the other three, but an inert subtree is out of the accessibility tree entirely, so a role on it reaches nobody. Rules that judge attribute syntax are unchanged, since that's a static-markup property regardless of what's visible today.
36
81
  - The committed WCAG coverage report was stale, still describing `bypass-blocks-present` as an automatic rule under `src/checks/automatic/` after it became manual. The generator stamped a generation time into both files, so every run produced a diff and real drift had nowhere to show. The timestamp is gone (git records when the file changed), output is byte-stable across runs, and `npm run coverage:check` fails on drift the way the ARIA and language-subtag generators already do. CI runs it.
37
- - A `runOnly` filter given as a comma-separated string (`includeRuleIds: 'img-alt-present'`) was silently dropped and every rule ran. The normalizer had always accepted a string; the check deciding whether `runOnly` carried any filters at all only recognised arrays, so the filter was parsed and then discarded. `ENGINE_OPTIONS.md` states these fields mirror their `engineOptions` counterparts, which have always taken a string. A bare array (`runOnly: ['img-alt-present']`) is still ignored, as documented.
38
- - A partial `engineOptions.messages` entry replaced the built-in dictionary for that locale instead of layering over it, so overriding one German string silently returned every *other* string in English. `docs/I18N.md` promised the opposite ("keys you don't supply fall back normally"). A supplied dictionary now sits on top of the built-in one for the same locale, and completeness counts both layers, so a one-key override no longer reports `partial-dictionary` either.
39
- - `engineOptions.locale` set to an inherited property name — `constructor`, `__proto__`, `toString` — was reported as the resolved locale, because the built-in table was tested for truthiness rather than for an own property holding a dictionary. The text stayed English, so the damage was confined to `engine.locale`, the one field whose job is to be accurate about which language came back.
82
+ - A `runOnly` filter given as a comma-separated string (`includeRuleIds: 'img-alt-present'`) was silently dropped and every rule ran. The normalizer had always accepted a string; the check deciding whether `runOnly` carried any filters at all only recognised arrays, so the filter was parsed and then discarded. `ENGINE_OPTIONS.md` states these fields mirror their `engineOptions` counterparts, which have always taken a string.
83
+ - A partial `engineOptions.messages` entry replaced the built-in dictionary for that locale instead of layering over it, so overriding one German string silently returned every *other* string in English, the opposite of what `docs/I18N.md` promised. A supplied dictionary now sits on top of the built-in one for the same locale, and completeness counts both layers.
84
+ - `engineOptions.locale` set to an inherited property name (`constructor`, `__proto__`, `toString`) was reported as the resolved locale, because the built-in table was tested for truthiness rather than for an own property holding a dictionary. Text stayed English; only `engine.locale` was wrong.
40
85
  - A malformed `engineOptions.messages` entry (`{ de: null }`, a string, an array) could crash a scan rather than being ignored.
41
- - `npm run validate:automatic-rules` and `validate:manual-rules` both failed, and had for some time, because nothing ran them. Three separate bugs: the module contract asserted rules export *exactly* `id`, `meta`, `runInPage`, but 14 legitimately export `applicability` too; the free-variable scan stripped single-quoted strings before double-quoted ones, so an apostrophe inside a double-quoted hint opened a phantom string and exposed the code after it; and regex literals were not stripped at all, so `replace(/"/g, ...)` did the same thing with its quote. All three are fixed, `applicability` is validated as a function when present, and the scan still rejects a bare `id`/`meta`, `{ id }` shorthand, `require(` and `import` while accepting `.id`, `id:` as a key, the word "id" in prose, and a regex containing a quote — `tests/validate-rule.test.js` covers each case. Both validators now run in CI as `npm run validate:rules`, so they cannot rot unnoticed again. `CONTRIBUTING.md` described the wrong contract and has been corrected.
42
- - `docs/RULE_TEMPLATE.js` told rule authors to name i18n keys `checks.<ruleId>.occurrence.<case>.summary`. No key in `en.json` has ever used that shape — 124 of the 125 rules use `<ruleName>_summary_<outcome>`, and the one exception uses `rules.<rule-id>.…`, not `checks.…`. A rule copied from the template would have invented keys in a namespace nothing resolves, and `i18n:sync` would then have propagated them to every locale. The template now states the real convention, including the optional `_<case>` discriminator, and points at the `en.json`/`i18n:sync` workflow. `docs/RULE_TEMPLATE.md` was already correct, which is how the `.js` copy went unnoticed.
43
- - `aria-deprecated-role`'s hint was English in every locale. Its two hint strings were `"{{guidance}}"` — a bare placeholder — and the text behind it was a hardcoded English literal passed in as a parameter, so a German, Spanish or French scan returned German, Spanish or French output with one English sentence in it. No coverage report could see this: the key was present in every dictionary and `engine.locale` reported a clean resolution. The guidance now lives in the dictionaries and is translated in all four locales. `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value that carries no translatable text, so a passthrough cannot be reintroduced.
44
- - `duplicate-id-aria` now reports `cantTell` instead of `fail`. A duplicated id doesn't break the reference — it resolves to the first element in tree order, so the name is still computed. Whether that's the element the author meant isn't in the markup, so the engine shouldn't assert a violation. WCAG 4.1.1, which covered duplicate ids outright, was removed in 2.2; what's left is 4.1.2, and that needs the name to be demonstrably wrong. New `cantTell` strings in all four locales; the reason code is unchanged, so existing baselines keep matching.
45
- - `duplicate-id-aria` reported duplicates that sat outside the scanned scope, so a run using `contextSelector` or `excludeSelectors` flagged elements it was never asked about — noise in component tests especially. Detection stays document-wide, since id uniqueness is a document property, but occurrences are now limited to the scanned scope.
86
+ - `npm run validate:automatic-rules` and `validate:manual-rules` both failed, and had for some time, because nothing ran them. Three bugs: the module contract asserted rules export *exactly* `id`, `meta`, `runInPage`, but 14 legitimately export `applicability` too; the free-variable scan stripped single-quoted strings before double-quoted ones, so an apostrophe inside a double-quoted hint opened a phantom string and exposed the code after it; and regex literals weren't stripped at all, so `replace(/"/g, ...)` did the same thing with its quote. All three are fixed and both validators now run in CI as `npm run validate:rules`. `CONTRIBUTING.md` described the wrong contract and has been corrected.
87
+ - `docs/RULE_TEMPLATE.js` told rule authors to name i18n keys `checks.<ruleId>.occurrence.<case>.summary`. No key in `en.json` has ever used that shape: 124 of the 125 rules use `<ruleName>_summary_<outcome>`. The template now states the real convention.
88
+ - `aria-deprecated-role`'s hint was English in every locale. Its two hint strings were `"{{guidance}}"`, a bare placeholder, and the text behind it was a hardcoded English literal passed in as a parameter, so a German, Spanish or French scan returned that language's output with one English sentence in it. The guidance now lives in the dictionaries and is translated in all four locales. `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value that carries no translatable text.
89
+ - `duplicate-id-aria` now reports `cantTell` instead of `fail`. A duplicated id doesn't break the reference: it resolves to the first element in tree order, so the name is still computed. Whether that's the element the author meant isn't in the markup. WCAG 4.1.1, which covered duplicate ids outright, was removed in 2.2; what's left is 4.1.2, and that needs the name to be demonstrably wrong.
90
+ - `duplicate-id-aria` reported duplicates that sat outside the scanned scope, so a run using `contextSelector` or `excludeSelectors` flagged elements it was never asked about. Detection stays document-wide, since id uniqueness is a document property, but occurrences are now limited to the scanned scope.
46
91
 
47
92
  ## [1.4.1] - 2026-08-13
48
93
 
49
94
  ### Added
50
95
  - `scripts/generate-aria-tables.js` generates `aria-allowed-attr`'s global/per-role/implicit-role tables from the `aria-query` package (a devDependency, output committed) instead of hand-maintaining them, with a `--check` mode that fails when the committed tables go stale.
51
- - `scripts/generate-language-subtags.js` writes the IANA primary language subtags into `dom-helpers` from the `language-subtag-registry` package (a devDependency), same `--check` convention. New shared `helpers.isValidLanguageTag` backs both `valid-lang` and `html-lang-attr-present`.
96
+ - `scripts/generate-language-subtags.js` writes the IANA primary language subtags into `dom-helpers` from the `language-subtag-registry` package, same `--check` convention. New shared `helpers.isValidLanguageTag` backs both `valid-lang` and `html-lang-attr-present`.
52
97
 
53
98
  ### Changed
54
- - `form-control-single-label` now grades by whether surplus `<label>`s actually compete for the accessible name, instead of always failing on label count: passes when an `aria-labelledby`/`aria-label` override supersedes the native labels (they contribute nothing to the name); `cantTell` when one non-empty label is joined by empty label associations with no override; fails only when two or more non-empty labels genuinely compete. Uses the same shared `labelContributesAccessibleName` helper as `form-control-programmatic-label-present`, so the two agree. Clears a false positive on the Angular Material selectable-card pattern, where a card adds an empty `<label for>` on top of Material's own label while the control is named by `aria-label`. New `cantTell` locale strings in all four locales.
55
- - `bypass-blocks-present` is now a manual rule (`cantTell`-capped) instead of automatic (`fail`-capable). WCAG 2.4.1 is about blocks "repeated on multiple Web pages" — whether a block is actually repeated across the site isn't decidable from one document, so a page that legitimately needs no bypass mechanism is indistinguishable from one that omits a required one. A single-snapshot scan can also catch a page mid-modal, when the real `<main>`/headings are correctly `aria-hidden`/`inert` for that state and only the dialog is exposed. Finding a recognized mechanism (a main landmark, a working same-page anchor, or a heading) still resolves to `notApplicable`; finding none now returns `cantTell` instead of asserting a violation the engine can't actually confirm. The same-page-anchor check is also now shadow-DOM-aware.
56
- - The concrete and abstract ARIA role sets in `aria-helpers` are now generated from `aria-query` too, alongside the attribute tables, so Digital Publishing roles (`doc-biblioref`, etc.) are recognised instead of reported as unknown. Three ARIA 1.3 roles the package doesn't carry yet (`comment`, `suggestion`, `text`) are supplemented back.
99
+ - `form-control-single-label` now grades by whether surplus `<label>`s actually compete for the accessible name, instead of always failing on label count: passes when an `aria-labelledby`/`aria-label` override supersedes the native labels; `cantTell` when one non-empty label is joined by empty label associations with no override; fails only when two or more non-empty labels genuinely compete. Uses the same shared `labelContributesAccessibleName` helper as `form-control-programmatic-label-present`, so the two agree. Clears a false positive on the Angular Material selectable-card pattern, where a card adds an empty `<label for>` on top of Material's own label while the control is named by `aria-label`.
100
+ - `bypass-blocks-present` is now a manual rule (`cantTell`-capped) instead of automatic (`fail`-capable). WCAG 2.4.1 is about blocks "repeated on multiple Web pages", and whether a block is actually repeated across the site isn't decidable from one document. A single-snapshot scan can also catch a page mid-modal, when the real `<main>`/headings are correctly `aria-hidden`/`inert` for that state and only the dialog is exposed. Finding a recognized mechanism (a main landmark, a working same-page anchor, or a heading) still resolves to `notApplicable`; finding none now returns `cantTell` instead of asserting a violation the engine can't confirm.
101
+ - The concrete and abstract ARIA role sets in `aria-helpers` are now generated from `aria-query` too, alongside the attribute tables, so Digital Publishing roles (`doc-biblioref`, etc.) are recognised instead of reported as unknown.
57
102
  - `aria-roles-valid` and `aria-deprecated-role` now skip programmatically hidden elements, where a role has no effect (ACT 674b10).
58
103
  - `autocomplete-valid` now follows ACT 73f2c2's applicability: the `on`/`off` toggle, disabled controls (including `aria-disabled`), and input types with a fixed value (`submit`, `checkbox`, ...) are out of scope, since `autocomplete` can't describe a purpose for any of them.
59
- - `meta-viewport-zoom-enabled` now applies only when `content` sets `maximum-scale` or `user-scalable` — setting neither can't restrict zoom, so there's nothing to judge.
104
+ - `meta-viewport-zoom-enabled` now applies only when `content` sets `maximum-scale` or `user-scalable`, since setting neither can't restrict zoom.
60
105
  - `aria-allowed-attr` now covers all 127 concrete ARIA roles instead of 35 hand-listed ones, so `button`, `link`, `img` and 27 other common roles are no longer skipped.
61
106
  - Form-control naming is now split by native element vs. explicit ARIA role, so a control isn't reported by two rules at once: `binary-control-name-present`/`slider-name-present` are role-only, and `form-control-programmatic-label-present` skips a control whose explicit role has its own naming rule. An unlabelled checkbox used to produce two findings; now one.
62
107
 
63
108
  ### Fixed
64
- - `contrast-minimum`/`contrast-enhanced` no longer report text hidden with the sr-only clip technique (`clip: rect(0,0,0,0)`/`clip-path: inset(50%+)`, the pattern Angular CDK's live-announcer, Bootstrap's `.visually-hidden` and most `.sr-only` implementations use) — there's no visually-presented color to check. `opacity: 0` and off-screen positioning stay in scope, since either can be a single-property mistake on text meant to be visible, unlike the multi-property clip pattern.
65
- - `aria-allowed-attr` failed the four ARIA 1.2 ex-globals (`aria-disabled`, `aria-errormessage`, `aria-haspopup`, `aria-invalid`) on any role that doesn't support them, but they're deprecated on that role, not prohibited — still allowed, just discouraged. Now reports `cantTell` (reason `ARIA_ATTR_DEPRECATED`) for those four instead of `fail`; a genuinely unsupported attribute (never a global, never deprecated on the role) still fails. Same "deprecated but allowed" treatment as the `role="generic"` fix below, applied to attributes instead of roles. New `cantTell` locale strings in all four locales.
66
- - `aria-deprecated-role` failed `role="generic"`, but WAI-ARIA 1.2 §5.4 states that rule at SHOULD-NOT strength ("primarily for implementors of user agents") — the usage is conforming, so failing it was a false positive. Now reports `cantTell` under reason code `ARIA_ROLE_AUTHOR_DISCOURAGED`, alongside the existing deprecated-role case. `fail` is retained for a role carrying an author MUST NOT; no ARIA 1.2/1.3 role does, outside the abstract roles `aria-roles-valid` already covers. Reconciled the deprecation data (in `aria-helpers`, since `aria-query` doesn't carry it) against the full WAI-ARIA 1.2 role characteristics tables — see `docs/ARIA_DEPRECATION.md`.
67
- - `nested-interactive-controls-absent` matched on role membership alone, so it failed a `role="listbox"` owning `role="option"` children, a `tablist` owning `tab`s, and every other composite widget whose managed children legitimately carry a widget role — an Angular Material autocomplete panel driven entirely via `aria-activedescendant`, with nothing inside it a separate focus target, was reported although WCAG 4.1.2's concern is two *operable* controls occupying one place. A descendant now counts only when it's also focusable (`helpers.getFocusableInfo`, accounting for `contenteditable`, `:disabled`, `inert`, invalid/negative `tabindex`); a genuinely focusable nested control (a `<button>` inside an `<a href>`, an option given its own `tabindex`) still fails.
68
- - `nested-interactive-controls-absent`'s focusability gate (above) still over-flagged a composite widget using the roving-tabindex pattern, where the active owned child genuinely carries `tabindex="0"` and so passed the focusable check despite being a managed part of its container, not an independent nested control. Added an explicit owned-child map (`option`→`listbox`/`combobox`, `tab`→`tablist`, `treeitem`→`tree`, `menuitem(checkbox|radio)`→`menu`/`menubar`, `radio`→`radiogroup`); a child matching both the role and a matching ancestor container is exempt regardless of its own tabindex. An orphan role with no owning container (e.g. a stray `role="option"` outside any `listbox`) is unaffected and still counts if focusable.
69
- - `avoid-inline-spacing` failed every inline `line-height`/`letter-spacing`/`word-spacing` declared `!important`, whatever the value — so `line-height: 2em !important` on 16px text was reported even though it already exceeds what WCAG 1.4.12 asks for. The criterion is about the resulting metrics, not the `!important` keyword: a forced value that already meets them leaves the user nothing to override. Now fails only below 1.5× font size for line-height, 0.12× for letter-spacing, 0.16× for word-spacing, taken from computed style where laid out and from the declared value otherwise. Applicability follows ACT: visible text of its own, rendered, not positioned off-screen; `inherit`/`unset` specify no spacing and are out of scope, `initial`/`revert` resolve to `normal` and stay in scope.
70
- - `table-th-has-data-cells` failed every `<th>` in any table with zero `<td>`, so a `role="presentation"` layout table carrying a stray `<th>`, a table whose only header was `display:none`/`aria-hidden`, or a `<th role="cell">` were all reported. Per ACT d0f69e a header cell now counts only while visible, in the accessibility tree, and not overridden to a role other than `rowheader`/`columnheader`; an explicit `role="rowheader"`/`role="columnheader"` stays in scope.
71
- - `label-in-name` compared label and name by character containment (`indexOf` on lowercased strings), which is wrong in both directions per WCAG 2.5.3's actual word-based algorithm: it accepted `aria-label="Discover Italy"` for the visible text "Discover It" (a substring, though "it" isn't the word "italy"), and rejected `aria-label="Search by date (YYYY-MM-DD)"` against "Search by date" or a label with decorative punctuation/emoji, though each matches once normalised. Now compares word lists — parenthesised text stripped, case-folded, NFKD-normalised, non-alphanumerics collapsed to spaces — and checks the label's words appear adjacent and in order in the name. An abbreviation or a differently-hyphenated word (`"University Ave."` vs `"University Avenue"`) now reports `cantTell` instead of failing, since neither is decidable from markup; a genuine mismatch alongside an uncertain one still fails. Two new locale strings in all four locales.
72
- - `img-alt-present`, `object-text-alternative-present` and `canvas-text-alternative-present` used `isAccTreeEligible`, which keeps a tabbable element eligible under `aria-hidden`, so they reported elements no assistive technology can reach. All three now use `isIncludedInAccessibilityTree`, matching the `*-name-present` rules; a plain decorative `aria-hidden` image (the attribute's ordinary use) was already out of scope and is unaffected.
73
- - `aria-roles-valid` judged only the first token of the `role` attribute, so `role="searchfield searchbox"` was reported invalid — `role` takes a fallback list, and a browser resolves it to the first token it recognises (`searchbox` here, confirmed against Chrome). The rule now fails only when no token names a concrete role.
74
- - `autocomplete-valid` accepted a contact modality token before a non-contact field, so `"work photo"` passed even though a contact token is only valid when a contact field follows it (`"work email"` is fine, `"work photo"` isn't).
75
- - `valid-lang` and `html-lang-attr-present` checked shape only (`/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/`), so `lang="eng"` and `lang="em-US"` passed although neither is a registered primary subtag (the registry lists a three-letter code only when no two-letter one exists — that's why `"en"` is registered and `"eng"` isn't). Both now validate against the real IANA registry. `valid-lang` also now applies only where text actually inherits the language from the element, and a whitespace-only value fails.
76
- - `meta-viewport-zoom-enabled` passed values it couldn't parse (`user-scalable=0.5`, `maximum-scale=invalid`, `maximum-scale=yes`), even though CSS Device Adaptation treats an unparseable value as `0` — which disables zoom exactly like an explicit `0` does. A negative `maximum-scale` is out of range and correctly still passes.
77
- - `meta-refresh-timing-absent` and `meta-refresh-no-exceptions` reported directives a browser never acts on (`"foo; URL=x"`, `"+72001"`, `"0:1"`), since a malformed value makes the whole directive invalid rather than falling back to a default delay. Both now parse `content` with the shared declarative-refresh steps.
78
- - `aria-allowed-attr` only looked at `[role]`, so an ARIA state/property on an element with no explicit role went unjudged (e.g. `<button aria-sort>`, `<p aria-checked>`) even though ACT 5c01ea applies to any element in the accessibility tree. The generator now also emits an implicit-role table, gated by an explicit allowlist of elements whose role is unconditional (verified against Chrome's own accessibility tree) so a future `aria-query` update can't silently widen the rule.
79
- - `aria-allowed-attr` treated `aria-disabled`, `aria-haspopup`, `aria-invalid` and `aria-errormessage` as global attributes, so misuse on a role that doesn't support them went unreported; the generated table has the real 17 globals. It also no longer judges attributes against `role="none"`/`"presentation"`, since presentational role conflict resolution can drop that role on a focusable element — `presentation-role-conflict` already owns that case.
80
- - `input-image-alt-present` treated `alt=""` as marking an image button decorative and passed it, but an image button is a control, not decoration — ACT 59796f requires a non-empty name, so an empty `alt` (or no `alt` at all) now fails. `alt=""` combined with `aria-label`/`aria-labelledby`/`title` still passes, since the control does have a name; that judgment call moved entirely to `input-image-alt-decorative`, so one element now yields one finding instead of two.
81
- - `input-image-alt-present` now scopes to elements included in the accessibility tree (was the looser `isAccTreeEligible`), and rejects an accessible name equal to the browser's own fallback for an image button (`"Submit Query"`/`"Submit"`) as no name at all, per ACT 59796f. New locale strings for the fallback-name case in all four locales.
82
- - `form-control-programmatic-label-present` used the looser `isAccTreeEligible` check, which keeps a focusable `aria-hidden` control "eligible", so it reported controls no assistive technology can reach. Switched to `isIncludedInAccessibilityTree`, matching the `*-name-present` rules and settling an existing inconsistency with `binary-control-name-present`, which already excluded the same case.
83
- - The 18 `*-name-present` rules judged an element whether or not it was actually reachable by assistive technology, so a focusable element inside `aria-hidden` (both the tabbable and the IDREF-referenced case) got a real pass/fail verdict even though ACT's own glossary says such elements aren't in the accessibility tree at all — Chrome agrees, reporting `ignored: true` with no name. New shared `helpers.isIncludedInAccessibilityTree` routes all 18 rules to `notApplicable` there instead; the focus-order defect itself is still reported by `aria-hidden-focus`, which already owns it.
84
- - `link-name-present`/`button-name-present` credited an accessible name from content regardless of an explicit `role`, so `<a href role="alert">Text</a>` passed even though Chrome computes an empty name for it. Naming from content is now gated by the ARIA 1.2 §5.2.8.5 allowlist of roles that actually support it; an unrecognised role falls back to the implicit `link`/`button` role and is unaffected. That allowlist initially missed module roles that *inherit* the behaviour from a superclass — `doc-noteref` inherits from `link`, so `<a role="doc-noteref"><sup>1</sup></a>` lost the name Chrome still reports (`"1"`) — so it's now generated from each role's `nameFrom` instead of hand-enumerated.
85
- - `landmark-banner-is-top-level` and `landmark-contentinfo-is-top-level` selected any roleless `<header>`/`<footer>` as a candidate, regardless of nesting — but per HTML-AAM those elements have no banner/contentinfo role at all once descended from `article`/`aside`/`main`/`nav`/`section`, so there was no landmark there to be "nested". Both now select through the same suppression-aware role lookup the rest of the file already used, which cut false positives sharply on a sample corpus (236/236 for banner, 1/2 for contentinfo). An explicit `role="banner"`/`role="contentinfo"` is unaffected and still flagged when genuinely nested.
86
- - `getContentNameInfo` let a descendant's `title` outrank its own text when computing a name from subtree content, so `<a title="T">Text</a>` could name itself `"T"` instead of `"Text"`. Content now wins over `title` unless the descendant's content is empty, matching Chrome and the accname spec. Same class of bug as the earlier `alt`-vs-`title` fix for image descendants, just never applied to the generic case.
109
+ - `contrast-minimum`/`contrast-enhanced` no longer report text hidden with the sr-only clip technique (`clip: rect(0,0,0,0)`/`clip-path: inset(50%+)`), since there's no visually-presented color to check. `opacity: 0` and off-screen positioning stay in scope, since either can be a single-property mistake on text meant to be visible, unlike the multi-property clip pattern.
110
+ - `aria-allowed-attr` failed the four ARIA 1.2 ex-globals (`aria-disabled`, `aria-errormessage`, `aria-haspopup`, `aria-invalid`) on any role that doesn't support them, but they're deprecated on that role, not prohibited, still allowed just discouraged. Now reports `cantTell` (reason `ARIA_ATTR_DEPRECATED`) for those four instead of `fail`; a genuinely unsupported attribute still fails.
111
+ - `aria-deprecated-role` failed `role="generic"`, but WAI-ARIA 1.2 §5.4 states that rule at SHOULD-NOT strength ("primarily for implementors of user agents"), so the usage is conforming. Now reports `cantTell` under reason code `ARIA_ROLE_AUTHOR_DISCOURAGED`. `fail` is retained for a role carrying an author MUST NOT; no ARIA 1.2/1.3 role does, outside the abstract roles `aria-roles-valid` already covers. See `docs/ARIA_DEPRECATION.md`.
112
+ - `nested-interactive-controls-absent` matched on role membership alone, so it failed a `role="listbox"` owning `role="option"` children, a `tablist` owning `tab`s, and every other composite widget whose managed children legitimately carry a widget role, even when driven entirely via `aria-activedescendant` with nothing separately focusable inside. A descendant now counts only when it's also focusable (`helpers.getFocusableInfo`, accounting for `contenteditable`, `:disabled`, `inert`, invalid/negative `tabindex`).
113
+ - The same rule's focusability gate still over-flagged the roving-tabindex pattern, where the active owned child genuinely carries `tabindex="0"` despite being a managed part of its container. Added an explicit owned-child map (`option`→`listbox`/`combobox`, `tab`→`tablist`, `treeitem`→`tree`, `menuitem(checkbox|radio)`→`menu`/`menubar`, `radio`→`radiogroup`); a child matching both the role and a matching ancestor container is exempt regardless of its own tabindex.
114
+ - `avoid-inline-spacing` failed every inline `line-height`/`letter-spacing`/`word-spacing` declared `!important` regardless of value, so `line-height: 2em !important` on 16px text was reported even though it already exceeds what WCAG 1.4.12 asks for. Now fails only below 1.5× font size for line-height, 0.12× for letter-spacing, 0.16× for word-spacing.
115
+ - `table-th-has-data-cells` failed every `<th>` in any table with zero `<td>`, so a `role="presentation"` layout table carrying a stray `<th>`, or a `<th>` that was `display:none`/`aria-hidden`, or a `<th role="cell">`, were all reported. Per ACT d0f69e a header cell now counts only while visible, in the accessibility tree, and not overridden to a role other than `rowheader`/`columnheader`.
116
+ - `label-in-name` compared label and name by character containment (`indexOf` on lowercased strings), which is wrong in both directions per WCAG 2.5.3's word-based algorithm: it accepted `aria-label="Discover Italy"` for the visible text "Discover It", and rejected `aria-label="Search by date (YYYY-MM-DD)"` against "Search by date". Now compares word lists (parenthesised text stripped, case-folded, NFKD-normalised, non-alphanumerics collapsed to spaces) and checks the label's words appear adjacent and in order in the name. An abbreviation or a differently-hyphenated word now reports `cantTell` instead of failing.
117
+ - `img-alt-present`, `object-text-alternative-present` and `canvas-text-alternative-present` used `isAccTreeEligible`, which keeps a tabbable element eligible under `aria-hidden`, so they reported elements no assistive technology can reach. All three now use `isIncludedInAccessibilityTree`, matching the `*-name-present` rules.
118
+ - `aria-roles-valid` judged only the first token of the `role` attribute, so `role="searchfield searchbox"` was reported invalid even though `role` takes a fallback list and a browser resolves it to the first token it recognises. The rule now fails only when no token names a concrete role.
119
+ - `autocomplete-valid` accepted a contact modality token before a non-contact field, so `"work photo"` passed even though a contact token is only valid when a contact field follows it.
120
+ - `valid-lang` and `html-lang-attr-present` checked shape only (`/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/`), so `lang="eng"` and `lang="em-US"` passed although neither is a registered primary subtag. Both now validate against the real IANA registry. `valid-lang` also now applies only where text actually inherits the language from the element, and a whitespace-only value fails.
121
+ - `meta-viewport-zoom-enabled` passed values it couldn't parse (`user-scalable=0.5`, `maximum-scale=invalid`, `maximum-scale=yes`), even though CSS Device Adaptation treats an unparseable value as `0`, which disables zoom exactly like an explicit `0` does.
122
+ - `meta-refresh-timing-absent` and `meta-refresh-no-exceptions` reported directives a browser never acts on (`"foo; URL=x"`, `"+72001"`, `"0:1"`), since a malformed value invalidates the whole directive rather than falling back to a default delay. Both now parse `content` with the shared declarative-refresh steps.
123
+ - `aria-allowed-attr` only looked at `[role]`, so an ARIA state/property on an element with no explicit role went unjudged (e.g. `<button aria-sort>`, `<p aria-checked>`) even though ACT 5c01ea applies to any element in the accessibility tree. The generator now also emits an implicit-role table, gated by an explicit allowlist of elements whose role is unconditional.
124
+ - `aria-allowed-attr` treated `aria-disabled`, `aria-haspopup`, `aria-invalid` and `aria-errormessage` as global attributes, so misuse on a role that doesn't support them went unreported; the generated table has the real 17 globals. It also no longer judges attributes against `role="none"`/`"presentation"`, since `presentation-role-conflict` already owns that case.
125
+ - `input-image-alt-present` treated `alt=""` as marking an image button decorative and passed it, but an image button is a control, not decoration: ACT 59796f requires a non-empty name, so an empty `alt` (or no `alt` at all) now fails. `alt=""` combined with `aria-label`/`aria-labelledby`/`title` still passes.
126
+ - `input-image-alt-present` now scopes to elements included in the accessibility tree (was the looser `isAccTreeEligible`), and rejects an accessible name equal to the browser's own fallback for an image button (`"Submit Query"`/`"Submit"`) as no name at all, per ACT 59796f.
127
+ - `form-control-programmatic-label-present` used the looser `isAccTreeEligible` check, so it reported controls no assistive technology can reach. Switched to `isIncludedInAccessibilityTree`, matching the `*-name-present` rules.
128
+ - The 18 `*-name-present` rules judged an element whether or not it was actually reachable by assistive technology, so a focusable element inside `aria-hidden` got a real pass/fail verdict even though such elements aren't in the accessibility tree at all. New shared `helpers.isIncludedInAccessibilityTree` routes all 18 rules to `notApplicable` there instead; the focus-order defect itself is still reported by `aria-hidden-focus`.
129
+ - `link-name-present`/`button-name-present` credited an accessible name from content regardless of an explicit `role`, so `<a href role="alert">Text</a>` passed even though a real accessibility tree computes an empty name for it. Naming from content is now gated by the ARIA 1.2 §5.2.8.5 allowlist of roles that actually support it, generated from each role's `nameFrom` so a role that inherits the behavior (like `doc-noteref` from `link`) is covered too.
130
+ - `landmark-banner-is-top-level` and `landmark-contentinfo-is-top-level` selected any roleless `<header>`/`<footer>` as a candidate, regardless of nesting, but per HTML-AAM those elements have no banner/contentinfo role at all once descended from `article`/`aside`/`main`/`nav`/`section`. Both now select through the same suppression-aware role lookup the rest of the file already used.
131
+ - `getContentNameInfo` let a descendant's `title` outrank its own text when computing a name from subtree content, so `<a title="T">Text</a>` could name itself `"T"` instead of `"Text"`. Content now wins over `title` unless the descendant's content is empty, matching the accname spec.
87
132
 
88
133
  ## [1.4.0] - 2026-08-08
89
134
 
90
135
  ### Added
91
- - `tests/i18n/i18n-locale-completeness.test.js`: an automated check for the key-parity drift `docs/I18N.md` warned about but never enforced — previously, adding a new i18n key to `en.js` without a matching `fr.js` entry shipped silently (the per-string English fallback documented there masks it entirely), so `fr`'s claimed 100% coverage could quietly rot with no test ever catching it. The new test fails the build for any locale file with an "orphaned" key not present in `en.js` (a typo, or a key left behind after a rule was renamed/removed), and separately fails if a locale listed in the test's own `FULLY_TRANSLATED_LOCALES` array (currently just `fr`) is missing any `en.js` key — a partial locale not in that list still passes, matching the documented graceful-degradation behavior for locales that are deliberately incomplete. `docs/I18N.md` updated to point at the test instead of the old "diff the keys by hand" instruction.
92
- - `src/i18n/de.js` and `src/i18n/es.js`: two new fully-translated (614/614 keys) locales, German and Spanish, joining `en`/`fr`. Both added to `FULLY_TRANSLATED_LOCALES` in `tests/i18n/i18n-locale-completeness.test.js`; `tests/i18n/i18n-locale-switch.test.js` now runs its EN-vs-translated contract check against every non-English locale file found in `src/i18n/`, not just `fr`. Documented in `docs/I18N.md`'s coverage table and mentioned in `README.md`'s "Localized reporting" principle.
93
- - `npm run i18n:new <locale>` (`scripts/i18n-scaffold.js`) and `npm run i18n:report` (`scripts/i18n-report.js`): tooling to lower the barrier for community translation contributions. `i18n:new` scaffolds `src/i18n/<locale>.js` pre-populated with every `en.js` key, seeded with the English text as a placeholder (immediately valid — passes the completeness test's no-orphaned-keys check on day one), refusing to overwrite an existing file unless `--force` is passed. `i18n:report` prints per-locale progress (a key is counted as translated once its value differs from the English placeholder — a coincidentally-identical string, e.g. a bare `{{placeholder}}`-only value, slightly undercounts, documented as a known heuristic limitation rather than hidden). Both are pure-function-plus-CLI-wrapper modules (same shape as `scripts/build-browser.js`) with dedicated tests (`tests/i18n-scaffold.test.js`, `tests/i18n-report.test.js`). `docs/I18N.md`'s "Contributing a translation" section and `CONTRIBUTING.md` rewritten to point at this workflow instead of manual file creation.
94
- - 107 new direct unit tests targeting previously-uncovered branches in the two most heavily-depended-on shared modules, `src/core/dom-helpers.js` (the accessible-name/description computation chain, IDREF resolution, `isDomVisibleEligible`/`getVisibilityHintsInfo`, and the `buildSelector*` family — the exact function family that produced the `buildSelector`/`buildSimpleSelector` trimming bugs fixed in `1.3.0`) and `src/core/contrast-helpers.js` (CSS color parsing, cache-degradation fallbacks, `getTextScan`). Coverage: `dom-helpers.js` 86.34%/67.91%/92.11% → 92.68%/74.26%/96.49% (lines/branches/functions); `contrast-helpers.js` 94.70%/71.02%/100% → 100%/84.76%/100%. No production-code changes resulted — every previously-untested branch investigated was confirmed correct (verified against the WAI-ARIA/HTML-AAM spec, sibling-function behavior, or existing fixtures) rather than assumed correct, including one asymmetry (`getAccessibleNameInfo` vs. `computeIdRefTargetTextAlternative` on the UA-default "Submit"/"Reset" label for a value-less `<input type="submit"|"reset">`) that looked like a bug at first but is confirmed intentional per `tests/fixtures/button-name-present-all-scenarios.html`'s case_10. New/extended test files: `tests/core/dom-helpers-name-computation.test.js`, `tests/core/dom-helpers-eligibility.test.js`, `tests/core/build-selector.test.js`, `tests/core/build-structural-path.test.js`, `tests/contrast-helpers.test.js`, `tests/contrast-helpers-dom.test.js`, `tests/cache-tests/contrast-helpers-cache.test.js`.
136
+ - `tests/i18n/i18n-locale-completeness.test.js`: an automated check for the key-parity drift `docs/I18N.md` warned about but never enforced. It fails the build for any locale file with an orphaned key not present in `en.js`, and separately fails if a locale listed in `FULLY_TRANSLATED_LOCALES` (currently just `fr`) is missing any `en.js` key.
137
+ - `src/i18n/de.js` and `src/i18n/es.js`: two new fully-translated (614/614 keys) locales, German and Spanish, joining `en`/`fr`. Documented in `docs/I18N.md`'s coverage table and mentioned in `README.md`'s "Localized reporting" principle.
138
+ - `npm run i18n:new <locale>` (`scripts/i18n-scaffold.js`) and `npm run i18n:report` (`scripts/i18n-report.js`): tooling for community translation contributions. `i18n:new` scaffolds `src/i18n/<locale>.js` pre-populated with every `en.js` key, seeded with the English text as a placeholder. `i18n:report` prints per-locale progress. `docs/I18N.md`'s "Contributing a translation" section and `CONTRIBUTING.md` now point at this workflow instead of manual file creation.
139
+ - 107 new direct unit tests targeting previously-uncovered branches in `src/core/dom-helpers.js` and `src/core/contrast-helpers.js`. Coverage: `dom-helpers.js` 86.34%/67.91%/92.11% → 92.68%/74.26%/96.49% (lines/branches/functions); `contrast-helpers.js` 94.70%/71.02%/100% → 100%/84.76%/100%. No production-code changes resulted; every previously-untested branch was confirmed correct against spec, sibling-function behavior, or existing fixtures.
95
140
 
96
141
  ### Fixed
97
- - `en.js`'s `mediaTranscriptPresent_summary_cantTell_missing` key contained French text (`'La présence d'une transcription...'`) instead of English — the canonical, fallback-of-last-resort locale was itself broken for this one string, meaning even a default (unspecified-locale) scan showed French to an English-reading user for this specific `cantTell` occurrence. Found incidentally while building the new `es` locale (a translating agent flagged that its English source sentence was actually French). Replaced with a proper English string, `'A transcript or other text alternative for this <{{element}}> is not strongly evidenced on the page.'`, matching the rule's own hardcoded fallback text and the `<{{element}}>` placeholder convention used by every sibling rule in the file (e.g. `iframeNamePresent_summary_fail`). `fr.js` was unaffected (already had a correct, if less specific, French translation); `de.js`/`es.js` were generated correctly from the start since both translating agents rendered the intended meaning rather than propagating the French text.
98
- - `buildSimpleSelector` (shared `src/core/dom-helpers.js`, the bare-tag/attribute-anchor fallback `buildSelector` degrades to once every other anchoring strategy fails) had the same raw-vs-trimmed bug just fixed in `buildSelectorUncached`'s anchor builders (see 1.3.0's `buildSelector` fix below), but that fix never propagated here: it embedded the *trimmed* id/data-testid/name attribute value into the selector string while only using the trimmed value to check truthiness — so an element with a padded attribute (e.g. `id=" foo "`, or a templated `data-testid`/`name` ending in whitespace) got a selector that could never resolve back to it via `querySelector`. Found while extending direct-unit-test coverage of `dom-helpers.js`'s selector builders, continuing the same sweep that found the `buildSelector` bug. Fixed by embedding the raw, untrimmed value in all three branches (id, data-testid family, name), matching every other anchor builder in the file. 3 new regression tests (`tests/core/build-selector.test.js`).
99
- - `computeIdRefTargetTextAlternative` (shared `src/core/dom-helpers.js`, resolves what an `aria-labelledby`/`aria-describedby` TARGET itself contributes by re-applying name computation to it, rather than reading raw `textContent`) had two related bugs found in the same sweep: (1) it checked the target's own `aria-label` before its own `aria-labelledby`, backwards from the accname spec's 2A-before-2B ordering and inconsistent with this same file's `getAriaNameInfo`, so a target carrying both a stale `aria-label` and a more specific, more current `aria-labelledby` resolved to the wrong, stale text; (2) it never consulted a native `<label>` association at all, so a target that is itself a labeled form control (e.g. `<input id="cb">` named via `<label for="cb">`, with no ARIA naming attributes of its own) resolved to empty text instead of the label — missing exactly the label-before-value/content priority `getAccessibleNameInfo` uses, which this function otherwise exists to mirror for a referenced target. Fixed by reordering the aria-labelledby/aria-label checks and inserting the same two-step native-label lookup (`.labels` API, then `label[for]` fallback) `getAccessibleNameInfo` uses, in the same priority slot. Caught immediately after, while extending coverage for the fix itself, that the new native-label lookup could re-enter a cycle undetected: a label whose content contains a descendant `aria-labelledby`'d back to the very control it labels (self-contradictory but not invalid markup) round-tripped through the same label's text once or more, since `getTextFromIdRefs`/`getTextFromIdRefsIdrefEligible` always started a brand-new cycle-detection `Set` rather than reusing one already in flight higher up the same resolution chain — bounded by the existing depth counter (so it terminated, not hung), but produced doubled/garbled text (e.g. `"Custom Custom ignored text label label"` instead of `"Custom ignored text label"`) for what used to resolve correctly before this same fix. Closed by threading an optional `opts.__idrefVisited` cycle-guard `Set` through `getTextFromIdRefs`/`getTextFromIdRefsIdrefEligible`/`computeIdRefTargetTextAlternative`, and seeding it with the element itself at both places a control's own `.labels` are walked (`getAccessibleNameInfo`'s own lookup, and the new one in `computeIdRefTargetTextAlternative`). 3 new regression tests (`tests/core/dom-helpers-name-computation.test.js`): the two original priority/native-label cases, plus the self-referencing-label cycle guard.
100
- - `embed-text-alternative-quality-manual` treated a merely-*present* (even broken/empty-resolving) `aria-labelledby` attribute as "still a mechanism, worth reviewing" — deliberately, per its own prior comment — producing a confusing `cantTell` ("review this text alternative for accuracy") for an `<embed>` that has no text alternative at all (e.g. `aria-labelledby` pointing at a nonexistent id). Its sibling automatic rule, `embed-text-alternative-present`, already reports that exact case as a `fail` (no accessible name), so this manual rule's job — reviewing the quality of a name that DOES exist — never applied. Found by diffing this rule against its `object-`/`svg-`/`canvas-text-alternative-quality-manual` siblings, all three of which correctly require `aria-labelledby` to resolve to non-empty text before treating it as a detected mechanism; `embed` alone disagreed. Now matches: only a resolved, non-empty `aria-labelledby` counts. 1 new fixture case + updated occurrence-count assertion (`tests/engine-checks/manual/embed-text-alternative-quality.test.js`).
101
- - `heading-order-manual`, `landmark-banner-is-top-level-manual`, `landmark-contentinfo-is-top-level-manual`, and `landmark-main-is-top-level-manual` never filtered their candidate elements through `helpers.isAccTreeEligible` — `queryAllSmart`'s default hidden-content policy only excludes "hard" CSS-based hiding (`display:none`, `visibility:hidden`, etc.), not the softer `aria-hidden` exclusion, which removes an element from the accessibility tree while leaving it visually rendered. For `heading-order`, an `aria-hidden` heading was both wrongly flagged itself (it isn't part of the AT-perceived document outline at all) AND could mask a real skip immediately after it, by wrongly advancing the "highest heading level reached so far" tracker on a level no assistive-technology user actually encounters (e.g. `<h1>`, `<h3 aria-hidden="true">`, `<h4>` reported the harmless `h1→h3` "skip" instead of the real `h1→h4` one). For the three `*-is-top-level` landmark rules, an `aria-hidden` `<header>`/`<footer>`/`<main>` nested inside another landmark was wrongly flagged as "nested inside another landmark region" even though, from AT's perspective, there's no real landmark there at all to be nested. Found while extending direct coverage of these rules and noticing every other rule in the catalog performs this check but these four didn't. All four now filter candidates through `isAccTreeEligible` before considering them. 4 new regression tests (one per rule).
102
- - `image-redundant-alt-manual` collected a candidate `<img>`'s sibling text unconditionally when checking for a redundant duplicate of its `alt`, including an `aria-hidden` sibling's text — which is never actually announced to assistive technology, so it can't cause the "same words twice" double-announcement this rule exists to catch (e.g. `<a><img alt="Home"><span aria-hidden="true">Home</span></a>` was wrongly flagged as redundant, even though AT only ever hears "Home" once, from the `alt`). Same root cause and same sweep as the four rules above. Now skips `aria-hidden`/otherwise AT-ineligible siblings when building the "other text" comparison. 1 new regression test.
103
- - `label-title-only-manual` checked only whether a `<label for="...">`/wrapping `<label>` structurally *existed* for a titled form control, never whether it actually contributed a name — an empty `<label for="x"></label>` or an empty wrapping `<label>` exempted the control from this rule even though `title` was still, functionally, its only real label (the same "structural association alone isn't enough" class of bug already fixed elsewhere in this engine, e.g. `dom-helpers.js`'s `hasLabelAssociation`/`labelContributesAccessibleName`). Rewritten to delegate to the shared `helpers.getAccessibleNameInfo` (the same aria → native-label → title precedence every other name-dependent rule uses) instead of a local, hand-rolled check, and to also filter candidates through `isAccTreeEligible` (the same gap as the rules above). 3 new regression tests (empty `label[for]`, empty wrapping `<label>`, plus an updated fixture case).
104
- - `empty-table-header-manual` computed a header cell's "visible text" via plain `el.textContent`, which includes text from `aria-hidden` descendants — text a real screen reader never announces, exactly the AT-announcement gap this rule's own header comment extensively researched (real NVDA/VoiceOver/JAWS testing). A `<th>` whose only text came from an `aria-hidden` descendant (e.g. `<th><span aria-hidden="true">Name</span></th>`) was wrongly treated as having visible text and never flagged, even though AT announces nothing for it at all. Found while extending direct coverage of this rule. The text walk now skips `aria-hidden`/otherwise AT-ineligible descendants, and a fully `aria-hidden` header cell itself is now excluded as a candidate (same gap as the rules above). 2 new regression tests.
105
- - `table-fake-caption-manual` treated an `aria-hidden` `<tr>` as the table's positional "first row" for its single-cell-first-row heuristic, and counted `aria-hidden` cells toward a row's cell count — an `aria-hidden` single-cell row sitting above ordinary multi-cell rows was wrongly flagged, even though the real, AT-exposed first row is an ordinary multi-cell row with no fake-caption shape at all. Same root cause and same sweep as the rules above. Rows and cells are now filtered through `isAccTreeEligible` before this heuristic runs. 1 new regression test.
106
- - `td-has-header` (the first `automatic`, `fail`/`pass`-capable rule caught by this sweep, rather than the `cantTell`-capped manual rules above) credited an `aria-hidden` `<th>` as a valid implicit row/column header for other cells — a real screen reader never announces an `aria-hidden` header, so a `<td>` relying solely on one was wrongly reported `pass` when it has no accessible header at all (a false negative on a `serious`-severity WCAG 1.3.1 check). Same root cause and same sweep as the manual rules above. An `aria-hidden` `<th>` no longer counts as a header for other cells, and an `aria-hidden` `<td>` is no longer flagged either (it isn't exposed to AT, so it has no need for one). 2 new regression tests.
107
- - `nested-interactive-controls-absent`'s nested-descendant search used the raw native `querySelectorAll`, not `helpers.queryAllSmart` — so, unlike every other rule's candidate collection, it wasn't subject to ANY hidden-content filtering at all, not even hard CSS-based hiding (`display:none`), let alone `aria-hidden`. A `display:none` or non-focusable-`aria-hidden` nested control was wrongly reported as a `fail` (nested interactive controls), even though a descendant that is never actually rendered or exposed to AT creates no real ambiguity for any user — it isn't there to be confused with the outer control. Found while extending direct coverage of this rule. Both the outer candidate and its nested-descendant search now filter through `isAccTreeEligible`; a nested control that is `aria-hidden` but *still tabbable* (a real, separately-flagged anti-pattern this engine's `aria-hidden-focus` rule targets) correctly remains flagged, since `isAccTreeEligible` already treats that specific combination as still AT-reachable in practice. 2 new regression tests.
108
- - `iframe-focusable-content`'s `hasFocusableCandidate` never checked whether a candidate inside a `tabindex="-1"` frame's embedded document was actually rendered — a `display:none`/`visibility:hidden`/`[hidden]` element (via itself or an ancestor) was wrongly reported as "still reachable by keyboard," even though it is never rendered or focusable in any real browser. Since the embedded document is a distinct realm (this rule's own header comment already explains why it can't reuse the outer document's shared eligibility helpers), added a small self-contained rendering check instead — deliberately checking only genuine non-rendering, not `aria-hidden`, since `aria-hidden` alone doesn't remove a real browser's native tab-order reachability (the same `aria-hidden-focus` anti-pattern noted above), so an `aria-hidden`-but-visually-rendered candidate correctly stays flagged. 3 new regression tests.
109
- - `aria-helpers.js`'s `hasAccessibleNameHint` (decides whether a `<section>` resolves to the `'section[named]'` role key, whose `ALLOWED_ROLES_BY_ELEMENT` entry is the only one that permits `role="region"`) only checked `aria-label`/`aria-labelledby`, not `title` — inconsistent with this same engine's own `getLandmarkNameInfo` (`aria-label` → `aria-labelledby` → `title`), which the 7 manual landmark-check rules already correctly delegate to after a prior fix (verified against a reference engine and a real page, DuckDuckGo's `<nav title="navigation">`). A `<section title="...">` named only via `title` was wrongly `fail`ed by `aria-allowed-role` for an explicit `role="region"` restatement, even though this engine's own landmark rules already treat a title-named section as a real, region-eligible landmark. `hasAccessibleNameHint` now matches `getLandmarkNameInfo`'s precedence. 1 new regression test.
110
- - `contrast-helpers.js`'s `getComputabilityBlocker` treated `backdrop-filter` the same as plain `filter`/`mix-blend-mode`/ancestor `opacity` — none occludable by a closer, fully-opaque ancestor background, per the reasoning in the `2026-08-01` `background-image` occlusion fix above (the `[1.3.0]` entry below). That reasoning doesn't apply to `backdrop-filter`: unlike `filter`/`mix-blend-mode` (compositing-GROUP operations on the element's own rendered subtree, which a closer opaque layer sits *inside* and can't escape), `backdrop-filter` samples whatever is already rendered *behind* the element — a closer-to-`el` fully-opaque `background-color` paints *over* that filtered result at `el`'s screen position and hides it completely, the same physical occlusion `background-image` gets. Confirmed with a live Chromium repro (not just spec-reading, per this engine's no-false-positives bar): a `backdrop-filter: blur()` ancestor containing an inner fully-opaque `background-color` div renders that div pixel-flat, zero blur bleed-through, while sibling content without that opaque layer clearly shows the blurred backdrop. `backdrop-filter` now participates in the same `paintOccluded` short-circuit as `background-image`/gradient; plain `filter`/`mix-blend-mode`/`opacity` remain unconditional blockers, unchanged. 4 new regression tests (2 in `tests/contrast-helpers-dom.test.js`, 2 in `tests/engine-checks/automatic/contrast-computable.test.js`): closer-opaque-occludes-backdrop-filter, semi-transparent-does-NOT-occlude (regression guard), and confirming plain `filter` is unaffected.
111
- - `aria-helpers.js`'s `validateAttrValue` treated an explicitly-EMPTY idref/idref-list ARIA attribute value (e.g. `aria-describedby=""`, `aria-activedescendant=""`) as invalid (`expected-single-idref`/`empty-idref-list`) — a false positive. A widely-used reference engine's own standards table sets `allowEmpty: true` on every idref/idref-list ARIA attribute with zero exceptions (verified across its whole bundled source: `aria-activedescendant`, `aria-controls`, `aria-describedby`, `aria-details`, `aria-errormessage`, `aria-flowto`, `aria-labelledby`, `aria-owns`), treating an empty value as a deliberate "no reference" rather than a broken one. Found while re-examining an existing `rule-mapping.js` scope note in the comparisons repo that had flagged this exact question as unverified; confirmed live on chase.com's login form, which ships `aria-describedby=""` unconditionally on its username/password inputs (a common React/Vue conditionally-empty-attribute templating pattern, not a markup error). Both the `idref` and `idref-list` cases now treat an empty value as valid; the already-verified partial-dangling-idref-list behavior (only flag when NONE of the space-separated ids resolve) is unchanged. 4 new regression tests (`tests/core/aria-helpers.test.js`, `tests/engine-checks/automatic/aria-valid-attr-value.test.js`); 1 existing fixture case (`avav_case_10`) flipped from expected-FAIL to expected-PASS.
112
- - `svg-text-alternative-present`'s applicability gate only recognized `role="img"` as an "intent to convey" signal for an `<svg>` root element, missing `role="graphics-symbol"` and `role="graphics-document"` — the other two ARIA Graphics-module roles a widely-used reference engine's `svg-img-alt` rule also treats as name-requiring (`selector: '[role="img"], [role="graphics-symbol"], svg[role="graphics-document"]'`). A previously-documented, small (3-record) known scope gap in the comparisons repo's `rule-mapping.js`; closed by adding both roles to the same applicability check `role === 'img'` already gated on. Deliberately still scoped to the `<svg>` root element only, not arbitrary `role="graphics-symbol"` descendants nested inside an `<svg>` (a separate, broader feature this check has never covered, not attempted here). 4 new regression tests plus 2 new fixture cases (`svg_case_25`/`26`).
113
- - `aria-prohibited-attr`'s "roleless element" branch (Tier 2, added 2026-07-31) only recognized a small, curated allowlist of NATIVE HTML tags as having no implicit role (`ROLELESS_NATIVE_TAGS`) — it never considered autonomous CUSTOM elements (author-defined, hyphenated web-component tags), which per the Custom Elements spec always have no implicit ARIA role, with none of the conditional-role nuance that makes native tags like `<a>`/`<section>`/`<form>` deliberately excluded from a blanket check. A real-world, generalizable gap, not a rare edge case: found via a `KNOWN_SCOPE_DIFFERENCE` re-audit against a widely-used reference engine, confirmed on rottentomatoes.com's homepage (106 occurrences of `<play-button aria-label="Play ...">` on one page alone) and Angular Material's demo site (`<app-carousel aria-label="Guides">`). Fixed by adding a second, separate applicability path: any tag containing a hyphen (the Custom Elements spec's mandatory naming requirement) EXCEPT the small, spec-reserved set of legacy hyphenated SVG/MathML tag names that predate Custom Elements and are not actually custom elements (`annotation-xml`, `color-profile`, `font-face` and its `-src`/`-uri`/`-format`/`-name` variants, `missing-glyph`). 5 new regression tests.
114
- - `isAccTreeEligible` (shared `dom-helpers.js`, backing `queryAllSmart`'s default hidden-content policy used by nearly every rule) treated `hidden="until-found"` identically to a plain `hidden` attribute, excluding the element ITSELF from every rule's candidate list. Per the HTML spec these are genuinely different states: the UA stylesheet applies `content-visibility: hidden` for `until-found` (hides descendants, not the element carrying it) vs. `display: none` for any other `hidden` value (hides everything) — confirmed live via `getComputedStyle` on a real `hidden="until-found"` element (`display: block`, `content-visibility: hidden`). A widely-used reference engine's own hidden-detection makes this exact self-vs-ancestor distinction for `content-visibility: hidden`. Fixed with a self-only override: when the element carrying `hidden="until-found"` is the one being checked (not a true ancestor of some other node), it's no longer excluded; a real descendant of such an element, or any element with a plain `hidden` attribute (any value other than "until-found"), is unaffected and still excluded exactly as before. Confirmed real, positive, measurable impact live on irs.gov's FAQ accordion panels (`<div hidden="until-found" aria-labelledby="...">`) — 4 other rule pairs (`aria-allowed-attr`, `aria-valid-attr`, `aria-valid-attr-value`, `aria-checked-state-mismatch`) now correctly evaluate these panels where before the fix surea11y had no record for them at all. Honest caveat: the originally-motivating `aria-prohibited-attr` case on the SAME irs.gov panels is NOT resolved by this fix alone — those specific panels also sit inside a `role="tabpanel"` ancestor, which trips a separate, pre-existing, unrelated exemption in `aria-prohibited-attr.js` ("roleless helper node inside a real widget — not flagged") that was not touched here; whether that exemption is too broad is a separate, debatable design question left for a future round, not addressed as part of this fix. 5 new regression tests (`tests/core/dom-helpers-eligibility.test.js`, `tests/engine-checks/automatic/aria-prohibited-attr.test.js`); full suite plus fixtures/real-world/live corpus regression passes clean.
115
- - `presentation-role-conflict-manual`'s conflicting-attribute check treated `aria-hidden="true"` (the exact valid truthy value) the same as any other global ARIA attribute, flagging it as "restoring the implicit role and cancelling the presentational intent" — but that consequence can never actually happen: `aria-hidden="true"` unconditionally removes the element (and its "restored" role, and any OTHER conflicting attribute alongside it, e.g. `aria-label`) from the accessibility tree regardless of role, so no assistive technology ever sees the thing this check warned about. Found while investigating the comparisons repo's cross-engine report: a reference engine's own `presentation-role-conflict` rule uses its default `excludeHidden: true` gather-time filter, which drops any `aria-hidden="true"` element before that rule's own equivalent check ever runs — chasing why surfaced that surea11y's flag on the same pattern was itself substantively wrong, not just differently scoped. Confirmed extremely common on real pages: decorative-icon double-hiding via `alt=""` + `aria-hidden="true"` together (or `role="presentation"`/`role="none"` + `aria-hidden="true"`), e.g. `<svg role="presentation" aria-hidden="true">` icon patterns. Fixed: an element's own `aria-hidden="true"` now clears its conflicting-attribute list entirely (any other attribute present alongside it is equally inert for the same reason); focusability is unaffected and still flags on its own, since a keyboard user can still tab onto an `aria-hidden="true"` focusable element regardless (the `aria-hidden-focus` anti-pattern, a real, separate hazard). An `aria-hidden=""` (empty/invalid value — does not hide) is unaffected and still triggers normally, matching the original Slack-homepage case this attribute was added for. 6 new regression tests, 3 new fixture cases (`prc_case_11`/`12`/`13`).
116
- - `listitem-parent-valid`'s applicability check inspected the `<li>`'s parent for an explicit role override but never the `<li>` element ITSELF, wrongly flagging `<li role="tab">`/`role="menuitem">`/`role="presentation">` etc. inside an invalid parent even though an explicit role fully overrides the `<li>`'s native "listitem" role — the same "any explicit role wins over the tag's native role" principle this check's own header comment already applied to the parent side, just never extended to the element itself. Found while investigating the comparisons repo's cross-engine report: a reference engine's own `listitem` rule's `no-role-matches` matcher excludes ANY `<li>` carrying a role attribute (any value) from candidacy entirely, which is what surfaced that surea11y's broader evaluation of those elements was itself wrong, not just a scope difference. Confirmed extremely common on real pages: HubSpot-generated nav menus (`role="none"`, one page alone had 745 occurrences), Docusaurus-style tab lists (`role="tab"`), Ant Design menu dropdowns (`role="presentation"`/`role="menuitem"`), carousel indicator dots, GitHub's file-tree sidebar (`role="treeitem"`). Fixed: an `<li>` whose own explicit role isn't empty or `"listitem"` is no longer evaluated at all; an explicit `role="listitem"` restatement is unaffected (a no-op, not an override) and still gets the normal parent-validity check. 6 new regression tests, 4 new fixture cases (`lpv_case_10`–`13`).
117
- - `aria-helpers.js`'s `ALLOWED_ROLES_BY_ELEMENT.button` list was missing `gridcell`, `separator`, `slider`, and `treeitem` — a real false positive, not just a scope gap. Found while investigating the comparisons repo's cross-engine report and independently confirmed against the actual W3C "ARIA in HTML" normative table for `<button>` (not just a reference engine's own implementation of it, specifically to rule out the reference engine itself being wrong before matching its behavior) — all 14 roles it lists, including the 4 missing ones, are the correct permitted set. Concretely found via MUI's DatePicker calendar, which renders every day cell as `<button role="gridcell" data-testid="day">` — a standard, ARIA-Authoring-Practices-Guide-recommended composite-grid pattern, 92 false-positive occurrences on one page alone. Fixed by adding the 4 missing roles to the list. 6 new regression tests, 5 new fixture cases (`aar_case_46`–`50`).
118
- - `focus-order-semantics-manual`'s `NON_INTERACTIVE_ROLES` set included `region`, flagging a tabbable `role="region"` (`<div role="region" tabindex="0">`) as a meaningless tab stop — but that's a real, common, WCAG 2.1.1/2.1.3-grounded pattern, not a mistake. Found via OneTrust's near-ubiquitous cookie-consent banner (`<div id="onetrust-banner-sdk" role="region" tabindex="0">`), plus carousels, a GitHub resizable filter pane, and a PrimeReact toast region. Verified via two independent sources before removing the flag, not just because a reference engine happened to disagree: (1) this engine's own sibling check, `scrollable-region-focusable`, already documents WCAG 2.1.1/2.1.3 as the normative basis for exactly this pattern (a region deliberately made keyboard-reachable), so flagging it here was internally inconsistent within the same engine, independent of any other tool's behavior; (2) a reference engine's own equivalent rule independently allowlists `region` (along with `navigation`/`status`/`tabpanel`) via a dedicated role table, confirming this is a deliberate, recognized exemption elsewhere too, not an accidental convenience to copy. Fixed by removing only `region` from the set — `navigation`/`status`/`tabpanel` remain flagged, since there's no confirmed over-flagging evidence for those three yet. 2 new regression tests, 1 new fixture case.
119
- - `aria-helpers.js`'s `CONCRETE_ROLES` registry (backing `isValidConcreteRole`, which gates `aria-roles-valid` and 7 other rules) was scoped to core WAI-ARIA 1.2 role tokens only, missing the three WAI-ARIA Graphics Module 1.0 roles (`graphics-document`/`graphics-object`/`graphics-symbol`) — a separate W3C Recommendation, same REC tier as core ARIA 1.2 itself, with a companion Graphics Accessibility API Mappings 1.0 REC defining real AT support (verified 2026-08-05 directly against both specs, not assumed from a reference engine's behavior). `aria-roles-valid` wrongly reported these as `ARIA_ROLE_INVALID` ("not a recognized ARIA role") even though they're genuine, AT-recognized tokens. Confirmed live on behance.net's primary nav (`role="graphics-symbol img"` on visible, non-decorative `<svg>` icons — not hidden, reaching real users) and notion.so's icon set; previously documented as a known, deliberately-deferred gap in the comparisons repo's `rule-mapping.js` (found via StubHub's `<img role="graphics-symbol">`). Traced the blast radius before fixing: all 8 rules gating on `isValidConcreteRole` (`aria-required-parent`, `aria-required-attr`, `aria-required-children`, `aria-allowed-attr`, `aria-allowed-role`, `aria-prohibited-children`, `aria-prohibited-attr`, `aria-deprecated-role`) key their own per-role tables by explicit role name and default to skipping/no-op for a role absent from that table, so newly recognizing these 3 roles as "concrete" cannot introduce a new false positive in any of them — confirmed by reading each one's table-lookup code, not assumed. Deliberately did NOT also add Digital Publishing WAI-ARIA (`doc-abstract` etc., a similarly real, REC-track module) in the same pass — no `doc-*` usage found anywhere in the live/real-world corpus, unlike `graphics-*` which has confirmed traffic; left for a future round if evidence turns up. 5 new regression tests (`tests/core/aria-helpers.test.js`, `tests/engine-checks/automatic/aria-roles-valid.test.js`) plus 1 new fixture case (`arv_case_07`); full suite plus fixtures/real-world/live corpus regression passes clean (real-world: 118/118 pages, 22 actionable divergences unchanged; live: 225/225 pages, confirmed the two newly-surfaced divergences from this round's first full unfiltered live rescan in a while are unrelated to this fix — see the comparisons repo's project notes).
142
+ - `en.js`'s `mediaTranscriptPresent_summary_cantTell_missing` key contained French text instead of English, so even a default English scan showed French for this one `cantTell` string. Replaced with the correct English string, matching the rule's own hardcoded fallback text and the placeholder convention used elsewhere in the file. `fr.js` was unaffected; `de.js`/`es.js` were generated correctly from the start.
143
+ - `buildSimpleSelector` (the bare-tag/attribute-anchor fallback `buildSelector` degrades to once every other anchoring strategy fails) had the same raw-vs-trimmed bug as `buildSelectorUncached`'s anchor builders (see the 1.3.0 `buildSelector` fix below), embedding the *trimmed* id/data-testid/name attribute value into the selector while only using the trimmed value to check truthiness, so a padded attribute produced a selector that could never resolve back to its element. Fixed by embedding the raw, untrimmed value in all three branches.
144
+ - `computeIdRefTargetTextAlternative` (resolves what an `aria-labelledby`/`aria-describedby` target itself contributes) had two bugs: it checked the target's own `aria-label` before its own `aria-labelledby`, backwards from the accname spec's ordering, so a target carrying both resolved to the stale one; and it never consulted a native `<label>` association at all, so a target that is itself a labeled form control resolved to empty text. Fixed by reordering the checks and adding the same native-label lookup `getAccessibleNameInfo` uses. A follow-up fix closed a cycle-detection gap this surfaced: a label whose content self-referenced back to the control it labels could round-trip and produce doubled text; an `opts.__idrefVisited` guard now threads through the resolution chain to prevent it.
145
+ - `embed-text-alternative-quality-manual` treated a merely-present (even broken/empty-resolving) `aria-labelledby` as a mechanism worth reviewing, producing a confusing `cantTell` for an `<embed>` that in fact has no text alternative at all. Its sibling rules (`object-`/`svg-`/`canvas-text-alternative-quality-manual`) already required the reference to resolve to non-empty text; `embed` alone disagreed. Now matches.
146
+ - `heading-order-manual`, `landmark-banner-is-top-level-manual`, `landmark-contentinfo-is-top-level-manual`, and `landmark-main-is-top-level-manual` never filtered candidates through `helpers.isAccTreeEligible`, so an `aria-hidden` heading or landmark (visually rendered but removed from the AT-perceived structure) was wrongly flagged, and for `heading-order` could also mask a real level skip immediately after it. All four now filter through `isAccTreeEligible`.
147
+ - `image-redundant-alt-manual` collected a candidate `<img>`'s sibling text unconditionally, including `aria-hidden` sibling text that assistive technology never announces, so it couldn't actually cause the double-announcement this rule exists to catch. Now skips AT-ineligible siblings when building the comparison.
148
+ - `label-title-only-manual` checked only whether a `<label for>`/wrapping `<label>` structurally existed, never whether it contributed a name, so an empty label exempted the control even though `title` was functionally its only real label. Rewritten to delegate to `helpers.getAccessibleNameInfo` and to also filter candidates through `isAccTreeEligible`.
149
+ - `empty-table-header-manual` computed a header cell's visible text via plain `el.textContent`, which includes `aria-hidden` descendant text a screen reader never announces. The text walk now skips AT-ineligible descendants, and a fully `aria-hidden` header cell is excluded as a candidate.
150
+ - `table-fake-caption-manual` treated an `aria-hidden` `<tr>` as the table's positional first row and counted `aria-hidden` cells toward a row's cell count. Rows and cells are now filtered through `isAccTreeEligible` first.
151
+ - `td-has-header` credited an `aria-hidden` `<th>` as a valid implicit header for other cells, so a `<td>` relying solely on one was wrongly reported `pass` when it has no accessible header at all (a false negative on a `serious` WCAG 1.3.1 check). An `aria-hidden` `<th>` no longer counts as a header; an `aria-hidden` `<td>` is no longer flagged either.
152
+ - `nested-interactive-controls-absent`'s nested-descendant search used raw `querySelectorAll` with no hidden-content filtering at all, so a `display:none` or non-focusable-`aria-hidden` nested control was wrongly failed. Both the outer candidate and the nested search now filter through `isAccTreeEligible`; a control that's `aria-hidden` but still tabbable correctly remains flagged, since that's the separate anti-pattern `aria-hidden-focus` targets.
153
+ - `iframe-focusable-content`'s `hasFocusableCandidate` never checked whether a candidate inside a `tabindex="-1"` frame's embedded document was actually rendered, so a `display:none`/`visibility:hidden`/`[hidden]` element was wrongly reported reachable. Added a self-contained rendering check scoped to genuine non-rendering, not `aria-hidden` (which alone doesn't remove native tab-order reachability).
154
+ - `aria-helpers.js`'s `hasAccessibleNameHint` (decides whether a `<section>` resolves to the `'section[named]'` role key, the only one that permits `role="region"`) only checked `aria-label`/`aria-labelledby`, not `title`, inconsistent with this engine's own `getLandmarkNameInfo` precedence. A `<section title="...">` was wrongly failed by `aria-allowed-role` for an explicit `role="region"` restatement. Now matches `getLandmarkNameInfo`'s precedence.
155
+ - `contrast-helpers.js`'s `getComputabilityBlocker` treated `backdrop-filter` the same as `filter`/`mix-blend-mode`/ancestor `opacity`, none occludable by a closer opaque ancestor background. That reasoning doesn't apply to `backdrop-filter`, which samples whatever's already rendered behind the element: a closer opaque `background-color` paints over that filtered result and hides it completely, the same physical occlusion `background-image` gets. `backdrop-filter` now participates in the same `paintOccluded` short-circuit as `background-image`/gradient; plain `filter`/`mix-blend-mode`/`opacity` remain unconditional blockers.
156
+ - `aria-helpers.js`'s `validateAttrValue` treated an explicitly-empty idref/idref-list ARIA attribute value (e.g. `aria-describedby=""`) as invalid. An empty value is a deliberate "no reference", not a broken one, and both the `idref` and `idref-list` cases now treat it as valid.
157
+ - `svg-text-alternative-present`'s applicability gate only recognized `role="img"` as an intent-to-convey signal for an `<svg>` root, missing `role="graphics-symbol"` and `role="graphics-document"`. Both roles are now recognized, still scoped to the `<svg>` root element only.
158
+ - `aria-prohibited-attr`'s roleless-element branch only recognized a small allowlist of native HTML tags as having no implicit role, never autonomous custom elements, which per the Custom Elements spec always have no implicit ARIA role. Fixed by adding a second applicability path: any tag containing a hyphen, except the small set of legacy hyphenated SVG/MathML tag names that predate Custom Elements (`annotation-xml`, `color-profile`, `font-face` and its variants, `missing-glyph`).
159
+ - `isAccTreeEligible` treated `hidden="until-found"` identically to a plain `hidden` attribute, excluding the element itself from every rule's candidate list. Per the HTML spec these differ: the UA stylesheet applies `content-visibility: hidden` for `until-found` (hides descendants, not the element carrying it) vs. `display: none` for any other `hidden` value. Fixed with a self-only override: the element carrying `hidden="until-found"` is no longer excluded when it's the one being checked; a real descendant, or any element with a plain `hidden` attribute, is unaffected.
160
+ - `presentation-role-conflict-manual`'s conflicting-attribute check treated `aria-hidden="true"` the same as any other global ARIA attribute, flagging it as restoring the implicit role, but `aria-hidden="true"` unconditionally removes the element (and any other attribute alongside it) from the accessibility tree regardless of role, so no assistive technology ever sees what this check warned about. An element's own `aria-hidden="true"` now clears its conflicting-attribute list entirely; focusability is unaffected and still flags on its own. `aria-hidden=""` (empty/invalid, doesn't hide) is unaffected and still triggers normally.
161
+ - `listitem-parent-valid`'s applicability check inspected the `<li>`'s parent for an explicit role override but never the `<li>` itself, wrongly flagging `<li role="tab">`/`role="menuitem">`/`role="presentation">` etc. inside an invalid parent, even though an explicit role fully overrides the `<li>`'s native listitem role. An `<li>` whose own explicit role isn't empty or `"listitem"` is no longer evaluated at all.
162
+ - `aria-helpers.js`'s `ALLOWED_ROLES_BY_ELEMENT.button` list was missing `gridcell`, `separator`, `slider`, and `treeitem`, confirmed against the W3C "ARIA in HTML" normative table. Fixed by adding the four missing roles.
163
+ - `focus-order-semantics-manual`'s `NON_INTERACTIVE_ROLES` set included `region`, flagging a tabbable `role="region"` as a meaningless tab stop, though that's a real, common, WCAG 2.1.1/2.1.3-grounded pattern. This engine's own sibling check, `scrollable-region-focusable`, already documents that basis for exactly this pattern, so flagging it here was internally inconsistent. Fixed by removing only `region` from the set; `navigation`/`status`/`tabpanel` remain flagged pending confirmed over-flagging evidence.
164
+ - `aria-helpers.js`'s `CONCRETE_ROLES` registry (backing `isValidConcreteRole`, which gates `aria-roles-valid` and 7 other rules) was scoped to core WAI-ARIA 1.2 tokens only, missing the three WAI-ARIA Graphics Module 1.0 roles (`graphics-document`/`graphics-object`/`graphics-symbol`), a separate W3C Recommendation with its own Accessibility API Mappings REC defining real AT support. `aria-roles-valid` wrongly reported these as invalid. All 8 rules gating on `isValidConcreteRole` key their per-role tables by explicit role name and default to skipping a role absent from the table, so recognizing these 3 as concrete introduces no new false positive in any of them. Digital Publishing WAI-ARIA roles were deliberately left for a future round pending confirmed real-world usage.
120
165
 
121
166
  ### Changed
122
- - **The CLI now ships as a separate package, [`@surea11y/cli`](https://github.com/SureA11y/cli), and `@surea11y/core` has zero runtime dependencies.** `jsdom` was previously a real `dependencies` entry of this package, pulling **39 transitive packages / ~25 MB** into every install — including all six first-party consumers (`@surea11y/playwright`, `puppeteer`, `selenium`, `cypress`, `webdriverio`, `test-matchers`), none of which ever load it, because they drive real browsers. The engine reads a DOM it is handed and never constructs one; only the CLI needed to parse HTML *into* a DOM, and therefore needed jsdom. Splitting it puts that cost solely on people who install the CLI, and makes `"dependencies": {}` literally true rather than a claim needing a footnote. This mirrors the convention every binding in the ecosystem already follows — the heavy environment-specific driver (`playwright`, `puppeteer`, `cypress`, …) is a peer/optional install, never a transitive one. **Breaking for CLI users**: `npx @surea11y/core scan ...` no longer exists; use `npx @surea11y/cli scan ...`. **Breaking for the documented Node+jsdom library workflow**: jsdom used to be available implicitly via npm hoisting off this package, so the README's Quick Start worked after `npm install @surea11y/core` alone; it now needs an explicit `npm install jsdom`. No rule logic, rule ID, outcome, or result-shape changed — `require('@surea11y/core')` returns exactly the same object it did in 1.3.0. Shipped in a minor rather than a major deliberately: the package has no external consumers at this version, and all six first-party ones were verified unaffected (none references jsdom in source, and `test-matchers` already declares its own).
123
- - `package.json` now declares an explicit `exports` map: `.`, `./baseline`, `./report`, `./sarif`, `./browser`, `./package.json`. Previously there was no map at all, so *every* internal file was reachable by deep `require()` and therefore implicitly public — which meant the engine's file layout could not be reorganised without risking someone's import. The two deep imports that were actually documented move from `@surea11y/core/src/baseline`/`src/report` to `@surea11y/core/baseline`/`report` (`docs/INTEGRATION.md`, `docs/REPORT.md` updated); `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*` and the generated `src/core.js` are now sealed and resolve with `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `<script src="node_modules/@surea11y/core/surea11y.browser.js">` form is a filesystem path, not module resolution, and is unaffected. Documented as a versioned contract in `docs/API_STABILITY.md`'s new "Package entry points" section.
124
- - `package.json` gained a `keywords` field (18 entries) — the package previously had none at all and was effectively unfindable via npm search. Claims are limited to what the catalog actually backs: WCAG 2.0/2.1/2.2 are tagged across all 125 rules, so those are included; `rgaa` (zero occurrences anywhere in the repo) and `act-rules` (one source comment, no tags or mappings) were deliberately left out rather than claimed. `description` rewritten from the generic "Lightweight DOM rules accessibility core with modular rules." to lead with the `cantTell` differentiator and the zero-dependency property.
125
- - `files` allowlist tightened from directory-level (`src`) to an explicit per-entry list, dropping ~721 KB of build *inputs* that `scripts/build-core.js` already inlines into the generated `src/core.js` and that nothing requires at runtime: `src/i18n/` (359 KB — all four locales are inlined), `src/core/` (296 KB), `src/coverage/`, `src/catalogs/`, `src/policy/`, and the `rules-and-tags.full.{csv,json}` data files. `src/checks/**` is deliberately **kept** despite also being a build input — the generated bundle `require()`s all 125 rule files at runtime, so dropping it would break every consumer. Published package: 175 → 152 files, 7.04 → 6.31 MB unpacked, 1.40 → 1.23 MB packed. Verified by installing the actual tarball into a clean project and running a real scan through every declared entry point.
126
-
127
- - A small `bin/surea11y-core.js` stub replaces the removed CLI entry point, so the pre-1.4.0 `npx @surea11y/core scan ...` still shown in older documentation prints an actionable redirect (`npx @surea11y/cli scan <file-or-url>`, exit 2) instead of npm's opaque `could not determine executable to run`. Deliberately **not** named `surea11y`: that binary belongs to `@surea11y/cli`, and since core is a transitive dependency of all seven bindings, the two would land in the same `node_modules/.bin` and collide — npm resolves such a conflict silently, with no warning, so a same-named stub could shadow a working CLI install. `npx` runs a package's single binary regardless of its name, which is what makes the redirect work without claiming the name. It writes only to stderr and adds no dependency.
167
+ - **The CLI now ships as a separate package, [`@surea11y/cli`](https://github.com/SureA11y/cli), and `@surea11y/core` has zero runtime dependencies.** `jsdom` was previously a real `dependencies` entry, pulling 39 transitive packages / ~25 MB into every install, including all six first-party consumers, none of which ever load it since they drive real browsers. The engine reads a DOM it's handed and never constructs one; only the CLI needed to parse HTML into a DOM. Splitting it puts that cost solely on people who install the CLI. **Breaking for CLI users**: `npx @surea11y/core scan ...` no longer exists; use `npx @surea11y/cli scan ...`. **Breaking for the documented Node+jsdom library workflow**: jsdom used to be available implicitly via npm hoisting; it now needs an explicit `npm install jsdom`. No rule logic, rule ID, outcome, or result shape changed. Shipped as a minor rather than a major since the package had no external consumers at this version and all six first-party ones were verified unaffected.
168
+ - `package.json` now declares an explicit `exports` map: `.`, `./baseline`, `./report`, `./sarif`, `./browser`, `./package.json`. Previously there was no map at all, so every internal file was reachable by deep `require()` and therefore implicitly public. The two documented deep imports move from `@surea11y/core/src/baseline`/`src/report` to `@surea11y/core/baseline`/`report`; `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*` and the generated `src/core.js` are now sealed. Documented in `docs/API_STABILITY.md`'s "Package entry points" section.
169
+ - `package.json` gained a `keywords` field (18 entries); the package previously had none and was effectively unfindable via npm search. `description` rewritten from a generic line to lead with the `cantTell` differentiator and the zero-dependency property.
170
+ - `files` allowlist tightened from directory-level (`src`) to an explicit per-entry list, dropping ~721 KB of build inputs that `scripts/build-core.js` already inlines into the generated `src/core.js` and that nothing requires at runtime. `src/checks/**` is kept, since the generated bundle `require()`s all 125 rule files at runtime. Published package: 175 → 152 files, 7.04 → 6.31 MB unpacked, 1.40 → 1.23 MB packed.
171
+ - A small `bin/surea11y-core.js` stub replaces the removed CLI entry point, so the pre-1.4.0 `npx @surea11y/core scan ...` shown in older documentation prints an actionable redirect instead of npm's opaque error. Not named `surea11y`, since that binary belongs to `@surea11y/cli` and the two would otherwise collide in `node_modules/.bin`.
128
172
 
129
173
  ### Removed
130
- - `bin/core.js` and `docs/CLI.md` — moved to the [`@surea11y/cli`](https://github.com/SureA11y/cli) package (see above). The binary name is unchanged (`surea11y`), as are all its flags, exit codes, and output formats; only the package you install it from changed.
174
+ - `bin/core.js` and `docs/CLI.md`, moved to the [`@surea11y/cli`](https://github.com/SureA11y/cli) package. The binary name, flags, exit codes, and output formats are unchanged; only the package you install it from did.
131
175
 
132
176
  ## [1.3.0] - 2026-08-02
133
177
 
134
178
  ### Added
135
- - `surea11y.browser.js`: a standalone browser bundle, regenerated by `npm run build` (`scripts/build-browser.js`) alongside `src/core.js`. Loading it via a plain `<script>` tag — no bundler, no module system — defines one global, `a11ycore`, exposing `runa11yCoreInPage`. Built by extracting that function's already-self-contained generated source directly out of `src/core.js` (the same section `page.evaluate()`-based consumers already inject) and wrapping it in an IIFE that assigns `window.a11ycore` instead of the `module.exports` `src/core.js` itself uses — `module.exports`/`require()` are exactly what make dropping `src/core.js` itself into a raw `<script>` tag throw `ReferenceError` today. Deliberately excludes `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` (cross-frame scanning needs the embedded frame to also load the engine and opt in — not a fit for a single script tag, and would roughly double the bundle's size for a feature most script-tag consumers won't use; still available via `require('@surea11y/core')`). See `docs/INTEGRATION.md`'s new "Pattern 3" and `README.md`'s "Standalone browser bundle" section (which now shows two examples: a plain call, and an advanced one combining `contextSelector`/`excludeSelectors`/`contrast.mode`/`runOnly.tags`). `tests/browser-bundle.test.js` loads the actual built file via a real `<script src="...">` tag (fetched through jsdom's own resource loader — the exact mechanism a real page uses, not `textContent` injection) to verify: no Node-only globals; the scan runs and catches a real violation; `contextSelector`/`excludeSelectors`/`runOnly.tags` all take effect through the bundle (not just default args); and results match the Node-required `runa11yCoreInPage` for the same page *and* the same non-default options. `tests/build-browser.test.js` unit-tests `scripts/build-browser.js`'s extraction logic in isolation against a synthetic fixture, including its error path if `build-core.js`'s marker comments ever go missing.
136
- - `surea11y scan --sarif <path>`: writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for GitHub Code Scanning or another SARIF-consuming dashboard, alongside the existing `--json`/`--html` outputs. `fail` occurrences map to SARIF `error`, `cantTell` to `warning`; `partialFingerprints` reuse the same `ruleId + reasonCode + html` identity key `--baseline` already uses (`computeBaselineKey`, `src/baseline.js`), and combining `--sarif` with `--baseline` omits already-known `fail` occurrences from the SARIF output entirely rather than downgrading them (a generic SARIF consumer has no "known, don't gate" concept of its own). New module `src/sarif.js` (`renderSarifReport`). See `docs/SARIF.md` for the full field mapping and known limitation (a scan of a live URL, as opposed to a local file in the repo, can't get an inline Code Scanning annotation — inherent to how SARIF associates a finding with source, not specific to this engine).
137
- - `docs/CI_INTEGRATIONS.md`: ready-to-paste GitHub Actions workflow (basic exit-code gating, a `--baseline`-gated variant, and a SARIF-upload-to-Code-Scanning variant) and a Bitbucket Pipelines step template wrapping the CLI.
138
- - `surea11y scan --custom-rules <path>`: the CLI now exposes `engineOptions.customRules` (previously library-only — see `docs/ENGINE_OPTIONS.md`), letting an org register its own rule(s) for a scan without forking the engine. `<path>` is a local JS file, `require()`d directly (never a URL — remote code as a rule would be a materially different trust model than the existing file-or-URL scan target), exporting a single rule descriptor or an array of them in the same shape as a built-in rule module (`{ id, meta, runInPage(ctx), applicability(ctx) }`). Because the CLI runs the rule in the same process as the scan, `runInPage`/`applicability` can be plain functions rather than the `fn.toString()` source string a cross-realm caller (e.g. a browser-automation binding) needs. The flag is repeatable, to load rules from more than one file. Validated at load time — a missing file, a `require()`-time throw, or a malformed export (no string `id`, no function-or-string `runInPage`) exits `2` with a clear error, rather than the engine's own per-entry silent-skip leaving a confusingly rule-short scan. An `id` colliding with a built-in rule overrides it for that scan, same override/`overriddenBuiltinIds` semantics as the library API. 8 new CLI integration tests (`tests/cli.test.js`): array export, single-descriptor-object export, the repeatable flag merging rules from multiple files, a built-in-overriding collision, and the three error paths. Documented in `docs/CLI.md` ("Custom rules" section, with a worked example) and cross-linked from `docs/ENGINE_OPTIONS.md`.
179
+ - `surea11y.browser.js`: a standalone browser bundle, regenerated by `npm run build` (`scripts/build-browser.js`) alongside `src/core.js`. Loading it via a plain `<script>` tag defines one global, `a11ycore`, exposing `runa11yCoreInPage`. Excludes `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` (cross-frame scanning needs the embedded frame to also load the engine and opt in, and would roughly double the bundle's size for a feature most script-tag consumers won't use; still available via `require('@surea11y/core')`). See `docs/INTEGRATION.md`'s "Pattern 3" and `README.md`'s "Standalone browser bundle" section.
180
+ - `surea11y scan --sarif <path>`: writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for GitHub Code Scanning or another SARIF-consuming dashboard, alongside the existing `--json`/`--html` outputs. `fail` occurrences map to SARIF `error`, `cantTell` to `warning`. New module `src/sarif.js`. See `docs/SARIF.md` for the full field mapping.
181
+ - `docs/CI_INTEGRATIONS.md`: ready-to-paste GitHub Actions workflow (basic exit-code gating, a `--baseline`-gated variant, and a SARIF-upload-to-Code-Scanning variant) and a Bitbucket Pipelines step template.
182
+ - `surea11y scan --custom-rules <path>`: the CLI now exposes `engineOptions.customRules` (previously library-only), letting an org register its own rule(s) for a scan without forking the engine. `<path>` is a local JS file, `require()`d directly, exporting a rule descriptor or an array of them. The flag is repeatable. Validated at load time; a missing file, a `require()`-time throw, or a malformed export exits `2` with a clear error.
139
183
 
140
184
  ### Changed
141
- - `src/core/aria-helpers.js` (the shared ARIA role/attribute-validation module backing `aria-allowed-attr`, `aria-allowed-role`, `aria-required-attr`/`-children`/`-parent`, `aria-valid-attr`/`-value`, `aria-prohibited-attr`/`-children`, etc.) is always inlined into the generated `src/core.js` bundle for both entry points, so — same root cause as the `runDomRulesInPage`/`runa11yCoreInPage` coverage-attribution gap `tests/node-runtime-parity.test.js` fixed for rule files — Node's coverage tool could never attribute its execution back to the module itself, and it had zero direct unit tests (function coverage 31%). Added `tests/core/aria-helpers.test.js`, requiring the real module directly and covering role classification, `validateAttrValue`'s per-value-type branches, the permitted-roles/native-role resolution `getElementRoleKey` conditions on (href/alt/multiple/aria-pressed/etc.), and `getContainmentRole` — including regression cases for the `a[href] role="group"`, `<aside role="dialog"><header>`, and tabulator.info `role="columngroup"` bugs fixed in earlier releases (see below). Function coverage: 31% → 100%.
142
- - `aria-prohibited-attr` now also flags `aria-label`/`aria-labelledby` on ROLELESS elements (no explicit `role=""`, no implicit/native role either), not just on the small set of explicitly-role-restated naming-prohibited roles it already covered. Found on emoji-mart's demo page (missive.github.io/emoji-mart): hundreds of `<span aria-label="party_parrot" class="emoji-mart-emoji-custom">` tiles, plain roleless spans with no other accessible-name source, which this rule previously ignored entirely (its Tier-1 branch only ever looked at an explicit `role=""` attribute). A roleless element has naming attributes prohibited unless its tag is on a small allow-list or its closest real ancestor role is a "widget"-type role. Empirically determined (not guessed) which native tags genuinely carry no role at all, by resolving each candidate tag's role against a live Chromium page — several surprises, including common text-level tags like `<p>`, `<strong>`, `<em>`, `<code>`, `<mark>`, `<time>`, which have no implicit role at all when used without an explicit `role=""` restatement. The new branch reports two confidence tiers rather than a flat fail: a roleless element whose subtree already produces a non-empty accessible name from its content (via the existing `helpers.getContentNameInfo`, same mechanism `link-name-present`/`button-name-present` use) is reported as `cantTell` (the naming attribute might be a redundant/intentional override), while a roleless element with no other accessible-name source at all (the emoji-mart case) is a confident, deterministic `fail`. A roleless helper element nested inside a real widget-type role (e.g. a `<span>` decorating a `role="slider"` thumb) is exempted. `getNativeRoleForElement` (`src/core/aria-helpers.js`, previously internal-only) is now re-exported to back the "does this tag have a real implicit role" check this needed. Caught and fixed same-day during review, before ever shipping: the "already has a role, not this branch's concern" guard checked only whether `role=""` was present, not whether the value was a real recognized role, so an invalid/typo'd role token (e.g. `role="totally-bogus"`) silently suppressed detection of an otherwise-flaggable roleless naming attribute — now validated via the existing `isValidConcreteRole`, matching the pattern this same change's own `getNearestAncestorRole` helper already used correctly.
185
+ - `src/core/aria-helpers.js` is always inlined into the generated `src/core.js` bundle, so Node's coverage tool could never attribute execution back to the module, and it had zero direct unit tests (function coverage 31%). Added `tests/core/aria-helpers.test.js`, covering role classification, `validateAttrValue`'s per-value-type branches, `getElementRoleKey`, and `getContainmentRole`. Function coverage: 31% → 100%.
186
+ - `aria-prohibited-attr` now also flags `aria-label`/`aria-labelledby` on roleless elements (no explicit role, no implicit/native role either), not just the small set of explicitly-role-restated naming-prohibited roles it already covered. A roleless element has naming attributes prohibited unless its tag is on a small allow-list or its closest real ancestor role is a widget-type role. The new branch reports two confidence tiers: a roleless element whose subtree already produces a non-empty accessible name from content is `cantTell` (the naming attribute might be a redundant/intentional override), while one with no other accessible-name source at all is a confident `fail`. A roleless helper element nested inside a real widget-type role is exempted. `getNativeRoleForElement` is now re-exported to back this check.
143
187
  - License updated to Mozilla Public License 2.0 (MPL-2.0). `LICENSE`, `package.json`'s `license` field, and `README.md`'s License section updated accordingly.
144
188
 
145
189
  ### Fixed
146
- - `buildSelector` (shared `src/core/dom-helpers.js`, backs every rule's `occurrence.selector`) built its id/data-testid/name/aria-label anchor selectors — both for the target element itself and for a climbed ancestor — by embedding the *trimmed* attribute value into the CSS selector string, while the uniqueness-index lookup that decided whether to use that anchor also keyed on the trimmed value; a CSS attribute/id selector requires an exact match against the real, untrimmed DOM attribute, so any anchor attribute with leading/trailing whitespace produced a selector that could never match its own element. Found 2026-08-02 via the cross-engine comparisons project on Slack's real homepage: 7 promo-card `<header>` elements each sit under a `<div role="region" aria-label="...">` whose templated aria-label ends in a trailing `", "` (a string-concatenation artifact, not a typo) — `el.matches(candidate)` correctly returned false for the trimmed-value candidate, degrading all 7 to `buildSimpleSelector`'s bare-tag-name fallback (`"header"`), a selector that resolves to the *first* `<header>` on the whole page (the real site banner) rather than any of the 7 actual elements — silently pointing any consumer of `occurrence.selector` (this comparisons project's own tooling included) at the wrong element. Fixed by keeping the trimmed value for the uniqueness-index key (unchanged) but embedding the raw, untrimmed attribute value in the actual selector string, across all six anchor sites (the five direct-anchor builders plus the ancestor-climbing anchor). 3 new regression tests (`tests/core/build-selector.test.js`): a direct aria-label anchor, an ancestor aria-label anchor reproducing the Slack shape, and a padded id.
147
- - `aria-required-parent`'s `hasAcceptableAncestorContext` treated an immediate `role="group"` ancestor as transparent for `listitem`/`treeitem` (continuing the walk past it, per `GROUP_TRANSPARENT_FOR_ROLES`) but never added the tested element's own role to the acceptable-context set at that point, unlike the reference engine's `getMissingContext` it was modeled on — so a standard, arbitrarily-deep ARIA tree (`tree > treeitem > group > treeitem > group > treeitem...`) stopped at the second `treeitem` ancestor and failed, since plain `"treeitem"` was never itself an acceptable context role. Found via a live-DOM cross-engine run on GitHub's PR "Files changed" file-tree sidebar (`github.com/*/pull/*/files`): 40 false-positive `fail` occurrences across nested directory/file `treeitem`s, all real reference-engine `pass`. `hasAcceptableAncestorContext` now mirrors the reference engine's actual behavior: passing a transparent `group` ancestor also adds the element's own role to the acceptable set from that point on (a lazily-cloned working copy, never mutating the caller's shared `Set`). 1 new regression test (multi-level nested treeitem).
148
- - `form-control-programmatic-label-present` (via the shared `labelContributesAccessibleName`, `src/core/dom-helpers.js`) never checked a `<label>`'s own `title` attribute as a last-resort name source — only its ARIA name and its content name — so a structurally-associated `<label for>`/wrapping `<label>` with empty content but a non-empty `title` (accname's title-fallback step, which applies to the label element itself, not just the control it labels) was treated as not contributing a name at all, wrongly failing an otherwise-correctly-labeled control. Found via a full fixtures cross-engine regression: `slider-name-present-all-scenarios.html`'s case_22 (`<label for="..." title="Search"></label>`, designed for a different rule but exercised here too since the cross-engine tool runs every rule against every fixture) is explicitly documented as an intentional `PASS`, and the reference engine's `label` rule already agreed — only this rule's own label-name check was missing the fallback. Now also checks `getNonEmptyTitle(lab)` after the aria-name and content-name checks come back empty. 1 new regression test.
149
- - `embed`/`object`/`video-poster-text-alternative-present`'s failing-occurrence `hint` text omitted `title` as a remediation option, even though each rule's own documented `@expectation` explicitly lists a title attribute as a valid "best-effort fallback" mechanism and each rule's own `runInPage` accepts it (`mechanism: 'title'`) — the hint just never mentioned it, understating the easiest fix available to authors. Found via the same systematic check as the `*-name-present` hint fix above, applied to the other `*-text-alternative-present` rules that accept a weak `title` fallback. Fixed in the rule source, `src/i18n/en.js`, and `src/i18n/fr.js`.
150
- - `listbox`/`searchbox`/`spinbutton`/`textbox`/`combobox`/`meter`/`progressbar-name-present`'s failing-occurrence `hint` text told authors to "provide visible text that is not hidden from assistive technologies" as a valid fix — but all seven of these roles are deliberately name-from-author-only per WAI-ARIA (verified against a reference engine's own checks; each rule's own `evaluate()`/`hasName()` explicitly has no content-based naming branch, several with their own real-world false-positive comments explaining exactly why). A developer following the hint would add visible text, rerun the scan, and see the same failure, since content was never a recognized mechanism for these roles — the hint sent them down a dead end. Found via a systematic diff of the `*-name-present` rule family (the same technique that found the `contrast-minimum`/`contrast-enhanced` occurrence-shape bug above): the family splits cleanly into roles that support content-based naming (`menuitem`/`option`/`tab`/`tooltip`/`treeitem`/`summary`, whose hints correctly mention visible text) and roles that don't (these seven, whose hints incorrectly did). Fixed in the rule source, `src/i18n/en.js`, and `src/i18n/fr.js` (the actual localized strings shown to users, which had the same bug, translated) — all three needed to change since the rule's own inline `hint` and the i18n bundle are independently duplicated copies. New regression tests assert the corrected hint text for a case with real visible text present that still, correctly, fails.
151
- - `contrast-minimum` attached a failing occurrence's element metadata (`selector`/`tagName`) as a non-standard top-level `occurrence.node` field — the only place in the entire rule catalog that did this; every other rule, including its own twin `contrast-enhanced` (same threshold logic, different WCAG level), nests this kind of diagnostic metadata under `occurrence.data.details`, the documented convention. Found while extending direct-unit-test coverage of the two rules and diffing them line-by-line as near-identical twins — a difference that shouldn't have existed. Now matches `contrast-enhanced`'s `occurrence.data.details.node` shape exactly. No rule/test previously relied on the old `occurrence.node` field's existence.
152
- - `getContentNameInfo` (shared `dom-helpers.js`, backing every `*-name-present` rule's "name from content" computation — `link-name-present`, `button-name-present`, `tab-name-present`, etc.) resolved an image-like descendant's (`img`/`area`/`input[type=image]`) contribution via the general `getAccessibleNameInfo`, which unconditionally falls back to a `title` attribute — so `title` silently outranked `alt` regardless of whether `alt` was present. Found while extending direct unit-test coverage of this function: `<a href="/home"><img alt="" title="Acme homepage"></a>` — a logo image deliberately marked decorative via `alt=""` (the standard "this conveys nothing" marker) — had "Acme homepage" wrongly adopted as the link's whole accessible name, hiding what should be a genuinely unnamed link (`link-name-present` `fail`). Worse, the far more common real-world shape — an image with a correct, present `alt` AND an unrelated `title` tooltip, e.g. `<button><img alt="Real label" title="Some tooltip"></button>` — silently used the tooltip text instead of the real label, for every rule that names an element from its content. Fixed by checking `getAriaNameInfo` (aria-labelledby/aria-label only, correct precedence) first, then — for the one genuinely labelable image-like tag, `input[type=image]` — its native `<label>` association, and only then `alt`, with `alt`'s own present/absent distinction (`getTextAlternativeInfo`) deciding whether `title` is a legitimate last-resort fallback (only when `alt` is structurally absent, never when it's merely empty). 8 new direct unit tests in `tests/core/dom-helpers-name-computation.test.js` covering all four mechanisms and their precedence, plus 2 new `link-name-present` regression tests.
153
- - `region` was scoped to DIRECT children of `<body>` only, a deliberate original choice to avoid false-positive noise. That scope turned out to be nearly inert on the single most common real-world page shape: a modern framework's single root mount `<div>` (`<body><div id="root">...everything...</div></body>`), confirmed present as the ONLY direct `<body>` child on 37 of ~90 pages in a real-world corpus. On that shape the old scan had at most one candidate for the whole page and either missed every real gap inside it or collapsed the entire page into one undifferentiated report. Replaced with a recursive walk: descend from `<body>`, stop at landmarks/live regions/dialogs/buttons/`<svg>`/`<iframe>`/resolvable skip-links (a deliberate exemption list — these are common legitimate patterns, not the "content organization" gap this rule exists to catch), collect the first node with genuine own content (checked non-recursively, so plain wrapper `<div>`s are transparently walked through), then collapse contiguous unplaced content back up to its tightest shared ancestor so nearby stray content reports as one occurrence instead of one per text node. This collapsing — not the old direct-children-only restriction — is what keeps ordinary landmarked pages quiet. Verified negligible performance impact even in a worst-case no-landmarks/12,000-element synthetic page (a few ms over the jsdom/harness baseline). 9 new regression tests (SPA-root recursion, the full exemption list, the false-positive guard for empty non-text elements like MUI focus-trap sentinels, and the unresolvable-skip-link non-exemption).
154
- - `getComputabilityBlocker` (shared `contrast-helpers.js`, backing `contrast-minimum`/`contrast-enhanced`/`contrast-computable`/`link-in-text-block`) walked an element's ENTIRE ancestor chain looking for a background-image/gradient and reported it as a computability blocker regardless of whether a CLOSER ancestor's own background-color was already fully opaque — even though an opaque paint layer visually occludes anything painted further out, making the farther-out image/gradient irrelevant to what's actually rendered behind the text. Found while investigating why `contrast-minimum`/`contrast-enhanced` were still ~91%/70% `INSUFFICIENT_DATA` across a live real-world corpus even after this cycle's `auditorAssist` mode fix; `BACKGROUND_IMAGE_OR_GRADIENT` was the dominant real-world reason by far. Minimal repro: solid black text on a fully-opaque white `<div>`, itself sitting on a `<body>` with a `background-image`, was reported `cantTell` even though the image cannot possibly affect that text's rendered background. Now tracks whether a closer, blend-mode/filter-free ancestor's own `background-color` resolved fully opaque and, if so, suppresses `BACKGROUND_IMAGE_OR_GRADIENT` for anything farther out — but deliberately does NOT extend the same short-circuit to `mix-blend-mode`/`filter`/`backdrop-filter`/ancestor `opacity`, since those are compositing-*group* operations applied to an ancestor's whole rendered subtree (including any "opaque" layer inside it) before blending against whatever is further out, not paint that a closer opaque layer can occlude — doing so would risk a confidently wrong pass, which this engine's no-false-positives bar rules out. Verified real-world impact is genuine but pattern-dependent: apple.com dropped from 5 to 3 `BACKGROUND_IMAGE_OR_GRADIENT` occurrences with the fix (a solid-card-over-hero-image layout), while nasa.gov/wikipedia.org's blockers turned out to be gradient "skrim" overlays applied directly via `background-image` on the nearest ancestor itself (no intervening opaque layer to occlude through) — correctly still `cantTell`, since a true gradient's color varies spatially and can't be reduced to one occluding solid color. 6 new regression tests (the occlusion case itself, a semi-transparent-intervening-background regression guard, three "still blocks past an opaque layer" cases for opacity/blend-mode, and an own-background-image regression guard).
155
- - `form-control-single-label` counted every `<label>` associated with a control (by wrapping or `for`) regardless of whether that label was actually accessibility-tree-eligible, so a genuinely hidden decoy/overlay label (`display:none`, `aria-hidden`, etc.) still triggered a false "multiple labels" flag even though it can't contribute to the control's accessible name — it should filter out labels hidden from everyone before counting, and didn't. Found on lichess.org's analysis board: a fullscreen-toggle checkbox has one visible `<label for>` plus a second, `display:none` `<label for>` used only as a fullscreen click-catcher mask; surea11y incorrectly flagged `fail`. Now filters candidate labels through the existing `helpers.isAccTreeEligible` before counting — a hidden label (whether via CSS or `aria-hidden`) no longer counts toward the ambiguity this rule exists to catch, while two genuinely visible/AT-reachable duplicate labels are still correctly flagged.
156
- - `aria-hidden-focus` now performs a conservative runtime focus-handoff probe for single-offender `aria-hidden` roots before deciding outcome confidence. Besides the immediate post-focus check, it now observes a short deterministic scheduling window (microtasks, one animation-frame turn, and short `setTimeout` callbacks up to 200ms) and traces `focusin` transitions. If focus is handed off outside the same `aria-hidden` subtree during that window, the finding is downgraded from a hard `fail` to `cantTell` (`ariaHiddenFocusable_runtimeRedirect_needsReview`) with probe evidence in `occurrences[].data.details.runtimeProbe`; clear non-handoff cases remain `fail`.
157
- - `getContainmentRole` (shared by `aria-required-parent`, `aria-required-children`, and `aria-prohibited-children`) treated ANY `role=""` attribute value as a real ancestor/descendant context role for required-context matching, even when the value isn't a valid, recognized ARIA role — real browser/AT behavior is to ignore an unrecognized enumerated attribute value, not honor it, falling back past it as if no role were present at all. Found on tabulator.info's column-grouping data-grid example: `role="columnheader"` cells sit inside a `role="columngroup"` wrapper div — not a real ARIA role, Tabulator's own invention — which itself sits inside the actual `role="row"` ancestor. `getContainmentRole` previously stopped the search at "columngroup" and reported a false required-context failure instead of treating it as transparent and finding "row". Now validates the explicit role via the existing `isValidConcreteRole` before accepting it, falling through to the native-tag containment map (or transparency) otherwise.
158
- - `contrast-minimum`/`contrast-enhanced` (shared `contrast-helpers.js` text-scan) never evaluated `<input type="submit"|"button"|"reset">`'s visible label at all: that label renders from the element's `value` attribute, not a DOM text node, and these are void elements (can't have text-node children), so the existing `SHOW_TEXT`-walk-based candidate collection was structurally blind to them regardless of contrast. Found on progressive.com's insurance-quote page: `<input type="submit" value="Get a quote">` at a genuine AAA-level contrast failure was silently skipped by both surea11y rules. Added a second candidate pass over `input[type="submit"|"button"|"reset"]` reusing the same eligibility gates (exclusion, visibility, inactive-UI-component) as the text-node path, so a disabled submit input is still correctly excluded (the same WCAG 1.4.3/1.4.6 Incidental exception as a disabled `<button>`). 2 new fixture cases + updated occurrence-count assertions in both rules' fixture-coverage tests.
159
- - `tests/contrast-helpers.test.js` never actually imported `src/core/contrast-helpers.js` — it hand-copied `parseCssColorToRgba`/`compositeRgba`/`contrastRatio` as a duplicate implementation and tested that instead, so a real regression in the shipped module could pass silently. It now requires the real module and exercises its exported functions directly.
160
- - `aria-prohibited-attr` and `aria-hidden-focus` both collect two independent confidence tiers in one run (some findings confident enough for `fail`, others only `cantTell`), and both hand-rolled the same "if any fail-tier finding exists, return only the fail bucket" decision — silently discarding every cantTell-tier finding whenever at least one fail-tier finding also existed on the same page. Found while reviewing `aria-prohibited-attr`'s roleless-naming widening above, then confirmed as the same architectural gap in `aria-hidden-focus` via an audit of every automatic rule for this exact shape. `target-size-minimum` had a related, worse variant: its "ambiguous spacing" and "plausibly essential/equivalent" uncertain cases were tracked only as a page-level boolean, with no occurrence object built for them at all — so even a page with *only* uncertain conflicts (no confident fail) reported `cantTell` with an empty `occurrences: []`, and mixing in any confident fail made them unrecoverable, same as the other two. Added a shared `helpers.resolveTieredOutcome(failOccurrences, cantTellOccurrences, severity)` (`src/core/dom-helpers.js`) that all three now use: when any fail-tier finding exists, the outcome is still `fail` (a real, confident violation must still gate CI), but both buckets' occurrences are returned together — each occurrence already carries its own distinguishing `reasonCode`, so nothing about which findings were confident vs. which need review is lost, only the single aggregate outcome label stays singular (an existing, accepted schema constraint, not something this change alters). `target-size-minimum` additionally now builds real occurrence objects (`undersized-ambiguous-spacing`, `undersized-plausibly-essential` reason codes) for its two uncertain cases instead of a boolean flag.
190
+ - `buildSelector` built its id/data-testid/name/aria-label anchor selectors by embedding the *trimmed* attribute value into the CSS selector string, while the uniqueness-index lookup that decided whether to use that anchor also keyed on the trimmed value. A CSS attribute/id selector requires an exact match against the real, untrimmed attribute, so any anchor attribute with leading/trailing whitespace produced a selector that could never match its own element and silently degraded to a bare-tag-name fallback resolving to the wrong element. Fixed by embedding the raw, untrimmed value in the actual selector string across all six anchor sites.
191
+ - `aria-required-parent`'s `hasAcceptableAncestorContext` treated an immediate `role="group"` ancestor as transparent for `listitem`/`treeitem` but never added the tested element's own role to the acceptable-context set at that point, so a standard arbitrarily-deep ARIA tree stopped at the second `treeitem` ancestor and failed. It now mirrors the correct behavior: passing a transparent `group` ancestor also adds the element's own role to the acceptable set from that point on.
192
+ - `form-control-programmatic-label-present` (via the shared `labelContributesAccessibleName`) never checked a `<label>`'s own `title` attribute as a last-resort name source, only its ARIA name and content name, so a structurally-associated label with empty content but a non-empty `title` was treated as not contributing a name. Now also checks `getNonEmptyTitle(lab)` after the aria-name and content-name checks come back empty.
193
+ - `embed`/`object`/`video-poster-text-alternative-present`'s failing-occurrence `hint` text omitted `title` as a remediation option, even though each rule's own `@expectation` lists a title attribute as a valid best-effort fallback and each rule's own `runInPage` accepts it. Fixed in the rule source, `src/i18n/en.js`, and `src/i18n/fr.js`.
194
+ - `listbox`/`searchbox`/`spinbutton`/`textbox`/`combobox`/`meter`/`progressbar-name-present`'s failing-occurrence `hint` text told authors to add visible text as a valid fix, but all seven roles are name-from-author-only per WAI-ARIA, so a developer following the hint would rerun the scan and see the same failure. Fixed in the rule source and both locale files.
195
+ - `contrast-minimum` attached a failing occurrence's element metadata as a non-standard top-level `occurrence.node` field, the only place in the whole rule catalog that did this; its twin `contrast-enhanced` already nests this under `occurrence.data.details`, the documented convention. Now matches.
196
+ - `getContentNameInfo` resolved an image-like descendant's contribution via `getAccessibleNameInfo`, which unconditionally falls back to a `title` attribute, so `title` silently outranked `alt` regardless of whether `alt` was present, including the common case of a correct `alt` alongside an unrelated `title` tooltip silently overriding it. Fixed by checking `getAriaNameInfo` first, then a native `<label>` association for `input[type=image]`, then `alt`, with `alt`'s own present/absent distinction deciding whether `title` is a legitimate last-resort fallback.
197
+ - `region` was scoped to direct children of `<body>` only, which turned out to be nearly inert on the most common real-world page shape, a single root mount `<div>` wrapping everything. Replaced with a recursive walk: descend from `<body>`, stop at landmarks/live regions/dialogs/buttons/`<svg>`/`<iframe>`/resolvable skip-links, collect the first node with genuine own content, then collapse contiguous unplaced content back up to its tightest shared ancestor.
198
+ - `getComputabilityBlocker` walked an element's entire ancestor chain looking for a background-image/gradient and reported it as a computability blocker regardless of whether a closer ancestor's own background-color was already fully opaque and would occlude it. Now tracks whether a closer, blend-mode/filter-free ancestor's background resolved fully opaque and, if so, suppresses `BACKGROUND_IMAGE_OR_GRADIENT` for anything farther out. Deliberately not extended to `mix-blend-mode`/`filter`/`backdrop-filter`/ancestor `opacity`, which are compositing-group operations that a closer opaque layer can't occlude through.
199
+ - `form-control-single-label` counted every associated `<label>` regardless of whether it was actually accessibility-tree-eligible, so a hidden decoy/overlay label still triggered a false "multiple labels" flag. Now filters candidate labels through `helpers.isAccTreeEligible` before counting.
200
+ - `aria-hidden-focus` now performs a conservative runtime focus-handoff probe for single-offender `aria-hidden` roots before deciding outcome confidence, observing a short deterministic scheduling window and tracing `focusin` transitions. If focus is handed off outside the same subtree during that window, the finding is downgraded from `fail` to `cantTell`.
201
+ - `getContainmentRole` treated any `role=""` attribute value as a real ancestor/descendant context role for required-context matching, even when the value isn't a valid recognized ARIA role, though real browser/AT behavior is to ignore an unrecognized value and fall back past it. Now validates the explicit role via `isValidConcreteRole` before accepting it.
202
+ - `contrast-minimum`/`contrast-enhanced` never evaluated `<input type="submit"|"button"|"reset">`'s visible label at all: that label renders from the `value` attribute, not a DOM text node, and these are void elements, so the existing text-walk-based candidate collection was structurally blind to them. Added a second candidate pass over these input types reusing the same eligibility gates as the text-node path.
203
+ - `tests/contrast-helpers.test.js` never actually imported `src/core/contrast-helpers.js`; it hand-copied `parseCssColorToRgba`/`compositeRgba`/`contrastRatio` as a duplicate implementation and tested that instead. It now requires the real module.
204
+ - `aria-prohibited-attr` and `aria-hidden-focus` both collect two independent confidence tiers in one run and both silently discarded every `cantTell`-tier finding whenever at least one `fail`-tier finding also existed on the same page. `target-size-minimum` had a related, worse variant: its uncertain cases were tracked only as a page-level boolean with no occurrence object at all. Added a shared `helpers.resolveTieredOutcome(failOccurrences, cantTellOccurrences, severity)` that all three now use: when any fail-tier finding exists the outcome stays `fail`, but both buckets' occurrences are returned together, each carrying its own distinguishing `reasonCode`.
161
205
 
162
206
  ## [1.2.0] - 2026-07-31
163
207
 
164
208
  ### Added
165
- - CLI baseline/allowlist mechanism: `surea11y scan --write-baseline <path>` records every current `fail` occurrence (never fails the build); `surea11y scan --baseline <path>` then gates only on occurrences not already recorded there. Matching identity is `ruleId` + `reasonCode` + the occurrence's `html` snippet (deliberately not `selector`/`structuralPath`, both of which are position-derived and can shift when unrelated markup changes elsewhere on the page) — multiset-matched, so repeated identical violations are counted correctly rather than all matching one baseline entry. The underlying `buildBaselineEntries`/`matchBaseline` functions (`src/baseline.js`) are also usable directly by library consumers, not just the CLI. See `docs/BASELINE.md` for the full design, file format, and known limitations (a flagged element with dynamic content in its own markup won't match itself across scans).
166
- - `engineOptions.fragment`: 14 rules that check for the presence of a page-wide property (`page-title-present`, `html-lang-attr-present`, `html-xml-lang-mismatch`, `aria-hidden-body`, `css-orientation-lock`, the 4 `meta-refresh`/`meta-viewport` rules, `page-title-patterns`, `region`, `bypass-blocks-present`, `landmark-one-main`, `page-has-heading-one`) now correctly report `notApplicable` — instead of an incorrect `fail`/`cantTell` — when a scan is scoped to a subtree narrower than the whole document (via `contextSelector`) or run with the new `engineOptions.fragment: true` (for a scan target that's the whole given document but was never meant to represent a real page, e.g. a component snippet parsed on its own — `contextSelector` scoping alone can't detect that case, since `document.documentElement` still exists and is unscoped). A scoped subtree or bare fragment was never expected to carry its own `<title>`/`<html lang>`/page-wide landmark structure, so flagging its absence was a false positive. New `helpers.isWholeDocumentScope()` (`src/core/dom-helpers.js`) backs this via each rule's `applicability(ctx)` export — the first real use of that already-existing, previously-dormant engine mechanism. See `docs/ENGINE_OPTIONS.md` and `docs/RULE_AUTHORING.md` §11.2.
167
- - Versioned public API contract: `docs/API_STABILITY.md` (new) codifies which result-shape fields are covered by semver, which aren't (`perfStats`/`ruleTimings`, `occurrences[].data.details`, the previously-unused `ruleVersion`/`ruleInterfaceVersion` scaffolding), and what triggers a patch/minor/major bump — e.g. explicitly calling out that a correctness fix changing which outcome a rule produces (like the `engineOptions.fragment` work above) is a patch, not a major bump. Also adds a rule-ID deprecation mechanism: `meta.deprecated`/`meta.deprecation` (`{ replacedBy, reason, sinceVersion }`) on any rule, validated by `normalizeRuleMeta` (`src/core/rule-meta.js`) and surfaced through `getChecksCatalog()`. A deprecated rule keeps running and producing results completely normally — this is a catalog-level migration signal for integrators, not an automatic exclusion (no `engineOptions.excludeDeprecated` flag). No rule is deprecated yet; this is the mechanism, exercised so far only by a synthetic rule in the test suite.
168
- - `surea11y scan --html <path>`: a self-contained, browsable HTML report (`src/report.js`'s `renderHtmlReport`) — hero summary, "worth reviewing" cards grouped by rule (not one per raw occurrence), a WCAG rollup grouped by conformance level sourced directly from `rulesResults[]`'s existing composite data (not an invented grouping), and a collapsed "full technical data" section with a searchable/filterable/paginated occurrence table. No external requests, dark-mode aware. See `docs/REPORT.md`.
209
+ - CLI baseline/allowlist mechanism: `surea11y scan --write-baseline <path>` records every current `fail` occurrence (never fails the build); `surea11y scan --baseline <path>` then gates only on occurrences not already recorded there. Matching identity is `ruleId` + `reasonCode` + the occurrence's `html` snippet (not `selector`/`structuralPath`, both position-derived and liable to shift when unrelated markup changes), multiset-matched so repeated identical violations are counted correctly. `buildBaselineEntries`/`matchBaseline` (`src/baseline.js`) are also usable directly by library consumers. See `docs/BASELINE.md`.
210
+ - `engineOptions.fragment`: 14 rules that check for a page-wide property now correctly report `notApplicable`, instead of a false `fail`/`cantTell`, when a scan is scoped to a subtree narrower than the whole document (via `contextSelector`) or run with `engineOptions.fragment: true`. New `helpers.isWholeDocumentScope()` backs this via each rule's `applicability(ctx)` export. See `docs/ENGINE_OPTIONS.md` and `docs/RULE_AUTHORING.md` §11.2.
211
+ - Versioned public API contract: `docs/API_STABILITY.md` codifies which result-shape fields are covered by semver and what triggers a patch/minor/major bump. Also adds a rule-ID deprecation mechanism: `meta.deprecated`/`meta.deprecation` (`{ replacedBy, reason, sinceVersion }`), validated by `normalizeRuleMeta` and surfaced through `getChecksCatalog()`. A deprecated rule keeps running normally; this is a catalog-level migration signal, not an automatic exclusion.
212
+ - `surea11y scan --html <path>`: a self-contained, browsable HTML report (`src/report.js`'s `renderHtmlReport`), hero summary, findings grouped by rule, a WCAG rollup, and a collapsed technical-data section with a searchable occurrence table. No external requests, dark-mode aware. See `docs/REPORT.md`.
169
213
 
170
214
  ### Fixed
171
- - `aria-prohibited-children` resolved an owned child's role via `getExplicitRole` (explicit `role=""` attribute only), unlike its sibling `aria-required-children`, which resolves via `getContainmentRole` (explicit role, falling back to a native-tag map — `li`→listitem, `tr`→row, `td`→cell, `th`→columnheader, `thead`/`tbody`/`tfoot`→rowgroup, `ul`/`ol`→list, `table`→table, `select`→listbox, `input[type=radio]`→radio). A bare `<li>` with no `role=""` attribute — the common CSS-reset pattern `<ul role="list"><li>...</li></ul>` that `getContainmentRole` exists specifically to handle — was read as roleless and therefore structurally transparent, so the ownership walk recursed straight through the listitem boundary and could report a focusable descendant several DOM levels down as a disallowed owned child of the list, instead of stopping at the (implicit) listitem the way `aria-required-children` already does. Found via a real Angular Material-style component library: an `<a routerlink>` nested several levels inside a bare `<li>` under `<ul role="list">` was reported as an unallowed owned child of the list. Now uses `getContainmentRole`, so both rules resolve an owned child's role identically; the fix is general, not list/listitem-specific — it applies to every container role in `REQUIRED_OWNED_ROLES` whose native-tag counterpart the containment map covers (e.g. a bare `<tr>`/`<td>` under `role="table"`/`role="grid"`/`role="row"` with no explicit `role=""` was subject to the same flattening bug).
172
- - `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-unique`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, and `region` all computed whether a `<header>`/`<footer>`/`<aside>` sits inside a sectioning-content ancestor (the W3C ARIA-in-HTML condition that suppresses its implicit banner/contentinfo/complementary role) by checking the ancestor's HTML tag name alone. The correct algorithm is role-aware: an ancestor's bare tag only counts when it carries no `role` attribute at all — once a `role` is present, only that role's own value decides membership (`article`/`complementary`/`navigation`/`region`, plus `main` for header/footer). Found on handsontable.com's demo page: a documentation-assistant side panel is an `<aside role="dialog">` containing its own `<header>` — `role="dialog"` isn't one of the four scoping roles, so the nested `<header>` should keep its implicit "banner" role and collide with the page's real banner, which surea11y silently missed entirely (not even a `cantTell`). All 7 rules previously carried their own duplicated copy of this tag-only check (one, `landmark-unique`, already had a partial, role-*unaware* fix for a related "must not also suppress on `<main>`" bug found earlier via Know Your Meme's homepage); they now share one `helpers.hasLandmarkScopingAncestor` implementation (`src/core/aria-helpers.js`, re-exported from `src/core/dom-helpers.js`), and a `<header>`/`<footer>` nested inside a role-overridden `<aside>` now correctly regains its landmark role. `getElementRoleKey`'s own `<header>` implicit-role branch (used by `aria-allowed-role`/`aria-roles-valid`-style checks to decide whether an explicit `role="banner"` restatement is a permitted no-op) now shares the same corrected logic instead of its own separate tag-only copy.
173
- - `hasLandmarkScopingAncestor` (the shared helper above) and the separate local `hasLandmarkAncestor` in `landmark-banner-is-top-level`/`landmark-contentinfo-is-top-level`/`landmark-main-is-top-level` both climbed via `parentElement` with no scope boundary, so a `contextSelector`-scoped scan could be affected by real DOM ancestry *outside* the analyzed subtree — e.g. a page's own `<nav>` wrapping a scanned `#widget` region would incorrectly count as a landmark ancestor of something inside `#widget`, even though that `<nav>` was never in scope. Found while auditing rules for the `engineOptions.fragment` work above. Both now stop climbing once they reach one of the scan's own resolved roots; unscoped (the default, root is `document.documentElement`), behavior is unchanged.
174
- - `tabindex`, `heading-order`, `empty-heading`, `empty-table-header`, and `scope-attr-valid` all queried `document.querySelectorAll` directly instead of the shared `helpers.queryAllSmart`, so none of them respected `contextSelector` scoping, `excludeSelectors`, or shadow-DOM traversal the way every other rule does — a violation anywhere in the document would still be reported even when the scan was explicitly scoped away from it. Found during the same audit as the fragment-scan work above (a separate, unrelated bug class — these aren't inherently whole-document, they were just never wired through the shared helper). All five now delegate to `helpers.queryAllSmart`/`.queryAll` like the rest of the rule catalog; unscoped behavior is unchanged.
215
+ - `aria-prohibited-children` resolved an owned child's role via `getExplicitRole` (explicit `role=""` only), unlike its sibling `aria-required-children`, which resolves via `getContainmentRole` (falling back to a native-tag map: `li`→listitem, `tr`→row, `td`→cell, etc.). A bare `<li>` with no `role` attribute, the common `<ul role="list"><li>` CSS-reset pattern, was read as roleless and structurally transparent, so the ownership walk could recurse past the listitem boundary and report a focusable descendant several levels down as a disallowed owned child. Now uses `getContainmentRole`, so both rules resolve an owned child's role identically. The fix is general: it applies to every container role whose native-tag counterpart the containment map covers.
216
+ - `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-unique`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, and `region` all computed whether a `<header>`/`<footer>`/`<aside>` sits inside a sectioning-content ancestor by checking the ancestor's tag name alone. The correct algorithm is role-aware: an ancestor's bare tag only counts when it carries no `role` attribute at all; once a role is present, only that role's value decides membership. All 7 rules previously carried a duplicated copy of this tag-only check; they now share one `helpers.hasLandmarkScopingAncestor` implementation.
217
+ - `hasLandmarkScopingAncestor` and the separate local `hasLandmarkAncestor` in the three `*-is-top-level` rules both climbed via `parentElement` with no scope boundary, so a `contextSelector`-scoped scan could be affected by real DOM ancestry outside the analyzed subtree. Both now stop climbing once they reach one of the scan's own resolved roots.
218
+ - `tabindex`, `heading-order`, `empty-heading`, `empty-table-header`, and `scope-attr-valid` all queried `document.querySelectorAll` directly instead of the shared `helpers.queryAllSmart`, so none of them respected `contextSelector` scoping, `excludeSelectors`, or shadow-DOM traversal. All five now delegate to `helpers.queryAllSmart`/`.queryAll`.
175
219
 
176
220
  ## [1.1.2] - 2026-07-30
177
221
 
178
222
  ### Fixed
179
- - `accesskeys` no longer over-reports duplicate `accesskey` values when one copy is structurally/CSS hidden by default (for example collapsed or `display:none` menu replicas). Candidate collection now follows the shared helper visibility policy, so only currently eligible elements are grouped unless `engineOptions.includeHiddenElements: true` is explicitly set.
223
+ - `accesskeys` no longer over-reports duplicate `accesskey` values when one copy is structurally/CSS hidden by default. Candidate collection now follows the shared helper visibility policy, so only currently eligible elements are grouped unless `engineOptions.includeHiddenElements: true` is set.
180
224
  - `skip-link` no longer treats fragment-target existence alone as sufficient. It now also flags skip links whose target exists but is currently unusable (hidden from the accessibility tree), while keeping geometry-based target checks gated to environments that expose reliable layout metrics.
181
- - `page-has-heading-one` and `bypass-blocks-present` credited a fully non-rendered `<h1>`/`<main>`/heading (inside a `display:none` ancestor, or otherwise removed from the accessibility tree via `visibility:hidden`/`[hidden]`/`aria-hidden`/`inert`) as satisfying the check, since both queried the raw DOM (`document.querySelectorAll`) with no visibility/accessibility-tree filtering. Found on CDC's flu page: its only `<h1>` sits inside a `display:none` ancestor — unreachable by sighted and screen reader users alike — and `page-has-heading-one` incorrectly reported `notApplicable`. `bypass-blocks-present`'s `<main>`/heading conditions had the identical gap, wrongly returning `pass` for a page with zero actual bypass mechanisms. Both now filter candidates through the existing `isAccTreeEligible` helper, matching `landmark-one-main`'s established precedent; confirmed this does not regress genuinely screen-reader-accessible but visually-clipped/off-screen headings and landmarks (e.g. eBay's homepage `<h1>`, hidden via clip-path with no `aria-hidden`), which `isAccTreeEligible` correctly continues to credit.
182
- - `button-name-present` and `link-name-present` credited a `<button>`/`<a href>` element's rendered content as its accessible name even when an explicit `role` overrode it to a role whose content represents a VALUE, not a NAME (`combobox`, `listbox`, `textbox`, `slider`, `spinbutton`, `progressbar`, `scrollbar` — name-from-author-only per the WAI-ARIA Accessible Name and Description Computation spec). Both checks gated "is this a name-from-content candidate" on the native host tag alone, never checking whether `role` had overridden it. Found on Spotify's "Today's Top Hits" playlist page: `<button role="combobox">List</button>` (a "sort by" control, no `aria-label`/`aria-labelledby`) was credited with the name "List" — the combobox's currently selected *value*, not a label for what it is — and surea11y reported no issue at all. Both rules now exclude these value-roles from name-from-content; a programmatic name (`aria-label`/`aria-labelledby`/`title`/native `<label>`) still works normally. `combobox-name-present`, `listbox-name-present`, `textbox-name-present`, `spinbutton-name-present`, `progressbar-name-present`, `meter-name-present`, `searchbox-name-present`, `slider-name-present`, and `dialog-name-present` were audited against the same gap and were already correctly name-from-author-only.
183
- - `createDomHelpers()`'s element-keyed caches (`outerHtmlCache`, `selectorCache`, etc.) were persisted on `window.__a11ycoreSharedCache` and only initialized once per `window`/`document`, not once per run. A window/document reused across separate `runDomRulesInPage()`/`runa11yCoreInPage()` calls — e.g. Jest's `jsdom` environment, which creates one `window` per test file — could read back a previous run's stale cached value for an element that persists by reference across runs (like `document.body`) while its content changed via an in-place mutation (`innerHTML = ...`) in between. Rule pass/fail outcomes were always computed correctly against the live DOM; only cached diagnostic data such as `occurrences[].html` (via `bypass-blocks-present`, reported in #2) could go stale. `runCore()` (`src/core/dom-runner.js`) now resets `window.__a11ycoreSharedCache` at the start of every run, keeping the intended within-a-run sharing while preventing leakage across runs.
184
- - `buildSelector` could emit ambiguous selectors in multi-region scans when the target element was the last same-tag sibling, because the `:nth-of-type()` disambiguator could be omitted on that segment. Selector construction now consistently disambiguates those cases, so each occurrence maps back to the intended node.
185
- - `queryAllSmart` could retain elements that are structurally hard-hidden when `isAccTreeEligible` short-circuited on an `inert` ancestor before reaching an outer `display:none`/`visibility:hidden`/`content-visibility:hidden` ancestor. It now performs a style-only DOM visibility fallback in that path and excludes these hard-hidden nodes by default, preserving `includeHiddenElements: true` opt-in behavior.
225
+ - `page-has-heading-one` and `bypass-blocks-present` credited a fully non-rendered `<h1>`/`<main>`/heading as satisfying the check, since both queried the raw DOM with no visibility filtering. Both now filter candidates through `isAccTreeEligible`, matching `landmark-one-main`'s established precedent.
226
+ - `button-name-present` and `link-name-present` credited a `<button>`/`<a href>` element's rendered content as its accessible name even when an explicit `role` overrode it to a value-bearing role (`combobox`, `listbox`, `textbox`, `slider`, `spinbutton`, `progressbar`, `scrollbar`), which is name-from-author-only per the accname spec. Both checks gated on the native host tag alone, never on whether `role` had overridden it. Both rules now exclude these value-roles from name-from-content; a programmatic name still works normally.
227
+ - `createDomHelpers()`'s element-keyed caches were persisted on `window.__a11ycoreSharedCache` and only initialized once per window/document, not once per run, so a window reused across separate calls (e.g. Jest's jsdom environment) could read back a stale cached value for an element that persists by reference across runs. Rule pass/fail outcomes were always computed against the live DOM; only cached diagnostic data like `occurrences[].html` could go stale. `runCore()` now resets the shared cache at the start of every run.
228
+ - `buildSelector` could emit ambiguous selectors in multi-region scans when the target element was the last same-tag sibling, since the `:nth-of-type()` disambiguator could be omitted on that segment. Selector construction now consistently disambiguates those cases.
229
+ - `queryAllSmart` could retain elements that are structurally hard-hidden when `isAccTreeEligible` short-circuited on an `inert` ancestor before reaching an outer `display:none`/`visibility:hidden`/`content-visibility:hidden` ancestor. It now performs a style-only DOM visibility fallback in that path.
186
230
 
187
231
  ### Changed
188
- - Test coverage tightened for this release cycle: re-enabled and stabilized the previously skipped `role-img-text-alternative-present` i18n assertions (EN/FR exact strings and keys), and added regression coverage for the inert + outer hard-hidden filtering path in `queryAllSmart`.
189
- - Removed a dead `root`/`safeRoot` second argument passed to `helpers.queryAllSmart`/`helpers.queryAll` across 67 rule files: both helpers only ever accepted a single selector argument and scope internally via the run's resolved `contextSelector` roots, so the extra argument was silently ignored in every call. No behavior change — `ctx.root` was always identical to the roots already baked into `helpers` at run start. Also fixed `docs/RULE_TEMPLATE.md`, the copy/paste template most likely responsible for the pattern spreading, which declared `safeRoot` but never even referenced it.
232
+ - Test coverage tightened for this release cycle: re-enabled and stabilized the previously skipped `role-img-text-alternative-present` i18n assertions, and added regression coverage for the inert + outer hard-hidden filtering path in `queryAllSmart`.
233
+ - Removed a dead `root`/`safeRoot` second argument passed to `helpers.queryAllSmart`/`helpers.queryAll` across 67 rule files: both helpers only ever accepted a single selector argument and scope internally via the run's resolved `contextSelector` roots, so the extra argument was silently ignored. No behavior change. Also fixed `docs/RULE_TEMPLATE.md`, which declared `safeRoot` but never referenced it.
190
234
 
191
235
  ## [1.1.1] - 2026-07-29
192
236
 
193
237
  ### Changed
194
- - **`engineOptions.includeHiddenElements` (default `false`)**: helper-driven rules now skip elements hidden by `display:none` (on the element or any ancestor), `visibility:hidden`/`collapse`, the `[hidden]` attribute, closed `<details>`, and other structurally-non-rendered content by default, matching the visibility-aware behavior of other established engines. Filtering happens upstream in the shared `queryAllSmart` helper, before a rule's own pass/fail logic runs, so it's a candidate-list exclusion, not a post-hoc annotation. Set `engineOptions.includeHiddenElements: true` to restore the previous behavior and evaluate hidden/collapsed subtrees anyway (e.g. to catch a markup defect, like a broken ARIA ID reference, before a `<dialog>` ever opens). 10 rule files whose own logic intentionally doesn't call the underlying eligibility check directly (static-markup-validity rules such as `aria-valid-attr`, `aria-valid-attr-value`, `aria-allowed-attr`, `aria-allowed-role`, `aria-prohibited-attr`, `table-headers-attr-valid`, `table-th-has-data-cells`, `deprecated-elements-not-used`, `iframe-title-unique`, `aria-checked-state-mismatch-manual`) still inherit this filtering through `queryAllSmart`; their doc comments were updated to say so. See `docs/ENGINE_OPTIONS.md` and `docs/LIMITATIONS.md`.
238
+ - **`engineOptions.includeHiddenElements` (default `false`)**: helper-driven rules now skip elements hidden by `display:none` (on the element or any ancestor), `visibility:hidden`/`collapse`, the `[hidden]` attribute, closed `<details>`, and other structurally-non-rendered content by default. Filtering happens upstream in the shared `queryAllSmart` helper, before a rule's own pass/fail logic runs. Set `engineOptions.includeHiddenElements: true` to restore the previous behavior. 10 rule files whose own logic doesn't call the underlying eligibility check directly still inherit this filtering through `queryAllSmart`; their doc comments were updated to say so. See `docs/ENGINE_OPTIONS.md` and `docs/LIMITATIONS.md`.
195
239
 
196
240
  ### Fixed
197
- - `docs/LIMITATIONS.md`: the `<dialog>`/UA-stylesheet-hidden-content note was stale — it claimed static-markup-validity checks still evaluate hidden content, which this release's default change makes no longer true. Corrected to describe the current default and how to opt back in.
241
+ - `docs/LIMITATIONS.md`: the `<dialog>`/UA-stylesheet-hidden-content note was stale, claiming static-markup-validity checks still evaluate hidden content, which this release's default change makes no longer true. Corrected to describe the current default and how to opt back in.
198
242
 
199
243
  ## [1.1.0] - 2026-07-28
200
244
 
201
245
  ### Added
202
- - `engineOptions.rules[ruleId].excludeSelectors`: rule-scoped exclusions, narrowing candidates for exactly one rule on top of (never instead of) the existing global `excludeSelectors`. Resolves the class of false positive where one rule misfires on a component (e.g. Angular Material's `mat-select` tripping `aria-required-children`) while every other rule still needs to see it. Filtering happens upstream of each rule's own outcome decision, so no rule files changed. See `docs/ENGINE_OPTIONS.md`'s "Rule-scoped `excludeSelectors`" section.
203
- - Completed French (`fr`) localization: translated the 313 remaining `src/i18n/fr.js` keys, bringing French to full parity with English (600/600 keys, up from 287/600). Verified against a live scan (`locale: 'fr'`) and confirmed no key/placeholder mismatches against `src/i18n/en.js`. Landmark terminology uses "point de repère" per MDN's French ARIA documentation.
246
+ - `engineOptions.rules[ruleId].excludeSelectors`: rule-scoped exclusions, narrowing candidates for exactly one rule on top of (never instead of) the existing global `excludeSelectors`. Resolves the class of false positive where one rule misfires on a component while every other rule still needs to see it. See `docs/ENGINE_OPTIONS.md`'s "Rule-scoped `excludeSelectors`" section.
247
+ - Completed French (`fr`) localization: translated the 313 remaining `src/i18n/fr.js` keys, bringing French to full parity with English (600/600 keys, up from 287/600).
204
248
 
205
249
  ### Fixed
206
- - `docs/RULE_TAXONOMY.md`: automatic rules' allowed outcomes was missing `cantTell` (4 rules use it as a defensive fallback); the "current intents"/"current families" lists were stale and read as exhaustive when the ruleset actually spans 54 suffixes/68 prefixes — reframed as illustrative with a pointer to the generated `RULE_CATALOG.md`; `data.visibilityFilter.targetSet` was missing the `'dom'` value (only `'acc'` was listed).
250
+ - `docs/RULE_TAXONOMY.md`: automatic rules' allowed outcomes was missing `cantTell` (4 rules use it as a defensive fallback); the "current intents"/"current families" lists were stale and read as exhaustive when the ruleset actually spans 54 suffixes/68 prefixes, reframed as illustrative with a pointer to the generated `RULE_CATALOG.md`; `data.visibilityFilter.targetSet` was missing the `'dom'` value.
207
251
  - `docs/OUTPUT_SCHEMA.md`: the `visibilityFilter` type was missing its always-present `eligible` field; the worked example's `structuralPath` values for the button/img pair were swapped; removed a citation to a note in `RULE_AUTHORING.md` that doesn't exist there.
208
- - `docs/I18N.md`: stale key counts (`en` listed as 590, actual 600; `fr` coverage listed as ~49%, actual ~48% at the time) — now updated to reflect full parity.
252
+ - `docs/I18N.md`: stale key counts (`en` listed as 590, actual 600; `fr` coverage listed as ~49%, actual ~48% at the time), now updated to reflect full parity.
209
253
 
210
254
  ## [1.0.1] - 2026-07-28
211
255
 
212
256
  ### Changed
213
- - Trimmed the published npm package: rule-authoring scaffolding (`docs/RULE_TEMPLATE.*`, `docs/RULE_TEST_TEMPLATE.md`, `docs/RULE_TEST_AUTHORING.md`, `docs/TEST_OUTCOME_STABILITY.md`) and the not-yet-documented `src/explain` module no longer ship in the tarball — both stay in the git repo for contributors.
257
+ - Trimmed the published npm package: rule-authoring scaffolding (`docs/RULE_TEMPLATE.*`, `docs/RULE_TEST_TEMPLATE.md`, `docs/RULE_TEST_AUTHORING.md`, `docs/TEST_OUTCOME_STABILITY.md`) and the not-yet-documented `src/explain` module no longer ship in the tarball; both stay in the git repo for contributors.
214
258
  - README rewritten for clarity: corrected the install command and `require()` examples to the actual package name (`@surea11y/core`), and updated the rule count to 125.
215
- - `docs/ENGINE_OPTIONS.md`: documented the previously-undocumented `visibilityMode` option (`'styleOnly'`/`'styleAndGeometry'`, scoped to the three contrast rules), and added a "Recipes" section with composed, runnable examples for common scenarios (CI gating, auditor-mode contrast passes, scoped re-scans, reproducible snapshots, custom rules).
259
+ - `docs/ENGINE_OPTIONS.md`: documented the previously-undocumented `visibilityMode` option (`'styleOnly'`/`'styleAndGeometry'`, scoped to the three contrast rules), and added a "Recipes" section with composed, runnable examples for common scenarios.
216
260
 
217
261
  ### Fixed
218
- - `aria-allowed-attr`'s `SUPPORTED_ATTRS_BY_ROLE` table reconciled against the published WAI-ARIA 1.2 Recommendation (via `aria-query`, since a number of the previous `aria-expanded` allowances turned out to be deliberate ARIA 1.1 legacy carryovers, not current-spec facts). Added `aria-expanded` to 10 roles (checkbox, columnheader, gridcell, listbox, menuitemcheckbox, menuitemradio, row, rowheader, switch, tab) and `aria-activedescendant` to 8 composite-widget roles (combobox, grid, listbox, radiogroup, row, spinbutton, tablist, treegrid), plus smaller posinset/setsize/readonly/required/level gaps; removed `tree`'s unverified `aria-readonly`. `listitem` was already correct and is unchanged.
219
- - README: a "Real browser execution" code sample passed four positional arguments to `page.evaluate()` and claimed it worked with "any" automation framework — Playwright's `page.evaluate()` only accepts one argument alongside the function and throws on this exact pattern. Now shown as Puppeteer-specific, with a pointer to `INTEGRATION.md`'s wrapper for Playwright.
262
+ - `aria-allowed-attr`'s `SUPPORTED_ATTRS_BY_ROLE` table reconciled against the published WAI-ARIA 1.2 Recommendation, since a number of the previous `aria-expanded` allowances turned out to be ARIA 1.1 legacy carryovers rather than current-spec facts. Added `aria-expanded` to 10 roles and `aria-activedescendant` to 8 composite-widget roles, plus smaller posinset/setsize/readonly/required/level gaps; removed `tree`'s unverified `aria-readonly`.
263
+ - README: a "Real browser execution" code sample passed four positional arguments to `page.evaluate()` and claimed it worked with "any" automation framework, but Playwright's `page.evaluate()` only accepts one argument alongside the function and throws on this pattern. Now shown as Puppeteer-specific, with a pointer to `INTEGRATION.md`'s wrapper for Playwright.
220
264
  - README: the JSON output example referenced a nonexistent rule id (`link-name-quality`); corrected to the real id, `link-name-quality-manual`.
221
- - README: Quick Start code samples labeled the runner's four positional arguments as `url, ruleFilter, options, policy`; corrected to the actual names (`pageUrl, contextSelector, engineOptions, runOnly`) used consistently elsewhere in the docs.
265
+ - README: Quick Start code samples labeled the runner's four positional arguments as `url, ruleFilter, options, policy`; corrected to the actual names (`pageUrl, contextSelector, engineOptions, runOnly`).
222
266
 
223
267
  ## [1.0.0] - 2026-07-26
224
268
 
225
269
  ### Added
226
- - 125 rules (77 automatic/`fail`-capable, 48 manual/advisory) — see `docs/RULE_CATALOG.md` for the full list.
227
- - Full i18n support (English complete, French partial — see `docs/I18N.md`).
270
+ - 125 rules (77 automatic/`fail`-capable, 48 manual/advisory). See `docs/RULE_CATALOG.md` for the full list.
271
+ - Full i18n support (English complete, French partial). See `docs/I18N.md`.
228
272
  - A generated public rule catalog (`docs/RULE_CATALOG.md`, via `npm run docs:rule-catalog`) and WCAG facet-coverage report (`coverage/coverage-report.md`, via `npm run coverage`).
229
273
  - This documentation set: `README.md`, `docs/OUTPUT_SCHEMA.md`, `docs/ENGINE_OPTIONS.md`, `docs/WCAG_CONFORMANCE.md`, `docs/POLICY.md`, `docs/I18N.md`, `docs/INTEGRATION.md`, `docs/LIMITATIONS.md`, `docs/TROUBLESHOOTING.md`, `LICENSE`.
230
274
  - Multi-region `contextSelector` support: pass an array of selectors (or one comma-separated selector string) to scan multiple, possibly disjoint regions in a single run. Overlapping/nested regions are deduped automatically. See `docs/ENGINE_OPTIONS.md`.
231
275
  - `includeShadowDom` now defaults to `true` (opt out with `includeShadowDom: false`).
232
- - `structuralPath` on every `fail`/`cantTell` occurrence: a sibling-index path from `documentElement` down to the flagged element, a more robust element-identity mechanism than `selector` alone (survives some DOM changes a selector wouldn't). See `docs/OUTPUT_SCHEMA.md`.
233
- - `engineOptions.customRules`: register additional rules at runtime, scan-scoped (not added to the static catalog), matching the shape of an internal rule module (`{ id, meta, runInPage, applicability?, data? }`). `runInPage`/`applicability` accept a real function or a function-source string, the latter needed for cross-realm callers (e.g. Playwright) whose `engineOptions` argument can't carry a live function across a serialization boundary. See `docs/ENGINE_OPTIONS.md`.
234
- - `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including genuinely cross-origin) scanning for the "plain script injection" consumption mode (no automation driver) — a cooperative `postMessage` protocol, with one real limitation (a non-cooperating child frame is unreachable). Bundler-free, like `runa11yCoreInPage`. See `docs/INTEGRATION.md`'s "Cross-frame scanning" section and `docs/OUTPUT_SCHEMA.md`'s "Cross-frame result" section.
235
- - `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion (a 2.1/2.2-introduced SC is tagged only with its true origin version, never also the pre-existing baseline tag), so a caller can select a WCAG 2.0/2.1/2.2 conformance target by combining tag sets. See `docs/ENGINE_OPTIONS.md`'s "Filtering by WCAG version" section and `src/coverage/wcag-version-map.js` for the canonical per-version SC list.
236
- - `docs/BINDING_AUTHORS_GUIDE.md`: a reference for building a *new* framework binding (Puppeteer, Cypress, ...) on top of this engine — what's already engine-level vs. what every binding has to build itself, checked against what the `@surea11y/playwright` sibling project actually needed.
276
+ - `structuralPath` on every `fail`/`cantTell` occurrence: a sibling-index path from `documentElement` down to the flagged element, a more robust element-identity mechanism than `selector` alone. See `docs/OUTPUT_SCHEMA.md`.
277
+ - `engineOptions.customRules`: register additional rules at runtime, scan-scoped, matching the shape of an internal rule module. `runInPage`/`applicability` accept a real function or a function-source string, the latter needed for cross-realm callers whose `engineOptions` argument can't carry a live function across a serialization boundary. See `docs/ENGINE_OPTIONS.md`.
278
+ - `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including cross-origin) scanning for the plain-script-injection consumption mode, a cooperative `postMessage` protocol. See `docs/INTEGRATION.md`'s "Cross-frame scanning" section and `docs/OUTPUT_SCHEMA.md`'s "Cross-frame result" section.
279
+ - `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion, so a caller can select a WCAG 2.0/2.1/2.2 conformance target by combining tag sets. See `docs/ENGINE_OPTIONS.md`'s "Filtering by WCAG version" section and `src/coverage/wcag-version-map.js`.
280
+ - `docs/BINDING_AUTHORS_GUIDE.md`: a reference for building a new framework binding on top of this engine, what's already engine-level vs. what every binding has to build itself.
237
281
 
238
282
  ### Fixed (selected)
239
- - A shared `buildSelector` helper bug where an ancestor element that was the *last* of several same-tag siblings got no `:nth-of-type()` disambiguation, producing selectors that matched multiple elements instead of the one they were built for.
240
- - Several `ALLOWED_ROLES_BY_ELEMENT` entries (`<label>`, `<table>`/`<td>`/`<th>`/`<tr>`, `input[type=checkbox][role=button]`) that were missing or too restrictive, found via real-world-page testing and verified against the W3C ARIA-in-HTML spec.
283
+ - A shared `buildSelector` helper bug where an ancestor element that was the last of several same-tag siblings got no `:nth-of-type()` disambiguation, producing selectors that matched multiple elements.
284
+ - Several `ALLOWED_ROLES_BY_ELEMENT` entries (`<label>`, `<table>`/`<td>`/`<th>`/`<tr>`, `input[type=checkbox][role=button]`) that were missing or too restrictive.
241
285
  - `aria-hidden-focus` false-flagging the common `tabindex="-1"`-behind-`aria-hidden` pattern (checked raw focusability instead of tabbability).
242
286
  - A label-naming bug (`hasLabelAssociation` ignoring a `<label>`'s own `aria-label`), duplicated across 7 rule files, all fixed identically.
243
287
  - A systemic "name from content" false-positive affecting 19 rule files (an `<img alt>` or `aria-label`-named descendant inside a link/button wasn't recognized as providing the accessible name).
244
- - `contextSelector` resolving via `document.querySelector` (first match only) instead of `querySelectorAll` — a selector matching several elements silently scanned only the first, dropping the rest with no indication.
245
- - Three rules (`form-control-programmatic-label-present`, `target-size-minimum`, `label-in-name`) that queried `ctx.root` directly instead of through the shared `queryAllSmart`/`queryAll` helpers, found while implementing multi-region `contextSelector` support — silently broke (found nothing) the moment `ctx.root` became an array.
246
- - `aria-required-parent`/`aria-required-children`'s ancestor/descendant searches and `getContentNameInfo`'s "name from content" walk not following shadow-DOM `<slot>` assignment; a duplicated `resolveAriaLabelledbyText` pattern across 16 rules and `getLabelText` across 7 not checking an `aria-labelledby`/`<label>` target's `title` attribute as a final accname fallback (e.g. an `<iframe title="...">` target, whose content is always empty).
247
- - `landmark-one-main` incorrectly also flagged "more than one main landmark" — out of its real scope (this rule checks presence only; duplicates are `landmark-no-duplicate-main`'s job, already implemented correctly) and missing the accessibility-tree visibility filter its sibling rule already has.
248
- - `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`, and `aria-required-attr` flagged containers/elements that were not currently exposed to the accessibility tree at all (`hidden`, a closed `<dialog>`, etc.) — e.g. a closed flyout `role="menu"` populated on open, or a custom `role="checkbox"` whose `aria-checked` is set on hydration. All four now skip elements that fail `isAccTreeEligible`; `aria-required-children`/`aria-required-attr` also honor `aria-busy="true"` as an explicit author signal of transient incompleteness (WAI-ARIA's own escape hatch for required owned elements, extended by analogy to required attributes).
249
- - `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source (only `aria-label`/`aria-labelledby`), though `title` is a valid landmark-naming fallback. Replaced all 7 copies with one shared helper, `helpers.getLandmarkNameInfo`.
288
+ - `contextSelector` resolving via `document.querySelector` (first match only) instead of `querySelectorAll`, so a selector matching several elements silently scanned only the first.
289
+ - Three rules (`form-control-programmatic-label-present`, `target-size-minimum`, `label-in-name`) that queried `ctx.root` directly instead of through the shared `queryAllSmart`/`queryAll` helpers, silently broken the moment `ctx.root` became an array.
290
+ - `aria-required-parent`/`aria-required-children`'s ancestor/descendant searches and `getContentNameInfo`'s name-from-content walk not following shadow-DOM `<slot>` assignment; a duplicated `resolveAriaLabelledbyText` pattern across 16 rules and `getLabelText` across 7 not checking an `aria-labelledby`/`<label>` target's `title` attribute as a final accname fallback.
291
+ - `landmark-one-main` incorrectly also flagged "more than one main landmark", out of its real scope (duplicates are `landmark-no-duplicate-main`'s job) and missing the accessibility-tree visibility filter its sibling rule already has.
292
+ - `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`, and `aria-required-attr` flagged containers/elements not currently exposed to the accessibility tree at all. All four now skip elements that fail `isAccTreeEligible`; `aria-required-children`/`aria-required-attr` also honor `aria-busy="true"` as an explicit author signal of transient incompleteness.
293
+ - `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source. Replaced all 7 copies with one shared helper, `helpers.getLandmarkNameInfo`.
250
294
 
251
295
  ### Known limitations
252
- See `docs/LIMITATIONS.md` — structural (keyboard-trap detection, reflow-at-zoom), environment-dependent (jsdom vs. real-browser geometry), and deliberately-not-automated (text-quality judgment calls) limitations, stated explicitly rather than left to be discovered.
296
+ See `docs/LIMITATIONS.md`: structural (keyboard-trap detection, reflow-at-zoom), environment-dependent (jsdom vs. real-browser geometry), and deliberately-not-automated (text-quality judgment calls) limitations, stated explicitly rather than left to be discovered.
253
297
 
254
298
  ---
255
299
 
256
300
  # How to add an entry
257
301
 
258
302
  When you ship a change worth calling out to consumers (not every commit):
259
- 1. Add a bullet under `[Unreleased]`, in the right subsection (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`) — create the subsection if it doesn't exist yet for this cycle.
303
+ 1. Add a bullet under `[Unreleased]`, in the right subsection (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`), create the subsection if it doesn't exist yet for this cycle.
260
304
  2. Write it from the consumer's perspective ("what changed for someone using this package"), not the implementation's.
261
305
  3. When you tag a release, rename `[Unreleased]` to `## [x.y.z] - YYYY-MM-DD` and start a fresh empty `[Unreleased]` above it.