@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
@@ -21,30 +21,56 @@
21
21
  * before it (nav, header, repeated blocks) in one step;
22
22
  * (b) a working same-page anchor link — technique G1/G123: an
23
23
  * <a href="#id"> (or legacy <a name="id">) whose target resolves to
24
- * a real element anywhere in the document. Deliberately NOT
25
- * required to be positioned before a <nav> or be keyboard-focus-
26
- * order-first — see implementation notes;
24
+ * a real element in the link's own tree (light DOM or the same shadow
25
+ * root). Deliberately NOT required to be positioned before a <nav> or
26
+ * be keyboard-focus-order-first — see implementation notes;
27
27
  * (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
28
28
  * H69: heading navigation is itself a standards-recognized bypass
29
29
  * mechanism (e.g. a screen reader's "jump by heading" command).
30
30
  * @implementation-notes
31
+ * - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
32
+ * `fail`). When a recognized mechanism is found the page has nothing to
33
+ * review here → `notApplicable` (matching page-has-heading-one-manual /
34
+ * skip-link-manual's "nothing to flag" convention). When none is found we
35
+ * return `cantTell` — "we could not detect a bypass mechanism, please
36
+ * verify" — rather than a hard `fail`. The absence of a *detectable*
37
+ * mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
38
+ * several reasons the engine cannot resolve from a single static snapshot:
39
+ * • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
40
+ * content "repeated on multiple Web pages"; whether any block is
41
+ * actually repeated across the site is not knowable from one document,
42
+ * so a page that legitimately needs no bypass mechanism would be
43
+ * indistinguishable from one that omits a required one.
44
+ * • Transient accessibility-tree state. When a modal dialog is open the
45
+ * rest of the page is routinely made `inert` or `aria-hidden="true"`,
46
+ * so the page's real <main>/headings are (correctly) filtered out by
47
+ * isAccTreeEligible for the duration of that state and only the dialog
48
+ * is exposed — a snapshot taken then would see "no mechanism" though
49
+ * the page has one once the dialog closes. The same applies to content
50
+ * that is display:none until revealed by script (tabs, accordions, an
51
+ * unmounted SPA view).
52
+ * Both cases would produce false positives under a hard `fail`, which this
53
+ * engine reserves for high-confidence violations; `cantTell` routes them to
54
+ * human review instead. No ACT rule hard-fails 2.4.1 by presence alone,
55
+ * for the same reason.
31
56
  * - This rule intentionally checks presence, not position, for the
32
57
  * same-page-anchor condition (b): a full bypass algorithm is heuristic
33
58
  * (see ROADMAP.md's Tier 1a note on why this rule was
34
59
  * deferred from the rest of that batch), and getting DOM-order /
35
60
  * keyboard-focus-order positioning exactly right without introducing
36
61
  * false positives is materially harder than the rest of Tier 1a. Being
37
- * lenient about condition (b) can only produce a false NEGATIVE (missing
38
- * a page whose only anchor link isn't a real skip mechanism, e.g. a
39
- * "back to top" link) — never a false positive — which matches this
40
- * engine's non-negotiable "fail is reserved for high-confidence
41
- * violations" policy. A future revision can tighten (b) once a
42
- * positional heuristic has been validated against real pages without
43
- * regressions.
44
- * - `fail` therefore means: no main landmark, no resolvable same-page
45
- * anchor link anywhere, and no heading anywhere on the page. That is a
46
- * strong, low-ambiguity signal that the page truly has zero recognized
47
- * bypass mechanism.
62
+ * lenient about condition (b) can only make us *miss* a review prompt
63
+ * (a page whose only anchor link isn't a real skip mechanism, e.g. a
64
+ * "back to top" link) — never raise a spurious one.
65
+ * - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
66
+ * shadow-DOM-aware (when the run enables includeShadowDom) and applies the
67
+ * engine's hidden-content policy. The same-page-anchor target is resolved
68
+ * in the link's own root (`getRootNode()` — the document, or the shadow
69
+ * root the link lives in) before falling back to the document, so a skip
70
+ * link encapsulated in a web component is credited the same as one in the
71
+ * light DOM. (Previously the anchor path used raw
72
+ * `document.querySelectorAll`/`getElementById`, which never pierced shadow
73
+ * roots — a genuine gap now closed.)
48
74
  */
49
75
 
50
76
  const id = 'bypass-blocks-present';
@@ -58,7 +84,7 @@ const meta = {
58
84
  descriptionKey: 'bypassBlocksPresent_description'
59
85
  },
60
86
  helpUrl: null,
61
- tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', 'automatic'],
87
+ tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', 'manual'],
62
88
  wcagSc: ['2.4.1'],
63
89
  normativeMappings: [
64
90
  {
@@ -69,9 +95,9 @@ const meta = {
69
95
  conformanceLevel: 'A'
70
96
  }
71
97
  ],
72
- defaultSeverity: 'serious',
98
+ defaultSeverity: 'moderate',
73
99
  category: 'operable',
74
- type: 'automatic',
100
+ type: 'manual',
75
101
  defaultConfidence: 'medium',
76
102
  coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
77
103
  };
@@ -122,7 +148,8 @@ function runInPage(ctx) {
122
148
  // (e.g. a page whose only <h1> sits inside a display:none ancestor,
123
149
  // unreachable by sighted and screen reader users alike). A fully
124
150
  // non-rendered <main>/heading must not be credited here, since that would
125
- // wrongly return `pass` for a page with zero actual bypass mechanisms.
151
+ // wrongly treat a page with zero currently-exposed bypass mechanisms as
152
+ // having one.
126
153
  function hasMainLandmark() {
127
154
  for (const el of queryAll('main, [role="main"]')) {
128
155
  if (el && isExposedToAt(el)) return true;
@@ -130,10 +157,41 @@ function runInPage(ctx) {
130
157
  return false;
131
158
  }
132
159
 
160
+ // Resolve a fragment id (or legacy <a name>) inside a specific root node
161
+ // (a Document or a ShadowRoot). Both expose getElementById; querySelector
162
+ // is used for the legacy anchor-name fallback.
163
+ function resolveInRoot(root, fragment) {
164
+ if (!root) return null;
165
+ let target;
166
+ try {
167
+ target = typeof root.getElementById === 'function' ? root.getElementById(fragment) : null;
168
+ } catch {
169
+ target = null;
170
+ }
171
+ if (target) return target;
172
+ try {
173
+ target =
174
+ typeof root.querySelector === 'function'
175
+ ? root.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]')
176
+ : null;
177
+ } catch {
178
+ target = null;
179
+ }
180
+ return target;
181
+ }
182
+
183
+ // Shadow-DOM-aware: gather anchors via queryAllSmart (pierces shadow roots
184
+ // when includeShadowDom is enabled, and drops hard-hidden links), and
185
+ // resolve each fragment in the link's own root before falling back to the
186
+ // document. This credits a skip link encapsulated in a web component the
187
+ // same way as one authored in the light DOM.
133
188
  function hasWorkingAnchorLink() {
134
189
  let links;
135
190
  try {
136
- links = document.querySelectorAll('a[href]');
191
+ links =
192
+ helpers && typeof helpers.queryAllSmart === 'function'
193
+ ? helpers.queryAllSmart('a[href]')
194
+ : document.querySelectorAll('a[href]');
137
195
  } catch {
138
196
  links = [];
139
197
  }
@@ -150,18 +208,19 @@ function runInPage(ctx) {
150
208
  fragment = fragment.trim();
151
209
  if (!fragment) continue;
152
210
 
153
- let target;
211
+ let root = document;
154
212
  try {
155
- target = document.getElementById(fragment);
213
+ if (typeof a.getRootNode === 'function') {
214
+ const r = a.getRootNode();
215
+ if (r) root = r;
216
+ }
156
217
  } catch {
157
- target = null;
218
+ root = document;
158
219
  }
159
- if (!target) {
160
- try {
161
- target = document.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]');
162
- } catch {
163
- target = null;
164
- }
220
+
221
+ let target = resolveInRoot(root, fragment);
222
+ if (!target && root !== document) {
223
+ target = resolveInRoot(document, fragment);
165
224
  }
166
225
  if (target) return true;
167
226
  }
@@ -179,37 +238,32 @@ function runInPage(ctx) {
179
238
  const anchorLink = mainLandmark ? false : hasWorkingAnchorLink();
180
239
  const heading = mainLandmark || anchorLink ? false : hasHeading();
181
240
 
241
+ // A recognized mechanism is present -> nothing to review on this page.
182
242
  if (mainLandmark || anchorLink || heading) {
183
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
243
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
184
244
  }
185
245
 
186
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
187
- const html = helpers.getOuterHtmlSnippet
188
- ? helpers.getOuterHtmlSnippet(body)
189
- : (body.outerHTML || '').slice(0, 200);
190
-
191
246
  const occurrences = [
192
- {
193
- selector: stableSelector,
194
- html,
195
- summary: 'This page has no recognized way to bypass repeated blocks of content.',
196
- hint: 'Add a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content.',
247
+ helpers.reportOccurrence(body, {
248
+ summary:
249
+ 'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
250
+ hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden — e.g. while a modal dialog makes the page inert — or provided on a per-site basis; this needs human confirmation.)',
197
251
  i18n: {
198
- summaryKey: 'bypassBlocksPresent_summary_fail',
199
- hintKey: 'bypassBlocksPresent_hint_fail',
252
+ summaryKey: 'bypassBlocksPresent_summary_cantTell',
253
+ hintKey: 'bypassBlocksPresent_hint_cantTell',
200
254
  params: {}
201
255
  },
202
256
  data: {
203
257
  details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
204
258
  visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
205
259
  }
206
- }
260
+ })
207
261
  ];
208
262
 
209
263
  return {
210
264
  ruleId: rule.ruleId,
211
- outcome: 'fail',
212
- severity: rule.defaultSeverity || 'serious',
265
+ outcome: 'cantTell',
266
+ severity: rule.defaultSeverity || 'moderate',
213
267
  occurrences
214
268
  };
215
269
  }
@@ -148,8 +148,6 @@ function runInPage(ctx) {
148
148
  const name = getAccessibleNameText(el);
149
149
  if (name) continue;
150
150
 
151
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
152
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
153
151
  const eligInfo = helpers.getEligibilityInfo
154
152
  ? (() => {
155
153
  try {
@@ -160,21 +158,21 @@ function runInPage(ctx) {
160
158
  })()
161
159
  : null;
162
160
 
163
- occurrences.push({
164
- selector: stableSelector,
165
- html,
166
- summary: 'This heading has no accessible name.',
167
- hint: 'Add text content (or aria-label/aria-labelledby) to this heading, or remove it if it is not needed.',
168
- i18n: {
169
- summaryKey: 'emptyHeading_summary_cantTell',
170
- hintKey: 'emptyHeading_hint_cantTell',
171
- params: {}
172
- },
173
- data: {
174
- details: { reasonCode: 'HEADING_EMPTY' },
175
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
176
- }
177
- });
161
+ occurrences.push(
162
+ helpers.reportOccurrence(el, {
163
+ summary: 'This heading has no accessible name.',
164
+ hint: 'Add text content (or aria-label/aria-labelledby) to this heading, or remove it if it is not needed.',
165
+ i18n: {
166
+ summaryKey: 'emptyHeading_summary_cantTell',
167
+ hintKey: 'emptyHeading_hint_cantTell',
168
+ params: {}
169
+ },
170
+ data: {
171
+ details: { reasonCode: 'HEADING_EMPTY' },
172
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
173
+ }
174
+ })
175
+ );
178
176
  }
179
177
 
180
178
  if (applicableCount === 0) {
@@ -141,43 +141,40 @@ function runInPage(ctx) {
141
141
 
142
142
  if (getVisibleText(el)) continue;
143
143
 
144
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
145
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
146
-
147
144
  const ariaName = getAriaOnlyName(el);
148
145
  if (ariaName) {
149
- occurrences.push({
150
- selector: stableSelector,
151
- html,
152
- summary:
153
- 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
154
- hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
146
+ occurrences.push(
147
+ helpers.reportOccurrence(el, {
148
+ summary:
149
+ 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
150
+ hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
151
+ i18n: {
152
+ summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
153
+ hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
154
+ params: {}
155
+ },
156
+ data: {
157
+ details: { reasonCode: 'TABLE_HEADER_NAME_NOT_VISIBLE_TEXT', ariaName }
158
+ }
159
+ })
160
+ );
161
+ continue;
162
+ }
163
+
164
+ occurrences.push(
165
+ helpers.reportOccurrence(el, {
166
+ summary: 'This table header cell has no accessible name.',
167
+ hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
155
168
  i18n: {
156
- summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
157
- hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
169
+ summaryKey: 'emptyTableHeader_summary_cantTell',
170
+ hintKey: 'emptyTableHeader_hint_cantTell',
158
171
  params: {}
159
172
  },
160
173
  data: {
161
- details: { reasonCode: 'TABLE_HEADER_NAME_NOT_VISIBLE_TEXT', ariaName }
174
+ details: { reasonCode: 'TABLE_HEADER_EMPTY' }
162
175
  }
163
- });
164
- continue;
165
- }
166
-
167
- occurrences.push({
168
- selector: stableSelector,
169
- html,
170
- summary: 'This table header cell has no accessible name.',
171
- hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
172
- i18n: {
173
- summaryKey: 'emptyTableHeader_summary_cantTell',
174
- hintKey: 'emptyTableHeader_hint_cantTell',
175
- params: {}
176
- },
177
- data: {
178
- details: { reasonCode: 'TABLE_HEADER_EMPTY' }
179
- }
180
- });
176
+ })
177
+ );
181
178
  }
182
179
 
183
180
  if (applicableCount === 0) {
@@ -185,20 +185,13 @@ function runInPage(ctx) {
185
185
 
186
186
  const reasonCode =
187
187
  method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
188
- const methodLabel =
189
- method === 'title'
190
- ? 'title'
191
- : method === 'placeholder'
192
- ? 'placeholder'
193
- : 'title or placeholder';
194
-
195
188
  const baseOccurrence = {
196
- summary: 'Form control’s primary label is derived from title or placeholder.',
189
+ summary: `Form control’s primary label is derived from ${method}.`,
197
190
  hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
198
191
  i18n: {
199
192
  summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
200
193
  hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
201
- params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
194
+ params: { element: (el.tagName || '').toLowerCase(), method }
202
195
  },
203
196
  data: {
204
197
  visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
@@ -125,29 +125,24 @@ function runInPage(ctx) {
125
125
  const { el, level } = headings[i];
126
126
 
127
127
  if (level > highestSoFar + 1) {
128
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
129
- const html = helpers.getOuterHtmlSnippet
130
- ? helpers.getOuterHtmlSnippet(el)
131
- : el.outerHTML || '';
132
-
133
- occurrences.push({
134
- selector: stableSelector,
135
- html,
136
- summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
137
- hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
138
- i18n: {
139
- summaryKey: 'headingOrder_summary_cantTell',
140
- hintKey: 'headingOrder_hint_cantTell',
141
- params: { fromLevel: String(highestSoFar), toLevel: String(level) }
142
- },
143
- data: {
144
- details: {
145
- reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
146
- fromLevel: highestSoFar,
147
- toLevel: level
128
+ occurrences.push(
129
+ helpers.reportOccurrence(el, {
130
+ summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
131
+ hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
132
+ i18n: {
133
+ summaryKey: 'headingOrder_summary_cantTell',
134
+ hintKey: 'headingOrder_hint_cantTell',
135
+ params: { fromLevel: String(highestSoFar), toLevel: String(level) }
136
+ },
137
+ data: {
138
+ details: {
139
+ reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
140
+ fromLevel: highestSoFar,
141
+ toLevel: level
142
+ }
148
143
  }
149
- }
150
- });
144
+ })
145
+ );
151
146
  }
152
147
 
153
148
  if (level > highestSoFar) highestSoFar = level;
@@ -111,23 +111,20 @@ function runInPage(ctx) {
111
111
 
112
112
  if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
113
113
 
114
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
115
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
116
-
117
- occurrences.push({
118
- selector: stableSelector,
119
- html,
120
- summary: "This image's alt text duplicates other visible text right next to it.",
121
- hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
122
- i18n: {
123
- summaryKey: 'imageRedundantAlt_summary_cantTell',
124
- hintKey: 'imageRedundantAlt_hint_cantTell',
125
- params: { alt }
126
- },
127
- data: {
128
- details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
129
- }
130
- });
114
+ occurrences.push(
115
+ helpers.reportOccurrence(el, {
116
+ summary: "This image's alt text duplicates other visible text right next to it.",
117
+ hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
118
+ i18n: {
119
+ summaryKey: 'imageRedundantAlt_summary_cantTell',
120
+ hintKey: 'imageRedundantAlt_hint_cantTell',
121
+ params: { alt }
122
+ },
123
+ data: {
124
+ details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
125
+ }
126
+ })
127
+ );
131
128
  }
132
129
 
133
130
  if (applicableCount === 0) {
@@ -71,6 +71,9 @@ function runInPage(ctx) {
71
71
  const isAccTreeEligible =
72
72
  helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
73
73
 
74
+ const getAriaNameInfo =
75
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
76
+
74
77
  const getFocusableInfo =
75
78
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
76
79
 
@@ -107,6 +110,26 @@ function runInPage(ctx) {
107
110
  return !focusable;
108
111
  }
109
112
 
113
+ // alt="" plus a name from aria-label/aria-labelledby/title is the judgement
114
+ // call this rule reviews. alt="" with no other source leaves the control
115
+ // unnamed, which input-image-alt-present fails outright.
116
+ function hasNameFromOtherSource(el) {
117
+ if (getAriaNameInfo) {
118
+ try {
119
+ const aria = getAriaNameInfo(el, ctx);
120
+ if (aria && aria.present && String(aria.value || '').trim()) return true;
121
+ } catch {
122
+ // fall through to title
123
+ }
124
+ }
125
+ try {
126
+ const title = el.getAttribute('title');
127
+ return title != null && String(title).trim() !== '';
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
110
133
  const els = (() => {
111
134
  try {
112
135
  return Array.from(
@@ -143,6 +166,7 @@ function runInPage(ctx) {
143
166
 
144
167
  // Rule-specific applicability (only elements that already have a text alternative mechanism)
145
168
  if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() === '')) continue;
169
+ if (!hasNameFromOtherSource(el)) continue;
146
170
 
147
171
  applicableCount += 1;
148
172
 
@@ -94,23 +94,21 @@ function runInPage(ctx) {
94
94
  if (!nameInfo || nameInfo.mechanism !== 'title') continue;
95
95
 
96
96
  const tag = el.tagName.toLowerCase();
97
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
98
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
99
-
100
- occurrences.push({
101
- selector: stableSelector,
102
- html,
103
- summary: 'This form control relies on the title attribute as its only label.',
104
- hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
105
- i18n: {
106
- summaryKey: 'labelTitleOnly_summary_cantTell',
107
- hintKey: 'labelTitleOnly_hint_cantTell',
108
- params: { element: tag }
109
- },
110
- data: {
111
- details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
112
- }
113
- });
97
+
98
+ occurrences.push(
99
+ helpers.reportOccurrence(el, {
100
+ summary: 'This form control relies on the title attribute as its only label.',
101
+ hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
102
+ i18n: {
103
+ summaryKey: 'labelTitleOnly_summary_cantTell',
104
+ hintKey: 'labelTitleOnly_hint_cantTell',
105
+ params: { element: tag }
106
+ },
107
+ data: {
108
+ details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
109
+ }
110
+ })
111
+ );
114
112
  }
115
113
 
116
114
  if (applicableCount === 0) {
@@ -28,19 +28,13 @@
28
28
  * HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
29
29
  * main→main, nav→navigation, aside→complementary, section/form→
30
30
  * region/form only when accessibly named).
31
- * - Candidate selection (`isBannerCandidate` below) is deliberately
32
- * unconditional — a `<header>`/`role="banner"` counts as a candidate
33
- * regardless of nesting — rather than reusing the same HTML-AAM
34
- * sectioning-ancestor suppression (`getImplicitLandmarkRole`'s
35
- * `hasSectioningAncestor` gate) that the violation check itself relies
36
- * on. Gating candidate selection on that suppression would be
37
- * self-defeating: the moment a `<header>` is nested inside another
38
- * landmark, that same nesting would make it stop counting as a banner
39
- * candidate in the first place, so the rule could never flag the one
40
- * case it exists to catch. The ancestor walk (`hasLandmarkAncestor`) is intentionally
41
- * asymmetric: it still uses the full suppression-aware
42
- * `getLandmarkRole` for each ancestor, since an ancestor genuinely
43
- * needs its own real role to count as blocking.
31
+ * - Candidate selection (`isBannerCandidate`) requires the element to really
32
+ * carry the banner role, via the suppression-aware `getLandmarkRole`. The
33
+ * two ancestor sets differ, so this does not make the rule vacuous: the
34
+ * suppression set is the sectioning tags plus `<main>`, while the blocking
35
+ * set is any landmark role, so a `<header>` inside `role="region"`,
36
+ * `<form>` or `<footer>` is still caught, and an explicit `role="banner"`
37
+ * is a candidate wherever it sits.
44
38
  */
45
39
 
46
40
  const id = 'landmark-banner-is-top-level';
@@ -145,16 +139,11 @@ function runInPage(ctx) {
145
139
  return getImplicitLandmarkRole(el);
146
140
  }
147
141
 
148
- // Candidate selection is deliberately NOT the same as getLandmarkRole()
149
- // === 'banner' — see the fix note above. A <header> is a
150
- // candidate purely by tag + absence of any role attribute, independent
151
- // of whether sectioning-ancestor nesting would currently suppress its
152
- // implicit role; an explicit role="banner" is always a candidate too.
142
+ // A candidate must actually have the banner role. Per HTML-AAM a <header>
143
+ // descended from article/aside/main/nav/section is not a banner at all, so
144
+ // flagging it as a nested banner reports a landmark that does not exist.
153
145
  function isBannerCandidate(el) {
154
- if (!el || !el.getAttribute) return false;
155
- const explicit = getExplicitRoleToken(el);
156
- if (explicit) return explicit === 'banner';
157
- return !!(el.tagName && el.tagName.toLowerCase() === 'header');
146
+ return getLandmarkRole(el) === 'banner';
158
147
  }
159
148
 
160
149
  function hasLandmarkAncestor(el) {
@@ -220,23 +209,20 @@ function runInPage(ctx) {
220
209
  for (const el of banners) {
221
210
  if (!hasLandmarkAncestor(el)) continue;
222
211
 
223
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
224
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
225
-
226
- occurrences.push({
227
- selector: stableSelector,
228
- html,
229
- summary: 'This banner landmark is nested inside another landmark region.',
230
- hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
231
- i18n: {
232
- summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
233
- hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
234
- params: {}
235
- },
236
- data: {
237
- details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
238
- }
239
- });
212
+ occurrences.push(
213
+ helpers.reportOccurrence(el, {
214
+ summary: 'This banner landmark is nested inside another landmark region.',
215
+ hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
216
+ i18n: {
217
+ summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
218
+ hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
219
+ params: {}
220
+ },
221
+ data: {
222
+ details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
223
+ }
224
+ })
225
+ );
240
226
  }
241
227
 
242
228
  if (occurrences.length) {