@surea11y/core 1.1.2 → 1.3.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 (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -6,12 +6,11 @@
6
6
  * @summary The banner landmark must not be nested inside another landmark
7
7
  * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
8
  * @applicability
9
- * Applies whenever the page contains at least one banner landmark
10
- * (explicit role="banner", or an implicit <header> that is not itself
11
- * nested inside <article>/<aside>/<main>/<nav>/<section> — see
12
- * implementation notes).
9
+ * 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).
13
12
  * @expectation
14
- * No banner landmark has an ancestor that is itself any landmark
13
+ * No banner candidate has an ancestor that is itself any landmark
15
14
  * region. A banner nested inside another landmark is not a top-level,
16
15
  * whole-page banner and confuses landmark-based navigation for
17
16
  * assistive technology users.
@@ -29,13 +28,35 @@
29
28
  * region/form only when accessibly named), not a byte-for-byte port
30
29
  * of a widely-used reference engine's internal algorithm — verify against upstream if exact
31
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.
32
52
  */
33
53
 
34
54
  const id = 'landmark-banner-is-top-level';
35
55
 
36
56
  const meta = {
37
57
  title: 'Banner landmark must be top-level',
38
- description: 'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
58
+ description:
59
+ 'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
39
60
  i18n: {
40
61
  titleKey: 'landmarkBannerIsTopLevel_title',
41
62
  descriptionKey: 'landmarkBannerIsTopLevel_description'
@@ -52,12 +73,14 @@ const meta = {
52
73
  };
53
74
 
54
75
  function runInPage(ctx) {
55
- const { document, helpers, rule } = ctx;
76
+ const { document, root, helpers, rule } = ctx;
56
77
 
57
78
  // Declared inside runInPage — see scripts/build-core.js header
58
79
  // ("runInPage MUST be self-contained").
59
80
  function normalizeWs(s) {
60
- return String(s || '').replace(/\s+/g, ' ').trim();
81
+ return String(s || '')
82
+ .replace(/\s+/g, ' ')
83
+ .trim();
61
84
  }
62
85
 
63
86
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
@@ -81,31 +104,52 @@ function runInPage(ctx) {
81
104
  return raw.split(/\s+/)[0].toLowerCase();
82
105
  }
83
106
 
84
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
85
-
86
- function isSuppressedBySectioningAncestor(el) {
87
- let p = el.parentElement;
88
- while (p) {
89
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
90
- if (SECTIONING_ANCESTORS.has(tag)) return true;
91
- p = p.parentElement;
92
- }
93
- return false;
107
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
108
+ // question "does this element sit inside a sectioning-content/<main>
109
+ // ancestor that suppresses its conditional implicit role" — role-aware
110
+ // (an ancestor's bare TAG only counts when it carries no role attribute
111
+ // at all; an explicit role="dialog"-style override no longer suppresses)
112
+ // 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.
117
+ function hasSectioningAncestor(el, includeMain) {
118
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
119
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
120
+ : false;
94
121
  }
95
122
 
96
123
  function getImplicitLandmarkRole(el) {
97
124
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
98
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
99
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
125
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
126
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
100
127
  if (tag === 'main') return 'main';
101
128
  if (tag === 'nav') return 'navigation';
102
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
129
+ 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.
135
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
136
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
137
+ }
103
138
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
104
139
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
105
140
  return '';
106
141
  }
107
142
 
108
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
143
+ const LANDMARK_ROLES = new Set([
144
+ 'banner',
145
+ 'contentinfo',
146
+ 'main',
147
+ 'navigation',
148
+ 'complementary',
149
+ 'region',
150
+ 'form',
151
+ 'search'
152
+ ]);
109
153
 
110
154
  function getLandmarkRole(el) {
111
155
  if (!el || !el.getAttribute) return '';
@@ -114,10 +158,26 @@ function runInPage(ctx) {
114
158
  return getImplicitLandmarkRole(el);
115
159
  }
116
160
 
161
+ // Candidate selection is deliberately NOT the same as getLandmarkRole()
162
+ // === 'banner' — see the 2026-08-01 fix note above. A <header> is a
163
+ // candidate purely by tag + absence of any role attribute, independent
164
+ // of whether sectioning-ancestor nesting would currently suppress its
165
+ // implicit role; an explicit role="banner" is always a candidate too.
166
+ function isBannerCandidate(el) {
167
+ if (!el || !el.getAttribute) return false;
168
+ const explicit = getExplicitRoleToken(el);
169
+ if (explicit) return explicit === 'banner';
170
+ return !!(el.tagName && el.tagName.toLowerCase() === 'header');
171
+ }
172
+
117
173
  function hasLandmarkAncestor(el) {
174
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
118
175
  let p = el.parentElement;
119
176
  while (p) {
120
177
  if (getLandmarkRole(p)) return true;
178
+ // Don't climb past the scanned scope -- see aria-helpers.js's
179
+ // hasLandmarkScopingAncestor for the same fix and rationale.
180
+ if (scopeRoots.includes(p)) break;
121
181
  p = p.parentElement;
122
182
  }
123
183
  return false;
@@ -127,11 +187,12 @@ function runInPage(ctx) {
127
187
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
128
188
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
129
189
  // invisible to a light-DOM-only query.
130
- let nodes = [];
190
+ let nodes;
131
191
  try {
132
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
133
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
134
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
192
+ nodes =
193
+ helpers && typeof helpers.queryAllSmart === 'function'
194
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
195
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
135
196
  } catch {
136
197
  nodes = [];
137
198
  }
@@ -141,7 +202,7 @@ function runInPage(ctx) {
141
202
  for (const el of nodes) {
142
203
  if (!el || seen.has(el)) continue;
143
204
  seen.add(el);
144
- if (getLandmarkRole(el) === 'banner') banners.push(el);
205
+ if (isBannerCandidate(el)) banners.push(el);
145
206
  }
146
207
 
147
208
  if (banners.length === 0) {
@@ -153,7 +214,7 @@ function runInPage(ctx) {
153
214
  if (!hasLandmarkAncestor(el)) continue;
154
215
 
155
216
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
156
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
217
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
157
218
 
158
219
  occurrences.push({
159
220
  selector: stableSelector,
@@ -172,7 +233,12 @@ function runInPage(ctx) {
172
233
  }
173
234
 
174
235
  if (occurrences.length) {
175
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
236
+ return {
237
+ ruleId: rule.ruleId,
238
+ outcome: 'cantTell',
239
+ severity: rule.defaultSeverity || 'minor',
240
+ occurrences
241
+ };
176
242
  }
177
243
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
178
244
  }
@@ -6,12 +6,12 @@
6
6
  * @summary The contentinfo landmark must not be nested inside another landmark
7
7
  * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
8
  * @applicability
9
- * Applies whenever the page contains at least one contentinfo landmark
10
- * (explicit role="contentinfo", or an implicit <footer> that is not
11
- * itself nested inside <article>/<aside>/<main>/<nav>/<section> — see
12
- * implementation notes).
9
+ * Applies whenever the page contains at least one contentinfo
10
+ * 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
13
  * @expectation
14
- * No contentinfo landmark has an ancestor that is itself any landmark
14
+ * No contentinfo candidate has an ancestor that is itself any landmark
15
15
  * region. A contentinfo nested inside another landmark is not a
16
16
  * top-level, whole-page footer region and confuses landmark-based
17
17
  * navigation for assistive technology users.
@@ -20,13 +20,21 @@
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent (this rule mirrors
22
22
  * 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.
23
30
  */
24
31
 
25
32
  const id = 'landmark-contentinfo-is-top-level';
26
33
 
27
34
  const meta = {
28
35
  title: 'Contentinfo landmark must be top-level',
29
- description: 'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
36
+ description:
37
+ 'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
30
38
  i18n: {
31
39
  titleKey: 'landmarkContentinfoIsTopLevel_title',
32
40
  descriptionKey: 'landmarkContentinfoIsTopLevel_description'
@@ -43,10 +51,12 @@ const meta = {
43
51
  };
44
52
 
45
53
  function runInPage(ctx) {
46
- const { document, helpers, rule } = ctx;
54
+ const { document, root, helpers, rule } = ctx;
47
55
 
48
56
  function normalizeWs(s) {
49
- return String(s || '').replace(/\s+/g, ' ').trim();
57
+ return String(s || '')
58
+ .replace(/\s+/g, ' ')
59
+ .trim();
50
60
  }
51
61
 
52
62
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
@@ -70,31 +80,52 @@ function runInPage(ctx) {
70
80
  return raw.split(/\s+/)[0].toLowerCase();
71
81
  }
72
82
 
73
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
74
-
75
- function isSuppressedBySectioningAncestor(el) {
76
- let p = el.parentElement;
77
- while (p) {
78
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
79
- if (SECTIONING_ANCESTORS.has(tag)) return true;
80
- p = p.parentElement;
81
- }
82
- return false;
83
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
84
+ // question "does this element sit inside a sectioning-content/<main>
85
+ // ancestor that suppresses its conditional implicit role" — role-aware
86
+ // (an ancestor's bare TAG only counts when it carries no role attribute
87
+ // at all; an explicit role="dialog"-style override no longer suppresses)
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.
93
+ function hasSectioningAncestor(el, includeMain) {
94
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
95
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
96
+ : false;
83
97
  }
84
98
 
85
99
  function getImplicitLandmarkRole(el) {
86
100
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
87
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
88
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
101
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
102
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
89
103
  if (tag === 'main') return 'main';
90
104
  if (tag === 'nav') return 'navigation';
91
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
105
+ 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.
111
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
112
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
113
+ }
92
114
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
93
115
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
94
116
  return '';
95
117
  }
96
118
 
97
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
119
+ const LANDMARK_ROLES = new Set([
120
+ 'banner',
121
+ 'contentinfo',
122
+ 'main',
123
+ 'navigation',
124
+ 'complementary',
125
+ 'region',
126
+ 'form',
127
+ 'search'
128
+ ]);
98
129
 
99
130
  function getLandmarkRole(el) {
100
131
  if (!el || !el.getAttribute) return '';
@@ -103,10 +134,26 @@ function runInPage(ctx) {
103
134
  return getImplicitLandmarkRole(el);
104
135
  }
105
136
 
137
+ // Candidate selection is deliberately NOT the same as getLandmarkRole()
138
+ // === 'contentinfo' — see the 2026-08-01 fix note above. A <footer> is
139
+ // a candidate purely by tag + absence of any role attribute, independent
140
+ // of whether sectioning-ancestor nesting would currently suppress its
141
+ // implicit role; an explicit role="contentinfo" is always a candidate too.
142
+ 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');
147
+ }
148
+
106
149
  function hasLandmarkAncestor(el) {
150
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
107
151
  let p = el.parentElement;
108
152
  while (p) {
109
153
  if (getLandmarkRole(p)) return true;
154
+ // Don't climb past the scanned scope -- see aria-helpers.js's
155
+ // hasLandmarkScopingAncestor for the same fix and rationale.
156
+ if (scopeRoots.includes(p)) break;
110
157
  p = p.parentElement;
111
158
  }
112
159
  return false;
@@ -116,11 +163,12 @@ function runInPage(ctx) {
116
163
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
117
164
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
118
165
  // invisible to a light-DOM-only query.
119
- let nodes = [];
166
+ let nodes;
120
167
  try {
121
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
122
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
123
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
168
+ nodes =
169
+ helpers && typeof helpers.queryAllSmart === 'function'
170
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
171
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
124
172
  } catch {
125
173
  nodes = [];
126
174
  }
@@ -130,7 +178,7 @@ function runInPage(ctx) {
130
178
  for (const el of nodes) {
131
179
  if (!el || seen.has(el)) continue;
132
180
  seen.add(el);
133
- if (getLandmarkRole(el) === 'contentinfo') contentinfos.push(el);
181
+ if (isContentinfoCandidate(el)) contentinfos.push(el);
134
182
  }
135
183
 
136
184
  if (contentinfos.length === 0) {
@@ -142,7 +190,7 @@ function runInPage(ctx) {
142
190
  if (!hasLandmarkAncestor(el)) continue;
143
191
 
144
192
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
145
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
193
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
146
194
 
147
195
  occurrences.push({
148
196
  selector: stableSelector,
@@ -161,7 +209,12 @@ function runInPage(ctx) {
161
209
  }
162
210
 
163
211
  if (occurrences.length) {
164
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
212
+ return {
213
+ ruleId: rule.ruleId,
214
+ outcome: 'cantTell',
215
+ severity: rule.defaultSeverity || 'minor',
216
+ occurrences
217
+ };
165
218
  }
166
219
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
167
220
  }
@@ -18,13 +18,21 @@
18
18
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
19
  * header comment for the shared rationale/precedent (this rule mirrors
20
20
  * 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.
21
28
  */
22
29
 
23
30
  const id = 'landmark-main-is-top-level';
24
31
 
25
32
  const meta = {
26
33
  title: 'Main landmark must be top-level',
27
- description: 'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
34
+ description:
35
+ 'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
28
36
  i18n: {
29
37
  titleKey: 'landmarkMainIsTopLevel_title',
30
38
  descriptionKey: 'landmarkMainIsTopLevel_description'
@@ -41,10 +49,12 @@ const meta = {
41
49
  };
42
50
 
43
51
  function runInPage(ctx) {
44
- const { document, helpers, rule } = ctx;
52
+ const { document, root, helpers, rule } = ctx;
45
53
 
46
54
  function normalizeWs(s) {
47
- return String(s || '').replace(/\s+/g, ' ').trim();
55
+ return String(s || '')
56
+ .replace(/\s+/g, ' ')
57
+ .trim();
48
58
  }
49
59
 
50
60
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
@@ -68,31 +78,52 @@ function runInPage(ctx) {
68
78
  return raw.split(/\s+/)[0].toLowerCase();
69
79
  }
70
80
 
71
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
72
-
73
- function isSuppressedBySectioningAncestor(el) {
74
- let p = el.parentElement;
75
- while (p) {
76
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
77
- if (SECTIONING_ANCESTORS.has(tag)) return true;
78
- p = p.parentElement;
79
- }
80
- return false;
81
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
82
+ // question "does this element sit inside a sectioning-content/<main>
83
+ // ancestor that suppresses its conditional implicit role" — role-aware
84
+ // (an ancestor's bare TAG only counts when it carries no role attribute
85
+ // at all; an explicit role="dialog"-style override no longer suppresses)
86
+ // 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.
91
+ function hasSectioningAncestor(el, includeMain) {
92
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
93
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
94
+ : false;
81
95
  }
82
96
 
83
97
  function getImplicitLandmarkRole(el) {
84
98
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
85
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
86
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
99
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
100
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
87
101
  if (tag === 'main') return 'main';
88
102
  if (tag === 'nav') return 'navigation';
89
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
103
+ 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.
109
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
110
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
111
+ }
90
112
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
91
113
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
92
114
  return '';
93
115
  }
94
116
 
95
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
117
+ const LANDMARK_ROLES = new Set([
118
+ 'banner',
119
+ 'contentinfo',
120
+ 'main',
121
+ 'navigation',
122
+ 'complementary',
123
+ 'region',
124
+ 'form',
125
+ 'search'
126
+ ]);
96
127
 
97
128
  function getLandmarkRole(el) {
98
129
  if (!el || !el.getAttribute) return '';
@@ -102,9 +133,13 @@ function runInPage(ctx) {
102
133
  }
103
134
 
104
135
  function hasLandmarkAncestor(el) {
136
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
105
137
  let p = el.parentElement;
106
138
  while (p) {
107
139
  if (getLandmarkRole(p)) return true;
140
+ // Don't climb past the scanned scope -- see aria-helpers.js's
141
+ // hasLandmarkScopingAncestor for the same fix and rationale.
142
+ if (scopeRoots.includes(p)) break;
108
143
  p = p.parentElement;
109
144
  }
110
145
  return false;
@@ -114,11 +149,12 @@ function runInPage(ctx) {
114
149
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
115
150
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
116
151
  // invisible to a light-DOM-only query.
117
- let nodes = [];
152
+ let nodes;
118
153
  try {
119
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
120
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
121
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
154
+ nodes =
155
+ helpers && typeof helpers.queryAllSmart === 'function'
156
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
157
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
122
158
  } catch {
123
159
  nodes = [];
124
160
  }
@@ -140,7 +176,7 @@ function runInPage(ctx) {
140
176
  if (!hasLandmarkAncestor(el)) continue;
141
177
 
142
178
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
143
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
179
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
144
180
 
145
181
  occurrences.push({
146
182
  selector: stableSelector,
@@ -159,7 +195,12 @@ function runInPage(ctx) {
159
195
  }
160
196
 
161
197
  if (occurrences.length) {
162
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
198
+ return {
199
+ ruleId: rule.ruleId,
200
+ outcome: 'cantTell',
201
+ severity: rule.defaultSeverity || 'minor',
202
+ occurrences
203
+ };
163
204
  }
164
205
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
165
206
  }