@surea11y/core 1.2.0 → 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 (157) hide show
  1. package/CHANGELOG.md +37 -7
  2. package/LICENSE +373 -21
  3. package/README.md +67 -1
  4. package/bin/core.js +144 -19
  5. package/docs/API_STABILITY.md +1 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/CLI.md +54 -1
  9. package/docs/ENGINE_OPTIONS.md +2 -0
  10. package/docs/INTEGRATION.md +18 -0
  11. package/docs/OUTPUT_SCHEMA.md +1 -1
  12. package/docs/RULE_CATALOG.md +1 -1
  13. package/docs/SARIF.md +59 -0
  14. package/package.json +15 -4
  15. package/src/baseline.js +0 -0
  16. package/src/catalogs/composites.wcag.js +414 -450
  17. package/src/checks/automatic/area-alt-present.js +59 -25
  18. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  19. package/src/checks/automatic/aria-allowed-role.js +21 -7
  20. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  21. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  22. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  23. package/src/checks/automatic/aria-hidden-body.js +42 -19
  24. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  25. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  26. package/src/checks/automatic/aria-prohibited-children.js +55 -16
  27. package/src/checks/automatic/aria-required-attr.js +23 -8
  28. package/src/checks/automatic/aria-required-children.js +37 -14
  29. package/src/checks/automatic/aria-required-parent.js +48 -14
  30. package/src/checks/automatic/aria-role-name-present.js +47 -21
  31. package/src/checks/automatic/aria-roles-valid.js +22 -12
  32. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  33. package/src/checks/automatic/aria-valid-attr.js +17 -5
  34. package/src/checks/automatic/autocomplete-valid.js +74 -16
  35. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  36. package/src/checks/automatic/binary-control-name-present.js +60 -50
  37. package/src/checks/automatic/button-name-present.js +48 -18
  38. package/src/checks/automatic/bypass-blocks-present.js +42 -25
  39. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  40. package/src/checks/automatic/combobox-name-present.js +38 -45
  41. package/src/checks/automatic/contrast-computable.js +361 -341
  42. package/src/checks/automatic/contrast-enhanced.js +487 -466
  43. package/src/checks/automatic/contrast-minimum.js +486 -465
  44. package/src/checks/automatic/css-orientation-lock.js +30 -8
  45. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  46. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  47. package/src/checks/automatic/dialog-name-present.js +37 -75
  48. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  49. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  50. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  51. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  52. package/src/checks/automatic/form-control-single-label.js +47 -10
  53. package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
  54. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  55. package/src/checks/automatic/iframe-name-present.js +31 -9
  56. package/src/checks/automatic/iframe-title-unique.js +29 -8
  57. package/src/checks/automatic/img-alt-present.js +47 -43
  58. package/src/checks/automatic/input-image-alt-present.js +143 -112
  59. package/src/checks/automatic/label-in-name.js +50 -22
  60. package/src/checks/automatic/language-page-present.js +109 -109
  61. package/src/checks/automatic/link-in-text-block.js +59 -19
  62. package/src/checks/automatic/link-name-present.js +45 -14
  63. package/src/checks/automatic/list-children-valid.js +26 -9
  64. package/src/checks/automatic/listbox-name-present.js +39 -19
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  66. package/src/checks/automatic/menuitem-name-present.js +39 -61
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
  70. package/src/checks/automatic/meter-name-present.js +36 -33
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  72. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  73. package/src/checks/automatic/option-name-present.js +38 -21
  74. package/src/checks/automatic/page-title-present.js +17 -6
  75. package/src/checks/automatic/progressbar-name-present.js +41 -34
  76. package/src/checks/automatic/role-img-alt-present.js +209 -157
  77. package/src/checks/automatic/searchbox-name-present.js +39 -19
  78. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  79. package/src/checks/automatic/slider-name-present.js +40 -47
  80. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  81. package/src/checks/automatic/summary-name-present.js +37 -17
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  83. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  84. package/src/checks/automatic/tab-name-present.js +37 -60
  85. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  86. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  87. package/src/checks/automatic/target-size-minimum.js +118 -48
  88. package/src/checks/automatic/td-has-header.js +29 -11
  89. package/src/checks/automatic/textbox-name-present.js +39 -19
  90. package/src/checks/automatic/tooltip-name-present.js +37 -18
  91. package/src/checks/automatic/treeitem-name-present.js +38 -21
  92. package/src/checks/automatic/valid-lang.js +20 -6
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  94. package/src/checks/manual/accesskeys-manual.js +14 -5
  95. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  96. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  98. package/src/checks/manual/aria-text-manual.js +14 -6
  99. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  100. package/src/checks/manual/css-hidden-focus.js +196 -165
  101. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  102. package/src/checks/manual/empty-heading-manual.js +24 -7
  103. package/src/checks/manual/empty-table-header-manual.js +17 -6
  104. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  105. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  106. package/src/checks/manual/heading-order-manual.js +22 -7
  107. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  108. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  109. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  110. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  111. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  112. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  113. package/src/checks/manual/label-title-only-manual.js +14 -5
  114. package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
  115. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
  116. package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
  117. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
  118. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
  119. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  120. package/src/checks/manual/landmark-one-main-manual.js +26 -20
  121. package/src/checks/manual/landmark-unique-manual.js +41 -15
  122. package/src/checks/manual/link-name-quality-manual.js +43 -12
  123. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  124. package/src/checks/manual/meta-viewport-large-manual.js +16 -5
  125. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  126. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  127. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  128. package/src/checks/manual/p-as-heading-manual.js +22 -7
  129. package/src/checks/manual/page-has-heading-one-manual.js +31 -22
  130. package/src/checks/manual/page-title-patterns-manual.js +78 -50
  131. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  132. package/src/checks/manual/region-manual.js +241 -48
  133. package/src/checks/manual/scope-attr-valid-manual.js +10 -3
  134. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  135. package/src/checks/manual/skip-link-manual.js +35 -12
  136. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  137. package/src/checks/manual/tabindex-manual.js +10 -3
  138. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  139. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  140. package/src/checks/manual/video-caption-manual.js +15 -4
  141. package/src/checks/manual-review.js +56 -12
  142. package/src/core/aria-helpers.js +1127 -886
  143. package/src/core/contrast-helpers.js +1217 -1062
  144. package/src/core/dom-helpers.js +4175 -3917
  145. package/src/core/dom-runner.js +720 -604
  146. package/src/core/frame-messaging.js +189 -138
  147. package/src/core/frame-scan.js +94 -82
  148. package/src/core/rollup-composites.js +94 -102
  149. package/src/core/rule-meta.js +58 -41
  150. package/src/core.js +36552 -29111
  151. package/src/i18n/en.js +1194 -889
  152. package/src/i18n/fr.js +1136 -795
  153. package/src/policy/contracts.js +13 -13
  154. package/src/policy/resolvePolicy.js +48 -44
  155. package/src/report.js +63 -43
  156. package/src/sarif.js +175 -0
  157. 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'
@@ -57,7 +78,9 @@ function runInPage(ctx) {
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
@@ -117,7 +140,16 @@ function runInPage(ctx) {
117
140
  return '';
118
141
  }
119
142
 
120
- 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
+ ]);
121
153
 
122
154
  function getLandmarkRole(el) {
123
155
  if (!el || !el.getAttribute) return '';
@@ -126,8 +158,20 @@ function runInPage(ctx) {
126
158
  return getImplicitLandmarkRole(el);
127
159
  }
128
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
+
129
173
  function hasLandmarkAncestor(el) {
130
- const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
174
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
131
175
  let p = el.parentElement;
132
176
  while (p) {
133
177
  if (getLandmarkRole(p)) return true;
@@ -143,11 +187,12 @@ function runInPage(ctx) {
143
187
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
144
188
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
145
189
  // invisible to a light-DOM-only query.
146
- let nodes = [];
190
+ let nodes;
147
191
  try {
148
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
149
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
150
- : 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]');
151
196
  } catch {
152
197
  nodes = [];
153
198
  }
@@ -157,7 +202,7 @@ function runInPage(ctx) {
157
202
  for (const el of nodes) {
158
203
  if (!el || seen.has(el)) continue;
159
204
  seen.add(el);
160
- if (getLandmarkRole(el) === 'banner') banners.push(el);
205
+ if (isBannerCandidate(el)) banners.push(el);
161
206
  }
162
207
 
163
208
  if (banners.length === 0) {
@@ -169,7 +214,7 @@ function runInPage(ctx) {
169
214
  if (!hasLandmarkAncestor(el)) continue;
170
215
 
171
216
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
172
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
217
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
173
218
 
174
219
  occurrences.push({
175
220
  selector: stableSelector,
@@ -188,7 +233,12 @@ function runInPage(ctx) {
188
233
  }
189
234
 
190
235
  if (occurrences.length) {
191
- 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
+ };
192
242
  }
193
243
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
194
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'
@@ -46,7 +54,9 @@ function runInPage(ctx) {
46
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
@@ -106,7 +116,16 @@ function runInPage(ctx) {
106
116
  return '';
107
117
  }
108
118
 
109
- 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
+ ]);
110
129
 
111
130
  function getLandmarkRole(el) {
112
131
  if (!el || !el.getAttribute) return '';
@@ -115,8 +134,20 @@ function runInPage(ctx) {
115
134
  return getImplicitLandmarkRole(el);
116
135
  }
117
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
+
118
149
  function hasLandmarkAncestor(el) {
119
- const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
150
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
120
151
  let p = el.parentElement;
121
152
  while (p) {
122
153
  if (getLandmarkRole(p)) return true;
@@ -132,11 +163,12 @@ function runInPage(ctx) {
132
163
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
133
164
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
134
165
  // invisible to a light-DOM-only query.
135
- let nodes = [];
166
+ let nodes;
136
167
  try {
137
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
138
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
139
- : 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]');
140
172
  } catch {
141
173
  nodes = [];
142
174
  }
@@ -146,7 +178,7 @@ function runInPage(ctx) {
146
178
  for (const el of nodes) {
147
179
  if (!el || seen.has(el)) continue;
148
180
  seen.add(el);
149
- if (getLandmarkRole(el) === 'contentinfo') contentinfos.push(el);
181
+ if (isContentinfoCandidate(el)) contentinfos.push(el);
150
182
  }
151
183
 
152
184
  if (contentinfos.length === 0) {
@@ -158,7 +190,7 @@ function runInPage(ctx) {
158
190
  if (!hasLandmarkAncestor(el)) continue;
159
191
 
160
192
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
161
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
193
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
162
194
 
163
195
  occurrences.push({
164
196
  selector: stableSelector,
@@ -177,7 +209,12 @@ function runInPage(ctx) {
177
209
  }
178
210
 
179
211
  if (occurrences.length) {
180
- 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
+ };
181
218
  }
182
219
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
183
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'
@@ -44,7 +52,9 @@ function runInPage(ctx) {
44
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
@@ -104,7 +114,16 @@ function runInPage(ctx) {
104
114
  return '';
105
115
  }
106
116
 
107
- 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
+ ]);
108
127
 
109
128
  function getLandmarkRole(el) {
110
129
  if (!el || !el.getAttribute) return '';
@@ -114,7 +133,7 @@ function runInPage(ctx) {
114
133
  }
115
134
 
116
135
  function hasLandmarkAncestor(el) {
117
- const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
136
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
118
137
  let p = el.parentElement;
119
138
  while (p) {
120
139
  if (getLandmarkRole(p)) return true;
@@ -130,11 +149,12 @@ function runInPage(ctx) {
130
149
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
131
150
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
132
151
  // invisible to a light-DOM-only query.
133
- let nodes = [];
152
+ let nodes;
134
153
  try {
135
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
136
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
137
- : 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]');
138
158
  } catch {
139
159
  nodes = [];
140
160
  }
@@ -156,7 +176,7 @@ function runInPage(ctx) {
156
176
  if (!hasLandmarkAncestor(el)) continue;
157
177
 
158
178
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
159
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
179
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
160
180
 
161
181
  occurrences.push({
162
182
  selector: stableSelector,
@@ -175,7 +195,12 @@ function runInPage(ctx) {
175
195
  }
176
196
 
177
197
  if (occurrences.length) {
178
- 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
+ };
179
204
  }
180
205
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
181
206
  }
@@ -35,7 +35,8 @@ const id = 'landmark-no-duplicate-banner';
35
35
 
36
36
  const meta = {
37
37
  title: 'Page must not have more than one banner landmark',
38
- description: 'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
38
+ description:
39
+ 'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
39
40
  i18n: {
40
41
  titleKey: 'landmarkNoDuplicateBanner_title',
41
42
  descriptionKey: 'landmarkNoDuplicateBanner_description'
@@ -55,7 +56,9 @@ function runInPage(ctx) {
55
56
  const { document, helpers, rule } = ctx;
56
57
 
57
58
  function normalizeWs(s) {
58
- return String(s || '').replace(/\s+/g, ' ').trim();
59
+ return String(s || '')
60
+ .replace(/\s+/g, ' ')
61
+ .trim();
59
62
  }
60
63
 
61
64
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
@@ -115,7 +118,16 @@ function runInPage(ctx) {
115
118
  return '';
116
119
  }
117
120
 
118
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
121
+ const LANDMARK_ROLES = new Set([
122
+ 'banner',
123
+ 'contentinfo',
124
+ 'main',
125
+ 'navigation',
126
+ 'complementary',
127
+ 'region',
128
+ 'form',
129
+ 'search'
130
+ ]);
119
131
 
120
132
  function getLandmarkRole(el) {
121
133
  if (!el || !el.getAttribute) return '';
@@ -128,16 +140,18 @@ function runInPage(ctx) {
128
140
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
129
141
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
130
142
  // invisible to a light-DOM-only query.
131
- let nodes = [];
143
+ let nodes;
132
144
  try {
133
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
134
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
135
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
145
+ nodes =
146
+ helpers && typeof helpers.queryAllSmart === 'function'
147
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
148
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
136
149
  } catch {
137
150
  nodes = [];
138
151
  }
139
152
 
140
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
153
+ const isAccTreeEligible =
154
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
141
155
 
142
156
  function isExposedToAt(el) {
143
157
  if (!isAccTreeEligible) return true;
@@ -165,7 +179,7 @@ function runInPage(ctx) {
165
179
 
166
180
  const occurrences = banners.map((el) => {
167
181
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
168
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
182
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
169
183
 
170
184
  return {
171
185
  selector: stableSelector,
@@ -183,7 +197,12 @@ function runInPage(ctx) {
183
197
  };
184
198
  });
185
199
 
186
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
200
+ return {
201
+ ruleId: rule.ruleId,
202
+ outcome: 'cantTell',
203
+ severity: rule.defaultSeverity || 'minor',
204
+ occurrences
205
+ };
187
206
  }
188
207
 
189
208
  module.exports = { id, meta, runInPage };
@@ -27,7 +27,8 @@ const id = 'landmark-no-duplicate-contentinfo';
27
27
 
28
28
  const meta = {
29
29
  title: 'Page must not have more than one contentinfo landmark',
30
- description: 'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
30
+ description:
31
+ 'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
31
32
  i18n: {
32
33
  titleKey: 'landmarkNoDuplicateContentinfo_title',
33
34
  descriptionKey: 'landmarkNoDuplicateContentinfo_description'
@@ -47,7 +48,9 @@ function runInPage(ctx) {
47
48
  const { document, helpers, rule } = ctx;
48
49
 
49
50
  function normalizeWs(s) {
50
- return String(s || '').replace(/\s+/g, ' ').trim();
51
+ return String(s || '')
52
+ .replace(/\s+/g, ' ')
53
+ .trim();
51
54
  }
52
55
 
53
56
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
@@ -107,7 +110,16 @@ function runInPage(ctx) {
107
110
  return '';
108
111
  }
109
112
 
110
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
113
+ const LANDMARK_ROLES = new Set([
114
+ 'banner',
115
+ 'contentinfo',
116
+ 'main',
117
+ 'navigation',
118
+ 'complementary',
119
+ 'region',
120
+ 'form',
121
+ 'search'
122
+ ]);
111
123
 
112
124
  function getLandmarkRole(el) {
113
125
  if (!el || !el.getAttribute) return '';
@@ -120,16 +132,18 @@ function runInPage(ctx) {
120
132
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
121
133
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
122
134
  // invisible to a light-DOM-only query.
123
- let nodes = [];
135
+ let nodes;
124
136
  try {
125
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
126
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
127
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
137
+ nodes =
138
+ helpers && typeof helpers.queryAllSmart === 'function'
139
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
140
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
128
141
  } catch {
129
142
  nodes = [];
130
143
  }
131
144
 
132
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
145
+ const isAccTreeEligible =
146
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
133
147
 
134
148
  function isExposedToAt(el) {
135
149
  if (!isAccTreeEligible) return true;
@@ -157,7 +171,7 @@ function runInPage(ctx) {
157
171
 
158
172
  const occurrences = contentinfos.map((el) => {
159
173
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
160
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
174
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
161
175
 
162
176
  return {
163
177
  selector: stableSelector,
@@ -175,7 +189,12 @@ function runInPage(ctx) {
175
189
  };
176
190
  });
177
191
 
178
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
192
+ return {
193
+ ruleId: rule.ruleId,
194
+ outcome: 'cantTell',
195
+ severity: rule.defaultSeverity || 'minor',
196
+ occurrences
197
+ };
179
198
  }
180
199
 
181
200
  module.exports = { id, meta, runInPage };
@@ -50,7 +50,9 @@ function runInPage(ctx) {
50
50
  const { document, helpers, rule } = ctx;
51
51
 
52
52
  function normalizeWs(s) {
53
- return String(s || '').replace(/\s+/g, ' ').trim();
53
+ return String(s || '')
54
+ .replace(/\s+/g, ' ')
55
+ .trim();
54
56
  }
55
57
 
56
58
  function getExplicitRoleToken(el) {
@@ -71,16 +73,18 @@ function runInPage(ctx) {
71
73
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
72
74
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
73
75
  // invisible to a light-DOM-only query.
74
- let nodes = [];
76
+ let nodes;
75
77
  try {
76
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
77
- ? helpers.queryAllSmart('main, [role]')
78
- : document.querySelectorAll('main, [role]');
78
+ nodes =
79
+ helpers && typeof helpers.queryAllSmart === 'function'
80
+ ? helpers.queryAllSmart('main, [role]')
81
+ : document.querySelectorAll('main, [role]');
79
82
  } catch {
80
83
  nodes = [];
81
84
  }
82
85
 
83
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
86
+ const isAccTreeEligible =
87
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
84
88
 
85
89
  function isExposedToAt(el) {
86
90
  if (!isAccTreeEligible) return true;
@@ -108,7 +112,7 @@ function runInPage(ctx) {
108
112
 
109
113
  const occurrences = mains.map((el) => {
110
114
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
111
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
115
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
112
116
 
113
117
  return {
114
118
  selector: stableSelector,
@@ -126,7 +130,12 @@ function runInPage(ctx) {
126
130
  };
127
131
  });
128
132
 
129
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
133
+ return {
134
+ ruleId: rule.ruleId,
135
+ outcome: 'cantTell',
136
+ severity: rule.defaultSeverity || 'minor',
137
+ occurrences
138
+ };
130
139
  }
131
140
 
132
141
  module.exports = { id, meta, runInPage };