@surea11y/core 1.4.0 → 1.5.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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,87 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.5.0] - 2026-08-16
8
+
9
+ ### Added
10
+ - `engine.locale` on the result records which dictionary a run actually used: `{ requested, resolved, reason }`, once per result. Locale fallback is graceful and per-string, so asking for a language the build doesn't carry has always produced fluent English with nothing in the output to say so; `reason` now distinguishes `ok`, `unknown-locale` (no dictionary for that code — a region subtag counts as its own locale) and `partial-dictionary` (dictionary used but missing keys). Purely additive, so `engine.schemaVersion` stays `"1.0.0"`. Documented in `docs/OUTPUT_SCHEMA.md` and `docs/API_STABILITY.md`.
11
+ - `npm run i18n:sync` rewrites every non-English locale file against `en.json`: adds keys that are new (seeded with the English text), drops keys `en.json` no longer has, and leaves existing translations untouched. `npm run i18n:check` performs the same comparison without writing and exits non-zero on drift. Adding a rule string previously meant editing four files by hand, with a missed one falling back to English silently.
12
+
13
+ ### Changed
14
+ - The standalone browser bundle carries English only, and every other locale ships beside it as `surea11y.i18n.<locale>.js`, loaded with a second `<script>` tag. The bundle drops from 1580 KB to 1300 KB and stops growing as languages are added — the change of shape matters more than the one-off 280 KB. Asking for a locale whose side file is not loaded returns English with `engine.locale.reason` set to `dictionary-not-loaded`, so it is visible rather than silent. `require('@surea11y/core')` and every binding are unaffected and still carry all locales. `scripts/build-browser.js` now generates its own English-only core in memory instead of reading the shipped `src/core.js`; the in-page runner keeps its table inside the function body, because the bindings serialize that function into the page, so the only way to give the bundle a smaller table is to build it with one.
15
+ - `npm run build` removes a `surea11y.i18n.<locale>.js` whose locale no longer exists. `files` matches those by glob, so a dropped language would otherwise have kept shipping.
16
+ - New `engineOptions.messages`, a `{ [locale]: { key: text } }` map checked before the built-in tables. It can override individual strings or supply a language the build does not carry, and it is how a locale side file reaches the in-page runner. Omitted keys fall back normally.
17
+ - Locale codes are matched case-insensitively, so `pt-br` and `PT-BR` both find `pt-BR.json`. Only an exact spelling matched before, which would have caught the first contributor to add a regional file. A code differing from its dictionary only in case reports `ok` rather than `primary-subtag` — no fallback happened.
18
+ - A locale code carrying a subtag now falls back to its base language before falling back to English: `de-DE` and `de-AT` both use `de.json`, matched case-insensitively, and an exact `de-DE.json` still wins if one exists. Previously any subtag resolved straight to English, so a browser-supplied `de-DE` produced English output while `de` produced German. `engine.locale.reason` reports `primary-subtag` for it. A translator now only needs `pt.json` to serve every Portuguese variant; a regional file is for when the wording genuinely differs.
19
+ - Locale sources are JSON (`src/i18n/*.json`) rather than CommonJS modules. A translation is now a data change with no executable code in the diff, and a contributor needs no build knowledge. The generated `src/core.js` and `surea11y.browser.js` are byte-identical across the conversion. `src/i18n/*` was already documented as internal and has never been in the published `files` allowlist, so nothing importable changed.
20
+ - `npm run i18n:new` writes a locale file mirroring `en.json`'s key order. Its refusal to overwrite an existing locale now points at `i18n:sync`, which updates a file without discarding translations.
21
+ - Locale completeness is now asserted for every locale rather than for a hand-maintained list of the ones documented as complete; `i18n:sync` makes full key parity the only valid state.
22
+ - `aria-deprecated-role` occurrences resolve their hint through `ariaDeprecatedRole_guidance_directory`/`_generic`/`_default`, replacing `ariaDeprecatedRole_hint_fail`/`_hint_cantTell`. Reason codes are unchanged, so existing baselines keep matching.
23
+ - `formControl_programmaticLabelQuality_summary_cantTell` interpolates `{{method}}` in place of `{{methodLabel}}`, which held the same value once an unreachable branch was removed.
24
+ - `css-orientation-lock` reports a rule whose selector cannot be read through `cssOrientationLock_summary_fail_unknownSelector` instead of substituting a placeholder selector name.
25
+ - The HTML report's meta bar carries the resolved locale alongside the engine tag and schema version, naming the requested locale too when the two differ. A report generated in a locale the engine does not carry previously read as an ordinary English one. A result from an older engine has no `engine.locale` and gets no chip.
26
+ - `target-size-minimum` now reports `cantTell` instead of `fail` when an undersized inline link's only spacing conflict is another inline link in the same run of text (e.g. pipe-separated links in a `<nav>`). The SC 2.5.8 inline exception ("the target is in a sentence or its size is otherwise constrained by the line-height of non-target text") plausibly covers such a link, but whether it applies isn't decidable from geometry — so the outcome shouldn't flip between `fail` and `pass` on the wrapping element's tag alone, which is exactly what happened before: the same links failed inside a `<nav>` yet passed inside a `<p>`, because the strict inline-text exception (`isInlineTextExceptionTarget`) requires a text-block container (`p`, `li`, `dd`, ...). The strict exception (link inside a text block → pass outright) is unchanged; the new middle tier is scoped narrowly by a shared `isInlineLinkTarget` helper (link-like, rendered inline/inline-*, with visible text) and only downgrades when both the target and its conflicting neighbor match, so an inline link crowding a `<button>` or a block-displayed nav link still fails. New `cantTell` locale strings (`targetSizeMinimum_summary_cantTell_inlineLinkRun`/`_hint_`) in all four locales; occurrences carry reason code `undersized-inline-link-run`.
27
+
28
+ ### Fixed
29
+ - `docs/TROUBLESHOOTING.md` still described two shipped locales and warned that key parity had to be maintained by hand. There are four, and `i18n:sync` with its build check is exactly what removed that risk.
30
+ - The README brand tag rendered twice on npm, once per theme. It relied on `#gh-dark-mode-only` and `#gh-light-mode-only`, fragments only GitHub acts on; every other renderer drew both images. A `<picture>` with a `prefers-color-scheme` source picks one variant on GitHub and npm alike. Its paths are absolute, since the tarball ships `docs/**/*.md` but not the SVGs beside them.
31
+ - A `fail` resting only on elements the engine could not walk to the root of is reported as `cantTell`. Ancestor walks stop after 200 steps so a malformed tree cannot hang a scan, but past that depth the walk cannot show an element is exposed — and three rules were asserting violations on content nested inside `aria-hidden`, which is the one outcome this engine must not produce. An occurrence alongside one the walk did reach still keeps the `fail`, since a single confirmed element justifies it. Counted as `ancestorsIncludingSelf.truncated` in `perfStats`. This only reaches rules that report their element through `helpers.reportOccurrence`; the rest keep the old behaviour until they are migrated.
32
+ - `region` dominated the runtime of any page it fired on, taking roughly four minutes on a thousand unplaced elements and scaling cubically from there. It hand-built its occurrence objects, and an occurrence that arrives without its element makes the engine re-find one with `document.querySelector` to build `structuralPath` — a document-wide query per occurrence. It reports the element now: the same page takes under a second, with identical occurrences. A new `perfStats` counter, `structuralPath.selectorFallback`, makes the expensive path visible, `docs/RULE_AUTHORING.md` §4.3 documents it as a performance contract rather than a convenience, and `tests/structural-path-fallback.test.js` ratchets the count of rules still building occurrences by hand so it can only shrink. Every rule now reports its element: `button-name-present` on 500 unnamed buttons went from 36.9s to 0.3s, and the ancestor-walk downgrade reaches all of them rather than only the 41 that already did. Output is byte-identical across all 130 fixtures throughout — the engine fills `selector` and `html` with the same helpers the rules were calling.
33
+ - `contrast-minimum`, `contrast-enhanced` and `contrast-computable` never examined text inside an open shadow root. Their text collection used a `TreeWalker`, which stops at a shadow boundary, and a `querySelectorAll` for value-bearing inputs, which does not cross one either — so low-contrast text in a web component was reported as `notApplicable` rather than checked, and `contrast-computable` did not flag it as unresolvable either. Every open shadow root beneath a scan root is now walked in its own right, nested roots included. `includeShadowDom: false` still skips them, closed roots remain unreachable, and text is counted once whether it is slotted or not. Background resolution already crossed the boundary and is unchanged.
34
+ - `aria-roles-valid` and `aria-deprecated-role` missed a hidden shadow host. Their ancestor walk used `parentElement`, which stops at a shadow root, so a host carrying `aria-hidden` was never seen from inside its own shadow content and the rule reported anyway. The walk now follows the composed tree, stepping over the shadow root to reach the host.
35
+ - `aria-roles-valid` and `aria-deprecated-role` now treat an `inert` subtree as programmatically hidden, alongside `display:none`, `visibility` and `aria-hidden`. The ACT glossary those two follow predates `inert` and names only the other three, but an inert subtree is out of the accessibility tree entirely, so a role on it reaches nobody. No ACT test case for either rule uses `inert`, so consistency with ACT is unaffected. Rules that judge attribute *syntax* are deliberately unchanged — that is a static-markup property, valid or not regardless of what is visible today.
36
+ - The committed WCAG coverage report was stale, still describing `bypass-blocks-present` as an automatic rule under `src/checks/automatic/` after it became manual. The generator stamped a generation time into both files, so every run produced a diff and real drift had nowhere to show. The timestamp is gone (git records when the file changed), output is byte-stable across runs, and `npm run coverage:check` fails on drift the way the ARIA and language-subtag generators already do. CI runs it.
37
+ - A `runOnly` filter given as a comma-separated string (`includeRuleIds: 'img-alt-present'`) was silently dropped and every rule ran. The normalizer had always accepted a string; the check deciding whether `runOnly` carried any filters at all only recognised arrays, so the filter was parsed and then discarded. `ENGINE_OPTIONS.md` states these fields mirror their `engineOptions` counterparts, which have always taken a string. A bare array (`runOnly: ['img-alt-present']`) is still ignored, as documented.
38
+ - A partial `engineOptions.messages` entry replaced the built-in dictionary for that locale instead of layering over it, so overriding one German string silently returned every *other* string in English. `docs/I18N.md` promised the opposite ("keys you don't supply fall back normally"). A supplied dictionary now sits on top of the built-in one for the same locale, and completeness counts both layers, so a one-key override no longer reports `partial-dictionary` either.
39
+ - `engineOptions.locale` set to an inherited property name — `constructor`, `__proto__`, `toString` — was reported as the resolved locale, because the built-in table was tested for truthiness rather than for an own property holding a dictionary. The text stayed English, so the damage was confined to `engine.locale`, the one field whose job is to be accurate about which language came back.
40
+ - A malformed `engineOptions.messages` entry (`{ de: null }`, a string, an array) could crash a scan rather than being ignored.
41
+ - `npm run validate:automatic-rules` and `validate:manual-rules` both failed, and had for some time, because nothing ran them. Three separate bugs: the module contract asserted rules export *exactly* `id`, `meta`, `runInPage`, but 14 legitimately export `applicability` too; the free-variable scan stripped single-quoted strings before double-quoted ones, so an apostrophe inside a double-quoted hint opened a phantom string and exposed the code after it; and regex literals were not stripped at all, so `replace(/"/g, ...)` did the same thing with its quote. All three are fixed, `applicability` is validated as a function when present, and the scan still rejects a bare `id`/`meta`, `{ id }` shorthand, `require(` and `import` while accepting `.id`, `id:` as a key, the word "id" in prose, and a regex containing a quote — `tests/validate-rule.test.js` covers each case. Both validators now run in CI as `npm run validate:rules`, so they cannot rot unnoticed again. `CONTRIBUTING.md` described the wrong contract and has been corrected.
42
+ - `docs/RULE_TEMPLATE.js` told rule authors to name i18n keys `checks.<ruleId>.occurrence.<case>.summary`. No key in `en.json` has ever used that shape — 124 of the 125 rules use `<ruleName>_summary_<outcome>`, and the one exception uses `rules.<rule-id>.…`, not `checks.…`. A rule copied from the template would have invented keys in a namespace nothing resolves, and `i18n:sync` would then have propagated them to every locale. The template now states the real convention, including the optional `_<case>` discriminator, and points at the `en.json`/`i18n:sync` workflow. `docs/RULE_TEMPLATE.md` was already correct, which is how the `.js` copy went unnoticed.
43
+ - `aria-deprecated-role`'s hint was English in every locale. Its two hint strings were `"{{guidance}}"` — a bare placeholder — and the text behind it was a hardcoded English literal passed in as a parameter, so a German, Spanish or French scan returned German, Spanish or French output with one English sentence in it. No coverage report could see this: the key was present in every dictionary and `engine.locale` reported a clean resolution. The guidance now lives in the dictionaries and is translated in all four locales. `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value that carries no translatable text, so a passthrough cannot be reintroduced.
44
+ - `duplicate-id-aria` now reports `cantTell` instead of `fail`. A duplicated id doesn't break the reference — it resolves to the first element in tree order, so the name is still computed. Whether that's the element the author meant isn't in the markup, so the engine shouldn't assert a violation. WCAG 4.1.1, which covered duplicate ids outright, was removed in 2.2; what's left is 4.1.2, and that needs the name to be demonstrably wrong. New `cantTell` strings in all four locales; the reason code is unchanged, so existing baselines keep matching.
45
+ - `duplicate-id-aria` reported duplicates that sat outside the scanned scope, so a run using `contextSelector` or `excludeSelectors` flagged elements it was never asked about — noise in component tests especially. Detection stays document-wide, since id uniqueness is a document property, but occurrences are now limited to the scanned scope.
46
+
47
+ ## [1.4.1] - 2026-08-13
48
+
49
+ ### Added
50
+ - `scripts/generate-aria-tables.js` generates `aria-allowed-attr`'s global/per-role/implicit-role tables from the `aria-query` package (a devDependency, output committed) instead of hand-maintaining them, with a `--check` mode that fails when the committed tables go stale.
51
+ - `scripts/generate-language-subtags.js` writes the IANA primary language subtags into `dom-helpers` from the `language-subtag-registry` package (a devDependency), same `--check` convention. New shared `helpers.isValidLanguageTag` backs both `valid-lang` and `html-lang-attr-present`.
52
+
53
+ ### Changed
54
+ - `form-control-single-label` now grades by whether surplus `<label>`s actually compete for the accessible name, instead of always failing on label count: passes when an `aria-labelledby`/`aria-label` override supersedes the native labels (they contribute nothing to the name); `cantTell` when one non-empty label is joined by empty label associations with no override; fails only when two or more non-empty labels genuinely compete. Uses the same shared `labelContributesAccessibleName` helper as `form-control-programmatic-label-present`, so the two agree. Clears a false positive on the Angular Material selectable-card pattern, where a card adds an empty `<label for>` on top of Material's own label while the control is named by `aria-label`. New `cantTell` locale strings in all four locales.
55
+ - `bypass-blocks-present` is now a manual rule (`cantTell`-capped) instead of automatic (`fail`-capable). WCAG 2.4.1 is about blocks "repeated on multiple Web pages" — whether a block is actually repeated across the site isn't decidable from one document, so a page that legitimately needs no bypass mechanism is indistinguishable from one that omits a required one. A single-snapshot scan can also catch a page mid-modal, when the real `<main>`/headings are correctly `aria-hidden`/`inert` for that state and only the dialog is exposed. Finding a recognized mechanism (a main landmark, a working same-page anchor, or a heading) still resolves to `notApplicable`; finding none now returns `cantTell` instead of asserting a violation the engine can't actually confirm. The same-page-anchor check is also now shadow-DOM-aware.
56
+ - The concrete and abstract ARIA role sets in `aria-helpers` are now generated from `aria-query` too, alongside the attribute tables, so Digital Publishing roles (`doc-biblioref`, etc.) are recognised instead of reported as unknown. Three ARIA 1.3 roles the package doesn't carry yet (`comment`, `suggestion`, `text`) are supplemented back.
57
+ - `aria-roles-valid` and `aria-deprecated-role` now skip programmatically hidden elements, where a role has no effect (ACT 674b10).
58
+ - `autocomplete-valid` now follows ACT 73f2c2's applicability: the `on`/`off` toggle, disabled controls (including `aria-disabled`), and input types with a fixed value (`submit`, `checkbox`, ...) are out of scope, since `autocomplete` can't describe a purpose for any of them.
59
+ - `meta-viewport-zoom-enabled` now applies only when `content` sets `maximum-scale` or `user-scalable` — setting neither can't restrict zoom, so there's nothing to judge.
60
+ - `aria-allowed-attr` now covers all 127 concrete ARIA roles instead of 35 hand-listed ones, so `button`, `link`, `img` and 27 other common roles are no longer skipped.
61
+ - Form-control naming is now split by native element vs. explicit ARIA role, so a control isn't reported by two rules at once: `binary-control-name-present`/`slider-name-present` are role-only, and `form-control-programmatic-label-present` skips a control whose explicit role has its own naming rule. An unlabelled checkbox used to produce two findings; now one.
62
+
63
+ ### Fixed
64
+ - `contrast-minimum`/`contrast-enhanced` no longer report text hidden with the sr-only clip technique (`clip: rect(0,0,0,0)`/`clip-path: inset(50%+)`, the pattern Angular CDK's live-announcer, Bootstrap's `.visually-hidden` and most `.sr-only` implementations use) — there's no visually-presented color to check. `opacity: 0` and off-screen positioning stay in scope, since either can be a single-property mistake on text meant to be visible, unlike the multi-property clip pattern.
65
+ - `aria-allowed-attr` failed the four ARIA 1.2 ex-globals (`aria-disabled`, `aria-errormessage`, `aria-haspopup`, `aria-invalid`) on any role that doesn't support them, but they're deprecated on that role, not prohibited — still allowed, just discouraged. Now reports `cantTell` (reason `ARIA_ATTR_DEPRECATED`) for those four instead of `fail`; a genuinely unsupported attribute (never a global, never deprecated on the role) still fails. Same "deprecated but allowed" treatment as the `role="generic"` fix below, applied to attributes instead of roles. New `cantTell` locale strings in all four locales.
66
+ - `aria-deprecated-role` failed `role="generic"`, but WAI-ARIA 1.2 §5.4 states that rule at SHOULD-NOT strength ("primarily for implementors of user agents") — the usage is conforming, so failing it was a false positive. Now reports `cantTell` under reason code `ARIA_ROLE_AUTHOR_DISCOURAGED`, alongside the existing deprecated-role case. `fail` is retained for a role carrying an author MUST NOT; no ARIA 1.2/1.3 role does, outside the abstract roles `aria-roles-valid` already covers. Reconciled the deprecation data (in `aria-helpers`, since `aria-query` doesn't carry it) against the full WAI-ARIA 1.2 role characteristics tables — see `docs/ARIA_DEPRECATION.md`.
67
+ - `nested-interactive-controls-absent` matched on role membership alone, so it failed a `role="listbox"` owning `role="option"` children, a `tablist` owning `tab`s, and every other composite widget whose managed children legitimately carry a widget role — an Angular Material autocomplete panel driven entirely via `aria-activedescendant`, with nothing inside it a separate focus target, was reported although WCAG 4.1.2's concern is two *operable* controls occupying one place. A descendant now counts only when it's also focusable (`helpers.getFocusableInfo`, accounting for `contenteditable`, `:disabled`, `inert`, invalid/negative `tabindex`); a genuinely focusable nested control (a `<button>` inside an `<a href>`, an option given its own `tabindex`) still fails.
68
+ - `nested-interactive-controls-absent`'s focusability gate (above) still over-flagged a composite widget using the roving-tabindex pattern, where the active owned child genuinely carries `tabindex="0"` and so passed the focusable check despite being a managed part of its container, not an independent nested control. Added an explicit owned-child map (`option`→`listbox`/`combobox`, `tab`→`tablist`, `treeitem`→`tree`, `menuitem(checkbox|radio)`→`menu`/`menubar`, `radio`→`radiogroup`); a child matching both the role and a matching ancestor container is exempt regardless of its own tabindex. An orphan role with no owning container (e.g. a stray `role="option"` outside any `listbox`) is unaffected and still counts if focusable.
69
+ - `avoid-inline-spacing` failed every inline `line-height`/`letter-spacing`/`word-spacing` declared `!important`, whatever the value — so `line-height: 2em !important` on 16px text was reported even though it already exceeds what WCAG 1.4.12 asks for. The criterion is about the resulting metrics, not the `!important` keyword: a forced value that already meets them leaves the user nothing to override. Now fails only below 1.5× font size for line-height, 0.12× for letter-spacing, 0.16× for word-spacing, taken from computed style where laid out and from the declared value otherwise. Applicability follows ACT: visible text of its own, rendered, not positioned off-screen; `inherit`/`unset` specify no spacing and are out of scope, `initial`/`revert` resolve to `normal` and stay in scope.
70
+ - `table-th-has-data-cells` failed every `<th>` in any table with zero `<td>`, so a `role="presentation"` layout table carrying a stray `<th>`, a table whose only header was `display:none`/`aria-hidden`, or a `<th role="cell">` were all reported. Per ACT d0f69e a header cell now counts only while visible, in the accessibility tree, and not overridden to a role other than `rowheader`/`columnheader`; an explicit `role="rowheader"`/`role="columnheader"` stays in scope.
71
+ - `label-in-name` compared label and name by character containment (`indexOf` on lowercased strings), which is wrong in both directions per WCAG 2.5.3's actual word-based algorithm: it accepted `aria-label="Discover Italy"` for the visible text "Discover It" (a substring, though "it" isn't the word "italy"), and rejected `aria-label="Search by date (YYYY-MM-DD)"` against "Search by date" or a label with decorative punctuation/emoji, though each matches once normalised. Now compares word lists — parenthesised text stripped, case-folded, NFKD-normalised, non-alphanumerics collapsed to spaces — and checks the label's words appear adjacent and in order in the name. An abbreviation or a differently-hyphenated word (`"University Ave."` vs `"University Avenue"`) now reports `cantTell` instead of failing, since neither is decidable from markup; a genuine mismatch alongside an uncertain one still fails. Two new locale strings in all four locales.
72
+ - `img-alt-present`, `object-text-alternative-present` and `canvas-text-alternative-present` used `isAccTreeEligible`, which keeps a tabbable element eligible under `aria-hidden`, so they reported elements no assistive technology can reach. All three now use `isIncludedInAccessibilityTree`, matching the `*-name-present` rules; a plain decorative `aria-hidden` image (the attribute's ordinary use) was already out of scope and is unaffected.
73
+ - `aria-roles-valid` judged only the first token of the `role` attribute, so `role="searchfield searchbox"` was reported invalid — `role` takes a fallback list, and a browser resolves it to the first token it recognises (`searchbox` here, confirmed against Chrome). The rule now fails only when no token names a concrete role.
74
+ - `autocomplete-valid` accepted a contact modality token before a non-contact field, so `"work photo"` passed even though a contact token is only valid when a contact field follows it (`"work email"` is fine, `"work photo"` isn't).
75
+ - `valid-lang` and `html-lang-attr-present` checked shape only (`/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/`), so `lang="eng"` and `lang="em-US"` passed although neither is a registered primary subtag (the registry lists a three-letter code only when no two-letter one exists — that's why `"en"` is registered and `"eng"` isn't). Both now validate against the real IANA registry. `valid-lang` also now applies only where text actually inherits the language from the element, and a whitespace-only value fails.
76
+ - `meta-viewport-zoom-enabled` passed values it couldn't parse (`user-scalable=0.5`, `maximum-scale=invalid`, `maximum-scale=yes`), even though CSS Device Adaptation treats an unparseable value as `0` — which disables zoom exactly like an explicit `0` does. A negative `maximum-scale` is out of range and correctly still passes.
77
+ - `meta-refresh-timing-absent` and `meta-refresh-no-exceptions` reported directives a browser never acts on (`"foo; URL=x"`, `"+72001"`, `"0:1"`), since a malformed value makes the whole directive invalid rather than falling back to a default delay. Both now parse `content` with the shared declarative-refresh steps.
78
+ - `aria-allowed-attr` only looked at `[role]`, so an ARIA state/property on an element with no explicit role went unjudged (e.g. `<button aria-sort>`, `<p aria-checked>`) even though ACT 5c01ea applies to any element in the accessibility tree. The generator now also emits an implicit-role table, gated by an explicit allowlist of elements whose role is unconditional (verified against Chrome's own accessibility tree) so a future `aria-query` update can't silently widen the rule.
79
+ - `aria-allowed-attr` treated `aria-disabled`, `aria-haspopup`, `aria-invalid` and `aria-errormessage` as global attributes, so misuse on a role that doesn't support them went unreported; the generated table has the real 17 globals. It also no longer judges attributes against `role="none"`/`"presentation"`, since presentational role conflict resolution can drop that role on a focusable element — `presentation-role-conflict` already owns that case.
80
+ - `input-image-alt-present` treated `alt=""` as marking an image button decorative and passed it, but an image button is a control, not decoration — ACT 59796f requires a non-empty name, so an empty `alt` (or no `alt` at all) now fails. `alt=""` combined with `aria-label`/`aria-labelledby`/`title` still passes, since the control does have a name; that judgment call moved entirely to `input-image-alt-decorative`, so one element now yields one finding instead of two.
81
+ - `input-image-alt-present` now scopes to elements included in the accessibility tree (was the looser `isAccTreeEligible`), and rejects an accessible name equal to the browser's own fallback for an image button (`"Submit Query"`/`"Submit"`) as no name at all, per ACT 59796f. New locale strings for the fallback-name case in all four locales.
82
+ - `form-control-programmatic-label-present` used the looser `isAccTreeEligible` check, which keeps a focusable `aria-hidden` control "eligible", so it reported controls no assistive technology can reach. Switched to `isIncludedInAccessibilityTree`, matching the `*-name-present` rules and settling an existing inconsistency with `binary-control-name-present`, which already excluded the same case.
83
+ - The 18 `*-name-present` rules judged an element whether or not it was actually reachable by assistive technology, so a focusable element inside `aria-hidden` (both the tabbable and the IDREF-referenced case) got a real pass/fail verdict even though ACT's own glossary says such elements aren't in the accessibility tree at all — Chrome agrees, reporting `ignored: true` with no name. New shared `helpers.isIncludedInAccessibilityTree` routes all 18 rules to `notApplicable` there instead; the focus-order defect itself is still reported by `aria-hidden-focus`, which already owns it.
84
+ - `link-name-present`/`button-name-present` credited an accessible name from content regardless of an explicit `role`, so `<a href role="alert">Text</a>` passed even though Chrome computes an empty name for it. Naming from content is now gated by the ARIA 1.2 §5.2.8.5 allowlist of roles that actually support it; an unrecognised role falls back to the implicit `link`/`button` role and is unaffected. That allowlist initially missed module roles that *inherit* the behaviour from a superclass — `doc-noteref` inherits from `link`, so `<a role="doc-noteref"><sup>1</sup></a>` lost the name Chrome still reports (`"1"`) — so it's now generated from each role's `nameFrom` instead of hand-enumerated.
85
+ - `landmark-banner-is-top-level` and `landmark-contentinfo-is-top-level` selected any roleless `<header>`/`<footer>` as a candidate, regardless of nesting — but per HTML-AAM those elements have no banner/contentinfo role at all once descended from `article`/`aside`/`main`/`nav`/`section`, so there was no landmark there to be "nested". Both now select through the same suppression-aware role lookup the rest of the file already used, which cut false positives sharply on a sample corpus (236/236 for banner, 1/2 for contentinfo). An explicit `role="banner"`/`role="contentinfo"` is unaffected and still flagged when genuinely nested.
86
+ - `getContentNameInfo` let a descendant's `title` outrank its own text when computing a name from subtree content, so `<a title="T">Text</a>` could name itself `"T"` instead of `"Text"`. Content now wins over `title` unless the descendant's content is empty, matching Chrome and the accname spec. Same class of bug as the earlier `alt`-vs-`title` fix for image descendants, just never applied to the generic case.
87
+
7
88
  ## [1.4.0] - 2026-08-08
8
89
 
9
90
  ### Added
@@ -146,12 +227,12 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
146
227
  - Full i18n support (English complete, French partial — see `docs/I18N.md`).
147
228
  - A generated public rule catalog (`docs/RULE_CATALOG.md`, via `npm run docs:rule-catalog`) and WCAG facet-coverage report (`coverage/coverage-report.md`, via `npm run coverage`).
148
229
  - This documentation set: `README.md`, `docs/OUTPUT_SCHEMA.md`, `docs/ENGINE_OPTIONS.md`, `docs/WCAG_CONFORMANCE.md`, `docs/POLICY.md`, `docs/I18N.md`, `docs/INTEGRATION.md`, `docs/LIMITATIONS.md`, `docs/TROUBLESHOOTING.md`, `LICENSE`.
149
- - Multi-region `contextSelector` support: pass an array of selectors (or one comma-separated selector string) to scan multiple, possibly disjoint regions in a single run — equivalent to the multi-region include capability found in other engines. Overlapping/nested regions are deduped automatically. See `docs/ENGINE_OPTIONS.md`.
230
+ - Multi-region `contextSelector` support: pass an array of selectors (or one comma-separated selector string) to scan multiple, possibly disjoint regions in a single run. Overlapping/nested regions are deduped automatically. See `docs/ENGINE_OPTIONS.md`.
150
231
  - `includeShadowDom` now defaults to `true` (opt out with `includeShadowDom: false`).
151
- - `structuralPath` on every `fail`/`cantTell` occurrence: a sibling-index path from `documentElement` down to the flagged element, a more robust element-identity mechanism than `selector` alone (survives some DOM changes a selector wouldn't) — equivalent to the ancestry/xpath-addressing feature found in other engines. See `docs/OUTPUT_SCHEMA.md`.
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`.
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.
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.
232
+ - `structuralPath` on every `fail`/`cantTell` occurrence: a sibling-index path from `documentElement` down to the flagged element, a more robust element-identity mechanism than `selector` alone (survives some DOM changes a selector wouldn't). See `docs/OUTPUT_SCHEMA.md`.
233
+ - `engineOptions.customRules`: register additional rules at runtime, scan-scoped (not added to the static catalog), matching the shape of an internal rule module (`{ id, meta, runInPage, applicability?, data? }`). `runInPage`/`applicability` accept a real function or a function-source string, the latter needed for cross-realm callers (e.g. Playwright) whose `engineOptions` argument can't carry a live function across a serialization boundary. See `docs/ENGINE_OPTIONS.md`.
234
+ - `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including genuinely cross-origin) scanning for the "plain script injection" consumption mode (no automation driver) — a cooperative `postMessage` protocol, with one real limitation (a non-cooperating child frame is unreachable). Bundler-free, like `runa11yCoreInPage`. See `docs/INTEGRATION.md`'s "Cross-frame scanning" section and `docs/OUTPUT_SCHEMA.md`'s "Cross-frame result" section.
235
+ - `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion (a 2.1/2.2-introduced SC is tagged only with its true origin version, never also the pre-existing baseline tag), so a caller can select a WCAG 2.0/2.1/2.2 conformance target by combining tag sets. See `docs/ENGINE_OPTIONS.md`'s "Filtering by WCAG version" section and `src/coverage/wcag-version-map.js` for the canonical per-version SC list.
155
236
  - `docs/BINDING_AUTHORS_GUIDE.md`: a reference for building a *new* framework binding (Puppeteer, Cypress, ...) on top of this engine — what's already engine-level vs. what every binding has to build itself, checked against what the `@surea11y/playwright` sibling project actually needed.
156
237
 
157
238
  ### Fixed (selected)
@@ -163,9 +244,9 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
163
244
  - `contextSelector` resolving via `document.querySelector` (first match only) instead of `querySelectorAll` — a selector matching several elements silently scanned only the first, dropping the rest with no indication.
164
245
  - Three rules (`form-control-programmatic-label-present`, `target-size-minimum`, `label-in-name`) that queried `ctx.root` directly instead of through the shared `queryAllSmart`/`queryAll` helpers, found while implementing multi-region `contextSelector` support — silently broke (found nothing) the moment `ctx.root` became an array.
165
246
  - `aria-required-parent`/`aria-required-children`'s ancestor/descendant searches and `getContentNameInfo`'s "name from content" walk not following shadow-DOM `<slot>` assignment; a duplicated `resolveAriaLabelledbyText` pattern across 16 rules and `getLabelText` across 7 not checking an `aria-labelledby`/`<label>` target's `title` attribute as a final accname fallback (e.g. an `<iframe title="...">` target, whose content is always empty).
166
- - `landmark-one-main` incorrectly also flagged "more than one main landmark" — out of its real scope (the equivalent check in other engines is presence-only; duplicates are `landmark-no-duplicate-main`'s job, already implemented correctly) and missing the accessibility-tree visibility filter its sibling rule already has.
247
+ - `landmark-one-main` incorrectly also flagged "more than one main landmark" — out of its real scope (this rule checks presence only; duplicates are `landmark-no-duplicate-main`'s job, already implemented correctly) and missing the accessibility-tree visibility filter its sibling rule already has.
167
248
  - `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`, and `aria-required-attr` flagged containers/elements that were not currently exposed to the accessibility tree at all (`hidden`, a closed `<dialog>`, etc.) — e.g. a closed flyout `role="menu"` populated on open, or a custom `role="checkbox"` whose `aria-checked` is set on hydration. All four now skip elements that fail `isAccTreeEligible`; `aria-required-children`/`aria-required-attr` also honor `aria-busy="true"` as an explicit author signal of transient incompleteness (WAI-ARIA's own escape hatch for required owned elements, extended by analogy to required attributes).
168
- - `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source (only `aria-label`/`aria-labelledby`) — confirmed against another engine's real scan that `title` is a valid landmark-naming fallback. Replaced all 7 copies with one shared helper, `helpers.getLandmarkNameInfo`.
249
+ - `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source (only `aria-label`/`aria-labelledby`), though `title` is a valid landmark-naming fallback. Replaced all 7 copies with one shared helper, `helpers.getLandmarkNameInfo`.
169
250
 
170
251
  ### Known limitations
171
252
  See `docs/LIMITATIONS.md` — structural (keyboard-trap detection, reflow-at-zoom), environment-dependent (jsdom vs. real-browser geometry), and deliberately-not-automated (text-quality judgment calls) limitations, stated explicitly rather than left to be discovered.
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # @surea11y/core
2
2
 
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)
3
+ <a href="https://www.npmjs.com/package/@surea11y/core"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-dark.svg"><img alt="surea11y core" src="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-light.svg"></picture></a>
5
4
  [![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
5
  [![node](https://img.shields.io/node/v/@surea11y/core?style=flat-square&label=node&labelColor=101413&color=3A4441)](package.json)
7
6
  [![license](https://img.shields.io/badge/license-MPL--2.0-3A4441?style=flat-square&labelColor=101413)](LICENSE)
@@ -281,6 +280,22 @@ the same `runa11yCoreInPage` function described above — calling it runs a
281
280
  real scan against the page it's loaded into and returns the same result
282
281
  shape documented in [Understanding the Results](#understanding-the-results).
283
282
 
283
+ The bundle carries English only, to keep the download from growing with
284
+ every language added. For another language, load its file after the bundle:
285
+
286
+ ```html
287
+ <script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
288
+ <script src="node_modules/@surea11y/core/surea11y.i18n.fr.js"></script>
289
+ <script>
290
+ const result = a11ycore.runa11yCoreInPage(location.href, null, { locale: "fr" }, null);
291
+ </script>
292
+ ```
293
+
294
+ Ask for a language you haven't loaded and you get English rather than an
295
+ error, with `result.engine.locale` saying so. The npm package is
296
+ unaffected — `require("@surea11y/core")` has every locale built in. See
297
+ [`docs/I18N.md`](./docs/I18N.md).
298
+
284
299
  `contextSelector`, `engineOptions`, and `runOnly` are the same three
285
300
  arguments described throughout this README and `docs/ENGINE_OPTIONS.md` —
286
301
  nothing about calling the engine changes just because it's loaded this
@@ -459,7 +474,8 @@ The repository is organised so that the accessibility engine, rule
459
474
  implementations and supporting infrastructure remain clearly separated.
460
475
 
461
476
  ```text
462
- surea11y.browser.js # Generated standalone browser bundle
477
+ surea11y.browser.js # Generated standalone browser bundle (English)
478
+ surea11y.i18n.<locale>.js # Generated per-locale side files for that bundle
463
479
 
464
480
  src/
465
481
  index.js # Public API
File without changes
@@ -6,7 +6,7 @@
6
6
 
7
7
  Removing, renaming, or changing the type/meaning of any of these is a **major** version bump:
8
8
 
9
- - Top-level result: `engine.tag`, `engine.schemaVersion`, `url`, `checksResults` (an array), `rulesResults` (an array).
9
+ - Top-level result: `engine.tag`, `engine.schemaVersion`, `engine.locale` (the field and its `requested`/`resolved`/`reason` keys — the set of `reason` *values* is open and may gain entries in a minor), `url`, `checksResults` (an array), `rulesResults` (an array).
10
10
  - Each `checksResults[i]` / `rulesResults[i]` entry: `ruleId`, `outcome`, `outcomeNormalized`, `severity`, `confidence`, `type`, `title`, `description`, `meta` (including `meta.normativeMappings`, `meta.deprecated`/`.deprecation` — see below), `engineOptions`, `schemaVersion`.
11
11
  - Each occurrence (`occurrences[i]`): `selector`, `html`, `summary`, `hint`, `i18n`, `structuralPath`.
12
12
  - The rule **catalog** (`getChecksCatalog()`/`getRulesCatalog()`, a separate surface from a scan result — see `RULE_AUTHORING.md`): `ruleId`, `title`, `description`, `tags`, `wcagSc`, `normativeMappings`, `defaultSeverity`, `defaultConfidence`, `type`, `deprecated`/`.deprecation`. Note `tags` lives here, not on a per-scan `checksResults[i].meta` — the two surfaces intentionally carry different subsets of a rule's metadata.
@@ -41,7 +41,7 @@ Declaring this map is what lets the engine's internal file layout change without
41
41
  - **Minor**: adding a new stable field, adding a new rule to the catalog, or marking an existing rule `deprecated` (see below).
42
42
  - **Major**: removing or renaming a stable field, changing a stable field's type or meaning, or removing a rule ID once its deprecation notice period has passed. Paired with an `engine.schemaVersion` bump specifically when the *shape* changes (as opposed to package-level major bumps for other reasons, e.g. dropping support for an old Node version).
43
43
 
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.
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. `engine.locale` is the second: a new field next to the existing ones, with no change to any field a consumer already reads.
45
45
 
46
46
  ## Release cadence
47
47
 
@@ -0,0 +1,95 @@
1
+ <!-- SPDX-License-Identifier: MPL-2.0 -->
2
+
3
+ # ARIA deprecation handling
4
+
5
+ WAI-ARIA states two strengths of author rule. SHOULD NOT leaves the usage
6
+ conforming; MUST NOT does not. The engine grades on that distinction:
7
+ deprecated or otherwise discouraged usage resolves to **`cantTell`**, so the
8
+ author decides, and only prohibited usage `fail`s.
9
+
10
+ `aria-query` encodes no deprecation status — its role fields are `props`,
11
+ `requiredProps` and `prohibitedProps`, and it simply drops a deprecated
12
+ property from the role's `props`. Left alone that turns every deprecated
13
+ pairing into a not-allowed `fail`. The deprecation data therefore lives in a
14
+ spec-derived layer the engine owns, the same pattern as `SUPPLEMENTAL_GLOBALS`
15
+ (the 1.3 globals aria-query lacks).
16
+
17
+ ## The data layer — `src/core/aria-helpers.js`
18
+
19
+ Four sets and four predicates, consumed by `aria-allowed-attr` (attributes)
20
+ and `aria-deprecated-role` (roles):
21
+
22
+ ```
23
+ DEPRECATED_ATTRS // states/properties deprecated on roles that do not
24
+ // support them -> cantTell
25
+ DEPRECATED_ROLES // roles deprecated but valid -> cantTell
26
+ AUTHOR_DISCOURAGED_ROLES // roles reserved for user agents at SHOULD NOT
27
+ // strength -> cantTell
28
+ AUTHOR_PROHIBITED_ROLES // roles carrying an author MUST NOT -> fail
29
+
30
+ isDeprecatedAttr(attr, role) // role param reserved for per-role granularity
31
+ isDeprecatedRole(role)
32
+ isAuthorDiscouragedRole(role)
33
+ isAuthorProhibitedRole(role)
34
+ ```
35
+
36
+ ## Contents, reconciled against WAI-ARIA 1.2
37
+
38
+ - `DEPRECATED_ATTRS` = `aria-disabled`, `aria-errormessage`, `aria-haspopup`,
39
+ `aria-invalid`. ARIA 1.2 kept these four in the global set as deprecated
40
+ rather than removing them (change log, 07-May-2020), and marks each
41
+ "deprecated on this role" in the characteristics table of every role that
42
+ does not support it — 84 of the 94 role definitions carry at least one such
43
+ annotation. No role treats any of the four as prohibited, so the flat set
44
+ cannot downgrade a prohibited pairing to `cantTell`. These four are also the
45
+ only attributes annotated that way anywhere in the specification.
46
+ - `DEPRECATED_ROLES` = `directory`, the only role marked
47
+ `[Deprecated in ARIA 1.2]`, superseded by `list`.
48
+ - `AUTHOR_DISCOURAGED_ROLES` = `generic`, which §5.4 describes as "primarily
49
+ for implementors of user agents. Authors SHOULD NOT use this role in
50
+ content."
51
+ - `AUTHOR_PROHIBITED_ROLES` is empty. The only author MUST NOT covering roles
52
+ applies to the abstract roles, which `aria-roles-valid` already reports.
53
+
54
+ `aria-dropeffect` and `aria-grabbed` are deprecated in full (since ARIA 1.1)
55
+ but remain global in 1.2 and 1.3, so they are allowed on every role and pass.
56
+ Deliberately: naming them would flag markup no version of the specification
57
+ disallows, and the spec offers no replacement to move to — it records only
58
+ that one is "expected to be replaced by a new feature in a future version".
59
+
60
+ The properties ARIA does prohibit — `aria-label`, `aria-labelledby`, and
61
+ `aria-roledescription` on `generic` — are disjoint from the deprecated set and
62
+ are `aria-prohibited-attr`'s concern.
63
+
64
+ Four pairings pass rather than reporting `cantTell`: `aria-errormessage` and
65
+ `aria-invalid` on `menuitemcheckbox` and `menuitemradio`, which aria-query
66
+ lists among the role's supported properties while ARIA 1.2 marks them
67
+ deprecated there. The generated table follows aria-query, which errs towards
68
+ allowing the usage.
69
+
70
+ ## Verifying a spec revision
71
+
72
+ The role characteristics tables in the specification are machine-readable:
73
+ each role section carries `td.role-properties` (supported), `td.role-inherited`
74
+ (inherited) and `td.role-disallowed` (prohibited), and a deprecated entry is
75
+ suffixed "(deprecated on this role in ARIA 1.2)". Extracting those three cells
76
+ per role gives the full allowed/deprecated/prohibited matrix, which can be
77
+ diffed against the generated tables in `aria-allowed-attr` (`GLOBAL_ATTRS`,
78
+ `SUPPORTED_ATTRS_BY_ROLE`) plus `DEPRECATED_ATTRS` to confirm that no pairing
79
+ the specification allows or merely deprecates resolves to `fail`.
80
+
81
+ ## Applying a later revision
82
+
83
+ A specification change is a data edit; no rule logic changes:
84
+
85
+ - A deprecation promoted to prohibited: remove it from `DEPRECATED_ATTRS` or
86
+ `DEPRECATED_ROLES` so it falls back to the `fail` path, or move a role into
87
+ `AUTHOR_PROHIBITED_ROLES`.
88
+ - A new deprecation: add it to the relevant set.
89
+ - A deprecation that becomes per-role rather than uniform: `isDeprecatedAttr`
90
+ already receives the role, so a `(role, attr)` map replaces the flat set
91
+ behind the same predicate.
92
+
93
+ Then extend `tests/engine-checks/automatic/aria-allowed-attr.test.js` and
94
+ `aria-deprecated-role.test.js`, run `npm run build`, `node scripts/run-tests.js`,
95
+ `npm run format:check` and `npm run i18n:report`.
@@ -18,7 +18,7 @@ runDomRulesInPage(url, null, {}, {
18
18
  });
19
19
  ```
20
20
 
21
- > ⚠️ **`runOnly` must be this object shape, not a bare array.** `runOnly: ['img-alt-present']` (a plain array — a convention used by another engine) is **silently ignored**; the engine runs every rule instead. This is the single most common integration mistake — see [`TROUBLESHOOTING.md`](./TROUBLESHOOTING.md).
21
+ > ⚠️ **`runOnly` must be this object shape, not a bare array.** `runOnly: ['img-alt-present']` (a plain array) is **silently ignored**; the engine runs every rule instead. This is the single most common integration mistake — see [`TROUBLESHOOTING.md`](./TROUBLESHOOTING.md).
22
22
 
23
23
  | Field | Type | Meaning |
24
24
  |---|---|---|
@@ -29,13 +29,15 @@ runDomRulesInPage(url, null, {}, {
29
29
  | `excludeTags` | `string[]` | Never run rules carrying any of these tags, applied after include. |
30
30
  | `includeMode` | `'and'` \| `'or'` | When **both** an ID include and a tag include are given: `'and'` (default) requires a rule to satisfy both; `'or'` runs a rule if it satisfies either. Irrelevant if you only use one dimension. |
31
31
 
32
+ Each of these accepts either an array or a comma-separated string, matching the `engineOptions` form below — `includeRuleIds: 'img-alt-present, button-name-present'` and `includeRuleIds: ['img-alt-present', 'button-name-present']` are equivalent.
33
+
32
34
  Rule IDs are bare (no engine prefix), e.g. `'img-alt-present'`. For backward compatibility, matching also accepts a legacy `a11ycore-`-prefixed form of the same id (`'a11ycore-img-alt-present'`).
33
35
 
34
- A **legacy, tag-filter shape used by other engines** is also accepted as the whole `runOnly` value: `{ type: 'tag', values: ['wcag2a', 'wcag2aa'] }` — equivalent to `{ tags: ['wcag2a', 'wcag2aa'] }`.
36
+ A **legacy tag-filter shape** is also accepted as the whole `runOnly` value: `{ type: 'tag', values: ['wcag2a', 'wcag2aa'] }` — equivalent to `{ tags: ['wcag2a', 'wcag2aa'] }`.
35
37
 
36
38
  ### Filtering by WCAG version (2.1 vs 2.2)
37
39
 
38
- Every rule and composite carries exactly one WCAG-version-origin level tag, matching the convention used by other engines: `wcag2a`/`wcag2aa`/`wcag2aaa` for a Success Criterion that's WCAG 2.0 baseline, `wcag21a`/`wcag21aa`/`wcag21aaa` for one newly introduced in WCAG 2.1 (e.g. `1.3.5` Identify Input Purpose), `wcag22a`/`wcag22aa`/`wcag22aaa` for one newly introduced in WCAG 2.2 (e.g. `2.5.8` Target Size Minimum). A rule gets **only** the tag for its SC's actual origin version — a 2.1-introduced SC is never also tagged `wcag2aa`, since it doesn't exist under a WCAG 2.0 conformance target. See `src/coverage/wcag-version-map.js` for the exact, canonical per-version SC list.
40
+ Every rule and composite carries exactly one WCAG-version-origin level tag: `wcag2a`/`wcag2aa`/`wcag2aaa` for a Success Criterion that's WCAG 2.0 baseline, `wcag21a`/`wcag21aa`/`wcag21aaa` for one newly introduced in WCAG 2.1 (e.g. `1.3.5` Identify Input Purpose), `wcag22a`/`wcag22aa`/`wcag22aaa` for one newly introduced in WCAG 2.2 (e.g. `2.5.8` Target Size Minimum). A rule gets **only** the tag for its SC's actual origin version — a 2.1-introduced SC is never also tagged `wcag2aa`, since it doesn't exist under a WCAG 2.0 conformance target. See `src/coverage/wcag-version-map.js` for the exact, canonical per-version SC list.
39
41
 
40
42
  Since versions are cumulative (2.1 = 2.0 + new; 2.2 = 2.0 + 2.1 + new), select a WCAG-version conformance target by combining tag sets — the engine's OR-matching on `tags` (any one match includes the rule) does the rest:
41
43
 
@@ -71,14 +73,15 @@ runDomRulesInPage(url, null, {
71
73
 
72
74
  ```js
73
75
  const engineOptions = {
74
- locale: 'en', // default 'en'; falls back to 'en' per-string if a key is missing in the requested locale
76
+ locale: 'en', // default 'en'; de-DE falls back to de, then to en per string
77
+ messages: { de: { /* key: text */ } }, // optional caller-supplied dictionaries; win over built-in ones
75
78
  includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
76
79
  includeShadowDom: true, // default true — opt OUT with `false` to skip open shadow roots
77
80
  fragment: false, // default false — set true when the scan target isn't a real page (see below)
78
81
  excludeSelectors: ['#cookie-banner', '.third-party-widget'], // array or comma-separated string
79
82
  timestamp: '2026-07-20T12:00:00Z', // optional — engine has no built-in clock, see OUTPUT_SCHEMA.md
80
83
  perfStats: false, // default false — internal timing counters, debug-only shape
81
- profileRules: false, // default false — per-rule timing breakdown inside perfStats
84
+ profileRules: false, // default false — per-rule timings; needs perfStats, and makes output non-deterministic
82
85
 
83
86
  contrast: {
84
87
  mode: 'strictConformance', // 'strictConformance' (default) | 'auditorAssist'
@@ -115,7 +118,8 @@ const engineOptions = {
115
118
 
116
119
  | Option | Meaning |
117
120
  |---|---|
118
- | `locale` | Any string; resolution is per-string with graceful fallback (requested locale → `en` → the rule's literal English fallback text), so a partially-translated locale never produces missing text. See [`I18N.md`](./I18N.md) for current locale coverage. |
121
+ | `locale` | Any string. A code with a subtag falls back to its base language first, so `de-DE` uses `de`; failing that, English. Individual strings then fall back the same way (chosen locale → `en` → the rule's literal English text), so a partly-translated locale never produces missing text. All of that is silent in the strings themselves, so the result reports what actually happened in `engine.locale` — check it if you need to know whether you got the language you asked for. See [`I18N.md`](./I18N.md). |
122
+ | `messages` | Optional `{ [locale]: { key: text } }`. Checked before the engine's own tables, so it can override individual strings or supply a language the build does not carry. Keys you omit fall back normally, so a partial override is fine. This is how the standalone browser bundle receives a locale side file, and it is the only way to get a dictionary into a page context, since the in-page runner is serialized and cannot read files. See [`I18N.md`](./I18N.md). |
119
123
  | `includeHiddenElements` | Default `false`: helper queries exclude elements hidden by structural/CSS mechanisms such as `display:none`, `[hidden]`, closed `<details>`, and hidden rendering-only host elements (with descendants excluded too). Set `true` to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). |
120
124
  | `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
121
125
  | `fragment` | Default `false`. A handful of rules check for the presence of a property that exists once per real page — `page-title-present`, `html-lang-attr-present`, `html-xml-lang-mismatch`, `aria-hidden-body`, `css-orientation-lock`, `meta-refresh-no-exceptions`, `meta-refresh-timing-absent`, `meta-viewport-zoom-enabled`, `meta-viewport-large`, `page-title-patterns`, `region`, `bypass-blocks-present`, `landmark-one-main`, `page-has-heading-one` — and correctly report `notApplicable` for these once `contextSelector` has scoped a run narrower than the whole document (`document.documentElement` no longer among the resolved roots), since a scoped subtree was never expected to carry its own `<title>`/`<html lang>`/etc. Set `fragment: true` for the case that scoping alone can't detect: a scan target that's the *whole* given document but was never meant to represent a real page at all (e.g. a raw component snippet parsed on its own) — this forces the same `notApplicable` gating even when unscoped. See `RULE_AUTHORING.md` §11.2 ("Whole-document checks") for the underlying rule-authoring convention, and `helpers.isWholeDocumentScope()` (`src/core/dom-helpers.js`) for the mechanism these 14 rules gate on via their `applicability(ctx)` export. |
@@ -128,7 +132,7 @@ const engineOptions = {
128
132
  | `output.includeSelector` / `.includeHtml` | Only affects the small number of rules (currently 4 of 125) that rely on the engine's automatic selector/HTML fill-in rather than building their own — most rules set `selector`/`html` themselves inside `runInPage` and are unaffected by this option. Not a reliable way to strip selectors/HTML from all output. |
129
133
  | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule currently reads `ctx.config`** for anything besides `excludeSelectors`. |
130
134
  | `probes` | An optional, JSON-safe evidence object your host application can supply (depth- and size-capped by the engine before rules see it, via `ctx.inputs.probes`) — for future rules that might accept externally-supplied signals (e.g. real layout measurements a static DOM scan can't compute itself). Not consumed by any current rule. |
131
- | `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` additionally adds a per-rule timing breakdown. Shape is not part of the stable output contract — don't build on it. |
135
+ | `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` **additionally** adds a per-rule timing breakdown there. `profileRules` on its own does nothing — `perfStats` is what creates the object the breakdown lives in. Shape is not part of the stable output contract — don't build on it. Note also that `profileRules` is the one option that makes output non-deterministic: counters are stable across identical runs, wall-clock timings are not. Leave it off if you diff results between runs. |
132
136
  | `pingWaitTime` / `frameWaitTime` | Only read by `runa11yCoreAcrossFrames` (see [`INTEGRATION.md`](./INTEGRATION.md#cross-frame-scanning-including-cross-origin)) — how long to wait for a child frame to answer a ping (default `500`ms) and a full run request (default `60000`ms) before treating it as unreachable. Ignored by `runDomRulesInPage`/`runa11yCoreInPage`. |
133
137
 
134
138
  ### Rule-scoped `excludeSelectors`
@@ -220,7 +224,7 @@ runDomRulesInPage(url, null, {
220
224
 
221
225
  See the option-by-option table above for anything not shown here, and the `customRules` section immediately below for the full descriptor contract.
222
226
 
223
- ## `customRules` — runtime-registered rules (equivalent to the rule/check registration pattern used by other engines)
227
+ ## `customRules` — runtime-registered rules
224
228
 
225
229
  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
230
 
@@ -240,7 +244,7 @@ A descriptor has the *same shape as an internal rule module's own export* — if
240
244
 
241
245
  - `runInPage`/`applicability` may be a **real function** or a **function-source string** (i.e. `fn.toString()`). Pass a real function when `engineOptions` never leaves the current JS realm (plain Node/jsdom use). Pass a string when it does — e.g. a Playwright `page.evaluate(runa11yCoreInPage, { engineOptions })` call, where `engineOptions` crosses a JSON/structured-clone boundary that cannot carry a live `Function` reference but can carry a string. The engine reconstructs a string via `new Function`, the same mechanism `scripts/build-core.js` already uses to embed every built-in rule's source into the in-page runner.
242
246
  - `meta` gets identical defaulting/validation to a build-time rule (via the same `normalizeRuleMeta` used for the other 125 rules) — omit anything you don't need; `severity` defaults to `moderate`, `confidence` to `medium`, `type` to `automatic`, etc.
243
- - A custom rule whose `id` collides with a built-in one **overrides it for that scan** (matches the override semantics used by other engines' configuration APIs), rather than running both. Since a same-named custom rule is just as likely to be an accidental collision as a deliberate override, every collision is surfaced two ways: a `console.warn` naming the id(s), and a top-level `overriddenBuiltinIds` array on the result (empty when there's no collision) — see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md).
247
+ - A custom rule whose `id` collides with a built-in one **overrides it for that scan**, rather than running both. Since a same-named custom rule is just as likely to be an accidental collision as a deliberate override, every collision is surfaced two ways: a `console.warn` naming the id(s), and a top-level `overriddenBuiltinIds` array on the result (empty when there's no collision) — see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md).
244
248
  - An invalid descriptor (missing/non-string `id`, or a `runInPage` that isn't a function and isn't a reconstructable source string) is silently skipped — the rest of the scan, including every built-in rule, still runs normally. This isn't a validation gap to fix: a custom rule is arbitrary caller-supplied code, so "fail this one entry closed, don't abort the scan" is the safer default, mirroring how a *built-in* rule that throws is contained to a `cantTell` for that rule rather than crashing the run.
245
249
  - Results appear in `checksResults` exactly like any other rule's, including automatic `selector`/`html`/`structuralPath` fill-in for `fail`/`cantTell` occurrences that only attach `{ __node }` (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)).
246
250
 
@@ -249,6 +253,6 @@ A descriptor has the *same shape as an internal rule module's own export* — if
249
253
  A CSS selector (or array of selectors) scoping the scan to one or more subtrees, resolved via `document.querySelectorAll` (all matches, not just the first), falling back to `document.documentElement`/`document.body` if nothing matches. Pass `null` to scan the whole document.
250
254
 
251
255
  - **A single string** may itself be a comma-separated selector list (ordinary CSS union semantics) — `'#a, #b'` scans both `#a` and `#b`.
252
- - **An array of strings** scans the union of every selector's matches — `['#a', '.card']` behaves the same as `'#a, .card'`; the array form exists for callers building the list programmatically. Other engines' equivalent is calling an `.include()` method multiple times.
256
+ - **An array of strings** scans the union of every selector's matches — `['#a', '.card']` behaves the same as `'#a, .card'`; the array form exists for callers building the list programmatically.
253
257
  - Overlapping/nested regions are deduped automatically — an element reachable from more than one matched root is only ever reported once, not once per region.
254
258
  - This changed from single-match (`querySelector`) to all-matches (`querySelectorAll`) semantics for the plain-string form too (2026-07-22) — a selector matching several elements previously scanned only the first, silently dropping the rest. If you relied on that first-match-only behavior, pin to a selector that only ever matches one element (e.g. an `#id`).