@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,15 +1,17 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-contentinfo-is-top-level
5
7
  * @atomic true
6
8
  * @summary The contentinfo 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 contentinfo
10
12
  * candidate: explicit role="contentinfo", OR a <footer> with NO role
11
- * attribute at all (regardless of nesting — see implementation notes'
12
- * 2026-08-01 fix).
13
+ * attribute at all, regardless of nesting (see implementation notes on
14
+ * why candidate selection is deliberately unconditional).
13
15
  * @expectation
14
16
  * No contentinfo candidate has an ancestor that is itself any landmark
15
17
  * region. A contentinfo nested inside another landmark is not a
@@ -20,13 +22,9 @@
20
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
23
  * header comment for the shared rationale/precedent (this rule mirrors
22
24
  * its structure with contentinfo/footer in place of banner/header).
23
- * - **Fixed 2026-08-01, same self-defeating applicability bug as
24
- * landmark-banner-is-top-level (see that file's header comment for the
25
- * full root cause and the TurboTax evidence) — candidate selection
26
- * (`isContentinfoCandidate` below) now matches a widely-used reference
27
- * engine's unconditional `footer:not([role]), [role=contentinfo]`
28
- * selector shape instead of reusing the sectioning-ancestor
29
- * suppression that the violation check itself depends on.
25
+ * - Candidate selection (`isContentinfoCandidate`) requires the element to
26
+ * really carry the contentinfo role, via the suppression-aware
27
+ * `getLandmarkRole` — same reasoning as landmark-banner-is-top-level.
30
28
  */
31
29
 
32
30
  const id = 'landmark-contentinfo-is-top-level';
@@ -61,9 +59,8 @@ function runInPage(ctx) {
61
59
 
62
60
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
63
61
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
64
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
65
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
66
- // copy of this logic.
62
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
63
+ // the title-attribute fallback consistent across the landmark rules.
67
64
  function getAccessibleLandmarkName(el) {
68
65
  try {
69
66
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -86,10 +83,8 @@ function runInPage(ctx) {
86
83
  // (an ancestor's bare TAG only counts when it carries no role attribute
87
84
  // at all; an explicit role="dialog"-style override no longer suppresses)
88
85
  // rather than a local tag-only copy. See that function's header comment
89
- // in src/core/aria-helpers.js for the full algorithm and the real page
90
- // (handsontable.com's docs-assistant side panel, an
91
- // <aside role="dialog"> containing its own <header>) that surfaced this
92
- // rule's own former tag-only copy as a false negative.
86
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
87
+ // <aside role="dialog"> containing its own <header>.
93
88
  function hasSectioningAncestor(el, includeMain) {
94
89
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
95
90
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -103,11 +98,9 @@ function runInPage(ctx) {
103
98
  if (tag === 'main') return 'main';
104
99
  if (tag === 'nav') return 'navigation';
105
100
  if (tag === 'aside') {
106
- // A named <aside> is never suppressed, even when nested — matches
107
- // landmark-unique's own verified-against-reference-engine precedent
108
- // (that engine's real `aside` implicit-role function keeps
109
- // "complementary" when the element has an accessible name, even
110
- // inside sectioning content); propagated here for consistency.
101
+ // A named <aside> is never suppressed, even when nested — it keeps
102
+ // "complementary" when it has an accessible name, even inside
103
+ // sectioning content. Matches landmark-unique's precedent.
111
104
  if (!hasSectioningAncestor(el, false)) return 'complementary';
112
105
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
113
106
  }
@@ -135,15 +128,15 @@ function runInPage(ctx) {
135
128
  }
136
129
 
137
130
  // Candidate selection is deliberately NOT the same as getLandmarkRole()
138
- // === 'contentinfo' — see the 2026-08-01 fix note above. A <footer> is
131
+ // === 'contentinfo' — see the header comment above. A <footer> is
139
132
  // a candidate purely by tag + absence of any role attribute, independent
140
133
  // of whether sectioning-ancestor nesting would currently suppress its
141
134
  // implicit role; an explicit role="contentinfo" is always a candidate too.
135
+ // A candidate must actually have the contentinfo role — a <footer> inside
136
+ // article/aside/main/nav/section is not one, so flagging it as nested
137
+ // would report a landmark that does not exist.
142
138
  function isContentinfoCandidate(el) {
143
- if (!el || !el.getAttribute) return false;
144
- const explicit = getExplicitRoleToken(el);
145
- if (explicit) return explicit === 'contentinfo';
146
- return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
139
+ return getLandmarkRole(el) === 'contentinfo';
147
140
  }
148
141
 
149
142
  function hasLandmarkAncestor(el) {
@@ -160,9 +153,8 @@ function runInPage(ctx) {
160
153
  }
161
154
 
162
155
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
163
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
164
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
165
- // invisible to a light-DOM-only query.
156
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
157
+ // widget's own landmark is invisible to a light-DOM-only query.
166
158
  let nodes;
167
159
  try {
168
160
  nodes =
@@ -178,7 +170,28 @@ function runInPage(ctx) {
178
170
  for (const el of nodes) {
179
171
  if (!el || seen.has(el)) continue;
180
172
  seen.add(el);
181
- if (isContentinfoCandidate(el)) contentinfos.push(el);
173
+ if (!isContentinfoCandidate(el)) continue;
174
+
175
+ // An aria-hidden contentinfo candidate is removed from the
176
+ // accessibility tree entirely -- it isn't part of the landmark
177
+ // structure assistive technology users navigate at all, so it
178
+ // shouldn't be flagged as "nested inside another landmark" (there's
179
+ // no real landmark there to begin with, from AT's perspective).
180
+ // queryAllSmart's default hidden-content policy only excludes "hard"
181
+ // CSS-based hiding (display:none, etc.), not the softer aria-hidden
182
+ // exclusion, so this needs its own check.
183
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
184
+ const elig = (() => {
185
+ try {
186
+ return helpers.isAccTreeEligible(el, ctx);
187
+ } catch {
188
+ return { eligible: true, reasons: [] };
189
+ }
190
+ })();
191
+ if (elig && elig.eligible === false) continue;
192
+ }
193
+
194
+ contentinfos.push(el);
182
195
  }
183
196
 
184
197
  if (contentinfos.length === 0) {
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-main-is-top-level
5
7
  * @atomic true
6
8
  * @summary The main 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 main landmark
10
12
  * (explicit role="main", or an implicit <main> element).
@@ -18,13 +20,13 @@
18
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
21
  * header comment for the shared rationale/precedent (this rule mirrors
20
22
  * its structure with main in place of banner/header).
21
- * - Did NOT need the 2026-08-01 fix applied to
22
- * landmark-banner-is-top-level/landmark-contentinfo-is-top-level (see
23
- * that file's header comment): `<main>`'s implicit role is
24
- * unconditional per HTML-AAM — unlike `<header>`/`<footer>`, nesting
25
- * never suppresses it — so `getImplicitLandmarkRole`'s `main` branch
26
- * was never subject to the same self-defeating candidate-selection
27
- * bug. Confirmed by inspection, not just by absence of a bug report.
23
+ * - Unlike landmark-banner-is-top-level/landmark-contentinfo-is-top-level
24
+ * (see that file's header comment), candidate selection here doesn't
25
+ * need to be unconditional: `<main>`'s implicit role is unconditional
26
+ * per HTML-AAM — unlike `<header>`/`<footer>`, nesting never suppresses
27
+ * it — so `getImplicitLandmarkRole`'s `main` branch is never subject to
28
+ * the self-defeating candidate-selection problem those two rules guard
29
+ * against.
28
30
  */
29
31
 
30
32
  const id = 'landmark-main-is-top-level';
@@ -59,9 +61,8 @@ 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. Sharing it keeps
65
+ // the title-attribute fallback consistent across the landmark rules.
65
66
  function getAccessibleLandmarkName(el) {
66
67
  try {
67
68
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -84,10 +85,8 @@ function runInPage(ctx) {
84
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
85
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
86
87
  // rather than a local tag-only copy. See that function's header comment
87
- // in src/core/aria-helpers.js for the full algorithm and the real page
88
- // (handsontable.com's docs-assistant side panel, an
89
- // <aside role="dialog"> containing its own <header>) that surfaced this
90
- // rule's own former tag-only copy as a false negative.
88
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
89
+ // <aside role="dialog"> containing its own <header>.
91
90
  function hasSectioningAncestor(el, includeMain) {
92
91
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
93
92
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -101,11 +100,9 @@ function runInPage(ctx) {
101
100
  if (tag === 'main') return 'main';
102
101
  if (tag === 'nav') return 'navigation';
103
102
  if (tag === 'aside') {
104
- // A named <aside> is never suppressed, even when nested — matches
105
- // landmark-unique's own verified-against-reference-engine precedent
106
- // (that engine's real `aside` implicit-role function keeps
107
- // "complementary" when the element has an accessible name, even
108
- // inside sectioning content); propagated here for consistency.
103
+ // A named <aside> is never suppressed, even when nested — it keeps
104
+ // "complementary" when it has an accessible name, even inside
105
+ // sectioning content. Matches landmark-unique's precedent.
109
106
  if (!hasSectioningAncestor(el, false)) return 'complementary';
110
107
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
111
108
  }
@@ -146,9 +143,8 @@ function runInPage(ctx) {
146
143
  }
147
144
 
148
145
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
149
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
150
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
151
- // invisible to a light-DOM-only query.
146
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
147
+ // widget's own landmark is invisible to a light-DOM-only query.
152
148
  let nodes;
153
149
  try {
154
150
  nodes =
@@ -164,7 +160,28 @@ function runInPage(ctx) {
164
160
  for (const el of nodes) {
165
161
  if (!el || seen.has(el)) continue;
166
162
  seen.add(el);
167
- if (getLandmarkRole(el) === 'main') mains.push(el);
163
+ if (getLandmarkRole(el) !== 'main') continue;
164
+
165
+ // An aria-hidden main candidate is removed from the accessibility
166
+ // tree entirely -- it isn't part of the landmark structure assistive
167
+ // technology users navigate at all, so it shouldn't be flagged as
168
+ // "nested inside another landmark" (there's no real landmark there to
169
+ // begin with, from AT's perspective). queryAllSmart's default hidden-
170
+ // content policy only excludes "hard" CSS-based hiding (display:none,
171
+ // etc.), not the softer aria-hidden exclusion, so this needs its own
172
+ // check.
173
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
174
+ const elig = (() => {
175
+ try {
176
+ return helpers.isAccTreeEligible(el, ctx);
177
+ } catch {
178
+ return { eligible: true, reasons: [] };
179
+ }
180
+ })();
181
+ if (elig && elig.eligible === false) continue;
182
+ }
183
+
184
+ mains.push(el);
168
185
  }
169
186
 
170
187
  if (mains.length === 0) {
@@ -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-banner
5
7
  * @atomic true
6
8
  * @summary A page must not have more than one banner 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 landmark
10
12
  * (explicit role="banner", or an implicit, non-nested <header> — see
@@ -21,14 +23,11 @@
21
23
  * header comment for the shared rationale/precedent.
22
24
  * - Flags every banner instance (not just the "extra" ones) when more
23
25
  * than one exists, since which instance is "correct" is ambiguous.
24
- * - Only landmarks actually exposed to assistive technology can collide —
25
- * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
26
- * its source directly: `query_selector_all_filter_default(..., elm =>
27
- * _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
28
- * rendering both a visible and a CSS-hidden duplicate `<header>` (found
29
- * on a real site — Trello's homepage, a desktop/mobile header pair) was
30
- * wrongly flagged as a duplicate landmark; the hidden copy is never
31
- * actually reachable by AT.
26
+ * - Only landmarks actually exposed to assistive technology can collide.
27
+ * Without this, a responsive layout rendering both a visible and a
28
+ * CSS-hidden duplicate `<header>` (a desktop/mobile header pair) is
29
+ * flagged as a duplicate landmark even though the hidden copy is never
30
+ * reachable by AT.
32
31
  */
33
32
 
34
33
  const id = 'landmark-no-duplicate-banner';
@@ -63,9 +62,7 @@ function runInPage(ctx) {
63
62
 
64
63
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
65
64
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
66
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
67
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
68
- // copy of this logic.
65
+ // local copy -- see that function's header comment in src/core/dom-helpers.js.
69
66
  function getAccessibleLandmarkName(el) {
70
67
  try {
71
68
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -88,10 +85,9 @@ function runInPage(ctx) {
88
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
89
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
90
87
  // rather than a local tag-only copy. See that function's header comment
91
- // in src/core/aria-helpers.js for the full algorithm and the real page
92
- // (handsontable.com's docs-assistant side panel, an
93
- // <aside role="dialog"> containing its own <header>) that surfaced this
94
- // rule's own former tag-only copy as a false negative.
88
+ // in src/core/aria-helpers.js for the full algorithm — e.g. an
89
+ // <aside role="dialog"> containing its own <header>, where the <header>
90
+ // keeps its banner role.
95
91
  function hasSectioningAncestor(el, includeMain) {
96
92
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
97
93
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -105,11 +101,9 @@ function runInPage(ctx) {
105
101
  if (tag === 'main') return 'main';
106
102
  if (tag === 'nav') return 'navigation';
107
103
  if (tag === 'aside') {
108
- // A named <aside> is never suppressed, even when nested — matches
109
- // landmark-unique's own verified-against-reference-engine precedent
110
- // (that engine's real `aside` implicit-role function keeps
104
+ // A named <aside> is never suppressed, even when nested: keeps
111
105
  // "complementary" when the element has an accessible name, even
112
- // inside sectioning content); propagated here for consistency.
106
+ // inside sectioning content. See landmark-unique-manual.js.
113
107
  if (!hasSectioningAncestor(el, false)) return 'complementary';
114
108
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
115
109
  }
@@ -137,9 +131,8 @@ function runInPage(ctx) {
137
131
  }
138
132
 
139
133
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
140
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
141
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
142
- // invisible to a light-DOM-only query.
134
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
135
+ // widget's own landmark is invisible to a light-DOM-only query.
143
136
  let nodes;
144
137
  try {
145
138
  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-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 =