@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
@@ -156,25 +156,21 @@ function runInPage(ctx) {
156
156
 
157
157
  for (const th of headers) {
158
158
  if (!th) continue;
159
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
160
- const html = helpers.getOuterHtmlSnippet
161
- ? helpers.getOuterHtmlSnippet(th)
162
- : th.outerHTML || '';
163
-
164
- occurrences.push({
165
- selector: stableSelector,
166
- html,
167
- summary: 'This table has header cells but no data cells for them to describe.',
168
- hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
169
- i18n: {
170
- summaryKey: 'tableThHasDataCells_summary_fail',
171
- hintKey: 'tableThHasDataCells_hint_fail',
172
- params: {}
173
- },
174
- data: {
175
- details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
176
- }
177
- });
159
+
160
+ occurrences.push(
161
+ helpers.reportOccurrence(th, {
162
+ summary: 'This table has header cells but no data cells for them to describe.',
163
+ hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
164
+ i18n: {
165
+ summaryKey: 'tableThHasDataCells_summary_fail',
166
+ hintKey: 'tableThHasDataCells_hint_fail',
167
+ params: {}
168
+ },
169
+ data: {
170
+ details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
171
+ }
172
+ })
173
+ );
178
174
  }
179
175
  }
180
176
 
@@ -28,7 +28,11 @@
28
28
  * its own wrapping link/button) is one visual region, not two independent
29
29
  * targets; that pattern is nested-interactive-controls-absent's
30
30
  * concern, not a spacing one.
31
- * - Inline: a link inside a run of text (isInlineTextExceptionTarget).
31
+ * - Inline: a link inside a text-block container passes outright
32
+ * (isInlineTextExceptionTarget). An inline link whose only spacing conflict
33
+ * is another inline link in the same run is reported as cantTell
34
+ * (isInlineLinkTarget) — the inline exception may cover it, but geometry
35
+ * can't confirm that.
32
36
  * - User Agent Control: an unstyled native checkbox/radio, detected via
33
37
  * `appearance` not being reset to `none` (see isUserAgentSizedControl) —
34
38
  * scoped narrowly to checkbox/radio specifically, not every form control,
@@ -147,23 +151,10 @@ function runInPage(ctx) {
147
151
  return 'html';
148
152
  }
149
153
 
150
- function htmlSnippet(el) {
151
- try {
152
- if (helpers && typeof helpers.getOuterHtmlSnippet === 'function')
153
- return helpers.getOuterHtmlSnippet(el);
154
- } catch {}
155
- try {
156
- return el && el.outerHTML ? String(el.outerHTML) : '';
157
- } catch {}
158
- return '';
159
- }
160
-
161
- function isInlineTextExceptionTarget(el) {
162
- // SC 2.5.8 exception: target is in a sentence/block of text (inline)
163
- // Deterministic heuristic:
164
- // - must be a link-like target
165
- // - must be rendered as inline/inline-*
166
- // - must be inside a typical text container
154
+ // A link-like target (a[href] or role="link") rendered inline/inline-* and
155
+ // carrying visible text. This is the shape the SC 2.5.8 inline exception is
156
+ // about, without the surrounding-container requirement.
157
+ function isInlineLinkTarget(el) {
167
158
  try {
168
159
  if (!el || el.nodeType !== 1) return false;
169
160
 
@@ -180,27 +171,31 @@ function runInPage(ctx) {
180
171
 
181
172
  const cs = getStyle(el);
182
173
  const display = cs && cs.display ? String(cs.display) : '';
183
- if (!display) return false;
184
-
185
- // Inline exception is for inline text runs; many design systems use inline-block for links,
186
- // so treat inline-block variants as eligible for this exception.
174
+ // Many design systems use inline-block for links, so treat the
175
+ // inline-block variants as inline here too.
187
176
  const isInline =
188
177
  display === 'inline' ||
189
178
  display === 'inline-block' ||
190
179
  display === 'inline-flex' ||
191
180
  display === 'inline-grid' ||
192
181
  display === 'inline-table';
193
-
194
182
  if (!isInline) return false;
195
183
 
196
- const t = (el.textContent || '').trim();
197
- if (!t) return false;
184
+ return (el.textContent || '').trim().length > 0;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ function isInlineTextExceptionTarget(el) {
191
+ // SC 2.5.8 exception: an inline link that sits inside a run of text.
192
+ try {
193
+ if (!isInlineLinkTarget(el)) return false;
198
194
 
199
- // Require that it sits in a "text block" context
200
195
  const textContainer = closest(el, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
201
196
  if (textContainer) return true;
202
197
 
203
- // Fallback: sometimes links are inside spans within a paragraph-like region
198
+ // Links are often wrapped in an inline element inside the text block.
204
199
  const inlineContainer = closest(el, 'span, em, strong, small, label');
205
200
  if (inlineContainer) {
206
201
  const outerTextBlock = closest(
@@ -590,27 +585,27 @@ function runInPage(ctx) {
590
585
  // the result once any other target on the page had a confident
591
586
  // fail (see helpers.resolveTieredOutcome's header comment). Now
592
587
  // reported as its own cantTell-tier occurrence instead.
593
- cantTellOccurrences.push({
594
- selector: buildSelector(it.el),
595
- html: htmlSnippet(it.el),
596
- occurrenceOutcome: 'cantTell',
597
- summary:
598
- 'Target may be too small and too close to another target, but the overlap is near the detection threshold and could not be confidently measured.',
599
- hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
600
- i18n: {
601
- summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
602
- hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
603
- params: {}
604
- },
605
- data: {
606
- details: {
607
- measured: { width: it.rect.width, height: it.rect.height },
608
- reasonCode: 'undersized-ambiguous-spacing',
609
- conflictHitCount: info.hitCount,
610
- conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
588
+ cantTellOccurrences.push(
589
+ helpers.reportOccurrence(it.el, {
590
+ occurrenceOutcome: 'cantTell',
591
+ summary:
592
+ 'Target may be too small and too close to another target, but the overlap is near the detection threshold and could not be confidently measured.',
593
+ hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
594
+ i18n: {
595
+ summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
596
+ hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
597
+ params: {}
598
+ },
599
+ data: {
600
+ details: {
601
+ measured: { width: it.rect.width, height: it.rect.height },
602
+ reasonCode: 'undersized-ambiguous-spacing',
603
+ conflictHitCount: info.hitCount,
604
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
605
+ }
611
606
  }
612
- }
613
- });
607
+ })
608
+ );
614
609
  continue;
615
610
  }
616
611
 
@@ -619,50 +614,78 @@ function runInPage(ctx) {
619
614
  // Confident spacing conflict, but the target may be exempt as part
620
615
  // of an essential graphic/image-map region — same "previously
621
616
  // unrecoverable" gap as above, now reported instead of dropped.
622
- cantTellOccurrences.push({
623
- selector: buildSelector(it.el),
624
- html: htmlSnippet(it.el),
625
- occurrenceOutcome: 'cantTell',
626
- summary:
627
- 'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
628
- hint: 'Verify whether this target’s size is genuinely essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
617
+ cantTellOccurrences.push(
618
+ helpers.reportOccurrence(it.el, {
619
+ occurrenceOutcome: 'cantTell',
620
+ summary:
621
+ 'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
622
+ hint: 'Verify whether this target’s size is genuinely essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
623
+ i18n: {
624
+ summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
625
+ hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
626
+ params: {}
627
+ },
628
+ data: {
629
+ details: {
630
+ measured: { width: it.rect.width, height: it.rect.height },
631
+ reasonCode: 'undersized-plausibly-essential',
632
+ conflictHitCount: info.hitCount,
633
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
634
+ }
635
+ }
636
+ })
637
+ );
638
+ continue;
639
+ }
640
+
641
+ // This target and its conflicting neighbor are both inline links in the
642
+ // same text run, where the SC 2.5.8 inline exception may apply. That
643
+ // can't be decided from geometry alone, so defer to manual review.
644
+ if (isInlineLinkTarget(it.el) && info.conflictEl && isInlineLinkTarget(info.conflictEl)) {
645
+ cantTellOccurrences.push(
646
+ helpers.reportOccurrence(it.el, {
647
+ occurrenceOutcome: 'cantTell',
648
+ summary:
649
+ 'Target is smaller than 24×24 CSS px and close to another inline link in the same run of text, where the inline exception may apply.',
650
+ hint: 'Confirm whether these links form a run of inline text (which is exempt); otherwise increase the target size to at least 24×24 CSS px or add spacing.',
651
+ i18n: {
652
+ summaryKey: 'targetSizeMinimum_summary_cantTell_inlineLinkRun',
653
+ hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
654
+ params: {}
655
+ },
656
+ data: {
657
+ details: {
658
+ measured: { width: it.rect.width, height: it.rect.height },
659
+ reasonCode: 'undersized-inline-link-run',
660
+ conflictHitCount: info.hitCount,
661
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
662
+ }
663
+ }
664
+ })
665
+ );
666
+ continue;
667
+ }
668
+
669
+ failOccurrences.push(
670
+ helpers.reportOccurrence(it.el, {
671
+ occurrenceOutcome: 'fail',
672
+ summary: 'Target is too small and too close to another target.',
673
+ hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
629
674
  i18n: {
630
- summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
631
- hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
675
+ summaryKey: 'targetSizeMinimum_summary_fail',
676
+ hintKey: 'targetSizeMinimum_hint_fail',
632
677
  params: {}
633
678
  },
634
679
  data: {
635
680
  details: {
636
681
  measured: { width: it.rect.width, height: it.rect.height },
637
- reasonCode: 'undersized-plausibly-essential',
682
+ reasonCode: 'undersized-and-too-close',
638
683
  conflictHitCount: info.hitCount,
639
684
  conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
640
685
  }
641
686
  }
642
- });
643
- continue;
644
- }
645
-
646
- failOccurrences.push({
647
- selector: buildSelector(it.el),
648
- html: htmlSnippet(it.el),
649
- occurrenceOutcome: 'fail',
650
- summary: 'Target is too small and too close to another target.',
651
- hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
652
- i18n: {
653
- summaryKey: 'targetSizeMinimum_summary_fail',
654
- hintKey: 'targetSizeMinimum_hint_fail',
655
- params: {}
656
- },
657
- data: {
658
- details: {
659
- measured: { width: it.rect.width, height: it.rect.height },
660
- reasonCode: 'undersized-and-too-close',
661
- conflictHitCount: info.hitCount,
662
- conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
663
- }
664
- }
665
- });
687
+ })
688
+ );
666
689
  }
667
690
  }
668
691
 
@@ -150,26 +150,21 @@ function runInPage(ctx) {
150
150
  if (hasColumnHeaderAbove(r, c)) continue;
151
151
  if (hasRowHeaderBefore(r, c)) continue;
152
152
 
153
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(cell) : 'html';
154
- const html = helpers.getOuterHtmlSnippet
155
- ? helpers.getOuterHtmlSnippet(cell)
156
- : cell.outerHTML || '';
157
-
158
- occurrences.push({
159
- selector: stableSelector,
160
- html,
161
- summary:
162
- 'This data cell has no associated header (no headers attribute, no column <th> above it, no row <th> to its left).',
163
- hint: 'Add a headers attribute referencing the relevant <th> id(s), or restructure the table so this cell has an implicit row/column header.',
164
- i18n: {
165
- summaryKey: 'tdHasHeader_summary_fail',
166
- hintKey: 'tdHasHeader_hint_fail',
167
- params: { row: String(r), column: String(c) }
168
- },
169
- data: {
170
- details: { reasonCode: 'TD_NO_ASSOCIATED_HEADER', row: r, column: c }
171
- }
172
- });
153
+ occurrences.push(
154
+ helpers.reportOccurrence(cell, {
155
+ summary:
156
+ 'This data cell has no associated header (no headers attribute, no column <th> above it, no row <th> to its left).',
157
+ hint: 'Add a headers attribute referencing the relevant <th> id(s), or restructure the table so this cell has an implicit row/column header.',
158
+ i18n: {
159
+ summaryKey: 'tdHasHeader_summary_fail',
160
+ hintKey: 'tdHasHeader_hint_fail',
161
+ params: { row: String(r), column: String(c) }
162
+ },
163
+ data: {
164
+ details: { reasonCode: 'TD_NO_ASSOCIATED_HEADER', row: r, column: c }
165
+ }
166
+ })
167
+ );
173
168
  }
174
169
  }
175
170
  }
@@ -227,24 +227,22 @@ function runInPage(ctx) {
227
227
  }
228
228
  })()
229
229
  : null;
230
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
231
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
232
230
 
233
- occurrences.push({
234
- selector: stableSelector,
235
- html,
236
- summary: 'This element has no accessible name.',
237
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this textbox's accessible name.",
238
- i18n: {
239
- summaryKey: 'textboxNamePresent_summary_fail',
240
- hintKey: 'textboxNamePresent_hint_fail',
241
- params: { controlType: 'textbox' }
242
- },
243
- data: {
244
- details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
245
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
246
- }
247
- });
231
+ occurrences.push(
232
+ helpers.reportOccurrence(el, {
233
+ summary: 'This element has no accessible name.',
234
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this textbox's accessible name.",
235
+ i18n: {
236
+ summaryKey: 'textboxNamePresent_summary_fail',
237
+ hintKey: 'textboxNamePresent_hint_fail',
238
+ params: { controlType: 'textbox' }
239
+ },
240
+ data: {
241
+ details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
242
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
243
+ }
244
+ })
245
+ );
248
246
  }
249
247
 
250
248
  if (applicableCount === 0) {
@@ -148,24 +148,22 @@ function runInPage(ctx) {
148
148
  }
149
149
  })()
150
150
  : null;
151
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
152
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
153
-
154
- occurrences.push({
155
- selector: stableSelector,
156
- html,
157
- summary: 'This tooltip has no accessible name.',
158
- hint: 'Provide tooltip text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
159
- i18n: {
160
- summaryKey: 'tooltipNamePresent_summary_fail',
161
- hintKey: 'tooltipNamePresent_hint_fail',
162
- params: {}
163
- },
164
- data: {
165
- details: { reasonCode: 'name_missing', controlType: 'tooltip', methodTried: res.method },
166
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
167
- }
168
- });
151
+
152
+ occurrences.push(
153
+ helpers.reportOccurrence(el, {
154
+ summary: 'This tooltip has no accessible name.',
155
+ hint: 'Provide tooltip text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
156
+ i18n: {
157
+ summaryKey: 'tooltipNamePresent_summary_fail',
158
+ hintKey: 'tooltipNamePresent_hint_fail',
159
+ params: {}
160
+ },
161
+ data: {
162
+ details: { reasonCode: 'name_missing', controlType: 'tooltip', methodTried: res.method },
163
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
164
+ }
165
+ })
166
+ );
169
167
  }
170
168
 
171
169
  if (applicableCount === 0) {
@@ -145,24 +145,22 @@ function runInPage(ctx) {
145
145
  }
146
146
  })()
147
147
  : null;
148
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
149
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
150
-
151
- occurrences.push({
152
- selector: stableSelector,
153
- html,
154
- summary: 'This element has no accessible name.',
155
- hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
156
- i18n: {
157
- summaryKey: 'treeitemNamePresent_summary_fail',
158
- hintKey: 'treeitemNamePresent_hint_fail',
159
- params: { controlType: 'treeitem' }
160
- },
161
- data: {
162
- details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
163
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
164
- }
165
- });
148
+
149
+ occurrences.push(
150
+ helpers.reportOccurrence(el, {
151
+ summary: 'This element has no accessible name.',
152
+ hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
153
+ i18n: {
154
+ summaryKey: 'treeitemNamePresent_summary_fail',
155
+ hintKey: 'treeitemNamePresent_hint_fail',
156
+ params: { controlType: 'treeitem' }
157
+ },
158
+ data: {
159
+ details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
160
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
161
+ }
162
+ })
163
+ );
166
164
  }
167
165
 
168
166
  if (applicableCount === 0) {
@@ -92,23 +92,21 @@ function runInPage(ctx) {
92
92
  if (isValidTag(raw)) continue;
93
93
 
94
94
  const tag = el.tagName.toLowerCase();
95
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
96
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
97
-
98
- occurrences.push({
99
- selector: stableSelector,
100
- html,
101
- summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
102
- hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
103
- i18n: {
104
- summaryKey: 'validLang_summary_fail',
105
- hintKey: 'validLang_hint_fail',
106
- params: { element: tag, value: raw }
107
- },
108
- data: {
109
- details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
110
- }
111
- });
95
+
96
+ occurrences.push(
97
+ helpers.reportOccurrence(el, {
98
+ summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
99
+ hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
100
+ i18n: {
101
+ summaryKey: 'validLang_summary_fail',
102
+ hintKey: 'validLang_hint_fail',
103
+ params: { element: tag, value: raw }
104
+ },
105
+ data: {
106
+ details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
107
+ }
108
+ })
109
+ );
112
110
  }
113
111
 
114
112
  if (applicableCount === 0) {
@@ -62,25 +62,24 @@ function runInPage(ctx) {
62
62
  for (const [key, els] of groups) {
63
63
  if (els.length <= 1) continue;
64
64
  for (const el of els) {
65
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
66
- const html = helpers.getOuterHtmlSnippet
67
- ? helpers.getOuterHtmlSnippet(el)
68
- : el.outerHTML || '';
69
-
70
- occurrences.push({
71
- selector: stableSelector,
72
- html,
73
- summary: "This element's accesskey is shared with another element on the page.",
74
- hint: 'Make each accesskey value unique across the page.',
75
- i18n: {
76
- summaryKey: 'accesskeys_summary_cantTell',
77
- hintKey: 'accesskeys_hint_cantTell',
78
- params: { accesskey: key, duplicateCount: String(els.length) }
79
- },
80
- data: {
81
- details: { reasonCode: 'ACCESSKEY_DUPLICATE', accesskey: key, duplicateCount: els.length }
82
- }
83
- });
65
+ occurrences.push(
66
+ helpers.reportOccurrence(el, {
67
+ summary: "This element's accesskey is shared with another element on the page.",
68
+ hint: 'Make each accesskey value unique across the page.',
69
+ i18n: {
70
+ summaryKey: 'accesskeys_summary_cantTell',
71
+ hintKey: 'accesskeys_hint_cantTell',
72
+ params: { accesskey: key, duplicateCount: String(els.length) }
73
+ },
74
+ data: {
75
+ details: {
76
+ reasonCode: 'ACCESSKEY_DUPLICATE',
77
+ accesskey: key,
78
+ duplicateCount: els.length
79
+ }
80
+ }
81
+ })
82
+ );
84
83
  }
85
84
  }
86
85
 
@@ -114,29 +114,26 @@ function runInPage(ctx) {
114
114
 
115
115
  if (normalizedAriaChecked === actualState) continue;
116
116
 
117
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
118
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
119
-
120
- occurrences.push({
121
- selector: stableSelector,
122
- html,
123
- summary:
124
- 'This element’s aria-checked value does not match its actual checked/indeterminate state.',
125
- hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
126
- i18n: {
127
- summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
128
- hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
129
- params: { ariaChecked: normalizedAriaChecked, actualState, type }
130
- },
131
- data: {
132
- details: {
133
- reasonCode: 'ARIA_CHECKED_STATE_MISMATCH',
134
- ariaChecked: normalizedAriaChecked,
135
- actualState,
136
- type
117
+ occurrences.push(
118
+ helpers.reportOccurrence(el, {
119
+ summary:
120
+ 'This element’s aria-checked value does not match its actual checked/indeterminate state.',
121
+ hint: 'Set aria-checked to match the element’s real state, or remove it — a native checkbox/radio already exposes this state without it.',
122
+ i18n: {
123
+ summaryKey: 'ariaCheckedStateMismatch_summary_cantTell',
124
+ hintKey: 'ariaCheckedStateMismatch_hint_cantTell',
125
+ params: { ariaChecked: normalizedAriaChecked, actualState, type }
126
+ },
127
+ data: {
128
+ details: {
129
+ reasonCode: 'ARIA_CHECKED_STATE_MISMATCH',
130
+ ariaChecked: normalizedAriaChecked,
131
+ actualState,
132
+ type
133
+ }
137
134
  }
138
- }
139
- });
135
+ })
136
+ );
140
137
  }
141
138
 
142
139
  if (applicableCount === 0) {
@@ -51,9 +51,8 @@
51
51
  * unmounted SPA view).
52
52
  * Both cases would produce false positives under a hard `fail`, which this
53
53
  * engine reserves for high-confidence violations; `cantTell` routes them to
54
- * human review instead. This mirrors how other tools treat 2.4.1 (e.g. axe
55
- * marks the no-mechanism case "needs review" via reviewOnFail rather than
56
- * failing it), and why no ACT rule hard-fails 2.4.1 by presence alone.
54
+ * human review instead. No ACT rule hard-fails 2.4.1 by presence alone,
55
+ * for the same reason.
57
56
  * - This rule intentionally checks presence, not position, for the
58
57
  * same-page-anchor condition (b): a full bypass algorithm is heuristic
59
58
  * (see ROADMAP.md's Tier 1a note on why this rule was
@@ -244,15 +243,8 @@ function runInPage(ctx) {
244
243
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
244
  }
246
245
 
247
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
248
- const html = helpers.getOuterHtmlSnippet
249
- ? helpers.getOuterHtmlSnippet(body)
250
- : (body.outerHTML || '').slice(0, 200);
251
-
252
246
  const occurrences = [
253
- {
254
- selector: stableSelector,
255
- html,
247
+ helpers.reportOccurrence(body, {
256
248
  summary:
257
249
  'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
258
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.)',
@@ -265,7 +257,7 @@ function runInPage(ctx) {
265
257
  details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
266
258
  visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
267
259
  }
268
- }
260
+ })
269
261
  ];
270
262
 
271
263
  return {