@surea11y/core 1.3.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 (163) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/CI_INTEGRATIONS.md +7 -7
  6. package/docs/ENGINE_OPTIONS.md +1 -1
  7. package/docs/I18N.md +12 -9
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/REPORT.md +1 -1
  11. package/package.json +50 -16
  12. package/src/baseline.js +0 -0
  13. package/src/checks/automatic/area-alt-present.js +4 -6
  14. package/src/checks/automatic/aria-allowed-attr.js +15 -51
  15. package/src/checks/automatic/aria-allowed-role.js +2 -0
  16. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  17. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  18. package/src/checks/automatic/aria-deprecated-role.js +4 -3
  19. package/src/checks/automatic/aria-hidden-body.js +6 -4
  20. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  21. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  22. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  23. package/src/checks/automatic/aria-required-attr.js +6 -7
  24. package/src/checks/automatic/aria-required-children.js +7 -10
  25. package/src/checks/automatic/aria-required-parent.js +20 -25
  26. package/src/checks/automatic/aria-role-name-present.js +2 -0
  27. package/src/checks/automatic/aria-roles-valid.js +2 -0
  28. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  29. package/src/checks/automatic/aria-valid-attr.js +2 -0
  30. package/src/checks/automatic/autocomplete-valid.js +2 -0
  31. package/src/checks/automatic/avoid-inline-spacing.js +3 -2
  32. package/src/checks/automatic/binary-control-name-present.js +2 -0
  33. package/src/checks/automatic/button-name-present.js +7 -7
  34. package/src/checks/automatic/bypass-blocks-present.js +9 -7
  35. package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
  36. package/src/checks/automatic/combobox-name-present.js +2 -0
  37. package/src/checks/automatic/contrast-computable.js +2 -0
  38. package/src/checks/automatic/contrast-enhanced.js +2 -0
  39. package/src/checks/automatic/contrast-minimum.js +2 -0
  40. package/src/checks/automatic/css-orientation-lock.js +21 -27
  41. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  42. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  43. package/src/checks/automatic/dialog-name-present.js +10 -10
  44. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  45. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  46. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  47. package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
  48. package/src/checks/automatic/form-control-single-label.js +6 -7
  49. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  50. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  51. package/src/checks/automatic/iframe-name-present.js +2 -0
  52. package/src/checks/automatic/iframe-title-unique.js +3 -1
  53. package/src/checks/automatic/img-alt-present.js +7 -9
  54. package/src/checks/automatic/input-image-alt-present.js +4 -6
  55. package/src/checks/automatic/label-in-name.js +15 -19
  56. package/src/checks/automatic/language-page-present.js +2 -0
  57. package/src/checks/automatic/link-in-text-block.js +2 -0
  58. package/src/checks/automatic/link-name-present.js +2 -0
  59. package/src/checks/automatic/list-children-valid.js +14 -24
  60. package/src/checks/automatic/listbox-name-present.js +2 -0
  61. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  62. package/src/checks/automatic/menuitem-name-present.js +2 -0
  63. package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
  64. package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
  65. package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
  66. package/src/checks/automatic/meter-name-present.js +4 -3
  67. package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
  68. package/src/checks/automatic/object-text-alternative-present.js +2 -0
  69. package/src/checks/automatic/option-name-present.js +2 -0
  70. package/src/checks/automatic/page-title-present.js +2 -0
  71. package/src/checks/automatic/progressbar-name-present.js +8 -10
  72. package/src/checks/automatic/role-img-alt-present.js +4 -4
  73. package/src/checks/automatic/searchbox-name-present.js +2 -0
  74. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  75. package/src/checks/automatic/slider-name-present.js +2 -0
  76. package/src/checks/automatic/spinbutton-name-present.js +2 -0
  77. package/src/checks/automatic/summary-name-present.js +2 -0
  78. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  79. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  80. package/src/checks/automatic/tab-name-present.js +2 -0
  81. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  82. package/src/checks/automatic/table-th-has-data-cells.js +2 -0
  83. package/src/checks/automatic/target-size-minimum.js +5 -0
  84. package/src/checks/automatic/td-has-header.js +24 -1
  85. package/src/checks/automatic/textbox-name-present.js +2 -0
  86. package/src/checks/automatic/tooltip-name-present.js +2 -0
  87. package/src/checks/automatic/treeitem-name-present.js +2 -0
  88. package/src/checks/automatic/valid-lang.js +2 -0
  89. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  90. package/src/checks/manual/accesskeys-manual.js +3 -1
  91. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  92. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  93. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  94. package/src/checks/manual/aria-text-manual.js +6 -5
  95. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  96. package/src/checks/manual/css-hidden-focus.js +184 -9
  97. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  98. package/src/checks/manual/empty-heading-manual.js +17 -17
  99. package/src/checks/manual/empty-table-header-manual.js +52 -25
  100. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  102. package/src/checks/manual/heading-order-manual.js +28 -1
  103. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  104. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  105. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  106. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  107. package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/label-title-only-manual.js +29 -22
  110. package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
  111. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
  112. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  113. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  114. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  115. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  116. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  117. package/src/checks/manual/landmark-unique-manual.js +37 -52
  118. package/src/checks/manual/link-name-quality-manual.js +2 -0
  119. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  120. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  121. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  122. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  123. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  124. package/src/checks/manual/p-as-heading-manual.js +2 -0
  125. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  126. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  127. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  128. package/src/checks/manual/region-manual.js +27 -36
  129. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  130. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  131. package/src/checks/manual/skip-link-manual.js +7 -6
  132. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  133. package/src/checks/manual/tabindex-manual.js +3 -1
  134. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  135. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  136. package/src/checks/manual/video-caption-manual.js +2 -0
  137. package/src/checks/manual-review.js +2 -0
  138. package/src/core.js +6772 -2158
  139. package/src/index.js +2 -0
  140. package/src/report.js +51 -9
  141. package/src/sarif.js +18 -3
  142. package/surea11y.browser.js +2731 -999
  143. package/bin/core.js +0 -473
  144. package/docs/CLI.md +0 -128
  145. package/src/catalogs/composites.wcag.js +0 -454
  146. package/src/checks/rules-and-tags.full.csv +0 -19
  147. package/src/checks/rules-and-tags.full.json +0 -259
  148. package/src/core/aria-helpers.js +0 -1211
  149. package/src/core/contrast-helpers.js +0 -1302
  150. package/src/core/dom-helpers.js +0 -4493
  151. package/src/core/dom-runner.js +0 -787
  152. package/src/core/frame-messaging.js +0 -261
  153. package/src/core/frame-scan.js +0 -190
  154. package/src/core/rollup-composites.js +0 -127
  155. package/src/core/rule-meta.js +0 -176
  156. package/src/coverage/wcag-facets.js +0 -1079
  157. package/src/coverage/wcag-version-map.js +0 -84
  158. package/src/i18n/en.js +0 -1228
  159. package/src/i18n/fr.js +0 -1185
  160. package/src/policy/contracts.js +0 -18
  161. package/src/policy/resolvePolicy.js +0 -59
  162. package/src/policy/schemas/engine-options.schema.json +0 -103
  163. package/src/policy/schemas/policy-contract.schema.json +0 -40
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ 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
+
7
51
  ## [1.3.0] - 2026-08-02
8
52
 
9
53
  ### Added
@@ -19,8 +63,8 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
19
63
 
20
64
  ### Fixed
21
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.
22
- - `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 axe `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).
23
- - `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 axe'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.
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.
24
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`.
25
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.
26
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.
package/README.md CHANGED
@@ -1,33 +1,57 @@
1
1
  # @surea11y/core
2
2
 
3
- > **Reliable accessibility testing for real web applications.**
3
+ [![surea11y core](docs/assets/brand-tag-dark.svg#gh-dark-mode-only)](https://www.npmjs.com/package/@surea11y/core#gh-dark-mode-only)
4
+ [![surea11y core](docs/assets/brand-tag-light.svg#gh-light-mode-only)](https://www.npmjs.com/package/@surea11y/core#gh-light-mode-only)
5
+ [![npm](https://img.shields.io/npm/v/@surea11y/core?style=flat-square&label=npm&labelColor=101413&color=3A4441)](https://www.npmjs.com/package/@surea11y/core)
6
+ [![node](https://img.shields.io/node/v/@surea11y/core?style=flat-square&label=node&labelColor=101413&color=3A4441)](package.json)
7
+ [![license](https://img.shields.io/badge/license-MPL--2.0-3A4441?style=flat-square&labelColor=101413)](LICENSE)
4
8
 
5
- surea11y is an accessibility engine designed to help development teams
6
- identify objective accessibility issues early in the software lifecycle.
7
- It runs against either static HTML or fully rendered browser pages,
8
- producing deterministic, standards-traceable results that are suitable
9
- for local development, automated testing and CI/CD pipelines.
9
+ > **Accessibility testing that tells you what it can't tell you.**
10
10
 
11
- Unlike browser extensions or cloud-based services, surea11y is a
12
- library-first project. You install it, run it where your code runs, and
13
- receive structured results that can be consumed by people, scripts or
14
- reporting tools.
11
+ surea11y is an accessibility engine for teams that need to know what automated
12
+ testing *can't* establish. It reports findings, non-findings, and — unusually —
13
+ explicit uncertainty, so results are auditable rather than reassuring.
15
14
 
16
- ## Why surea11y?
15
+ *Sure* means certainty about what is known, and honesty about what isn't.
17
16
 
18
- Accessibility automation is only valuable if developers can trust its
19
- results.
17
+ It runs against either static HTML or fully rendered browser pages, producing
18
+ deterministic, standards-traceable results suitable for local development,
19
+ automated testing and CI/CD pipelines.
20
20
 
21
- surea11y is built around a conservative philosophy: **never report
22
- certainty when certainty cannot be established objectively.**
21
+ Unlike browser extensions or cloud-based services, surea11y is a library-first
22
+ project. You install it, run it where your code runs, and receive structured
23
+ results that can be consumed by people, scripts or reporting tools.
23
24
 
24
- Instead of relying on heuristics that may generate false positives, each
25
- rule makes a single deterministic decision. If a violation can be
26
- proven, the outcome is `fail`. If human judgement is required, the
27
- engine reports `cantTell` instead of guessing.
25
+ ## What automated testing can and cannot do
28
26
 
29
- This makes the engine predictable, reproducible and suitable for
30
- automated quality gates.
27
+ Automated tools are commonly reckoned to catch somewhere around a third of WCAG
28
+ issues. The remainder require human judgement. That ceiling is a property of
29
+ static analysis itself, not a gap in any particular tool.
30
+
31
+ surea11y's answer is to be explicit about which side of that line every result
32
+ falls on. Each rule makes a single deterministic decision:
33
+
34
+ - **`fail`** — a violation provable from the DOM. Reserved for objective,
35
+ normative cases.
36
+ - **`pass`** — this rule's specific condition is met. Not a claim that the page
37
+ is accessible.
38
+ - **`cantTell`** — a human has to decide this, and the result says what was
39
+ ambiguous.
40
+ - **`notApplicable`** — the rule's precondition isn't present.
41
+
42
+ `cantTell` is the point of the project. An engine that quietly discards what it
43
+ cannot determine produces a shorter report and a false sense of coverage.
44
+
45
+ ## What this engine does not detect
46
+
47
+ Keyboard traps, reflow and clipping at 400% zoom, anything that only exists
48
+ after a click or an async load, and judgement calls such as whether a heading is
49
+ meaningful — these lie outside what a static DOM scan can establish. Each is a
50
+ reasoned decision rather than an oversight.
51
+
52
+ [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md) lists them in full with the
53
+ reasoning for each. A `pass` from this engine — or from any automated tool — is
54
+ never a substitute for the manual review WCAG itself requires.
31
55
 
32
56
  ### Key principles
33
57
 
@@ -44,7 +68,9 @@ automated quality gates.
44
68
  - **Extensible.** Add custom rules, register policies and filter scans
45
69
  by rule IDs, tags or WCAG version.
46
70
  - **Localized reporting.** Human-readable messages can be translated
47
- without affecting machine-readable data.
71
+ without affecting machine-readable data. Ships with `en`, `fr`, `de`,
72
+ and `es` today — see [`docs/I18N.md`](./docs/I18N.md) to use one or
73
+ contribute another.
48
74
 
49
75
  ---
50
76
 
@@ -92,6 +118,26 @@ For a detailed comparison of both execution models, see
92
118
 
93
119
  ---
94
120
 
121
+ ## Which package do I need?
122
+
123
+ surea11y is a family of packages sharing one engine. Install the one that
124
+ matches how you test — each pulls in `@surea11y/core` for you.
125
+
126
+ | I want to… | Install |
127
+ |---|---|
128
+ | Add accessibility checks to **Playwright** tests | [`@surea11y/playwright`](https://github.com/SureA11y/playwright#readme) |
129
+ | …**Puppeteer** | [`@surea11y/puppeteer`](https://github.com/SureA11y/puppeteer#readme) |
130
+ | …**Selenium** | [`@surea11y/selenium`](https://github.com/SureA11y/selenium#readme) |
131
+ | …**Cypress** | [`@surea11y/cypress`](https://github.com/SureA11y/cypress#readme) |
132
+ | …**WebdriverIO** | [`@surea11y/webdriverio`](https://github.com/SureA11y/webdriverio#readme) |
133
+ | Assert in **Jest or Vitest** component tests | [`@surea11y/test-matchers`](https://github.com/SureA11y/test-matchers#readme) |
134
+ | Scan static HTML from a **terminal or CI pipeline** | [`@surea11y/cli`](https://github.com/SureA11y/cli#readme) |
135
+ | Run the engine against **a DOM I already have** | `@surea11y/core` (this package) |
136
+
137
+ The rest of this README covers `@surea11y/core` itself.
138
+
139
+ ---
140
+
95
141
  ## Installation
96
142
 
97
143
  Install the core package from npm:
@@ -100,10 +146,13 @@ Install the core package from npm:
100
146
  npm install @surea11y/core
101
147
  ```
102
148
 
103
- The core engine has no runtime dependencies — requiring the library
104
- itself never loads `jsdom`. Only the bundled CLI loads it, and only
105
- when you actually run a scan, keeping the library lightweight and
106
- suitable for embedding into your own tooling.
149
+ The core engine has **zero runtime dependencies**. Installing it pulls
150
+ nothing else into your tree, which keeps it lightweight and suitable for
151
+ embedding into your own tooling.
152
+
153
+ The engine needs a DOM to read, but it never creates one — you supply it,
154
+ whether that's jsdom, a Playwright page, or the live document in a
155
+ browser. That is why nothing is installed on your behalf.
107
156
 
108
157
  ---
109
158
 
@@ -111,18 +160,21 @@ suitable for embedding into your own tooling.
111
160
 
112
161
  ### CLI
113
162
 
114
- The CLI is the fastest way to analyse a page without writing any code.
163
+ The CLI ships as a separate package, [`@surea11y/cli`](https://www.npmjs.com/package/@surea11y/cli),
164
+ so that installing the engine never pulls a DOM implementation into
165
+ projects that already have one:
115
166
 
116
167
  ```bash
117
- npx @surea11y/core scan ./index.html
118
- npx @surea11y/core scan https://example.com/
168
+ npx @surea11y/cli scan ./index.html
169
+ npx @surea11y/cli scan https://example.com/
119
170
  ```
120
171
 
121
172
  The CLI analyses static HTML. It does not execute client-side
122
173
  JavaScript, making it ideal for static sites and server-rendered
123
174
  applications.
124
175
 
125
- For available options, exit codes and advanced usage, see `docs/CLI.md`.
176
+ For available options, exit codes and advanced usage, see the
177
+ [CLI documentation](https://github.com/SureA11y/cli#readme).
126
178
 
127
179
  ---
128
180
 
@@ -133,7 +185,12 @@ surea11y exposes two entry points depending on where your code executes.
133
185
  #### Node.js + jsdom
134
186
 
135
187
  Use `runDomRulesInPage()` when your application already has a DOM
136
- available through jsdom.
188
+ available through jsdom. jsdom is not a dependency of this package, so
189
+ install it alongside if you don't already have it:
190
+
191
+ ```bash
192
+ npm install jsdom
193
+ ```
137
194
 
138
195
  ```js
139
196
  const { JSDOM } = require("jsdom");
@@ -331,7 +388,6 @@ and progressively explore more advanced features.
331
388
  |---|---|
332
389
  | `docs/OUTPUT_SCHEMA.md` | Complete description of every field returned by the engine. |
333
390
  | `docs/API_STABILITY.md` | Semver guarantees on the result shape, and the rule-ID deprecation policy. |
334
- | `docs/CLI.md` | CLI commands, options, exit codes and examples. |
335
391
  | `docs/BASELINE.md` | CI baseline/allowlist: gate builds only on new violations. |
336
392
  | `docs/REPORT.md` | Self-contained HTML report: browsable summary, WCAG rollup, filterable occurrence table. |
337
393
  | `docs/SARIF.md` | SARIF 2.1.0 report for GitHub Code Scanning and other SARIF dashboards. |
@@ -405,9 +461,6 @@ implementations and supporting infrastructure remain clearly separated.
405
461
  ```text
406
462
  surea11y.browser.js # Generated standalone browser bundle
407
463
 
408
- bin/
409
- core.js # CLI entry point
410
-
411
464
  src/
412
465
  index.js # Public API
413
466
  core.js # Generated runtime bundle
@@ -485,6 +538,27 @@ supported versions and the preferred disclosure process.
485
538
 
486
539
  ---
487
540
 
541
+ ## Versioning & stability
542
+
543
+ `@surea11y/core` follows [semantic versioning](https://semver.org/). The result
544
+ shape is a written contract — see [`docs/API_STABILITY.md`](docs/API_STABILITY.md)
545
+ for exactly which fields are covered by semver, what triggers a patch/minor/major
546
+ bump, the release cadence, and the rule-ID deprecation policy.
547
+
548
+ In short: patch and minor releases are always backward-compatible, so a consumer
549
+ pinned to a `^1.y.0` range is never broken by an upgrade within the `1.x` line.
550
+ Correctness fixes ship as patches when ready; feature work is batched into
551
+ periodic minors; breaking changes are reserved for major versions and are rare by
552
+ design.
553
+
554
+ ## Maintainer
555
+
556
+ surea11y is built and maintained by [Jorge Rumoroso](https://github.com/rumoroso).
557
+
558
+ Bug reports and rule proposals are welcome via
559
+ [issues](https://github.com/SureA11y/core/issues). For security disclosures see
560
+ [`SECURITY.md`](./SECURITY.md).
561
+
488
562
  ## License
489
563
 
490
564
  This project is released under the Mozilla Public License 2.0 (MPL-2.0).
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /* SPDX-License-Identifier: MPL-2.0 */
3
+
4
+ 'use strict';
5
+
6
+ // Redirects the pre-1.4.0 `npx @surea11y/core scan ...` that older docs still
7
+ // show. Not named `surea11y`: that belongs to @surea11y/cli, and core is a
8
+ // transitive dependency of every binding, so both would collide in one .bin.
9
+
10
+ process.stderr.write(
11
+ `The surea11y CLI is no longer part of @surea11y/core (moved in 1.4.0).
12
+
13
+ npx @surea11y/cli scan <file-or-url>
14
+
15
+ Install it with: npm install --save-dev @surea11y/cli
16
+ Docs: https://github.com/SureA11y/cli
17
+ `
18
+ );
19
+
20
+ process.exit(2);
@@ -13,6 +13,22 @@ Removing, renaming, or changing the type/meaning of any of these is a **major**
13
13
 
14
14
  This list is deliberately not a new, invented guarantee — it codifies what the 6 real consumers above (and `docs/OUTPUT_SCHEMA.md`'s own worked examples) already depend on today, either directly or as documented shape.
15
15
 
16
+ ## Package entry points (covered by semver)
17
+
18
+ Since 1.4.0 the package declares an explicit `exports` map. These are the only importable paths, and removing or repointing one is a **major** bump:
19
+
20
+ | Specifier | Resolves to | Contents |
21
+ |---|---|---|
22
+ | `@surea11y/core` | `src/index.js` | the full engine surface (`runDomRulesInPage`, `runa11yCoreInPage`, catalog accessors, …) |
23
+ | `@surea11y/core/baseline` | `src/baseline.js` | `buildBaselineEntries()`, `matchBaseline()` |
24
+ | `@surea11y/core/report` | `src/report.js` | `renderHtmlReport()` |
25
+ | `@surea11y/core/sarif` | `src/sarif.js` | `renderSarifReport()` |
26
+ | `@surea11y/core/browser` | `surea11y.browser.js` | the standalone browser bundle, for bundlers that resolve it as a module |
27
+
28
+ Anything **not** in that table — `src/core/*`, `src/checks/*`, `src/i18n/*`, `src/policy/*`, and the generated `src/core.js` itself — is internal. Before 1.4.0 there was no `exports` map, so those paths were technically reachable via deep `require()`; they were never documented as public and are no longer resolvable. The `<script src="node_modules/@surea11y/core/surea11y.browser.js">` form documented in the README is a filesystem path, not module resolution, and is unaffected.
29
+
30
+ Declaring this map is what lets the engine's internal file layout change without a major bump. Note that `src/checks/*` is still *shipped* (the generated bundle `require()`s it at runtime) — shipped is not the same as public.
31
+
16
32
  ## Explicitly unstable (not covered by semver)
17
33
 
18
34
  - `perfStats` and `ruleTimings` — internal timing/debug counters, only present when `engineOptions.perfStats`/`.profileRules` is set. Shape not covered by this document.
@@ -27,6 +43,16 @@ This list is deliberately not a new, invented guarantee — it codifies what the
27
43
 
28
44
  `engine.schemaVersion` has been `"1.0.0"` since the engine's first release and has never needed a bump — nothing has changed a stable field's shape yet. Adding the `deprecated`/`deprecation` meta fields described below is purely additive (new optional fields, ignored safely by anything not looking for them), so it does **not** warrant a schema bump either — this is the policy's first real application.
29
45
 
46
+ ## Release cadence
47
+
48
+ The version number is the contract — not a measure of how much has changed or how often. surea11y follows semver strictly, so what a bump *means* is fixed regardless of how frequently they happen:
49
+
50
+ - **Patch (`x.y.Z`)** — rule-correctness fixes and documentation updates. Released promptly, as needed, rather than held back; always safe to adopt within a major line.
51
+ - **Minor (`x.Y.0`)** — additive, backward-compatible work: new rules, new locales, new `engineOptions`, new output formats. Batched into periodic releases rather than shipped one change at a time.
52
+ - **Major (`X.0.0`)** — a breaking change to a stable field (see above). Rare by design; the entire point of the stable-fields list is to keep these infrequent and well-signposted.
53
+
54
+ Because every `1.x` release is backward-compatible, a consumer pinned to a `^1.y.0` range is never broken by an upgrade within the line — so a steady stream of patch/minor releases reflects active maintenance and prompt fixes, not instability. Frequency of releases is not a signal of churn; a change to a **major** version is.
55
+
30
56
  ## Rule-ID deprecation policy
31
57
 
32
58
  A rule can be marked deprecated in its own `meta`:
@@ -1,6 +1,6 @@
1
1
  # CI/CD pipeline integrations
2
2
 
3
- Ready-to-paste templates wrapping the [CLI](./CLI.md) (`npx @surea11y/core scan ...`) in GitHub Actions and Bitbucket Pipelines. If you're calling the library directly from your own Node script instead of the CLI, see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result) instead — this page is specifically about the CLI as a pipeline step.
3
+ Ready-to-paste templates wrapping the [CLI](https://github.com/SureA11y/cli#readme) (`npx @surea11y/cli scan ...`) in GitHub Actions and Bitbucket Pipelines. If you're calling the library directly from your own Node script instead of the CLI, see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result) instead — this page is specifically about the CLI as a pipeline step.
4
4
 
5
5
  All of these rely on the CLI's own exit codes (`0` clean, `1` at least one — or one *new*, with `--baseline` — `fail` outcome, `2` a usage/scan error) to gate the pipeline; no extra scripting is required for basic pass/fail gating.
6
6
 
@@ -21,7 +21,7 @@ jobs:
21
21
  with:
22
22
  node-version: 20
23
23
  - run: npm ci && npm run build # produce whatever static HTML you're scanning
24
- - run: npx @surea11y/core scan ./dist/index.html
24
+ - run: npx @surea11y/cli scan ./dist/index.html
25
25
  ```
26
26
 
27
27
  This fails the job the moment any `fail` outcome is found. For an existing site with pre-existing violations, see the baseline variant below instead of disabling the step.
@@ -30,13 +30,13 @@ This fails the job the moment any `fail` outcome is found. For an existing site
30
30
 
31
31
  ```sh
32
32
  # Once, locally: record every current fail occurrence, commit the file.
33
- npx @surea11y/core scan ./dist/index.html --write-baseline a11y-baseline.json
33
+ npx @surea11y/cli scan ./dist/index.html --write-baseline a11y-baseline.json
34
34
  git add a11y-baseline.json
35
35
  ```
36
36
 
37
37
  ```yaml
38
38
  - run: npm ci && npm run build
39
- - run: npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json
39
+ - run: npx @surea11y/cli scan ./dist/index.html --baseline a11y-baseline.json
40
40
  ```
41
41
 
42
42
  See [`BASELINE.md`](./BASELINE.md) for what counts as "known" vs. "new", and how to regenerate the file as violations get fixed.
@@ -63,7 +63,7 @@ jobs:
63
63
  node-version: 20
64
64
  - run: npm ci && npm run build
65
65
  - name: Scan (report, don't fail the job here)
66
- run: npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json --sarif results.sarif
66
+ run: npx @surea11y/cli scan ./dist/index.html --baseline a11y-baseline.json --sarif results.sarif
67
67
  continue-on-error: true
68
68
  id: scan
69
69
  - name: Upload SARIF to Code Scanning
@@ -91,7 +91,7 @@ pipelines:
91
91
  script:
92
92
  - npm ci
93
93
  - npm run build
94
- - npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json --html a11y-report.html
94
+ - npx @surea11y/cli scan ./dist/index.html --baseline a11y-baseline.json --html a11y-report.html
95
95
  artifacts:
96
96
  - a11y-report.html
97
97
  ```
@@ -100,4 +100,4 @@ The step fails the pipeline on the CLI's exit code exactly like any other `scrip
100
100
 
101
101
  ## Free-tier/private-repo minute limits
102
102
 
103
- If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [`CLI.md`](./CLI.md#what-it-can-and-cant-scan) for what that trades away (no client-rendered content, no real CSS layout).
103
+ If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md#what-it-can-and-cant-scan) for what that trades away (no client-rendered content, no real CSS layout).
@@ -224,7 +224,7 @@ See the option-by-option table above for anything not shown here, and the `custo
224
224
 
225
225
  Every shipped rule is baked into `src/core.js` at build time. `engineOptions.customRules` is the runtime escape hatch: an array of rule descriptors registered for that one call only — nothing is added to the static catalog (`getRulesCatalog()`/`getChecksCatalog()`), and nothing persists between calls. This is deliberate, not a limitation to work around: surea11y already takes fresh `engineOptions` per call with no mutable global config (unlike some other engines, which need a `configure()`/`reset()` step against a shared runtime), and custom rules follow that same per-call model.
226
226
 
227
- Calling the library directly is one way in; the CLI also exposes this via `--custom-rules <path>` (a local file, loaded once per scan) — see [`CLI.md`](./CLI.md#custom-rules).
227
+ Calling the library directly is one way in; the CLI also exposes this via `--custom-rules <path>` (a local file, loaded once per scan) — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md#custom-rules).
228
228
 
229
229
  A descriptor has the *same shape as an internal rule module's own export* — if you already know how to write a rule file for this engine, you already know this API:
230
230
 
package/docs/I18N.md CHANGED
@@ -6,10 +6,12 @@ Every rule's title/description, and every occurrence's summary/hint, is localize
6
6
 
7
7
  | Locale | File | Keys | Coverage vs. English |
8
8
  |---|---|---|---|
9
- | `en` (English) | `src/i18n/en.js` | 600 | 100% (the canonical/fallback set) |
10
- | `fr` (French) | `src/i18n/fr.js` | 600 | 100% |
9
+ | `en` (English) | `src/i18n/en.js` | 614 | 100% (the canonical/fallback set) |
10
+ | `fr` (French) | `src/i18n/fr.js` | 614 | 100% |
11
+ | `de` (German) | `src/i18n/de.js` | 614 | 100% |
12
+ | `es` (Spanish) | `src/i18n/es.js` | 614 | 100% |
11
13
 
12
- Both locales are fully translated as of this writing. That won't stay automatically true — every time a new rule (or a new i18n key) is added to `en.js`, `fr.js` needs the same key added, or it silently falls back to English for that string (see the fallback behavior below). There's no automated check for this yet; diff `Object.keys(require('./src/i18n/en.js'))` against `fr.js` after adding a rule to catch drift before it ships.
14
+ All four locales are fully translated as of this writing. That won't stay automatically true — every time a new rule (or a new i18n key) is added to `en.js`, every other locale needs the same key added, or it silently falls back to English for that string (see the fallback behavior below). `tests/i18n/i18n-locale-completeness.test.js` catches this: it fails the build if a locale listed in `FULLY_TRANSLATED_LOCALES` (currently `fr`, `de`, `es`) is missing any key present in `en.js`, and fails for *any* locale that has an orphaned key not in `en.js` (a sign of a typo or a stale key left behind after a rule was removed). Run `npm run i18n:report` any time to see per-locale coverage.
13
15
 
14
16
  ## Selecting a locale
15
17
 
@@ -17,7 +19,7 @@ Both locales are fully translated as of this writing. That won't stay automatica
17
19
  runDomRulesInPage(url, null, { locale: 'fr' }, null);
18
20
  ```
19
21
 
20
- Default is `'en'` if omitted. Any string is accepted — an unrecognized locale (e.g. `'de'`, not yet built) behaves exactly like a locale that's 0% translated: every string falls through to English (see below), not an error.
22
+ Default is `'en'` if omitted. Any string is accepted — an unrecognized locale (e.g. `'ja'`, not yet built) behaves exactly like a locale that's 0% translated: every string falls through to English (see below), not an error.
21
23
 
22
24
  ## Fallback behavior (per-string, not per-locale)
23
25
 
@@ -40,8 +42,9 @@ Both are included in the result alongside the already-resolved text, so you can
40
42
 
41
43
  ## Contributing a translation
42
44
 
43
- 1. Open `src/i18n/en.js` — it's the canonical key list (590 entries, one `module.exports` object of `key: string`).
44
- 2. Add matching keys to `src/i18n/<locale>.js` (create the file if the locale doesn't exist yet — follow `fr.js`'s structure exactly: `'use strict'; module.exports = { ...keys };`).
45
- 3. You don't need every key on day one — the fallback behavior above means a partial translation degrades gracefully to English per-string, exactly like `fr` does today. Ship what you have.
46
- 4. Keep `{{placeholder}}` tokens in translated strings exactly as they appear in the English source — they're substituted verbatim regardless of locale (e.g. `{{element}}`, `{{role}}`).
47
- 5. Run `npm run build && npm test` — there's no locale-completeness test today (a partial locale is valid, not a failure), but this confirms nothing else broke.
45
+ 1. Scaffold the file: `npm run i18n:new <locale>` (e.g. `npm run i18n:new de`) creates `src/i18n/<locale>.js` with all of `en.js`'s keys already present, each seeded with the English text as a placeholder. It refuses to overwrite an existing locale file unless you pass `--force`.
46
+ 2. Replace the placeholder values with real translations, key by key. Leave any you're unsure about as-is for now — a value identical to English is treated as untranslated, not broken (see the fallback behavior above).
47
+ 3. Check progress any time with `npm run i18n:report` — it prints, per locale, how many keys have been translated vs. still match the English placeholder, plus any missing or orphaned keys.
48
+ 4. You don't need every key on day one — the fallback behavior above means a partial translation degrades gracefully to English per-string, exactly like `fr`/`de`/`es` did during their own early stages. Ship what you have. A partial locale is valid and won't fail `i18n-locale-completeness.test.js` unless you also add it to `FULLY_TRANSLATED_LOCALES` in that file — only do that once `npm run i18n:report` shows 100% coverage.
49
+ 5. Keep `{{placeholder}}` tokens (and `{{#foo}}...{{/foo}}` conditional blocks) in translated strings exactly as they appear in the English source — they're substituted/evaluated verbatim regardless of locale (e.g. `{{element}}`, `{{role}}`). Never translate HTML tag/attribute names (`<img>`, `aria-label`, `role="dialog"`, etc.) — they're code identifiers, not prose.
50
+ 6. Run `npm run build && npm test` to confirm nothing broke, including locale-completeness.
@@ -130,7 +130,7 @@ if (failures.length > 0) {
130
130
 
131
131
  Notes for CI specifically:
132
132
  - `cantTell` outcomes are advisory by design (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md#outcome-values)) — most teams log them without failing the build, since they require human judgment the CI run can't make.
133
- - For "only fail on *new* violations," the CLI has a built-in baseline/allowlist mechanism (`--write-baseline`/`--baseline`, see [`BASELINE.md`](./BASELINE.md)). Calling the library directly, the same matching logic is available as `buildBaselineEntries(result)`/`matchBaseline(result, baselineEntries)` from `require('@surea11y/core/src/baseline')` — or diff `checksResults` against a saved prior run yourself if your pages don't fit that model (see `BASELINE.md`'s "known limitation").
133
+ - For "only fail on *new* violations," the CLI has a built-in baseline/allowlist mechanism (`--write-baseline`/`--baseline`, see [`BASELINE.md`](./BASELINE.md)). Calling the library directly, the same matching logic is available as `buildBaselineEntries(result)`/`matchBaseline(result, baselineEntries)` from `require('@surea11y/core/baseline')` — or diff `checksResults` against a saved prior run yourself if your pages don't fit that model (see `BASELINE.md`'s "known limitation").
134
134
  - Prefer Pattern 1 (jsdom) in CI unless you specifically need real-browser layout — it avoids the extra weight of a Puppeteer/Playwright + browser-binary install in your pipeline.
135
135
 
136
136
  ## Browser extension context
@@ -14,7 +14,7 @@ surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at
14
14
 
15
15
  - **jsdom (Node, no real browser) has no CSS layout engine.** Rules needing real geometry — most notably `target-size-minimum` (WCAG 2.5.8, needs real `getBoundingClientRect()`) — report `notApplicable` under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2) to get real findings from these rules.
16
16
  - **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec), along with any other subtree hidden via `display:none`, `visibility:hidden`, `[hidden]`, or closed `<details>`, are **excluded from rule evaluation by default** — matching the visibility-aware behavior of other established engines. This is a deliberate default (`engineOptions.includeHiddenElements: false`), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set `engineOptions.includeHiddenElements: true` to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#engineoptions--the-rest) for the option and exactly which hiding mechanisms it covers.
17
- - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/core scan <url>`) specifically fetches static HTML only, with no JS execution — see [`CLI.md`](./CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. Other engines running inside an actual loaded browser tab see the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with either engine's rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
17
+ - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. Other engines running inside an actual loaded browser tab see the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with either engine's rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
18
18
 
19
19
  ## Deliberately not attempted — judgment calls, not automatable safely
20
20
 
package/docs/REPORT.md CHANGED
@@ -18,7 +18,7 @@ Open `report.html` directly from disk. Works alongside any other output mode —
18
18
  ## Library usage
19
19
 
20
20
  ```js
21
- const { renderHtmlReport } = require('@surea11y/core/src/report');
21
+ const { renderHtmlReport } = require('@surea11y/core/report');
22
22
  const { runDomRulesInPage } = require('@surea11y/core');
23
23
 
24
24
  const result = runDomRulesInPage(url, null, {}, null);