@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 label-title-only
5
7
  * @atomic true
6
8
  * @summary Form controls should not rely on the title attribute as their only label
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to labelable form controls (input, excluding
10
12
  * hidden/submit/reset/button/image; select; textarea) that have a
@@ -46,7 +48,7 @@ const meta = {
46
48
  };
47
49
 
48
50
  function runInPage(ctx) {
49
- const { document, helpers, rule } = ctx;
51
+ const { helpers, rule } = ctx;
50
52
 
51
53
  const selector =
52
54
  'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
@@ -54,37 +56,42 @@ function runInPage(ctx) {
54
56
  ? helpers.queryAllSmart(selector)
55
57
  : helpers.queryAll(selector);
56
58
 
57
- const labelsByFor = new Map();
58
- const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
59
- for (const lab of allLabels) {
60
- if (!lab || !lab.getAttribute) continue;
61
- const forValue = String(lab.getAttribute('for') || '').trim();
62
- if (!forValue) continue;
63
- if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
64
- labelsByFor.get(forValue).push(lab);
65
- }
66
-
67
59
  const occurrences = [];
68
60
  let applicableCount = 0;
69
61
 
70
62
  for (const el of nodes) {
71
63
  if (!el || !el.getAttribute) continue;
72
64
 
65
+ if (helpers.isAccTreeEligible) {
66
+ const elig = (() => {
67
+ try {
68
+ return helpers.isAccTreeEligible(el, ctx);
69
+ } catch {
70
+ return { eligible: true, reasons: [] };
71
+ }
72
+ })();
73
+ if (elig && elig.eligible === false) continue;
74
+ }
75
+
73
76
  const title = String(el.getAttribute('title') || '').trim();
74
77
  if (!title) continue;
75
78
 
76
79
  applicableCount += 1;
77
80
 
78
- const ariaLabel = String(el.getAttribute('aria-label') || '').trim();
79
- if (ariaLabel) continue;
80
- const ariaLabelledby = String(el.getAttribute('aria-labelledby') || '').trim();
81
- if (ariaLabelledby) continue;
82
-
83
- const wrappingLabel = el.closest ? el.closest('label') : null;
84
- if (wrappingLabel) continue;
85
-
86
- const controlId = String(el.getAttribute('id') || '').trim();
87
- if (controlId && labelsByFor.has(controlId)) continue;
81
+ // Delegates to the shared helpers.getAccessibleNameInfo (aria ->
82
+ // native <label> -> title, the same precedence every other
83
+ // name-dependent rule in this engine uses) rather than a local,
84
+ // hand-rolled "does a <label for>/wrapping <label> exist" check. A
85
+ // structural-association-only check (for="..."/wrapping) never verifies
86
+ // the label actually contributes a name -- an empty <label for="x">
87
+ // </label> or empty wrapping <label> would exempt the control even
88
+ // though title is functionally its only real label (see
89
+ // dom-helpers.js's hasLabelAssociation/labelContributesAccessibleName).
90
+ // If the resolved mechanism isn't 'title', some higher-priority
91
+ // mechanism (aria-label/aria-labelledby/a real contributing label)
92
+ // already won and this control isn't title-only.
93
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
94
+ if (!nameInfo || nameInfo.mechanism !== 'title') continue;
88
95
 
89
96
  const tag = el.tagName.toLowerCase();
90
97
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
@@ -1,23 +1,25 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-banner-is-top-level
5
7
  * @atomic true
6
8
  * @summary The banner landmark must not be nested inside another landmark
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever the page contains at least one banner candidate:
10
- * explicit role="banner", OR a <header> with NO role attribute at all
11
- * (regardless of nesting — see implementation notes' 2026-08-01 fix).
12
+ * explicit role="banner", OR a <header> with NO role attribute at all,
13
+ * regardless of nesting (see implementation notes on why candidate
14
+ * selection is deliberately unconditional).
12
15
  * @expectation
13
16
  * No banner candidate has an ancestor that is itself any landmark
14
17
  * region. A banner nested inside another landmark is not a top-level,
15
18
  * whole-page banner and confuses landmark-based navigation for
16
19
  * assistive technology users.
17
20
  * @implementation-notes
18
- * - Not WCAG-normative (a widely-used reference engine classifies this as "Best Practices," no SC
19
- * tag) — authored as an advisory, cantTell-capped `type: 'manual'`
20
- * rule per ROADMAP.md Tier 1b and the design doc's policy model
21
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * `type: 'manual'` rule per ROADMAP.md Tier 1b and the design doc's policy model
21
23
  * ("Advisory / best-practice rules may exist, but must not produce
22
24
  * `fail`"). Matches the existing `page-title-patterns-manual.js`
23
25
  * precedent: deterministic DOM analysis, no human required, but
@@ -25,30 +27,20 @@
25
27
  * - Landmark detection here models WAI-ARIA APG landmark roles and the
26
28
  * HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
27
29
  * main→main, nav→navigation, aside→complementary, section/form→
28
- * region/form only when accessibly named), not a byte-for-byte port
29
- * of a widely-used reference engine's internal algorithm — verify against upstream if exact
30
- * parity is ever required.
31
- * - **Fixed 2026-08-01, a self-defeating applicability bug found via the
32
- * cross-engine comparisons project (verified live on TurboTax's real
33
- * homepage, `<header>` nested inside a `<div id="main" role="main">`
34
- * two levels up):** candidate selection used to run the *same*
35
- * HTML-AAM sectioning-ancestor suppression used for the violation
36
- * check itself (`getImplicitLandmarkRole`'s `hasSectioningAncestor`
37
- * gate) — so the moment a `<header>` was nested inside another
38
- * landmark, that same nesting made it stop counting as a banner
39
- * candidate in the first place, and the rule could never flag the one
40
- * case it exists to catch. A widely-used reference engine's own
41
- * `landmark-banner-is-top-level` avoids this: its selector
42
- * (`header:not([role]), [role=banner]`) is unconditional — it doesn't
43
- * care whether the header *currently* carries the banner role, only
44
- * whether it's a `<header>`/`role="banner"` with a landmark ancestor
45
- * above it (verified by reading that engine's real
46
- * `landmark-is-top-level-evaluate` source, not guessed). Candidate
47
- * selection (`isBannerCandidate` below) now matches that unconditional
48
- * selector shape; the ancestor walk (`hasLandmarkAncestor`) still uses
49
- * the full suppression-aware `getLandmarkRole` for each ancestor,
50
- * which is correct and unchanged — an ancestor genuinely needs its own
51
- * real role to count as blocking.
30
+ * region/form only when accessibly named).
31
+ * - Candidate selection (`isBannerCandidate` below) is deliberately
32
+ * unconditional — a `<header>`/`role="banner"` counts as a candidate
33
+ * regardless of nesting — rather than reusing the same HTML-AAM
34
+ * sectioning-ancestor suppression (`getImplicitLandmarkRole`'s
35
+ * `hasSectioningAncestor` gate) that the violation check itself relies
36
+ * on. Gating candidate selection on that suppression would be
37
+ * self-defeating: the moment a `<header>` is nested inside another
38
+ * landmark, that same nesting would make it stop counting as a banner
39
+ * candidate in the first place, so the rule could never flag the one
40
+ * case it exists to catch. The ancestor walk (`hasLandmarkAncestor`) is intentionally
41
+ * asymmetric: it still uses the full suppression-aware
42
+ * `getLandmarkRole` for each ancestor, since an ancestor genuinely
43
+ * needs its own real role to count as blocking.
52
44
  */
53
45
 
54
46
  const id = 'landmark-banner-is-top-level';
@@ -85,9 +77,8 @@ function runInPage(ctx) {
85
77
 
86
78
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
87
79
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
88
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
89
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
90
- // copy of this logic.
80
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
81
+ // the title-attribute fallback consistent across the landmark rules.
91
82
  function getAccessibleLandmarkName(el) {
92
83
  try {
93
84
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -110,10 +101,8 @@ function runInPage(ctx) {
110
101
  // (an ancestor's bare TAG only counts when it carries no role attribute
111
102
  // at all; an explicit role="dialog"-style override no longer suppresses)
112
103
  // rather than a local tag-only copy. See that function's header comment
113
- // in src/core/aria-helpers.js for the full algorithm and the real page
114
- // (handsontable.com's docs-assistant side panel, an
115
- // <aside role="dialog"> containing its own <header>) that surfaced this
116
- // rule's own former tag-only copy as a false negative.
104
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
105
+ // <aside role="dialog"> containing its own <header>.
117
106
  function hasSectioningAncestor(el, includeMain) {
118
107
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
119
108
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -127,11 +116,9 @@ function runInPage(ctx) {
127
116
  if (tag === 'main') return 'main';
128
117
  if (tag === 'nav') return 'navigation';
129
118
  if (tag === 'aside') {
130
- // A named <aside> is never suppressed, even when nested — matches
131
- // landmark-unique's own verified-against-reference-engine precedent
132
- // (that engine's real `aside` implicit-role function keeps
133
- // "complementary" when the element has an accessible name, even
134
- // inside sectioning content); propagated here for consistency.
119
+ // A named <aside> is never suppressed, even when nested — it keeps
120
+ // "complementary" when it has an accessible name, even inside
121
+ // sectioning content. Matches landmark-unique's precedent.
135
122
  if (!hasSectioningAncestor(el, false)) return 'complementary';
136
123
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
137
124
  }
@@ -159,7 +146,7 @@ function runInPage(ctx) {
159
146
  }
160
147
 
161
148
  // Candidate selection is deliberately NOT the same as getLandmarkRole()
162
- // === 'banner' — see the 2026-08-01 fix note above. A <header> is a
149
+ // === 'banner' — see the fix note above. A <header> is a
163
150
  // candidate purely by tag + absence of any role attribute, independent
164
151
  // of whether sectioning-ancestor nesting would currently suppress its
165
152
  // implicit role; an explicit role="banner" is always a candidate too.
@@ -184,9 +171,8 @@ function runInPage(ctx) {
184
171
  }
185
172
 
186
173
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
187
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
188
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
189
- // invisible to a light-DOM-only query.
174
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
175
+ // widget's own landmark is invisible to a light-DOM-only query.
190
176
  let nodes;
191
177
  try {
192
178
  nodes =
@@ -202,7 +188,28 @@ function runInPage(ctx) {
202
188
  for (const el of nodes) {
203
189
  if (!el || seen.has(el)) continue;
204
190
  seen.add(el);
205
- if (isBannerCandidate(el)) banners.push(el);
191
+ if (!isBannerCandidate(el)) continue;
192
+
193
+ // An aria-hidden banner candidate is removed from the accessibility
194
+ // tree entirely -- it isn't part of the landmark structure assistive
195
+ // technology users navigate at all, so it shouldn't be flagged as
196
+ // "nested inside another landmark" (there's no real landmark there to
197
+ // begin with, from AT's perspective). queryAllSmart's default hidden-
198
+ // content policy only excludes "hard" CSS-based hiding (display:none,
199
+ // etc.), not the softer aria-hidden exclusion, so this needs its own
200
+ // check.
201
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
202
+ const elig = (() => {
203
+ try {
204
+ return helpers.isAccTreeEligible(el, ctx);
205
+ } catch {
206
+ return { eligible: true, reasons: [] };
207
+ }
208
+ })();
209
+ if (elig && elig.eligible === false) continue;
210
+ }
211
+
212
+ banners.push(el);
206
213
  }
207
214
 
208
215
  if (banners.length === 0) {
@@ -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,12 @@
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
25
+ * - Candidate selection (`isContentinfoCandidate` below) is deliberately
26
+ * unconditional, instead of reusing the sectioning-ancestor suppression
27
+ * that the violation check itself depends on — same
28
+ * self-defeating-candidate-selection reasoning as
24
29
  * 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.
30
+ * full root cause).
30
31
  */
31
32
 
32
33
  const id = 'landmark-contentinfo-is-top-level';
@@ -61,9 +62,8 @@ function runInPage(ctx) {
61
62
 
62
63
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
63
64
  // 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.
65
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
66
+ // the title-attribute fallback consistent across the landmark rules.
67
67
  function getAccessibleLandmarkName(el) {
68
68
  try {
69
69
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -86,10 +86,8 @@ function runInPage(ctx) {
86
86
  // (an ancestor's bare TAG only counts when it carries no role attribute
87
87
  // at all; an explicit role="dialog"-style override no longer suppresses)
88
88
  // 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.
89
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
90
+ // <aside role="dialog"> containing its own <header>.
93
91
  function hasSectioningAncestor(el, includeMain) {
94
92
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
95
93
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -103,11 +101,9 @@ function runInPage(ctx) {
103
101
  if (tag === 'main') return 'main';
104
102
  if (tag === 'nav') return 'navigation';
105
103
  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.
104
+ // A named <aside> is never suppressed, even when nested — it keeps
105
+ // "complementary" when it has an accessible name, even inside
106
+ // sectioning content. Matches landmark-unique's precedent.
111
107
  if (!hasSectioningAncestor(el, false)) return 'complementary';
112
108
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
113
109
  }
@@ -135,7 +131,7 @@ function runInPage(ctx) {
135
131
  }
136
132
 
137
133
  // Candidate selection is deliberately NOT the same as getLandmarkRole()
138
- // === 'contentinfo' — see the 2026-08-01 fix note above. A <footer> is
134
+ // === 'contentinfo' — see the header comment above. A <footer> is
139
135
  // a candidate purely by tag + absence of any role attribute, independent
140
136
  // of whether sectioning-ancestor nesting would currently suppress its
141
137
  // implicit role; an explicit role="contentinfo" is always a candidate too.
@@ -160,9 +156,8 @@ function runInPage(ctx) {
160
156
  }
161
157
 
162
158
  // 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.
159
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
160
+ // widget's own landmark is invisible to a light-DOM-only query.
166
161
  let nodes;
167
162
  try {
168
163
  nodes =
@@ -178,7 +173,28 @@ function runInPage(ctx) {
178
173
  for (const el of nodes) {
179
174
  if (!el || seen.has(el)) continue;
180
175
  seen.add(el);
181
- if (isContentinfoCandidate(el)) contentinfos.push(el);
176
+ if (!isContentinfoCandidate(el)) continue;
177
+
178
+ // An aria-hidden contentinfo candidate is removed from the
179
+ // accessibility tree entirely -- it isn't part of the landmark
180
+ // structure assistive technology users navigate at all, so it
181
+ // shouldn't be flagged as "nested inside another landmark" (there's
182
+ // no real landmark there to begin with, from AT's perspective).
183
+ // queryAllSmart's default hidden-content policy only excludes "hard"
184
+ // CSS-based hiding (display:none, etc.), not the softer aria-hidden
185
+ // exclusion, so this needs its own check.
186
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
187
+ const elig = (() => {
188
+ try {
189
+ return helpers.isAccTreeEligible(el, ctx);
190
+ } catch {
191
+ return { eligible: true, reasons: [] };
192
+ }
193
+ })();
194
+ if (elig && elig.eligible === false) continue;
195
+ }
196
+
197
+ contentinfos.push(el);
182
198
  }
183
199
 
184
200
  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 =