@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
package/CHANGELOG.md CHANGED
@@ -4,17 +4,91 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.4.0] - 2026-08-08
8
+
9
+ ### Added
10
+ - `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.
11
+ - `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.
12
+ - `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.
13
+ - 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`.
14
+
15
+ ### Fixed
16
+ - `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.
17
+ - `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`).
18
+ - `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.
19
+ - `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`).
20
+ - `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).
21
+ - `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.
22
+ - `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).
23
+ - `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.
24
+ - `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.
25
+ - `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.
26
+ - `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.
27
+ - `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.
28
+ - `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.
29
+ - `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.
30
+ - `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.
31
+ - `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`).
32
+ - `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.
33
+ - `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.
34
+ - `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`).
35
+ - `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`).
36
+ - `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`).
37
+ - `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.
38
+ - `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).
39
+
40
+ ### Changed
41
+ - **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).
42
+ - `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.
43
+ - `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.
44
+ - `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.
45
+
46
+ - 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.
47
+
48
+ ### Removed
49
+ - `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.
50
+
51
+ ## [1.3.0] - 2026-08-02
52
+
53
+ ### Added
54
+ - `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.
55
+ - `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).
56
+ - `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.
57
+ - `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`.
58
+
59
+ ### Changed
60
+ - `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%.
61
+ - `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.
62
+ - 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.
63
+
64
+ ### Fixed
65
+ - `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.
66
+ - `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).
67
+ - `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.
68
+ - `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`.
69
+ - `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.
70
+ - `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.
71
+ - `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.
72
+ - `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).
73
+ - `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).
74
+ - `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.
75
+ - `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`.
76
+ - `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.
77
+ - `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.
78
+ - `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.
79
+ - `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.
80
+
7
81
  ## [1.2.0] - 2026-07-31
8
82
 
9
83
  ### Added
10
84
  - 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).
11
- - `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. Investigated axe-core's own equivalent behavior first (it has no special fragment-detection either — its `document-title` rule's selector just happens to target `<html>` itself, so it naturally finds no matches when `<html>` is out of scope) rather than assuming a design. See `docs/ENGINE_OPTIONS.md` and `docs/RULE_AUTHORING.md` §11.2.
85
+ - `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.
12
86
  - 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.
13
- - `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. Structurally adapted from the cross-engine-diff project's own HTML report tool (its shell — self-contained file, hero-bar-plus-legend, grouped cards with an overflow cap, collapsible technical detail — is generic and reusable; its actual organizing principle, a 7-way "do the two engines agree" taxonomy, has no single-engine analog and wasn't reused). See `docs/REPORT.md`.
87
+ - `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`.
14
88
 
15
89
  ### Fixed
16
90
  - `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).
17
- - `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. A widely-used reference engine's real 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), verified by reading that engine's `getSectioningContentSelector`/`getSectioningContentPlusMainSelector` source directly. Found via the cross-engine comparisons project 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 that reference engine correctly flags and 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.
91
+ - `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.
18
92
  - `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.
19
93
  - `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.
20
94
 
@@ -23,8 +97,8 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
23
97
  ### Fixed
24
98
  - `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.
25
99
  - `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.
26
- - `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 via the cross-engine comparisons project 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` reported `notApplicable` where axe-core correctly fails. `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.
27
- - `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; mirrors axe-core's `controlValueRoles`, verified against its source). Both checks gated "is this a name-from-content candidate" on the native host tag alone, never checking whether `role` had overridden it. Found via the cross-engine comparisons project 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 reported no issue at all, while axe-core's `button-name` correctly failed it. 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.
100
+ - `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.
101
+ - `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.
28
102
  - `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.
29
103
  - `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.
30
104
  - `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.
@@ -60,7 +134,7 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
60
134
  - `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).
61
135
 
62
136
  ### Fixed
63
- - `aria-allowed-attr`'s `SUPPORTED_ATTRS_BY_ROLE` table reconciled against the published WAI-ARIA 1.2 Recommendation (via `aria-query`, not axe-core's table — axe-core's own source comments confirm many of its `aria-expanded` allowances are 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.
137
+ - `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.
64
138
  - 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.
65
139
  - README: the JSON output example referenced a nonexistent rule id (`link-name-quality`); corrected to the real id, `link-name-quality-manual`.
66
140
  - 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.
@@ -78,7 +152,7 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
78
152
  - `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? }`) — equivalent to the rule/check registration pattern used by other engines. `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`.
79
153
  - `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including genuinely cross-origin) scanning for the "plain script injection" consumption mode (no automation driver) — a cooperative `postMessage` protocol similar in spirit to the cross-frame messaging mechanisms used by other engines, including the same 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.
80
154
  - `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion (matching the tagging convention used by other engines — 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.
81
- - `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.
155
+ - `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.
82
156
 
83
157
  ### Fixed (selected)
84
158
  - 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.