@surea11y/core 1.3.0 → 1.4.1

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 (166) hide show
  1. package/CHANGELOG.md +87 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/CI_INTEGRATIONS.md +7 -7
  7. package/docs/ENGINE_OPTIONS.md +1 -1
  8. package/docs/I18N.md +12 -9
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +1 -1
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_CATALOG.md +6 -6
  13. package/package.json +52 -16
  14. package/src/baseline.js +0 -0
  15. package/src/checks/automatic/area-alt-present.js +4 -6
  16. package/src/checks/automatic/aria-allowed-attr.js +663 -134
  17. package/src/checks/automatic/aria-allowed-role.js +2 -0
  18. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  19. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  20. package/src/checks/automatic/aria-deprecated-role.js +107 -38
  21. package/src/checks/automatic/aria-hidden-body.js +6 -4
  22. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  23. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  24. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  25. package/src/checks/automatic/aria-required-attr.js +6 -7
  26. package/src/checks/automatic/aria-required-children.js +7 -10
  27. package/src/checks/automatic/aria-required-parent.js +20 -25
  28. package/src/checks/automatic/aria-role-name-present.js +2 -0
  29. package/src/checks/automatic/aria-roles-valid.js +33 -6
  30. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  31. package/src/checks/automatic/aria-valid-attr.js +2 -0
  32. package/src/checks/automatic/autocomplete-valid.js +39 -1
  33. package/src/checks/automatic/avoid-inline-spacing.js +181 -20
  34. package/src/checks/automatic/binary-control-name-present.js +13 -3
  35. package/src/checks/automatic/button-name-present.js +54 -21
  36. package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
  37. package/src/checks/automatic/combobox-name-present.js +10 -1
  38. package/src/checks/automatic/contrast-computable.js +2 -0
  39. package/src/checks/automatic/contrast-enhanced.js +2 -0
  40. package/src/checks/automatic/contrast-minimum.js +2 -0
  41. package/src/checks/automatic/css-orientation-lock.js +21 -27
  42. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  43. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  44. package/src/checks/automatic/dialog-name-present.js +18 -11
  45. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  46. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  47. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  48. package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
  49. package/src/checks/automatic/form-control-single-label.js +110 -43
  50. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  51. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  52. package/src/checks/automatic/iframe-name-present.js +2 -0
  53. package/src/checks/automatic/iframe-title-unique.js +3 -1
  54. package/src/checks/automatic/img-alt-present.js +23 -18
  55. package/src/checks/automatic/input-image-alt-present.js +101 -52
  56. package/src/checks/automatic/label-in-name.js +97 -27
  57. package/src/checks/automatic/language-page-present.js +7 -1
  58. package/src/checks/automatic/link-in-text-block.js +2 -0
  59. package/src/checks/automatic/link-name-present.js +52 -17
  60. package/src/checks/automatic/list-children-valid.js +14 -24
  61. package/src/checks/automatic/listbox-name-present.js +10 -1
  62. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  63. package/src/checks/automatic/menuitem-name-present.js +10 -1
  64. package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
  65. package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
  66. package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
  67. package/src/checks/automatic/meter-name-present.js +12 -4
  68. package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
  69. package/src/checks/automatic/object-text-alternative-present.js +16 -7
  70. package/src/checks/automatic/option-name-present.js +10 -1
  71. package/src/checks/automatic/page-title-present.js +2 -0
  72. package/src/checks/automatic/progressbar-name-present.js +16 -11
  73. package/src/checks/automatic/role-img-alt-present.js +4 -4
  74. package/src/checks/automatic/searchbox-name-present.js +10 -1
  75. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  76. package/src/checks/automatic/slider-name-present.js +13 -2
  77. package/src/checks/automatic/spinbutton-name-present.js +10 -1
  78. package/src/checks/automatic/summary-name-present.js +10 -1
  79. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  80. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  81. package/src/checks/automatic/tab-name-present.js +10 -1
  82. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  83. package/src/checks/automatic/table-th-has-data-cells.js +69 -6
  84. package/src/checks/automatic/target-size-minimum.js +5 -0
  85. package/src/checks/automatic/td-has-header.js +24 -1
  86. package/src/checks/automatic/textbox-name-present.js +10 -1
  87. package/src/checks/automatic/tooltip-name-present.js +10 -1
  88. package/src/checks/automatic/treeitem-name-present.js +10 -1
  89. package/src/checks/automatic/valid-lang.js +18 -3
  90. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  91. package/src/checks/manual/accesskeys-manual.js +3 -1
  92. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  93. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  94. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  95. package/src/checks/manual/aria-text-manual.js +6 -5
  96. package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
  97. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  98. package/src/checks/manual/css-hidden-focus.js +184 -9
  99. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  100. package/src/checks/manual/empty-heading-manual.js +17 -17
  101. package/src/checks/manual/empty-table-header-manual.js +52 -25
  102. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  103. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  104. package/src/checks/manual/heading-order-manual.js +28 -1
  105. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  106. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  107. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
  110. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  111. package/src/checks/manual/label-title-only-manual.js +29 -22
  112. package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
  113. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
  114. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  115. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  116. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  117. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  118. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  119. package/src/checks/manual/landmark-unique-manual.js +37 -52
  120. package/src/checks/manual/link-name-quality-manual.js +2 -0
  121. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  122. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  123. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  124. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  125. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  126. package/src/checks/manual/p-as-heading-manual.js +2 -0
  127. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  128. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  129. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  130. package/src/checks/manual/region-manual.js +27 -36
  131. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  132. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  133. package/src/checks/manual/skip-link-manual.js +7 -6
  134. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  135. package/src/checks/manual/tabindex-manual.js +3 -1
  136. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  137. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  138. package/src/checks/manual/video-caption-manual.js +2 -0
  139. package/src/checks/manual-review.js +2 -0
  140. package/src/core.js +11820 -3317
  141. package/src/index.js +2 -0
  142. package/src/report.js +51 -9
  143. package/src/sarif.js +20 -5
  144. package/surea11y.browser.js +4943 -1388
  145. package/bin/core.js +0 -473
  146. package/docs/CLI.md +0 -128
  147. package/src/catalogs/composites.wcag.js +0 -454
  148. package/src/checks/automatic/bypass-blocks-present.js +0 -215
  149. package/src/checks/rules-and-tags.full.csv +0 -19
  150. package/src/checks/rules-and-tags.full.json +0 -259
  151. package/src/core/aria-helpers.js +0 -1211
  152. package/src/core/contrast-helpers.js +0 -1302
  153. package/src/core/dom-helpers.js +0 -4493
  154. package/src/core/dom-runner.js +0 -787
  155. package/src/core/frame-messaging.js +0 -261
  156. package/src/core/frame-scan.js +0 -190
  157. package/src/core/rollup-composites.js +0 -127
  158. package/src/core/rule-meta.js +0 -176
  159. package/src/coverage/wcag-facets.js +0 -1079
  160. package/src/coverage/wcag-version-map.js +0 -84
  161. package/src/i18n/en.js +0 -1228
  162. package/src/i18n/fr.js +0 -1185
  163. package/src/policy/contracts.js +0 -18
  164. package/src/policy/resolvePolicy.js +0 -59
  165. package/src/policy/schemas/engine-options.schema.json +0 -103
  166. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,36 +1,30 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check empty-table-header
5
7
  * @atomic true
6
8
  * @summary Table header cells must not be empty
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to <th> elements that don't carry a conflicting explicit role,
10
12
  * plus any element (native <th> or not) with role="columnheader" or
11
- * role="rowheader" — matches a widely-used reference engine's own
12
- * `empty-table-header` selector exactly (`th:not([role]), [role="columnheader"], [role="rowheader"]`,
13
- * verified directly against its rule descriptor): a <th> that explicitly
14
- * restates role="columnheader"/"rowheader" is still covered via the second
15
- * clause, but a <th role="presentation"> (no longer meaningfully a header)
16
- * is correctly excluded, and an ARIA-role-only header (e.g. a <div
17
- * role="columnheader"> in a role="grid"/role="table" widget) is caught too
18
- * — found missing entirely via the cross-engine diff tool 2026-07-23.
13
+ * role="rowheader" (`th:not([role]), [role="columnheader"], [role="rowheader"]`):
14
+ * a <th> that explicitly restates role="columnheader"/"rowheader" is still
15
+ * covered via the second clause, but a <th role="presentation"> (no longer
16
+ * meaningfully a header) is correctly excluded, and an ARIA-role-only header
17
+ * (e.g. a <div role="columnheader"> in a role="grid"/role="table" widget) is
18
+ * caught too.
19
19
  * @expectation
20
20
  * The header cell has visible text content. A <th> named only via
21
21
  * aria-label/aria-labelledby (no visible text) is ALSO flagged, not
22
- * treated as equivalent — real screen-reader/browser testing (found via
23
- * a live-DOM cross-engine run 2026-07-21, verified against
24
- * https://butterpep.com/table-header-naming.html and
25
- * https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/)
26
- * confirms aria-label support on <th> is genuinely inconsistent in
27
- * practice: NVDA+Firefox and iOS VoiceOver+Safari ignore it entirely
28
- * (only visible text is announced), JAWS+Chrome/IE11 also only announce
29
- * visible text in the header cell itself. Visible text is the one
30
- * mechanism confirmed to work across every tested combination. A widely-used
31
- * reference engine's own equivalent check (`has-visible-text` only, no aria-label/
32
- * aria-labelledby alternative — confirmed directly against its rule
33
- * descriptor) reaches the same conclusion.
22
+ * treated as equivalent — aria-label support on <th> is genuinely
23
+ * inconsistent in practice: NVDA+Firefox and iOS VoiceOver+Safari ignore
24
+ * it entirely (only visible text is announced), JAWS+Chrome/IE11 also only
25
+ * announce visible text in the header cell itself. Visible text is the one
26
+ * mechanism confirmed to work across every tested combination. See
27
+ * https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/.
34
28
  * @implementation-notes
35
29
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
36
30
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
@@ -72,8 +66,36 @@ function runInPage(ctx) {
72
66
  .trim();
73
67
  }
74
68
 
69
+ const isAccTreeEligible =
70
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
71
+
72
+ function isEligible(node) {
73
+ if (!isAccTreeEligible) return true;
74
+ try {
75
+ const r = isAccTreeEligible(node, ctx);
76
+ return !!(r && r.eligible);
77
+ } catch {
78
+ return true;
79
+ }
80
+ }
81
+
82
+ // Plain el.textContent includes text from aria-hidden descendants, which
83
+ // a real screen reader never announces. A <th> whose only text comes from
84
+ // an aria-hidden descendant (e.g. <th><span aria-hidden="true">Name</span
85
+ // ></th>) would otherwise be treated as having visible text, even though
86
+ // AT announces nothing for it.
75
87
  function getVisibleText(el) {
76
- return normalizeWs(el.textContent);
88
+ function walk(node) {
89
+ if (node.nodeType === 3) return node.nodeValue || '';
90
+ if (node.nodeType !== 1) return '';
91
+ if (!isEligible(node)) return '';
92
+ let text = '';
93
+ for (const child of node.childNodes || []) text += walk(child);
94
+ return text;
95
+ }
96
+ let text = '';
97
+ for (const child of el.childNodes || []) text += walk(child);
98
+ return normalizeWs(text);
77
99
  }
78
100
 
79
101
  function getAriaOnlyName(el) {
@@ -97,9 +119,8 @@ function runInPage(ctx) {
97
119
  return '';
98
120
  }
99
121
 
100
- // Matches a widely-used reference engine's own empty-table-header selector exactly: a <th> with
101
- // no conflicting explicit role, plus any element carrying an explicit
102
- // columnheader/rowheader role (native or not).
122
+ // A <th> with no conflicting explicit role, plus any element carrying an
123
+ // explicit columnheader/rowheader role (native or not).
103
124
  const selector = 'th:not([role]), [role="columnheader"], [role="rowheader"]';
104
125
  const nodes = helpers.queryAllSmart
105
126
  ? helpers.queryAllSmart(selector)
@@ -110,6 +131,12 @@ function runInPage(ctx) {
110
131
 
111
132
  for (const el of nodes) {
112
133
  if (!el) continue;
134
+
135
+ // A fully aria-hidden header cell isn't part of the AT-perceived
136
+ // table structure at all -- queryAllSmart's default hidden-content
137
+ // policy only excludes "hard" CSS-based hiding, not aria-hidden.
138
+ if (!isEligible(el)) continue;
139
+
113
140
  applicableCount += 1;
114
141
 
115
142
  if (getVisibleText(el)) continue;
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check focus-order-semantics
5
7
  * @atomic true
6
8
  * @summary Elements added to the tab order should have interactive semantics
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Elements with an explicit `tabindex` of `0` or greater (in the tab
10
12
  * order) AND an explicit `role` attribute that is one of a curated set
@@ -16,8 +18,8 @@
16
18
  * announce, which is confusing for keyboard users who land on it and
17
19
  * get no indication of what activating it (if anything) would do.
18
20
  * @implementation-notes
19
- * - Not WCAG-normative by a widely-used reference engine's own classification — authored as an
20
- * advisory, cantTell-capped `type: 'manual'` rule.
21
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * `type: 'manual'` rule.
21
23
  * - The non-interactive role list is deliberately curated and
22
24
  * conservative (structural/document roles only) — legitimate custom
23
25
  * widget patterns using `tabindex` with a genuinely interactive role
@@ -25,6 +27,17 @@
25
27
  * `tabindex` and NO role at all are also not flagged: native semantics
26
28
  * or an intentionally generic custom-interactive pattern cannot be
27
29
  * distinguished from markup alone with the same confidence.
30
+ * - `region` is deliberately NOT in the non-interactive role list: a
31
+ * tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded pattern
32
+ * this engine's own `scrollable-region-focusable` check exists to
33
+ * RECOMMEND (a scrollable landmark with no other focusable content
34
+ * needs `tabindex="0"` to be keyboard-reachable at all) — flagging it
35
+ * here would be internally inconsistent with that sibling check. A
36
+ * `role="region"` is also commonly made tabbable on its own merits
37
+ * (e.g. a cookie-consent banner or notification/toast region a keyboard
38
+ * user should be able to reach directly, as in
39
+ * `<div role="region" tabindex="0">`). Scoped to `region` only;
40
+ * navigation/status/tabpanel remain flagged pending their own evidence.
28
41
  */
29
42
 
30
43
  const id = 'focus-order-semantics';
@@ -59,7 +72,6 @@ function runInPage(ctx) {
59
72
  'none',
60
73
  'img',
61
74
  'heading',
62
- 'region',
63
75
  'article',
64
76
  'banner',
65
77
  'contentinfo',
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check heading-order
5
7
  * @atomic true
6
8
  * @summary Heading levels must not skip a level going deeper
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever the page contains two or more heading elements
10
12
  * (native <h1>-<h6>, or explicit role="heading" with aria-level —
@@ -83,6 +85,31 @@ function runInPage(ctx) {
83
85
  for (const el of nodes) {
84
86
  if (!el || seen.has(el)) continue;
85
87
  seen.add(el);
88
+
89
+ // queryAllSmart's default hidden-content policy only excludes "hard"
90
+ // CSS-based hiding (display:none, visibility:hidden, etc.) -- it does
91
+ // NOT exclude aria-hidden, a softer/semantic removal from the
92
+ // accessibility tree that still leaves an element visually rendered.
93
+ // A heading order rule whose whole premise is "the document outline
94
+ // assistive technology users rely on" (see this rule's own header
95
+ // comment) must not let an aria-hidden heading participate in that
96
+ // outline at all: it's invisible to exactly the users this rule
97
+ // exists to protect. Without this check an aria-hidden heading is both
98
+ // flagged itself (though it isn't part of the AT-perceived outline)
99
+ // and can mask a real skip immediately after it, by advancing the
100
+ // "highest level reached so far" tracker on a level no AT user
101
+ // actually encountered.
102
+ if (helpers.isAccTreeEligible) {
103
+ const elig = (() => {
104
+ try {
105
+ return helpers.isAccTreeEligible(el, ctx);
106
+ } catch {
107
+ return { eligible: true, reasons: [] };
108
+ }
109
+ })();
110
+ if (elig && elig.eligible === false) continue;
111
+ }
112
+
86
113
  const level = getHeadingLevel(el);
87
114
  if (level > 0) headings.push({ el, level });
88
115
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check image-redundant-alt
5
7
  * @atomic true
6
8
  * @summary An image's alt text must not duplicate adjacent visible text
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to <img> elements with non-empty alt text whose immediate
10
12
  * parent element also has other visible text content (i.e. text nodes
@@ -57,12 +59,30 @@ function runInPage(ctx) {
57
59
  }
58
60
 
59
61
  function getOwnTextExcludingImg(parent, imgEl) {
62
+ const isAccTreeEligible =
63
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
64
+
60
65
  let text = '';
61
66
  for (const child of parent.childNodes || []) {
62
67
  if (child === imgEl) continue;
63
68
  if (child.nodeType === 3) {
64
69
  text += ' ' + (child.nodeValue || '');
65
70
  } else if (child.nodeType === 1 && child !== imgEl) {
71
+ // An aria-hidden sibling is never actually announced to assistive
72
+ // technology, so its text can't cause the "same words twice"
73
+ // double-announcement this rule exists to catch -- counting it
74
+ // anyway flags a redundancy that doesn't exist in what AT users
75
+ // actually hear.
76
+ if (isAccTreeEligible) {
77
+ const elig = (() => {
78
+ try {
79
+ return isAccTreeEligible(child, ctx);
80
+ } catch {
81
+ return { eligible: true, reasons: [] };
82
+ }
83
+ })();
84
+ if (elig && elig.eligible === false) continue;
85
+ }
66
86
  text += ' ' + (child.textContent || '');
67
87
  }
68
88
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -69,6 +71,9 @@ function runInPage(ctx) {
69
71
  const isAccTreeEligible =
70
72
  helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
71
73
 
74
+ const getAriaNameInfo =
75
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
76
+
72
77
  const getFocusableInfo =
73
78
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
74
79
 
@@ -105,6 +110,26 @@ function runInPage(ctx) {
105
110
  return !focusable;
106
111
  }
107
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
+
108
133
  const els = (() => {
109
134
  try {
110
135
  return Array.from(
@@ -141,6 +166,7 @@ function runInPage(ctx) {
141
166
 
142
167
  // Rule-specific applicability (only elements that already have a text alternative mechanism)
143
168
  if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() === '')) continue;
169
+ if (!hasNameFromOtherSource(el)) continue;
144
170
 
145
171
  applicableCount += 1;
146
172
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check label-title-only
5
7
  * @atomic true
6
8
  * @summary Form controls should not rely on the title attribute as their only label
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to labelable form controls (input, excluding
10
12
  * hidden/submit/reset/button/image; select; textarea) that have a
@@ -46,7 +48,7 @@ const meta = {
46
48
  };
47
49
 
48
50
  function runInPage(ctx) {
49
- const { document, helpers, rule } = ctx;
51
+ const { helpers, rule } = ctx;
50
52
 
51
53
  const selector =
52
54
  'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
@@ -54,37 +56,42 @@ function runInPage(ctx) {
54
56
  ? helpers.queryAllSmart(selector)
55
57
  : helpers.queryAll(selector);
56
58
 
57
- const labelsByFor = new Map();
58
- const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
59
- for (const lab of allLabels) {
60
- if (!lab || !lab.getAttribute) continue;
61
- const forValue = String(lab.getAttribute('for') || '').trim();
62
- if (!forValue) continue;
63
- if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
64
- labelsByFor.get(forValue).push(lab);
65
- }
66
-
67
59
  const occurrences = [];
68
60
  let applicableCount = 0;
69
61
 
70
62
  for (const el of nodes) {
71
63
  if (!el || !el.getAttribute) continue;
72
64
 
65
+ if (helpers.isAccTreeEligible) {
66
+ const elig = (() => {
67
+ try {
68
+ return helpers.isAccTreeEligible(el, ctx);
69
+ } catch {
70
+ return { eligible: true, reasons: [] };
71
+ }
72
+ })();
73
+ if (elig && elig.eligible === false) continue;
74
+ }
75
+
73
76
  const title = String(el.getAttribute('title') || '').trim();
74
77
  if (!title) continue;
75
78
 
76
79
  applicableCount += 1;
77
80
 
78
- const ariaLabel = String(el.getAttribute('aria-label') || '').trim();
79
- if (ariaLabel) continue;
80
- const ariaLabelledby = String(el.getAttribute('aria-labelledby') || '').trim();
81
- if (ariaLabelledby) continue;
82
-
83
- const wrappingLabel = el.closest ? el.closest('label') : null;
84
- if (wrappingLabel) continue;
85
-
86
- const controlId = String(el.getAttribute('id') || '').trim();
87
- if (controlId && labelsByFor.has(controlId)) continue;
81
+ // Delegates to the shared helpers.getAccessibleNameInfo (aria ->
82
+ // native <label> -> title, the same precedence every other
83
+ // name-dependent rule in this engine uses) rather than a local,
84
+ // hand-rolled "does a <label for>/wrapping <label> exist" check. A
85
+ // structural-association-only check (for="..."/wrapping) never verifies
86
+ // the label actually contributes a name -- an empty <label for="x">
87
+ // </label> or empty wrapping <label> would exempt the control even
88
+ // though title is functionally its only real label (see
89
+ // dom-helpers.js's hasLabelAssociation/labelContributesAccessibleName).
90
+ // If the resolved mechanism isn't 'title', some higher-priority
91
+ // mechanism (aria-label/aria-labelledby/a real contributing label)
92
+ // already won and this control isn't title-only.
93
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
94
+ if (!nameInfo || nameInfo.mechanism !== 'title') continue;
88
95
 
89
96
  const tag = el.tagName.toLowerCase();
90
97
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
@@ -1,23 +1,25 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-banner-is-top-level
5
7
  * @atomic true
6
8
  * @summary The banner landmark must not be nested inside another landmark
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever the page contains at least one banner candidate:
10
- * explicit role="banner", OR a <header> with NO role attribute at all
11
- * (regardless of nesting — see implementation notes' 2026-08-01 fix).
12
+ * explicit role="banner", OR a <header> with NO role attribute at all,
13
+ * regardless of nesting (see implementation notes on why candidate
14
+ * selection is deliberately unconditional).
12
15
  * @expectation
13
16
  * No banner candidate has an ancestor that is itself any landmark
14
17
  * region. A banner nested inside another landmark is not a top-level,
15
18
  * whole-page banner and confuses landmark-based navigation for
16
19
  * assistive technology users.
17
20
  * @implementation-notes
18
- * - Not WCAG-normative (a widely-used reference engine classifies this as "Best Practices," no SC
19
- * tag) — authored as an advisory, cantTell-capped `type: 'manual'`
20
- * rule per ROADMAP.md Tier 1b and the design doc's policy model
21
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * `type: 'manual'` rule per ROADMAP.md Tier 1b and the design doc's policy model
21
23
  * ("Advisory / best-practice rules may exist, but must not produce
22
24
  * `fail`"). Matches the existing `page-title-patterns-manual.js`
23
25
  * precedent: deterministic DOM analysis, no human required, but
@@ -25,30 +27,14 @@
25
27
  * - Landmark detection here models WAI-ARIA APG landmark roles and the
26
28
  * HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
27
29
  * main→main, nav→navigation, aside→complementary, section/form→
28
- * region/form only when accessibly named), not a byte-for-byte port
29
- * of a widely-used reference engine's internal algorithm — verify against upstream if exact
30
- * parity is ever required.
31
- * - **Fixed 2026-08-01, a self-defeating applicability bug found via the
32
- * cross-engine comparisons project (verified live on TurboTax's real
33
- * homepage, `<header>` nested inside a `<div id="main" role="main">`
34
- * two levels up):** candidate selection used to run the *same*
35
- * HTML-AAM sectioning-ancestor suppression used for the violation
36
- * check itself (`getImplicitLandmarkRole`'s `hasSectioningAncestor`
37
- * gate) — so the moment a `<header>` was nested inside another
38
- * landmark, that same nesting made it stop counting as a banner
39
- * candidate in the first place, and the rule could never flag the one
40
- * case it exists to catch. A widely-used reference engine's own
41
- * `landmark-banner-is-top-level` avoids this: its selector
42
- * (`header:not([role]), [role=banner]`) is unconditional — it doesn't
43
- * care whether the header *currently* carries the banner role, only
44
- * whether it's a `<header>`/`role="banner"` with a landmark ancestor
45
- * above it (verified by reading that engine's real
46
- * `landmark-is-top-level-evaluate` source, not guessed). Candidate
47
- * selection (`isBannerCandidate` below) now matches that unconditional
48
- * selector shape; the ancestor walk (`hasLandmarkAncestor`) still uses
49
- * the full suppression-aware `getLandmarkRole` for each ancestor,
50
- * which is correct and unchanged — an ancestor genuinely needs its own
51
- * real role to count as blocking.
30
+ * region/form only when accessibly named).
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.
52
38
  */
53
39
 
54
40
  const id = 'landmark-banner-is-top-level';
@@ -85,9 +71,8 @@ function runInPage(ctx) {
85
71
 
86
72
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
87
73
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
88
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
89
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
90
- // copy of this logic.
74
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
75
+ // the title-attribute fallback consistent across the landmark rules.
91
76
  function getAccessibleLandmarkName(el) {
92
77
  try {
93
78
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -110,10 +95,8 @@ function runInPage(ctx) {
110
95
  // (an ancestor's bare TAG only counts when it carries no role attribute
111
96
  // at all; an explicit role="dialog"-style override no longer suppresses)
112
97
  // rather than a local tag-only copy. See that function's header comment
113
- // in src/core/aria-helpers.js for the full algorithm and the real page
114
- // (handsontable.com's docs-assistant side panel, an
115
- // <aside role="dialog"> containing its own <header>) that surfaced this
116
- // rule's own former tag-only copy as a false negative.
98
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
99
+ // <aside role="dialog"> containing its own <header>.
117
100
  function hasSectioningAncestor(el, includeMain) {
118
101
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
119
102
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -127,11 +110,9 @@ function runInPage(ctx) {
127
110
  if (tag === 'main') return 'main';
128
111
  if (tag === 'nav') return 'navigation';
129
112
  if (tag === 'aside') {
130
- // A named <aside> is never suppressed, even when nested — matches
131
- // landmark-unique's own verified-against-reference-engine precedent
132
- // (that engine's real `aside` implicit-role function keeps
133
- // "complementary" when the element has an accessible name, even
134
- // inside sectioning content); propagated here for consistency.
113
+ // A named <aside> is never suppressed, even when nested — it keeps
114
+ // "complementary" when it has an accessible name, even inside
115
+ // sectioning content. Matches landmark-unique's precedent.
135
116
  if (!hasSectioningAncestor(el, false)) return 'complementary';
136
117
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
137
118
  }
@@ -158,16 +139,11 @@ function runInPage(ctx) {
158
139
  return getImplicitLandmarkRole(el);
159
140
  }
160
141
 
161
- // Candidate selection is deliberately NOT the same as getLandmarkRole()
162
- // === 'banner' — see the 2026-08-01 fix note above. A <header> is a
163
- // candidate purely by tag + absence of any role attribute, independent
164
- // of whether sectioning-ancestor nesting would currently suppress its
165
- // 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.
166
145
  function isBannerCandidate(el) {
167
- if (!el || !el.getAttribute) return false;
168
- const explicit = getExplicitRoleToken(el);
169
- if (explicit) return explicit === 'banner';
170
- return !!(el.tagName && el.tagName.toLowerCase() === 'header');
146
+ return getLandmarkRole(el) === 'banner';
171
147
  }
172
148
 
173
149
  function hasLandmarkAncestor(el) {
@@ -184,9 +160,8 @@ function runInPage(ctx) {
184
160
  }
185
161
 
186
162
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
187
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
188
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
189
- // invisible to a light-DOM-only query.
163
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
164
+ // widget's own landmark is invisible to a light-DOM-only query.
190
165
  let nodes;
191
166
  try {
192
167
  nodes =
@@ -202,7 +177,28 @@ function runInPage(ctx) {
202
177
  for (const el of nodes) {
203
178
  if (!el || seen.has(el)) continue;
204
179
  seen.add(el);
205
- if (isBannerCandidate(el)) banners.push(el);
180
+ if (!isBannerCandidate(el)) continue;
181
+
182
+ // An aria-hidden banner candidate is removed from the accessibility
183
+ // tree entirely -- it isn't part of the landmark structure assistive
184
+ // technology users navigate at all, so it shouldn't be flagged as
185
+ // "nested inside another landmark" (there's no real landmark there to
186
+ // begin with, from AT's perspective). queryAllSmart's default hidden-
187
+ // content policy only excludes "hard" CSS-based hiding (display:none,
188
+ // etc.), not the softer aria-hidden exclusion, so this needs its own
189
+ // check.
190
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
191
+ const elig = (() => {
192
+ try {
193
+ return helpers.isAccTreeEligible(el, ctx);
194
+ } catch {
195
+ return { eligible: true, reasons: [] };
196
+ }
197
+ })();
198
+ if (elig && elig.eligible === false) continue;
199
+ }
200
+
201
+ banners.push(el);
206
202
  }
207
203
 
208
204
  if (banners.length === 0) {