@surea11y/core 1.4.1 → 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 (104) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/README.md +19 -3
  3. package/docs/API_STABILITY.md +2 -2
  4. package/docs/ENGINE_OPTIONS.md +14 -10
  5. package/docs/I18N.md +176 -20
  6. package/docs/INTEGRATION.md +28 -6
  7. package/docs/LIMITATIONS.md +1 -1
  8. package/docs/OUTPUT_SCHEMA.md +13 -3
  9. package/docs/REPORT.md +2 -0
  10. package/docs/RULE_AUTHORING.md +53 -0
  11. package/docs/TROUBLESHOOTING.md +2 -2
  12. package/package.json +6 -1
  13. package/src/checks/automatic/aria-allowed-attr.js +27 -30
  14. package/src/checks/automatic/aria-allowed-role.js +14 -16
  15. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  16. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  17. package/src/checks/automatic/aria-deprecated-role.js +62 -49
  18. package/src/checks/automatic/aria-hidden-body.js +2 -9
  19. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  20. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  21. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  22. package/src/checks/automatic/aria-required-attr.js +14 -17
  23. package/src/checks/automatic/aria-required-children.js +17 -20
  24. package/src/checks/automatic/aria-required-parent.js +17 -20
  25. package/src/checks/automatic/aria-roles-valid.js +37 -23
  26. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  27. package/src/checks/automatic/aria-valid-attr.js +14 -17
  28. package/src/checks/automatic/autocomplete-valid.js +15 -17
  29. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  30. package/src/checks/automatic/binary-control-name-present.js +19 -21
  31. package/src/checks/automatic/button-name-present.js +23 -28
  32. package/src/checks/automatic/combobox-name-present.js +15 -17
  33. package/src/checks/automatic/css-orientation-lock.js +22 -22
  34. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  35. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  36. package/src/checks/automatic/dialog-name-present.js +16 -18
  37. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  38. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  39. package/src/checks/automatic/form-control-single-label.js +38 -40
  40. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  41. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  42. package/src/checks/automatic/iframe-name-present.js +15 -17
  43. package/src/checks/automatic/iframe-title-unique.js +18 -23
  44. package/src/checks/automatic/label-in-name.js +31 -36
  45. package/src/checks/automatic/link-in-text-block.js +19 -21
  46. package/src/checks/automatic/link-name-present.js +25 -30
  47. package/src/checks/automatic/list-children-valid.js +15 -17
  48. package/src/checks/automatic/listbox-name-present.js +15 -17
  49. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  50. package/src/checks/automatic/menuitem-name-present.js +16 -18
  51. package/src/checks/automatic/meta-refresh-no-exceptions.js +15 -18
  52. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -17
  53. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  54. package/src/checks/automatic/meter-name-present.js +16 -18
  55. package/src/checks/automatic/nested-interactive-controls-absent.js +17 -19
  56. package/src/checks/automatic/option-name-present.js +16 -18
  57. package/src/checks/automatic/progressbar-name-present.js +19 -21
  58. package/src/checks/automatic/searchbox-name-present.js +19 -17
  59. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  60. package/src/checks/automatic/slider-name-present.js +15 -17
  61. package/src/checks/automatic/spinbutton-name-present.js +19 -17
  62. package/src/checks/automatic/summary-name-present.js +16 -18
  63. package/src/checks/automatic/tab-name-present.js +16 -18
  64. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  65. package/src/checks/automatic/table-th-has-data-cells.js +15 -19
  66. package/src/checks/automatic/target-size-minimum.js +104 -81
  67. package/src/checks/automatic/td-has-header.js +15 -20
  68. package/src/checks/automatic/textbox-name-present.js +15 -17
  69. package/src/checks/automatic/tooltip-name-present.js +16 -18
  70. package/src/checks/automatic/treeitem-name-present.js +16 -18
  71. package/src/checks/automatic/valid-lang.js +15 -17
  72. package/src/checks/manual/accesskeys-manual.js +18 -19
  73. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  74. package/src/checks/manual/bypass-blocks-present-manual.js +4 -12
  75. package/src/checks/manual/empty-heading-manual.js +15 -17
  76. package/src/checks/manual/empty-table-header-manual.js +27 -30
  77. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  78. package/src/checks/manual/heading-order-manual.js +17 -22
  79. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  80. package/src/checks/manual/label-title-only-manual.js +15 -17
  81. package/src/checks/manual/landmark-banner-is-top-level-manual.js +14 -17
  82. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +14 -17
  83. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  84. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  85. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  86. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  87. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  88. package/src/checks/manual/landmark-unique-manual.js +22 -27
  89. package/src/checks/manual/link-name-quality-manual.js +15 -17
  90. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  91. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  92. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  93. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  94. package/src/checks/manual/region-manual.js +13 -6
  95. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  96. package/src/checks/manual/skip-link-manual.js +39 -47
  97. package/src/checks/manual/tabindex-manual.js +14 -17
  98. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  99. package/src/core.js +4203 -3604
  100. package/src/report.js +14 -0
  101. package/surea11y.browser.js +1960 -3671
  102. package/surea11y.i18n.de.js +22 -0
  103. package/surea11y.i18n.es.js +22 -0
  104. package/surea11y.i18n.fr.js +22 -0
@@ -15,7 +15,11 @@ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `ru
15
15
 
16
16
  ```ts
17
17
  {
18
- engine: { tag: string, schemaVersion: string },
18
+ engine: {
19
+ tag: string,
20
+ schemaVersion: string,
21
+ locale: { requested: string, resolved: string, reason: string }
22
+ },
19
23
  url: string | null,
20
24
  title: string | null,
21
25
  timestamp: string | null,
@@ -31,6 +35,8 @@ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `ru
31
35
  |---|---|
32
36
  | `engine.tag` | The engine's own identity tag, currently `"a11ycore"`. Every rule (built-in or custom) carries it in `meta.tags` — rule `ruleId`s themselves are bare (no prefix). |
33
37
  | `engine.schemaVersion` | The result-schema version (`"1.0.0"`). Bump-worthy if this document's shape ever changes incompatibly — pin to it if you're parsing output programmatically. See [`API_STABILITY.md`](./API_STABILITY.md) for the full stable/unstable field list and version-bump policy. |
38
+ | `engine.locale` | Which dictionary the run actually used. `requested` is your `engineOptions.locale` after trimming (`"en"` if you passed nothing or a non-string); `resolved` is the locale whose dictionary was used; `reason` explains the pairing. Because locale fallback is graceful and per-string, asking for a language the build does not carry produces English text rather than an error — this field is how you find that out without reading the strings. Reported once per result: a run uses one dictionary throughout. |
39
+ | `engine.locale.reason` | `"ok"` — you got the dictionary you asked for, and it carries every key. `"primary-subtag"` — your code had a subtag with no dictionary of its own, so its base language was used: `"de-DE"` resolves to `"de"`. `"dictionary-not-loaded"` — the project ships that language, but this build does not carry it and none was supplied (the standalone browser bundle, without its locale side file). `"unknown-locale"` — the project has no such translation at all. `"partial-dictionary"` — the dictionary was used but is missing keys English has, so those strings fell back to English. Treat the set as open; later releases can add to it. |
34
40
  | `url` | The `pageUrl` argument you passed in, or `document.location.href` if you passed `null`/omitted it, or `null` if neither is available. |
35
41
  | `title` | `document.title` at scan time, or `null`. |
36
42
  | `timestamp` | **Not auto-generated.** Only set if you pass `engineOptions.timestamp` as a non-empty string — the engine has no built-in clock (deterministic-by-design). If you want a scan timestamp in the result, supply it yourself. |
@@ -97,7 +103,7 @@ Notes:
97
103
 
98
104
  - **`outcome` vs `outcomeNormalized`**: identical except `notApplicable` becomes `"inapplicable"` in `outcomeNormalized`. Both are provided so you can match either your own vocabulary or the engine's internal one.
99
105
  - **`type: "manual"` rules can never report `outcome: "fail"`.** If a manual rule's own logic would have said `fail`, the engine coerces it to `cantTell` and appends an explanatory note to `error` — this is enforced centrally (`policy.coerceManualFailToCantTell`, on by default under the `a11y` policy contract; see [`POLICY.md`](./POLICY.md)), not something each rule has to remember. `fail` is reserved for deterministic, high-confidence, `type: "automatic"` findings only.
100
- - **`meta.normativeMappings`** is how a check result ties back to a WCAG Success Criterion — `[]` for rules with no formal WCAG mapping (other engines call these "Best Practices"; this engine calls them advisory `type: "manual"` rules). See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up.
106
+ - **`meta.normativeMappings`** is how a check result ties back to a WCAG Success Criterion — `[]` for rules with no formal WCAG mapping (this engine calls them advisory `type: "manual"` rules). See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up.
101
107
  - **`error`**: only present if the rule implementation threw an uncaught exception, or if the manual-fail coercion above fired. A thrown rule always surfaces as `outcome: "cantTell"` with `occurrences: []` and `error` set to the exception message — the engine never lets one broken rule crash the whole scan.
102
108
  - **`engineOptions`** on each result is the *resolved* options object (after locale/contrast defaults were applied), not literally what you passed in — useful for confirming what a given rule actually saw, especially the resolved `locale` and `contrast.mode`/`contrast.rootCanvasFallback`.
103
109
 
@@ -185,7 +191,11 @@ const result = runDomRulesInPage(
185
191
 
186
192
  ```json
187
193
  {
188
- "engine": { "tag": "a11ycore", "schemaVersion": "1.0.0" },
194
+ "engine": {
195
+ "tag": "a11ycore",
196
+ "schemaVersion": "1.0.0",
197
+ "locale": { "requested": "en", "resolved": "en", "reason": "ok" }
198
+ },
189
199
  "url": "https://example.test/",
190
200
  "title": "Example",
191
201
  "timestamp": null,
package/docs/REPORT.md CHANGED
@@ -15,6 +15,8 @@ Open `report.html` directly from disk. Works alongside any other output mode —
15
15
  - **WCAG rollup**: grouped by conformance level (A / AA / AAA), sourced directly from the engine's own `rulesResults[]` composite rollups (`docs/WCAG_CONFORMANCE.md`) — one row per Success Criterion, its outcome, a pass/fail/needs-review/n/a breakdown, and which atomic rules contributed. This is real engine data, not an invented grouping — the same rollup you'd get from the raw JSON's `rulesResults`.
16
16
  - **Full technical data** (collapsed by default): a scorecard (tiles per outcome) and a searchable, filterable (by outcome), paginated table of every individual occurrence across the whole scan.
17
17
 
18
+ The meta bar under the header carries the rule count, occurrence count, engine tag, schema version, and the locale the scan resolved to. Locale fallback is per-string and invisible in the text itself, so a report requested in a language the engine does not carry reads as an ordinary English one — the chip names the requested locale alongside the resolved one when the two differ. See [`I18N.md`](./I18N.md).
19
+
18
20
  ## Library usage
19
21
 
20
22
  ```js
@@ -132,6 +132,10 @@ The build/runtime resolves i18n by:
132
132
  2) falling back to `en` if missing,
133
133
  3) falling back to the literal `title`/`description` strings if still missing.
134
134
 
135
+ Add the key and its English text to `src/i18n/en.json`, then run
136
+ `npm run i18n:sync` so every other locale picks it up. `npm test` fails if you
137
+ forget. See [`I18N.md`](./I18N.md).
138
+
135
139
  #### `meta.tags`
136
140
  Tags are used for grouping/filtering. Typical tag families in this ruleset include:
137
141
  - WCAG tagging: `wcag2a`, `wcag111`
@@ -163,6 +167,32 @@ const meta = {
163
167
 
164
168
  ---
165
169
 
170
+ ## 4.3 Reporting an occurrence
171
+
172
+ Build occurrences with `helpers.reportOccurrence(element, { summary, hint, i18n, data })`
173
+ rather than assembling the object by hand:
174
+
175
+ ```js
176
+ occurrences.push(helpers.reportOccurrence(el, { summary: '…', hint: '…' }));
177
+ ```
178
+
179
+ It attaches the element for the engine to finalize, which is how `selector`,
180
+ `html` and `structuralPath` get filled in centrally instead of in each of the
181
+ 124 rules.
182
+
183
+ **This is a performance contract, not just a convenience.** Every occurrence
184
+ gets a `structuralPath`. Given the element, the engine computes it directly.
185
+ Given only a hand-built occurrence, it re-finds the element with
186
+ `document.querySelector(selector)` — one DOM query per occurrence. That is
187
+ fine for a rule reporting a single document-level finding, and quadratic for
188
+ one reporting many: `region` hand-built its occurrences and took four minutes
189
+ on a thousand-element page, against under a second afterwards.
190
+
191
+ `perfStats.counters['structuralPath.selectorFallback']` counts how often the
192
+ engine had to re-find an element, so a slow rule can be spotted without
193
+ guessing. `tests/structural-path-fallback.test.js` fails if that count starts
194
+ growing with page size.
195
+
166
196
  ## 5) i18n in occurrences (repo reality)
167
197
 
168
198
  Occurrences also support i18n via keys + params.
@@ -189,6 +219,29 @@ At normalization time, the engine:
189
219
  Translation strings use `{{paramName}}` placeholders.
190
220
  `params` is shallow-copied and passed into interpolation.
191
221
 
222
+ **A param carries a value, never prose.** Element names, roles, attribute names,
223
+ selectors, ids, counts and ratios are values: they read the same in every
224
+ language, because the author will search their own source for them. An English
225
+ word or sentence is not, and passing one means it stays English in every locale
226
+ — with nothing to reveal it, since the key is present everywhere and coverage
227
+ reports look complete.
228
+
229
+ When a message varies by case, give each case its own key rather than
230
+ interpolating the differing text:
231
+
232
+ ```js
233
+ // wrong: the sentence lives in the rule, so no locale can reach it
234
+ i18n: { hintKey: 'myRule_hint_fail', params: { advice: 'Replace it with role="list".' } }
235
+
236
+ // right: one key per case, each translatable on its own
237
+ i18n: { hintKey: 'myRule_hint_fail_directory', params: { role } }
238
+ ```
239
+
240
+ `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value with
241
+ no translatable text of its own, which catches the `"{{advice}}"` shape above.
242
+ It cannot catch a param carrying prose into an otherwise-normal sentence, so
243
+ that one is on you.
244
+
192
245
  ---
193
246
 
194
247
  ## 6) Helpers contract used by rules (ctx.helpers)
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## "I passed `runOnly: ['some-rule-id']` but every rule still ran"
4
4
 
5
- `runOnly` must be an object, not a bare array — `runOnly: ['img-alt-present']` is silently ignored (the engine falls through to "run everything"), because that shape has none of the fields the engine actually checks (`includeRuleIds`, `tags`, etc.). This is easy to get wrong if you're coming from another engine that does accept a bare array.
5
+ `runOnly` must be an object, not a bare array — `runOnly: ['img-alt-present']` is silently ignored (the engine falls through to "run everything"), because that shape has none of the fields the engine actually checks (`includeRuleIds`, `tags`, etc.). A bare array is easy to reach for, so this is worth checking first.
6
6
 
7
7
  Fix:
8
8
 
@@ -41,7 +41,7 @@ Treat it as "needs a human to look" — it's neither pass nor fail by design. Mo
41
41
 
42
42
  ## "What happens if a locale is only partially translated?"
43
43
 
44
- Missing keys fall back to English per-string (never a blank or broken result), so a partial locale degrades gracefully rather than failing outright — see [`I18N.md`](./I18N.md) for the mechanism and current coverage. Both shipped locales (`en`, `fr`) are at full parity as of this writing, but that's not guaranteed to stay true automatically: adding a new rule adds a new key to `en.js`, and unless the same key is added to `fr.js` (or any other locale file you maintain), that string falls back to English until it is.
44
+ Missing keys fall back to English per-string (never a blank or broken result), so a partial locale degrades gracefully rather than failing outright — see [`I18N.md`](./I18N.md) for the mechanism and current coverage. Key parity is enforced rather than hoped for: `npm run i18n:sync` carries any new or renamed `en.json` key into every other locale file, and the build fails if one is out of step. A key it adds holds the English text until someone translates it, so a locale can be behind on wording without ever being behind on keys.
45
45
 
46
46
  ## "`runDomRulesInPage` vs `runa11yCoreInPage` — which one do I want?"
47
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Deterministic WCAG 2.2 accessibility engine that tells you what it can't tell you. Zero dependencies.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -61,6 +61,7 @@
61
61
  "src/checks/**/*.js",
62
62
  "bin/surea11y-core.js",
63
63
  "surea11y.browser.js",
64
+ "surea11y.i18n.*.js",
64
65
  "docs/**/*.md",
65
66
  "README.md",
66
67
  "LICENSE",
@@ -87,11 +88,15 @@
87
88
  "test:typography-helpers": "node tests/contrast-typography.test.js",
88
89
  "coverage": "node scripts/generate-wcag-coverage.js --rulesDir src/checks",
89
90
  "coverage:strict": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --strictFacets",
91
+ "coverage:check": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --check",
90
92
  "fixtures:index": "node scripts/generate-fixture-index.js",
91
93
  "docs:rule-catalog": "npm run build && node scripts/generate-rule-catalog.js",
92
94
  "validate:automatic-rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic",
93
95
  "validate:manual-rules": "npm run build && node scripts/validate-all-rules.js src/checks/manual",
96
+ "validate:rules": "npm run validate:automatic-rules && npm run validate:manual-rules",
94
97
  "i18n:new": "node scripts/i18n-scaffold.js",
98
+ "i18n:sync": "node scripts/i18n-sync.js",
99
+ "i18n:check": "node scripts/i18n-sync.js --check",
95
100
  "i18n:report": "node scripts/i18n-report.js"
96
101
  },
97
102
  "devDependencies": {
@@ -858,9 +858,6 @@ function runInPage(ctx) {
858
858
 
859
859
  if (!disallowed || !disallowed.length) continue;
860
860
 
861
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
862
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
863
-
864
861
  for (const name of disallowed) {
865
862
  // A property ARIA deprecated (rather than prohibited) on this role is
866
863
  // still allowed — surfaced as cantTell for the author to decide, not a
@@ -869,38 +866,38 @@ function runInPage(ctx) {
869
866
  typeof ariaHelpers.isDeprecatedAttr === 'function' &&
870
867
  ariaHelpers.isDeprecatedAttr(name, role);
871
868
  if (deprecated) {
872
- cantTellOccurrences.push({
873
- selector: stableSelector,
874
- html,
875
- summary: 'This ARIA attribute is deprecated for this element’s role.',
876
- hint: 'It is still allowed but discouraged; remove it or use a role that supports it, as a future ARIA version may disallow it.',
877
- occurrenceOutcome: 'cantTell',
869
+ cantTellOccurrences.push(
870
+ helpers.reportOccurrence(el, {
871
+ summary: 'This ARIA attribute is deprecated for this element’s role.',
872
+ hint: 'It is still allowed but discouraged; remove it or use a role that supports it, as a future ARIA version may disallow it.',
873
+ occurrenceOutcome: 'cantTell',
874
+ i18n: {
875
+ summaryKey: 'ariaAllowedAttr_summary_cantTell',
876
+ hintKey: 'ariaAllowedAttr_hint_cantTell',
877
+ params: { attr: name, role }
878
+ },
879
+ data: {
880
+ details: { reasonCode: 'ARIA_ATTR_DEPRECATED', attr: name, role }
881
+ }
882
+ })
883
+ );
884
+ continue;
885
+ }
886
+ failOccurrences.push(
887
+ helpers.reportOccurrence(el, {
888
+ summary: 'This ARIA attribute is not permitted for this element’s role.',
889
+ hint: 'Remove this attribute, or use a role that supports it.',
890
+ occurrenceOutcome: 'fail',
878
891
  i18n: {
879
- summaryKey: 'ariaAllowedAttr_summary_cantTell',
880
- hintKey: 'ariaAllowedAttr_hint_cantTell',
892
+ summaryKey: 'ariaAllowedAttr_summary_fail',
893
+ hintKey: 'ariaAllowedAttr_hint_fail',
881
894
  params: { attr: name, role }
882
895
  },
883
896
  data: {
884
- details: { reasonCode: 'ARIA_ATTR_DEPRECATED', attr: name, role }
897
+ details: { reasonCode: 'ARIA_ATTR_NOT_ALLOWED', attr: name, role }
885
898
  }
886
- });
887
- continue;
888
- }
889
- failOccurrences.push({
890
- selector: stableSelector,
891
- html,
892
- summary: 'This ARIA attribute is not permitted for this element’s role.',
893
- hint: 'Remove this attribute, or use a role that supports it.',
894
- occurrenceOutcome: 'fail',
895
- i18n: {
896
- summaryKey: 'ariaAllowedAttr_summary_fail',
897
- hintKey: 'ariaAllowedAttr_hint_fail',
898
- params: { attr: name, role }
899
- },
900
- data: {
901
- details: { reasonCode: 'ARIA_ATTR_NOT_ALLOWED', attr: name, role }
902
- }
903
- });
899
+ })
900
+ );
904
901
  }
905
902
  }
906
903
 
@@ -82,24 +82,22 @@ function runInPage(ctx) {
82
82
 
83
83
  if (info.allowed) continue;
84
84
 
85
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
86
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
87
85
  const tag = (el.tagName || '').toLowerCase();
88
86
 
89
- occurrences.push({
90
- selector: stableSelector,
91
- html,
92
- summary: 'This role is not permitted on this element.',
93
- hint: 'Use a role permitted for this element, or change the host element.',
94
- i18n: {
95
- summaryKey: 'ariaAllowedRole_summary_fail',
96
- hintKey: 'ariaAllowedRole_hint_fail',
97
- params: { role, element: tag }
98
- },
99
- data: {
100
- details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
101
- }
102
- });
87
+ occurrences.push(
88
+ helpers.reportOccurrence(el, {
89
+ summary: 'This role is not permitted on this element.',
90
+ hint: 'Use a role permitted for this element, or change the host element.',
91
+ i18n: {
92
+ summaryKey: 'ariaAllowedRole_summary_fail',
93
+ hintKey: 'ariaAllowedRole_hint_fail',
94
+ params: { role, element: tag }
95
+ },
96
+ data: {
97
+ details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
98
+ }
99
+ })
100
+ );
103
101
  }
104
102
 
105
103
  if (applicableCount === 0) {
@@ -119,29 +119,27 @@ function runInPage(ctx) {
119
119
 
120
120
  if (!missing.length) continue;
121
121
 
122
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
123
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
124
122
  const tag = (el.tagName || '').toLowerCase();
125
123
 
126
124
  for (const m of missing) {
127
- occurrences.push({
128
- selector: stableSelector,
129
- html,
130
- summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
131
- hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
132
- i18n: {
133
- summaryKey: 'ariaBrailleEquivalent_summary_fail',
134
- hintKey: 'ariaBrailleEquivalent_hint_fail',
135
- params: { element: tag, attr: m.attr, requires: m.requires }
136
- },
137
- data: {
138
- details: {
139
- reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT',
140
- attr: m.attr,
141
- requires: m.requires
125
+ occurrences.push(
126
+ helpers.reportOccurrence(el, {
127
+ summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
128
+ hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
129
+ i18n: {
130
+ summaryKey: 'ariaBrailleEquivalent_summary_fail',
131
+ hintKey: 'ariaBrailleEquivalent_hint_fail',
132
+ params: { element: tag, attr: m.attr, requires: m.requires }
133
+ },
134
+ data: {
135
+ details: {
136
+ reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT',
137
+ attr: m.attr,
138
+ requires: m.requires
139
+ }
142
140
  }
143
- }
144
- });
141
+ })
142
+ );
145
143
  }
146
144
  }
147
145
 
@@ -86,28 +86,26 @@ function runInPage(ctx) {
86
86
  const invalidValue = trim(el.getAttribute('aria-invalid')).toLowerCase();
87
87
  if (TRUTHY_INVALID_VALUES.has(invalidValue)) continue;
88
88
 
89
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
89
  const tag = (el.tagName || '').toLowerCase();
92
90
 
93
- occurrences.push({
94
- selector: stableSelector,
95
- html,
96
- summary:
97
- 'This element has aria-errormessage but aria-invalid is missing or "false", so the error message is not exposed.',
98
- hint: 'Set aria-invalid to "true" (or "grammar"/"spelling") whenever aria-errormessage should be exposed to assistive technology.',
99
- i18n: {
100
- summaryKey: 'ariaConditionalAttr_summary_fail',
101
- hintKey: 'ariaConditionalAttr_hint_fail',
102
- params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
103
- },
104
- data: {
105
- details: {
106
- reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
107
- ariaInvalid: invalidValue
91
+ occurrences.push(
92
+ helpers.reportOccurrence(el, {
93
+ summary:
94
+ 'This element has aria-errormessage but aria-invalid is missing or "false", so the error message is not exposed.',
95
+ hint: 'Set aria-invalid to "true" (or "grammar"/"spelling") whenever aria-errormessage should be exposed to assistive technology.',
96
+ i18n: {
97
+ summaryKey: 'ariaConditionalAttr_summary_fail',
98
+ hintKey: 'ariaConditionalAttr_hint_fail',
99
+ params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
100
+ },
101
+ data: {
102
+ details: {
103
+ reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
104
+ ariaInvalid: invalidValue
105
+ }
108
106
  }
109
- }
110
- });
107
+ })
108
+ );
111
109
  }
112
110
 
113
111
  if (applicableCount === 0) {
@@ -81,8 +81,20 @@ function runInPage(ctx) {
81
81
  if (typeof helpers.isDomVisibleEligible === 'function') {
82
82
  if (!helpers.isDomVisibleEligible(el, ctx)) return true;
83
83
  }
84
- for (let n = el; n && n.getAttribute; n = n.parentElement) {
84
+ // Walk the composed tree, not parentElement: that stops at a shadow
85
+ // root, so a host carrying aria-hidden or inert would never be seen
86
+ // from inside its own shadow content.
87
+ const up =
88
+ typeof helpers.composedParent === 'function'
89
+ ? helpers.composedParent
90
+ : (n) => n.parentElement;
91
+
92
+ // A shadow root has no getAttribute, so skip past it rather than
93
+ // stopping: the host one step further up is the node that matters.
94
+ for (let n = el; n; n = up(n)) {
95
+ if (!n.getAttribute) continue;
85
96
  if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
97
+ if (n.hasAttribute && n.hasAttribute('inert')) return true;
86
98
  }
87
99
  } catch {
88
100
  return false;
@@ -113,63 +125,64 @@ function runInPage(ctx) {
113
125
  ariaHelpers.isAuthorProhibitedRole(role);
114
126
  if (!deprecated && !discouraged && !prohibited) continue;
115
127
 
116
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
117
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
118
128
  const guidance = ariaHelpers.getDeprecatedRoleGuidance
119
129
  ? ariaHelpers.getDeprecatedRoleGuidance(role)
120
- : 'Replace the deprecated role with its recommended replacement.';
130
+ : {
131
+ key: 'ariaDeprecatedRole_guidance_default',
132
+ text: 'Replace the deprecated role with its recommended replacement.'
133
+ };
121
134
 
122
135
  if (prohibited) {
123
136
  // Author MUST NOT: the usage is non-conforming, not merely discouraged.
124
- failOccurrences.push({
125
- selector: stableSelector,
126
- html,
127
- summary: `This element uses role="${role}", which authors must not explicitly declare.`,
128
- hint: guidance,
129
- occurrenceOutcome: 'fail',
130
- i18n: {
131
- summaryKey: 'ariaDeprecatedRole_summary_fail',
132
- hintKey: 'ariaDeprecatedRole_hint_fail',
133
- params: { role, guidance }
134
- },
135
- data: {
136
- details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance }
137
- }
138
- });
137
+ failOccurrences.push(
138
+ helpers.reportOccurrence(el, {
139
+ summary: `This element uses role="${role}", which authors must not explicitly declare.`,
140
+ hint: guidance.text,
141
+ occurrenceOutcome: 'fail',
142
+ i18n: {
143
+ summaryKey: 'ariaDeprecatedRole_summary_fail',
144
+ hintKey: guidance.key,
145
+ params: { role }
146
+ },
147
+ data: {
148
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance: guidance.text }
149
+ }
150
+ })
151
+ );
139
152
  } else if (discouraged) {
140
153
  // Reserved for user-agent-internal use, at SHOULD NOT strength.
141
- cantTellOccurrences.push({
142
- selector: stableSelector,
143
- html,
144
- summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
145
- hint: guidance,
146
- occurrenceOutcome: 'cantTell',
147
- i18n: {
148
- summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
149
- hintKey: 'ariaDeprecatedRole_hint_cantTell',
150
- params: { role, guidance }
151
- },
152
- data: {
153
- details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance }
154
- }
155
- });
154
+ cantTellOccurrences.push(
155
+ helpers.reportOccurrence(el, {
156
+ summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
157
+ hint: guidance.text,
158
+ occurrenceOutcome: 'cantTell',
159
+ i18n: {
160
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
161
+ hintKey: guidance.key,
162
+ params: { role }
163
+ },
164
+ data: {
165
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance: guidance.text }
166
+ }
167
+ })
168
+ );
156
169
  } else {
157
170
  // Deprecated but still valid: surfaced for the author to decide.
158
- cantTellOccurrences.push({
159
- selector: stableSelector,
160
- html,
161
- summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
162
- hint: guidance,
163
- occurrenceOutcome: 'cantTell',
164
- i18n: {
165
- summaryKey: 'ariaDeprecatedRole_summary_cantTell',
166
- hintKey: 'ariaDeprecatedRole_hint_cantTell',
167
- params: { role, guidance }
168
- },
169
- data: {
170
- details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
171
- }
172
- });
171
+ cantTellOccurrences.push(
172
+ helpers.reportOccurrence(el, {
173
+ summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
174
+ hint: guidance.text,
175
+ occurrenceOutcome: 'cantTell',
176
+ i18n: {
177
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell',
178
+ hintKey: guidance.key,
179
+ params: { role }
180
+ },
181
+ data: {
182
+ details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance: guidance.text }
183
+ }
184
+ })
185
+ );
173
186
  }
174
187
  }
175
188
 
@@ -86,15 +86,8 @@ function runInPage(ctx) {
86
86
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
87
87
  }
88
88
 
89
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
90
- const html = helpers.getOuterHtmlSnippet
91
- ? helpers.getOuterHtmlSnippet(body)
92
- : (body.outerHTML || '').slice(0, 200);
93
-
94
89
  const occurrences = [
95
- {
96
- selector: stableSelector,
97
- html,
90
+ helpers.reportOccurrence(body, {
98
91
  summary:
99
92
  'The document body has aria-hidden="true", which hides the entire page from assistive technologies.',
100
93
  hint: 'Remove aria-hidden from <body>. Hide specific elements instead, if that was the intent.',
@@ -106,7 +99,7 @@ function runInPage(ctx) {
106
99
  data: {
107
100
  details: { reasonCode: 'ARIA_HIDDEN_BODY' }
108
101
  }
109
- }
102
+ })
110
103
  ];
111
104
 
112
105
  return {