@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,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-unique
5
7
  * @atomic true
6
8
  * @summary Landmarks sharing the same role must have unique accessible names
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 two or more landmark regions on the page share the
10
12
  * same landmark role (banner, contentinfo, main, navigation,
@@ -59,9 +61,7 @@ function runInPage(ctx) {
59
61
 
60
62
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
61
63
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
62
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
63
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
64
- // copy of this logic.
64
+ // local copy -- see that function's header comment in src/core/dom-helpers.js.
65
65
  function getAccessibleLandmarkName(el) {
66
66
  try {
67
67
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -81,27 +81,21 @@ function runInPage(ctx) {
81
81
  // Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
82
82
  // an ancestor's bare TAG only counts when it carries no role attribute at
83
83
  // all; an explicit role="dialog"-style override no longer suppresses —
84
- // see that function's header comment in src/core/aria-helpers.js) rather
85
- // than the two local tag-only Sets this file used to carry. Two distinct
86
- // ancestor scopes, verified 2026-07-20 against a widely-used reference
87
- // engine's own implicit-role functions directly rather than assumed from
88
- // one shared list: <header>/<footer> use "sectioning content PLUS <main>"
89
- // (includeMain: true) to decide banner/contentinfo suppression, but
90
- // <aside> uses PLAIN sectioning content only — NOT main (includeMain:
91
- // false) — to decide complementary suppression. The old single
92
- // SECTIONING_ANCESTORS set (which included 'main') was correct for
93
- // header/footer but wrong for aside — found via a real page: Know Your
94
- // Meme's two unnamed <aside class="extra-large-only"> elements are direct
95
- // children of <main>, which incorrectly suppressed their implicit
96
- // "complementary" role entirely, hiding a real duplicate-landmark
97
- // violation that reference engine correctly flags. The tag-only
98
- // (non-role-aware) half of this bug was separately found and fixed
99
- // 2026-07-30 via the cross-engine comparisons project, on
100
- // handsontable.com's docs-assistant side panel: an <aside role="dialog">
101
- // containing its own <header> — role="dialog" isn't one of the four
102
- // scoping roles, so the nested <header> keeps "banner" per spec, but a
103
- // tag-only check unconditionally suppressed it just because the ancestor
104
- // TAG was <aside>.
84
+ // see that function's header comment in src/core/aria-helpers.js), using
85
+ // two distinct ancestor scopes rather than one shared list: <header>/
86
+ // <footer> use "sectioning content PLUS <main>" (includeMain: true) to
87
+ // decide banner/contentinfo suppression, but <aside> uses PLAIN
88
+ // sectioning content only — NOT main (includeMain: false) — to decide
89
+ // complementary suppression. A single shared sectioning-ancestors set
90
+ // that includes 'main' is correct for header/footer but wrong for aside:
91
+ // e.g. two unnamed <aside> elements that are direct children of <main>
92
+ // would have their implicit "complementary" role incorrectly suppressed,
93
+ // hiding a real duplicate-landmark violation. The role-aware half matters
94
+ // too: e.g. an <aside role="dialog"> containing its own <header> —
95
+ // role="dialog" isn't one of the four scoping roles, so the nested
96
+ // <header> keeps "banner" per spec, but a tag-only (non-role-aware)
97
+ // check would unconditionally suppress it just because the ancestor TAG
98
+ // was <aside>.
105
99
  function hasSectioningAncestor(el, includeMain) {
106
100
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
107
101
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -115,10 +109,9 @@ function runInPage(ctx) {
115
109
  if (tag === 'main') return 'main';
116
110
  if (tag === 'nav') return 'navigation';
117
111
  if (tag === 'aside') {
118
- // Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
119
- // sectioning-content ancestor ONLY when the <aside> also has no
120
- // accessible name — a named <aside> is never suppressed, even when
121
- // nested.
112
+ // An <aside> is suppressed by a sectioning-content ancestor ONLY
113
+ // when it also has no accessible name — a named <aside> is never
114
+ // suppressed, even when nested.
122
115
  if (!hasSectioningAncestor(el, false)) return 'complementary';
123
116
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
124
117
  }
@@ -146,15 +139,12 @@ function runInPage(ctx) {
146
139
  // <form>/<section> only count as landmarks when they have an
147
140
  // accessible name — a property of the ELEMENT, not of how the role
148
141
  // got there. This applies whether the role is implicit (already
149
- // handled in getImplicitLandmarkRole below) or explicit, but an
150
- // explicit role bypassed the check entirely before this fix. Verified
151
- // against a widely-used reference engine's isLandmarkVirtual (checks
152
- // nodeName === 'section' || 'form' unconditionally, regardless of role source) and the W3C
142
+ // handled in getImplicitLandmarkRole below) or explicit. Per the W3C
153
143
  // ARIA-in-HTML spec ("a form is not exposed as a landmark region
154
- // unless it has been provided an accessible name"). Found via a real
155
- // page: europa.eu's unnamed <form role="search"> nested inside an
156
- // unnamed <div role="search"> was wrongly counted as a second
157
- // distinct "search" landmark.
144
+ // unless it has been provided an accessible name"). Otherwise an
145
+ // unnamed <form role="search"> nested inside an unnamed
146
+ // <div role="search"> gets wrongly counted as a second distinct
147
+ // "search" landmark.
158
148
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
159
149
  if (tag === 'form' || tag === 'section') {
160
150
  return getAccessibleLandmarkName(el) ? explicit : '';
@@ -179,12 +169,10 @@ function runInPage(ctx) {
179
169
  }
180
170
 
181
171
  // queryAllSmart (shadow-DOM-aware, includeShadowDom defaults true) instead of a plain
182
- // document.querySelectorAll -- a real page (Airtable's homepage, 2026-07-23) has a
183
- // third-party Transcend cookie-consent widget rendering its own unnamed <nav>/<footer>
184
- // inside a shadow root (#transcend-shadow-root), which a widely-used reference engine's
185
- // own landmark-unique (a real browser DOM, shadow roots included by design) correctly sees as colliding
186
- // with the page's own unnamed header <nav>/page <footer> -- a real, confirmed surea11y
187
- // false-negative miss, invisible to plain querySelectorAll's light-DOM-only reach.
172
+ // document.querySelectorAll -- a third-party widget rendering its own
173
+ // unnamed <nav>/<footer> inside a shadow root collides with the page's
174
+ // own unnamed header <nav>/page <footer>, but is invisible to plain
175
+ // querySelectorAll's light-DOM-only reach.
188
176
  let nodes;
189
177
  try {
190
178
  nodes =
@@ -195,15 +183,12 @@ function runInPage(ctx) {
195
183
  nodes = [];
196
184
  }
197
185
 
198
- // Only landmarks actually exposed to assistive technology can collide —
199
- // matches a widely-used reference engine's own `landmarkUniqueMatches` gate
200
- // (`_isVisibleToScreenReaders`), confirmed by reading its source
201
- // directly. Without this, responsive layouts that render both a
202
- // desktop and a mobile copy of the same named nav (one hidden via CSS
203
- // at any given viewport — found on real sites: BuzzFeed, Kraken,
204
- // weather.com) were wrongly flagged as duplicate landmarks, since the
205
- // hidden copy is never actually reachable by AT and can't really
206
- // collide with the visible one.
186
+ // Only landmarks actually exposed to assistive technology can collide.
187
+ // Without this, responsive layouts that render both a desktop and a
188
+ // mobile copy of the same named nav (one hidden via CSS at any given
189
+ // viewport) are wrongly flagged as duplicate landmarks, since the hidden
190
+ // copy is never reachable by AT and can't really collide with the
191
+ // visible one.
207
192
  const byRole = new Map(); // role -> [{el, name}]
208
193
  const seen = new Set();
209
194
  for (const el of nodes) {
@@ -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,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check meta-viewport-large
5
7
  * @atomic true
6
8
  * @summary Viewport meta tag should allow zooming up to 500% (AAA-level)
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 <meta name="viewport"> elements that carry a non-empty
10
12
  * content attribute.
@@ -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
  /**
@@ -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 page-has-heading-one
5
7
  * @atomic true
6
8
  * @summary The page should have at least one level-one heading
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
  * Always applicable to any HTML document with a <body> element —
10
12
  * "does the page have an h1" is a whole-page concern, matching
@@ -21,16 +23,15 @@
21
23
  * header comment for the shared rationale/precedent.
22
24
  * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
23
25
  * display:none/inert elements don't count as "the page has a heading
24
- * one"), matching `landmark-one-main`'s own precedent. Found via a real
25
- * page (CDC's flu page, 2026-07-30): its only `<h1>` sits inside a
26
- * `display:none` ancestor — genuinely unreachable by sighted and screen
27
- * reader users alike — and a raw `document.querySelectorAll` credited it
28
- * anyway, incorrectly reporting `notApplicable`. This
29
- * does NOT regress purely-visually-clipped-but-AT-exposed headings (e.g.
30
- * eBay's homepage `<h1>` hidden via clip-path/off-screen positioning,
31
- * `visibility:visible`, no `aria-hidden`) — `isAccTreeEligible` only
32
- * excludes elements actually removed from the accessibility tree, not
33
- * ones merely clipped from the visual viewport.
26
+ * one"), matching `landmark-one-main`'s own precedent — a raw
27
+ * `document.querySelectorAll` would wrongly credit an `<h1>` that sits
28
+ * inside a `display:none` ancestor, genuinely unreachable by sighted and
29
+ * screen reader users alike, as satisfying this check. This does NOT
30
+ * regress purely-visually-clipped-but-AT-exposed headings (e.g. an `<h1>`
31
+ * hidden via clip-path/off-screen positioning, `visibility:visible`, no
32
+ * `aria-hidden`) — `isAccTreeEligible` only excludes elements actually
33
+ * removed from the accessibility tree, not ones merely clipped from the
34
+ * visual viewport.
34
35
  */
35
36
 
36
37
  const id = 'page-has-heading-one';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'page-title-patterns';
@@ -1,16 +1,17 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check presentation-role-conflict
5
7
  * @atomic true
6
8
  * @summary role="presentation"/"none" must not be combined with a global ARIA naming attribute or focusability
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 elements with an explicit role="presentation" or
10
12
  * role="none", OR an <img alt=""> (empty alt gives an <img> an implicit
11
13
  * presentation role per HTML-AAM, even with no explicit role attribute
12
- * at all — verified against a widely-used reference engine's own selector
13
- * for this exact check, `img[alt=''], [role="none"], [role="presentation"]`).
14
+ * at all — `img[alt=''], [role="none"], [role="presentation"]`).
14
15
  * @expectation
15
16
  * The element does not also carry a WAI-ARIA *global* state/property
16
17
  * (aria-label, aria-hidden, aria-describedby, aria-live, aria-current,
@@ -25,32 +26,27 @@
25
26
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
26
27
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
27
28
  * header comment for the shared rationale/precedent.
28
- * - The conflicting-attribute set matches a widely-used reference engine's
29
- * own `none: ['is-element-focusable', 'has-global-aria-attribute']` condition
30
- * exactly — `has-global-aria-attribute` checks against the full list of
31
- * ARIA attributes marked `global: true` in that engine's own standards data
32
- * (confirmed by reading `standards.ariaAttrs` directly at runtime, not
33
- * guessed), which is much broader than the 3-attribute naming-only list
34
- * this rule originally checked (added 2026-07-20, then widened
35
- * 2026-07-20 after a real page — Slack's homepage has
36
- * `<img alt="" aria-hidden="">`, both gaps at once: the img[alt=''] case
37
- * wasn't in the applicability selector at all, and aria-hidden wasn't in
38
- * the conflicting-attribute set even if it had been).
39
- * - Deliberately NOT replicating that reference engine's
40
- * `hasImplicitChromiumRoleMatches` applicability gate, which (per direct
41
- * probing of the reference engine's runtime) makes its own check
42
- * inapplicable to role="presentation" on elements with no
43
- * native implicit role to suppress in the first place (e.g.
44
- * `<div role="presentation" aria-hidden="true">` — a <div> has no native
45
- * role, so there's nothing for the presentational role to "conflict"
46
- * with per that engine's own scope decision). surea11y stays
47
- * broader/more cautious here rather than narrower, which is the safer
48
- * direction to diverge in, and no real false positive from staying
49
- * broad has surfaced in any corpus round to date.
50
- * - The native-implicit-role table needed to replicate that gate (if this
51
- * scope decision is ever revisited) has since been produced — see
52
- * ROADMAP.md §7 item 9 (2026-07-31) for the full table and the open
53
- * maintainer decision; not implemented here pending that call.
29
+ * - `aria-hidden="true"` (the exact valid truthy value) on the
30
+ * presentational element itself is deliberately EXCLUDED as a trigger,
31
+ * even though it's a global ARIA attribute: it removes the element from
32
+ * the accessibility tree unconditionally, so the "role restoration"
33
+ * this rule warns about never actually reaches assistive tech, making a
34
+ * flag misleading. Any OTHER conflicting attribute present alongside
35
+ * `aria-hidden="true"` is equally inert for the same reason and is not
36
+ * flagged either. An `aria-hidden=""` (empty/invalid value, does NOT
37
+ * hide) still triggers normally. Focusability is unaffected by this
38
+ * exemption (see the code comment at the check site).
39
+ * - The conflicting-attribute set is the full list of ARIA attributes
40
+ * marked `global: true`, not a narrower naming-only list.
41
+ * - Deliberately NOT applying an implicit-role applicability gate that
42
+ * would make the check inapplicable to role="presentation" on elements
43
+ * with no native implicit role to suppress (e.g. `<div
44
+ * role="presentation" aria-hidden="true">` — a <div> has no native role,
45
+ * so there's nothing for the presentational role to "conflict" with).
46
+ * surea11y stays broader/more cautious here rather than narrower, which
47
+ * is the safer direction to diverge in. The native-implicit-role table
48
+ * needed to add that gate, if this scope decision is ever revisited, is
49
+ * in ROADMAP.md §7 item 9.
54
50
  * - Focusability is computed via helpers.getFocusableInfo (native +
55
51
  * tabindex), same helper aria-hidden-focus already relies on — a
56
52
  * `:disabled` or otherwise non-focusable element is not flagged.
@@ -81,9 +77,8 @@ function runInPage(ctx) {
81
77
  const { helpers, rule } = ctx;
82
78
 
83
79
  // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
84
- // spec (confirmed against a widely-used reference engine's own
85
- // `standards.ariaAttrs` data at runtime, 2026-07-20) — any of these present on a presentational
86
- // element restores its implicit role, not just the naming ones.
80
+ // spec — any of these present on a presentational element restores its
81
+ // implicit role, not just the naming ones.
87
82
  const CONFLICTING_ATTRS = [
88
83
  'aria-atomic',
89
84
  'aria-braillelabel',
@@ -128,14 +123,33 @@ function runInPage(ctx) {
128
123
 
129
124
  // Presence, not value truthiness: the WAI-ARIA role-conflict-resolution
130
125
  // rule triggers on a global ARIA attribute being SPECIFIED at all, even
131
- // with an empty value — found on a real site, Slack's homepage has
132
- // <img alt="" aria-hidden="">, where aria-hidden="" (empty string) is
133
- // still a specified attribute. A truthy-value check would have missed
134
- // this even after aria-hidden was added to CONFLICTING_ATTRS above.
135
- const present = CONFLICTING_ATTRS.filter((attr) =>
126
+ // with an empty value — e.g. <img alt="" aria-hidden="">, where
127
+ // aria-hidden="" (empty string) is still a specified attribute. A
128
+ // truthy-value check would miss this.
129
+ let present = CONFLICTING_ATTRS.filter((attr) =>
136
130
  el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
137
131
  );
138
132
 
133
+ // aria-hidden="true" (the exact, valid truthy value — not the
134
+ // empty-string case above, which never actually hides anything) is a
135
+ // special case: it removes the element and its subtree from the
136
+ // accessibility tree unconditionally, independent of role. That makes
137
+ // the "role restoration" this rule warns about ("...which restores its
138
+ // implicit role and cancels the presentational intent") factually
139
+ // inert — no AT will ever expose the restored role OR any of the other
140
+ // conflicting attributes (aria-label, aria-describedby, ...) present
141
+ // alongside it, since the whole element stays out of the tree
142
+ // regardless. This pattern is extremely common (e.g. <svg
143
+ // role="presentation" aria-hidden="true"> decorative icons — a
144
+ // defensive belt-and-suspenders double-hide, not an authoring mistake).
145
+ // Focusability is NOT covered by this exemption — a keyboard user can
146
+ // still tab onto an aria-hidden="true" focusable element (the
147
+ // aria-hidden-focus anti-pattern), a real, independent hazard
148
+ // aria-hidden does nothing to prevent.
149
+ if (el.getAttribute('aria-hidden') === 'true') {
150
+ present = [];
151
+ }
152
+
139
153
  let isFocusable = false;
140
154
  if (getFocusableInfo) {
141
155
  try {
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check region
5
7
  * @atomic true
6
8
  * @summary Page content should be contained within a landmark region
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 any element under <body> that directly carries visible text
10
12
  * (or other own content — see @implementation-notes) and is not itself a
@@ -20,18 +22,15 @@
20
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
23
  * header comment for the shared rationale/precedent and the landmark-
22
24
  * detection model.
23
- * - Recursive tree walk, not a direct-<body>-children-only scan (that was
24
- * this check's original scope, replaced 2026-08-01). The direct-children
25
- * scope was originally chosen to keep this check quiet, but it turned out
26
- * to be nearly inert on the single most common real-world page shape: a
27
- * modern framework's single root mount div
28
- * (`<body><div id="root">...everything...</div></body>`, confirmed
29
- * present as the ONLY direct <body> child on 37 of ~90 pages in a
30
- * real-world corpus). On that shape, the old scan had at most one
31
- * candidate for the entire page and either missed every real gap inside
32
- * it or collapsed the whole page into one undifferentiated report.
33
- * - Algorithm, using this engine's own (already more spec-correct in
34
- * places — see hasLandmarkScopingAncestor's header comment) helpers:
25
+ * - Recursive tree walk, not a direct-<body>-children-only scan: a
26
+ * direct-children-only scope is nearly inert on the single most common
27
+ * real-world page shape, a modern framework's single root mount div
28
+ * (`<body><div id="root">...everything...</div></body>`) — that shape
29
+ * gives at most one candidate for the entire page and either misses
30
+ * every real gap inside it or collapses the whole page into one
31
+ * undifferentiated report.
32
+ * - Algorithm, using this engine's own helpers (see
33
+ * hasLandmarkScopingAncestor's header comment):
35
34
  * 1. Depth-first walk from <body>'s children.
36
35
  * 2. At each node: if it's ineligible for the accessibility tree
37
36
  * (helpers.isAccTreeEligible), OR is itself a "stopper" (landmark,
@@ -52,7 +51,7 @@
52
51
  * contiguous unplaced content into one occurrence per real gap instead
53
52
  * of reporting every individual text-bearing leaf — this is what keeps
54
53
  * the walk from being noisy on ordinary pages that mix landmarked and
55
- * stray content, not the old direct-children-only restriction.
54
+ * stray content.
56
55
  * - "Stopper" exemptions (button, dialog, <svg>, resolvable skip-links)
57
56
  * are a deliberate scope choice, not an oversight: these
58
57
  * are extremely common real-world patterns (floating action buttons,
@@ -119,9 +118,7 @@ function runInPage(ctx) {
119
118
 
120
119
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
121
120
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
122
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
123
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
124
- // copy of this logic.
121
+ // local copy -- see that function's header comment in src/core/dom-helpers.js.
125
122
  function getAccessibleLandmarkName(el) {
126
123
  try {
127
124
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -144,10 +141,9 @@ function runInPage(ctx) {
144
141
  // (an ancestor's bare TAG only counts when it carries no role attribute
145
142
  // at all; an explicit role="dialog"-style override no longer suppresses)
146
143
  // rather than a local tag-only copy. See that function's header comment
147
- // in src/core/aria-helpers.js for the full algorithm and the real page
148
- // (handsontable.com's docs-assistant side panel, an
149
- // <aside role="dialog"> containing its own <header>) that surfaced this
150
- // rule's own former tag-only copy as a false negative.
144
+ // in src/core/aria-helpers.js for the full algorithm — e.g. an
145
+ // <aside role="dialog"> containing its own <header>, where the <header>
146
+ // keeps its banner role.
151
147
  function hasSectioningAncestor(el, includeMain) {
152
148
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
153
149
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -161,11 +157,9 @@ function runInPage(ctx) {
161
157
  if (tag === 'main') return 'main';
162
158
  if (tag === 'nav') return 'navigation';
163
159
  if (tag === 'aside') {
164
- // A named <aside> is never suppressed, even when nested — matches
165
- // landmark-unique's own verified-against-reference-engine precedent
166
- // (that engine's real `aside` implicit-role function keeps
160
+ // A named <aside> is never suppressed, even when nested: keeps
167
161
  // "complementary" when the element has an accessible name, even
168
- // inside sectioning content); propagated here for consistency.
162
+ // inside sectioning content. See landmark-unique-manual.js.
169
163
  if (!hasSectioningAncestor(el, false)) return 'complementary';
170
164
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
171
165
  }
@@ -196,8 +190,7 @@ function runInPage(ctx) {
196
190
 
197
191
  // Roles/attributes that make an element its own self-contained
198
192
  // announced area — not literally a WAI-ARIA landmark, but not "content
199
- // that needs a landmark" either. Same live-region role list a widely-used
200
- // reference engine's own region rule stops recursion at.
193
+ // that needs a landmark" either.
201
194
  const LIVE_REGION_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer']);
202
195
 
203
196
  function isAriaLive(el) {
@@ -257,12 +250,11 @@ function runInPage(ctx) {
257
250
  const VISUAL_CONTENT_TAGS = new Set(['img', 'video', 'audio', 'canvas', 'object', 'embed']);
258
251
 
259
252
  // Non-recursive "does THIS element, on its own, carry content" check —
260
- // deliberately mirrors only the direct-content half of getContentNameInfo/
261
- // a widely-used reference engine's own has-content check, not a full
262
- // name-from-content recursion: the whole point is to keep recursing
263
- // through plain wrapper elements (a framework's root mount <div> included)
264
- // until reaching the actual content-bearing node, rather than a coarse
265
- // ancestor swallowing everything beneath it into one report.
253
+ // deliberately mirrors only the direct-content half of getContentNameInfo,
254
+ // not a full name-from-content recursion: the whole point is to keep
255
+ // recursing through plain wrapper elements (a framework's root mount
256
+ // <div> included) until reaching the actual content-bearing node, rather
257
+ // than a coarse ancestor swallowing everything beneath it into one report.
266
258
  function hasOwnContent(el) {
267
259
  const kids = el.childNodes || [];
268
260
  for (let i = 0; i < kids.length; i++) {
@@ -341,9 +333,8 @@ function runInPage(ctx) {
341
333
  // Collapse each candidate leaf upward through parents that have no OTHER
342
334
  // stopper anywhere in their subtree, so contiguous unplaced content
343
335
  // merges into ONE occurrence per real gap instead of one per text node --
344
- // this collapsing, not the old direct-children-only scope, is what keeps
345
- // ordinary pages (landmarked content mixed with a little stray content)
346
- // from producing noisy, one-per-leaf reports.
336
+ // this collapsing is what keeps ordinary pages (landmarked content mixed
337
+ // with a little stray content) from producing noisy, one-per-leaf reports.
347
338
  const collapsed = [];
348
339
  const seen = new Set();
349
340
  for (const leaf of leaves) {
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check scope-attr-valid
5
7
  * @atomic true
6
8
  * @summary The scope attribute must have a valid value
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 elements with a non-empty scope attribute.
10
12
  * @expectation
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,20 +1,21 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check skip-link
5
7
  * @atomic true
6
8
  * @summary A "skip" link must resolve to a real, usable target
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 <a href="#fragment"> elements whose accessible name
10
12
  * matches a common "skip to ..." / "jump to ..." authoring convention
11
13
  * (case-insensitive "skip" or "jump to" in the name) — the recognizable
12
14
  * pattern for a skip-navigation link, not every same-page anchor link
13
- * on the page. "jump to" added 2026-07-23 after a real page (Wish.com)
14
- * surfaced a skip link reading "Jump to section" with a genuinely
15
- * missing target — invisible to the original "skip"-only pattern,
16
- * while a widely-used reference engine's own (purely positional, not text-based) matching
17
- * caught it. Text-pattern matching itself stays deliberate (see
15
+ * on the page. "jump to" is included alongside "skip" since real skip
16
+ * links use both conventions (e.g. a "Jump to section" link, which a
17
+ * purely positional match would catch but a "skip"-only text pattern
18
+ * would miss). Text-pattern matching itself stays deliberate (see
18
19
  * implementation-notes) — this only widens the known-convention list.
19
20
  * @expectation
20
21
  * The link's fragment resolves to a real element in the document
@@ -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 tabindex
5
7
  * @atomic true
6
8
  * @summary tabindex should not be greater than 0
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 elements with a tabindex attribute whose value parses as
10
12
  * a valid integer.
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check table-duplicate-name
5
7
  * @atomic true
6
8
  * @summary A table's caption must not duplicate its summary attribute
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 <table> elements that have both a <caption> with text
10
12
  * content and a (deprecated but still encountered) summary attribute.
@@ -17,9 +19,8 @@
17
19
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
21
  * header comment for the shared rationale/precedent.
20
- * - Narrowly scoped to caption-vs-summary duplication specifically
21
- * (matching a reference engine's table-duplicate-name check), not a general "table
22
- * name quality" check.
22
+ * - Narrowly scoped to caption-vs-summary duplication specifically, not a
23
+ * general "table name quality" check.
23
24
  */
24
25
 
25
26
  const id = 'table-duplicate-name';