@surea11y/core 1.3.0 → 1.4.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 (163) hide show
  1. package/CHANGELOG.md +46 -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/CI_INTEGRATIONS.md +7 -7
  6. package/docs/ENGINE_OPTIONS.md +1 -1
  7. package/docs/I18N.md +12 -9
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/REPORT.md +1 -1
  11. package/package.json +50 -16
  12. package/src/baseline.js +0 -0
  13. package/src/checks/automatic/area-alt-present.js +4 -6
  14. package/src/checks/automatic/aria-allowed-attr.js +15 -51
  15. package/src/checks/automatic/aria-allowed-role.js +2 -0
  16. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  17. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  18. package/src/checks/automatic/aria-deprecated-role.js +4 -3
  19. package/src/checks/automatic/aria-hidden-body.js +6 -4
  20. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  21. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  22. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  23. package/src/checks/automatic/aria-required-attr.js +6 -7
  24. package/src/checks/automatic/aria-required-children.js +7 -10
  25. package/src/checks/automatic/aria-required-parent.js +20 -25
  26. package/src/checks/automatic/aria-role-name-present.js +2 -0
  27. package/src/checks/automatic/aria-roles-valid.js +2 -0
  28. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  29. package/src/checks/automatic/aria-valid-attr.js +2 -0
  30. package/src/checks/automatic/autocomplete-valid.js +2 -0
  31. package/src/checks/automatic/avoid-inline-spacing.js +3 -2
  32. package/src/checks/automatic/binary-control-name-present.js +2 -0
  33. package/src/checks/automatic/button-name-present.js +7 -7
  34. package/src/checks/automatic/bypass-blocks-present.js +9 -7
  35. package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
  36. package/src/checks/automatic/combobox-name-present.js +2 -0
  37. package/src/checks/automatic/contrast-computable.js +2 -0
  38. package/src/checks/automatic/contrast-enhanced.js +2 -0
  39. package/src/checks/automatic/contrast-minimum.js +2 -0
  40. package/src/checks/automatic/css-orientation-lock.js +21 -27
  41. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  42. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  43. package/src/checks/automatic/dialog-name-present.js +10 -10
  44. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  45. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  46. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  47. package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
  48. package/src/checks/automatic/form-control-single-label.js +6 -7
  49. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  50. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  51. package/src/checks/automatic/iframe-name-present.js +2 -0
  52. package/src/checks/automatic/iframe-title-unique.js +3 -1
  53. package/src/checks/automatic/img-alt-present.js +7 -9
  54. package/src/checks/automatic/input-image-alt-present.js +4 -6
  55. package/src/checks/automatic/label-in-name.js +15 -19
  56. package/src/checks/automatic/language-page-present.js +2 -0
  57. package/src/checks/automatic/link-in-text-block.js +2 -0
  58. package/src/checks/automatic/link-name-present.js +2 -0
  59. package/src/checks/automatic/list-children-valid.js +14 -24
  60. package/src/checks/automatic/listbox-name-present.js +2 -0
  61. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  62. package/src/checks/automatic/menuitem-name-present.js +2 -0
  63. package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
  64. package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
  65. package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
  66. package/src/checks/automatic/meter-name-present.js +4 -3
  67. package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
  68. package/src/checks/automatic/object-text-alternative-present.js +2 -0
  69. package/src/checks/automatic/option-name-present.js +2 -0
  70. package/src/checks/automatic/page-title-present.js +2 -0
  71. package/src/checks/automatic/progressbar-name-present.js +8 -10
  72. package/src/checks/automatic/role-img-alt-present.js +4 -4
  73. package/src/checks/automatic/searchbox-name-present.js +2 -0
  74. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  75. package/src/checks/automatic/slider-name-present.js +2 -0
  76. package/src/checks/automatic/spinbutton-name-present.js +2 -0
  77. package/src/checks/automatic/summary-name-present.js +2 -0
  78. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  79. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  80. package/src/checks/automatic/tab-name-present.js +2 -0
  81. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  82. package/src/checks/automatic/table-th-has-data-cells.js +2 -0
  83. package/src/checks/automatic/target-size-minimum.js +5 -0
  84. package/src/checks/automatic/td-has-header.js +24 -1
  85. package/src/checks/automatic/textbox-name-present.js +2 -0
  86. package/src/checks/automatic/tooltip-name-present.js +2 -0
  87. package/src/checks/automatic/treeitem-name-present.js +2 -0
  88. package/src/checks/automatic/valid-lang.js +2 -0
  89. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  90. package/src/checks/manual/accesskeys-manual.js +3 -1
  91. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  92. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  93. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  94. package/src/checks/manual/aria-text-manual.js +6 -5
  95. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  96. package/src/checks/manual/css-hidden-focus.js +184 -9
  97. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  98. package/src/checks/manual/empty-heading-manual.js +17 -17
  99. package/src/checks/manual/empty-table-header-manual.js +52 -25
  100. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  102. package/src/checks/manual/heading-order-manual.js +28 -1
  103. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  104. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  105. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  106. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  107. package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/label-title-only-manual.js +29 -22
  110. package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
  111. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
  112. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  113. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  114. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  115. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  116. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  117. package/src/checks/manual/landmark-unique-manual.js +37 -52
  118. package/src/checks/manual/link-name-quality-manual.js +2 -0
  119. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  120. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  121. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  122. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  123. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  124. package/src/checks/manual/p-as-heading-manual.js +2 -0
  125. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  126. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  127. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  128. package/src/checks/manual/region-manual.js +27 -36
  129. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  130. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  131. package/src/checks/manual/skip-link-manual.js +7 -6
  132. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  133. package/src/checks/manual/tabindex-manual.js +3 -1
  134. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  135. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  136. package/src/checks/manual/video-caption-manual.js +2 -0
  137. package/src/checks/manual-review.js +2 -0
  138. package/src/core.js +6772 -2158
  139. package/src/index.js +2 -0
  140. package/src/report.js +51 -9
  141. package/src/sarif.js +18 -3
  142. package/surea11y.browser.js +2731 -999
  143. package/bin/core.js +0 -473
  144. package/docs/CLI.md +0 -128
  145. package/src/catalogs/composites.wcag.js +0 -454
  146. package/src/checks/rules-and-tags.full.csv +0 -19
  147. package/src/checks/rules-and-tags.full.json +0 -259
  148. package/src/core/aria-helpers.js +0 -1211
  149. package/src/core/contrast-helpers.js +0 -1302
  150. package/src/core/dom-helpers.js +0 -4493
  151. package/src/core/dom-runner.js +0 -787
  152. package/src/core/frame-messaging.js +0 -261
  153. package/src/core/frame-scan.js +0 -190
  154. package/src/core/rollup-composites.js +0 -127
  155. package/src/core/rule-meta.js +0 -176
  156. package/src/coverage/wcag-facets.js +0 -1079
  157. package/src/coverage/wcag-version-map.js +0 -84
  158. package/src/i18n/en.js +0 -1228
  159. package/src/i18n/fr.js +0 -1185
  160. package/src/policy/contracts.js +0 -18
  161. package/src/policy/resolvePolicy.js +0 -59
  162. package/src/policy/schemas/engine-options.schema.json +0 -103
  163. 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-no-duplicate-contentinfo
5
7
  * @atomic true
6
8
  * @summary A page must not have more than one contentinfo 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 contentinfo landmark
10
12
  * (explicit role="contentinfo", or an implicit, non-nested <footer>).
@@ -16,11 +18,8 @@
16
18
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
17
19
  * header comment for the shared rationale/precedent.
18
20
  * - Only landmarks actually exposed to assistive technology can collide —
19
- * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
20
- * its source directly: `query_selector_all_filter_default(..., elm =>
21
- * _isVisibleToScreenReaders(elm))`) — same fix applied to the sibling
22
- * banner/main rules after finding real hidden-duplicate false positives
23
- * on Trello and Zoom.
21
+ * same as the sibling banner/main rules, avoiding hidden-duplicate false
22
+ * positives.
24
23
  */
25
24
 
26
25
  const id = 'landmark-no-duplicate-contentinfo';
@@ -55,9 +54,8 @@ function runInPage(ctx) {
55
54
 
56
55
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
57
56
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
58
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
59
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
60
- // copy of this logic.
57
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
58
+ // the title-attribute fallback consistent across the landmark rules.
61
59
  function getAccessibleLandmarkName(el) {
62
60
  try {
63
61
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -80,10 +78,8 @@ function runInPage(ctx) {
80
78
  // (an ancestor's bare TAG only counts when it carries no role attribute
81
79
  // at all; an explicit role="dialog"-style override no longer suppresses)
82
80
  // rather than a local tag-only copy. See that function's header comment
83
- // in src/core/aria-helpers.js for the full algorithm and the real page
84
- // (handsontable.com's docs-assistant side panel, an
85
- // <aside role="dialog"> containing its own <header>) that surfaced this
86
- // rule's own former tag-only copy as a false negative.
81
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
82
+ // <aside role="dialog"> containing its own <header>.
87
83
  function hasSectioningAncestor(el, includeMain) {
88
84
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
89
85
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -97,11 +93,9 @@ function runInPage(ctx) {
97
93
  if (tag === 'main') return 'main';
98
94
  if (tag === 'nav') return 'navigation';
99
95
  if (tag === 'aside') {
100
- // A named <aside> is never suppressed, even when nested — matches
101
- // landmark-unique's own verified-against-reference-engine precedent
102
- // (that engine's real `aside` implicit-role function keeps
103
- // "complementary" when the element has an accessible name, even
104
- // inside sectioning content); propagated here for consistency.
96
+ // A named <aside> is never suppressed, even when nested — it keeps
97
+ // "complementary" when it has an accessible name, even inside
98
+ // sectioning content. Matches landmark-unique's precedent.
105
99
  if (!hasSectioningAncestor(el, false)) return 'complementary';
106
100
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
107
101
  }
@@ -129,9 +123,8 @@ function runInPage(ctx) {
129
123
  }
130
124
 
131
125
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
132
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
133
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
134
- // invisible to a light-DOM-only query.
126
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
127
+ // widget's own landmark is invisible to a light-DOM-only query.
135
128
  let nodes;
136
129
  try {
137
130
  nodes =
@@ -1,29 +1,27 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-no-duplicate-main
5
7
  * @atomic true
6
8
  * @summary A page must not have more than one main 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 main landmark
10
12
  * (explicit role="main", or an implicit <main>).
11
13
  * @expectation
12
14
  * At most one main landmark exists on the page. Distinct, atomic
13
15
  * decision from landmark-one-main (that rule flags zero
14
- * mains too; this one only flags more than one) — matches a widely-used
15
- * reference engine shipping both as separate rules with some overlap by design.
16
+ * mains too; this one only flags more than one).
16
17
  * @implementation-notes
17
18
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
19
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
20
  * header comment for the shared rationale/precedent.
20
- * - Only landmarks actually exposed to assistive technology can collide —
21
- * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
22
- * its source directly: `query_selector_all_filter_default(..., elm =>
23
- * _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
24
- * rendering both a visible and a CSS-hidden duplicate `<main>` (found on
25
- * a real site — Zoom's homepage) was wrongly flagged as a duplicate
26
- * landmark; the hidden copy is never actually reachable by AT.
21
+ * - Only landmarks actually exposed to assistive technology can collide.
22
+ * Without this, a responsive layout rendering both a visible and a
23
+ * CSS-hidden duplicate `<main>` is flagged as a duplicate landmark even
24
+ * though the hidden copy is never reachable by AT.
27
25
  */
28
26
 
29
27
  const id = 'landmark-no-duplicate-main';
@@ -70,9 +68,8 @@ function runInPage(ctx) {
70
68
  }
71
69
 
72
70
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
73
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
74
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
75
- // invisible to a light-DOM-only query.
71
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
72
+ // widget's own landmark is invisible to a light-DOM-only query.
76
73
  let nodes;
77
74
  try {
78
75
  nodes =
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-one-main
5
7
  * @atomic true
6
8
  * @summary The page should have a main 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
  * Always applicable to any HTML document with a <body> element —
10
12
  * "does the page have a main landmark" is a whole-page concern,
@@ -18,27 +20,15 @@
18
20
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
21
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
20
22
  * header comment for the shared rationale/precedent.
21
- * - Presence-only, matching a widely-used reference engine's real
22
- * `landmark-one-main` scope exactly (confirmed 2026-07-22 by reading that
23
- * engine's source directly: its `page-has-main` check is a plain
24
- * descendant-exists test, `has-descendant-evaluate` — it does NOT flag
25
- * more than one). This rule previously ALSO flagged "more than one main,"
26
- * which was both a real scope mismatch against that reference engine (it
27
- * ships that as a fully separate rule, `landmark-no-duplicate-main` /
28
- * `page-no-duplicate-main`, already correctly implemented here as
29
- * `landmark-no-duplicate-main`) and missing that sibling rule's
30
- * accessibility-tree visibility filter, so it double-flagged cases the
31
- * sibling rule already handles correctly — found via a real page
32
- * (2026-07-22, live-DOM corpus): Resy's and DuckDuckGo's homepages each
33
- * genuinely have two visible `<main>` elements, which that reference
34
- * engine's `landmark-one-main` doesn't flag at all (out of its scope) but
35
- * this rule wrongly did, disagreeing with it for a
36
- * reason that wasn't a real coverage gap on either side — just a
37
- * redundant, incorrectly-scoped extra branch here.
23
+ * - Presence-only: a plain descendant-exists test. It does NOT flag more
24
+ * than one main. "More than one main" is a separate rule,
25
+ * `landmark-no-duplicate-main`, already implemented — deliberately not
26
+ * duplicated here, since a page can genuinely have two visible `<main>`
27
+ * elements, which is out of scope for "does a main landmark exist," not
28
+ * a violation this rule should report.
38
29
  * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
39
30
  * display:none/inert elements don't count as "a main landmark exists"),
40
- * matching `landmark-no-duplicate-main`'s own precedent and
41
- * that reference engine's own accessibility-tree-scoped matching.
31
+ * matching `landmark-no-duplicate-main`'s own precedent.
42
32
  */
43
33
 
44
34
  const id = 'landmark-one-main';
@@ -106,9 +96,8 @@ function runInPage(ctx) {
106
96
  }
107
97
 
108
98
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
109
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
110
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
111
- // invisible to a light-DOM-only query.
99
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
100
+ // widget's own landmark is invisible to a light-DOM-only query.
112
101
  let nodes;
113
102
  try {
114
103
  nodes =
@@ -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 {