@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
@@ -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
@@ -79,31 +82,52 @@ function runInPage(ctx) {
79
82
  return raw.split(/\s+/)[0].toLowerCase();
80
83
  }
81
84
 
82
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
83
-
84
- function isSuppressedBySectioningAncestor(el) {
85
- let p = el.parentElement;
86
- while (p) {
87
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
88
- if (SECTIONING_ANCESTORS.has(tag)) return true;
89
- p = p.parentElement;
90
- }
91
- return false;
85
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
86
+ // question "does this element sit inside a sectioning-content/<main>
87
+ // ancestor that suppresses its conditional implicit role" — role-aware
88
+ // (an ancestor's bare TAG only counts when it carries no role attribute
89
+ // at all; an explicit role="dialog"-style override no longer suppresses)
90
+ // 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.
95
+ function hasSectioningAncestor(el, includeMain) {
96
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
97
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
98
+ : false;
92
99
  }
93
100
 
94
101
  function getImplicitLandmarkRole(el) {
95
102
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
96
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
97
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
103
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
104
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
98
105
  if (tag === 'main') return 'main';
99
106
  if (tag === 'nav') return 'navigation';
100
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
107
+ 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
111
+ // "complementary" when the element has an accessible name, even
112
+ // inside sectioning content); propagated here for consistency.
113
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
114
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
115
+ }
101
116
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
102
117
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
103
118
  return '';
104
119
  }
105
120
 
106
- 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
+ ]);
107
131
 
108
132
  function getLandmarkRole(el) {
109
133
  if (!el || !el.getAttribute) return '';
@@ -116,16 +140,18 @@ function runInPage(ctx) {
116
140
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
117
141
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
118
142
  // invisible to a light-DOM-only query.
119
- let nodes = [];
143
+ let nodes;
120
144
  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]');
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]');
124
149
  } catch {
125
150
  nodes = [];
126
151
  }
127
152
 
128
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
153
+ const isAccTreeEligible =
154
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
129
155
 
130
156
  function isExposedToAt(el) {
131
157
  if (!isAccTreeEligible) return true;
@@ -153,7 +179,7 @@ function runInPage(ctx) {
153
179
 
154
180
  const occurrences = banners.map((el) => {
155
181
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
156
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
182
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
157
183
 
158
184
  return {
159
185
  selector: stableSelector,
@@ -171,7 +197,12 @@ function runInPage(ctx) {
171
197
  };
172
198
  });
173
199
 
174
- 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
+ };
175
206
  }
176
207
 
177
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
@@ -71,31 +74,52 @@ function runInPage(ctx) {
71
74
  return raw.split(/\s+/)[0].toLowerCase();
72
75
  }
73
76
 
74
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
75
-
76
- function isSuppressedBySectioningAncestor(el) {
77
- let p = el.parentElement;
78
- while (p) {
79
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
80
- if (SECTIONING_ANCESTORS.has(tag)) return true;
81
- p = p.parentElement;
82
- }
83
- return false;
77
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
78
+ // question "does this element sit inside a sectioning-content/<main>
79
+ // ancestor that suppresses its conditional implicit role" — role-aware
80
+ // (an ancestor's bare TAG only counts when it carries no role attribute
81
+ // at all; an explicit role="dialog"-style override no longer suppresses)
82
+ // rather than a local tag-only copy. See that function's header comment
83
+ // in src/core/aria-helpers.js for the full algorithm and the real page
84
+ // (handsontable.com's docs-assistant side panel, an
85
+ // <aside role="dialog"> containing its own <header>) that surfaced this
86
+ // rule's own former tag-only copy as a false negative.
87
+ function hasSectioningAncestor(el, includeMain) {
88
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
89
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
90
+ : false;
84
91
  }
85
92
 
86
93
  function getImplicitLandmarkRole(el) {
87
94
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
88
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
89
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
95
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
96
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
90
97
  if (tag === 'main') return 'main';
91
98
  if (tag === 'nav') return 'navigation';
92
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
99
+ if (tag === 'aside') {
100
+ // A named <aside> is never suppressed, even when nested — matches
101
+ // landmark-unique's own verified-against-reference-engine precedent
102
+ // (that engine's real `aside` implicit-role function keeps
103
+ // "complementary" when the element has an accessible name, even
104
+ // inside sectioning content); propagated here for consistency.
105
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
106
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
107
+ }
93
108
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
94
109
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
95
110
  return '';
96
111
  }
97
112
 
98
- 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
+ ]);
99
123
 
100
124
  function getLandmarkRole(el) {
101
125
  if (!el || !el.getAttribute) return '';
@@ -108,16 +132,18 @@ function runInPage(ctx) {
108
132
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
109
133
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
110
134
  // invisible to a light-DOM-only query.
111
- let nodes = [];
135
+ let nodes;
112
136
  try {
113
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
114
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
115
- : 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]');
116
141
  } catch {
117
142
  nodes = [];
118
143
  }
119
144
 
120
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
145
+ const isAccTreeEligible =
146
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
121
147
 
122
148
  function isExposedToAt(el) {
123
149
  if (!isAccTreeEligible) return true;
@@ -145,7 +171,7 @@ function runInPage(ctx) {
145
171
 
146
172
  const occurrences = contentinfos.map((el) => {
147
173
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
148
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
174
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
149
175
 
150
176
  return {
151
177
  selector: stableSelector,
@@ -163,7 +189,12 @@ function runInPage(ctx) {
163
189
  };
164
190
  });
165
191
 
166
- 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
+ };
167
198
  }
168
199
 
169
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 };
@@ -61,6 +61,14 @@ const meta = {
61
61
  coverage: {}
62
62
  };
63
63
 
64
+ // This check is inherently whole-document (does the PAGE have this
65
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
66
+ // scoped this run narrower than the whole document, or when
67
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
68
+ function applicability(ctx) {
69
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
70
+ }
71
+
64
72
  function runInPage(ctx) {
65
73
  const { document, helpers, rule } = ctx;
66
74
 
@@ -70,7 +78,7 @@ function runInPage(ctx) {
70
78
  }
71
79
 
72
80
  function getExplicitRoleToken(el) {
73
- const raw = (el.getAttribute && el.getAttribute('role') || '').trim();
81
+ const raw = ((el.getAttribute && el.getAttribute('role')) || '').trim();
74
82
  if (!raw) return '';
75
83
  return raw.split(/\s+/)[0].toLowerCase();
76
84
  }
@@ -83,7 +91,8 @@ function runInPage(ctx) {
83
91
  return tag === 'main';
84
92
  }
85
93
 
86
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
94
+ const isAccTreeEligible =
95
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
87
96
 
88
97
  function isExposedToAt(el) {
89
98
  if (!isAccTreeEligible) return true;
@@ -100,11 +109,12 @@ function runInPage(ctx) {
100
109
  // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
101
110
  // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
102
111
  // invisible to a light-DOM-only query.
103
- let nodes = [];
112
+ let nodes;
104
113
  try {
105
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
106
- ? helpers.queryAllSmart('main, [role]')
107
- : document.querySelectorAll('main, [role]');
114
+ nodes =
115
+ helpers && typeof helpers.queryAllSmart === 'function'
116
+ ? helpers.queryAllSmart('main, [role]')
117
+ : document.querySelectorAll('main, [role]');
108
118
  } catch {
109
119
  nodes = [];
110
120
  }
@@ -125,27 +135,31 @@ function runInPage(ctx) {
125
135
  }
126
136
 
127
137
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
128
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(body) : (body.outerHTML || '').slice(0, 200);
138
+ const html = helpers.getOuterHtmlSnippet
139
+ ? helpers.getOuterHtmlSnippet(body)
140
+ : (body.outerHTML || '').slice(0, 200);
129
141
 
130
142
  return {
131
143
  ruleId: rule.ruleId,
132
144
  outcome: 'cantTell',
133
145
  severity: rule.defaultSeverity || 'minor',
134
- occurrences: [{
135
- selector: stableSelector,
136
- html,
137
- summary: 'This page has no main landmark.',
138
- hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
139
- i18n: {
140
- summaryKey: 'landmarkOneMain_summary_cantTell_missing',
141
- hintKey: 'landmarkOneMain_hint_cantTell_missing',
142
- params: {}
143
- },
144
- data: {
145
- details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
146
+ occurrences: [
147
+ {
148
+ selector: stableSelector,
149
+ html,
150
+ summary: 'This page has no main landmark.',
151
+ hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
152
+ i18n: {
153
+ summaryKey: 'landmarkOneMain_summary_cantTell_missing',
154
+ hintKey: 'landmarkOneMain_hint_cantTell_missing',
155
+ params: {}
156
+ },
157
+ data: {
158
+ details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
159
+ }
146
160
  }
147
- }]
161
+ ]
148
162
  };
149
163
  }
150
164
 
151
- module.exports = { id, meta, runInPage };
165
+ module.exports = { id, meta, runInPage, applicability };
@@ -31,7 +31,8 @@ const id = 'landmark-unique';
31
31
 
32
32
  const meta = {
33
33
  title: 'Landmarks with the same role must have unique names',
34
- description: 'Checks that when two or more landmarks share the same role, each has a distinct accessible name.',
34
+ description:
35
+ 'Checks that when two or more landmarks share the same role, each has a distinct accessible name.',
35
36
  i18n: {
36
37
  titleKey: 'landmarkUnique_title',
37
38
  descriptionKey: 'landmarkUnique_description'
@@ -51,7 +52,9 @@ function runInPage(ctx) {
51
52
  const { document, helpers, rule } = ctx;
52
53
 
53
54
  function normalizeWs(s) {
54
- return String(s || '').replace(/\s+/g, ' ').trim();
55
+ return String(s || '')
56
+ .replace(/\s+/g, ' ')
57
+ .trim();
55
58
  }
56
59
 
57
60
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
@@ -75,46 +78,48 @@ function runInPage(ctx) {
75
78
  return raw.split(/\s+/)[0].toLowerCase();
76
79
  }
77
80
 
78
- // Two distinct ancestor sets, verified 2026-07-20 against a widely-used
79
- // reference engine's own implicit-role functions directly rather than
80
- // assumed from one shared list: <header>/<footer> use "sectioning content
81
- // PLUS <main>" (that engine's getSectioningContentPlusMainSelector) to decide
82
- // banner/contentinfo suppression, but <aside> uses PLAIN sectioning
83
- // content only (article/aside/nav/section — NOT main) to decide
84
- // complementary suppression. The old single SECTIONING_ANCESTORS set
85
- // (which included 'main') was correct for header/footer but wrong for
86
- // aside — found via a real page: Know Your Meme's two unnamed
87
- // <aside class="extra-large-only"> elements are direct children of
88
- // <main>, which incorrectly suppressed their implicit "complementary"
89
- // role entirely, hiding a real duplicate-landmark violation that
90
- // reference engine correctly flags.
91
- const SECTIONING_ANCESTORS_PLUS_MAIN = new Set(['article', 'aside', 'main', 'nav', 'section']);
92
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'nav', 'section']);
93
-
94
- function hasSectioningAncestorFrom(el, set) {
95
- let p = el.parentElement;
96
- while (p) {
97
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
98
- if (set.has(tag)) return true;
99
- p = p.parentElement;
100
- }
101
- return false;
81
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
82
+ // an ancestor's bare TAG only counts when it carries no role attribute at
83
+ // all; an explicit role="dialog"-style override no longer suppresses —
84
+ // see that function's header comment in src/core/aria-helpers.js) rather
85
+ // than the two local tag-only Sets this file used to carry. Two distinct
86
+ // ancestor scopes, verified 2026-07-20 against a widely-used reference
87
+ // engine's own implicit-role functions directly rather than assumed from
88
+ // one shared list: <header>/<footer> use "sectioning content PLUS <main>"
89
+ // (includeMain: true) to decide banner/contentinfo suppression, but
90
+ // <aside> uses PLAIN sectioning content only — NOT main (includeMain:
91
+ // false) — to decide complementary suppression. The old single
92
+ // SECTIONING_ANCESTORS set (which included 'main') was correct for
93
+ // header/footer but wrong for aside — found via a real page: Know Your
94
+ // Meme's two unnamed <aside class="extra-large-only"> elements are direct
95
+ // children of <main>, which incorrectly suppressed their implicit
96
+ // "complementary" role entirely, hiding a real duplicate-landmark
97
+ // violation that reference engine correctly flags. The tag-only
98
+ // (non-role-aware) half of this bug was separately found and fixed
99
+ // 2026-07-30 via the cross-engine comparisons project, on
100
+ // handsontable.com's docs-assistant side panel: an <aside role="dialog">
101
+ // containing its own <header> — role="dialog" isn't one of the four
102
+ // scoping roles, so the nested <header> keeps "banner" per spec, but a
103
+ // tag-only check unconditionally suppressed it just because the ancestor
104
+ // TAG was <aside>.
105
+ function hasSectioningAncestor(el, includeMain) {
106
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
107
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
108
+ : false;
102
109
  }
103
110
 
104
111
  function getImplicitLandmarkRole(el) {
105
112
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
106
- if (tag === 'header') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'banner';
107
- if (tag === 'footer') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'contentinfo';
113
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
114
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
108
115
  if (tag === 'main') return 'main';
109
116
  if (tag === 'nav') return 'navigation';
110
117
  if (tag === 'aside') {
111
118
  // Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
112
119
  // sectioning-content ancestor ONLY when the <aside> also has no
113
120
  // accessible name — a named <aside> is never suppressed, even when
114
- // nested. Not yet evidenced by a real page in this corpus, but
115
- // implemented to match the verified source exactly rather than
116
- // leaving a known partial fix in place.
117
- if (!hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS)) return 'complementary';
121
+ // nested.
122
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
118
123
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
119
124
  }
120
125
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
@@ -122,7 +127,16 @@ function runInPage(ctx) {
122
127
  return '';
123
128
  }
124
129
 
125
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
130
+ const LANDMARK_ROLES = new Set([
131
+ 'banner',
132
+ 'contentinfo',
133
+ 'main',
134
+ 'navigation',
135
+ 'complementary',
136
+ 'region',
137
+ 'form',
138
+ 'search'
139
+ ]);
126
140
 
127
141
  function getLandmarkRole(el) {
128
142
  if (!el || !el.getAttribute) return '';
@@ -150,7 +164,8 @@ function runInPage(ctx) {
150
164
  return getImplicitLandmarkRole(el);
151
165
  }
152
166
 
153
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
167
+ const isAccTreeEligible =
168
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
154
169
 
155
170
  function isExposedToAt(el) {
156
171
  if (!isAccTreeEligible) return true;
@@ -170,11 +185,12 @@ function runInPage(ctx) {
170
185
  // own landmark-unique (a real browser DOM, shadow roots included by design) correctly sees as colliding
171
186
  // with the page's own unnamed header <nav>/page <footer> -- a real, confirmed surea11y
172
187
  // false-negative miss, invisible to plain querySelectorAll's light-DOM-only reach.
173
- let nodes = [];
188
+ let nodes;
174
189
  try {
175
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
176
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
177
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
190
+ nodes =
191
+ helpers && typeof helpers.queryAllSmart === 'function'
192
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
193
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
178
194
  } catch {
179
195
  nodes = [];
180
196
  }
@@ -219,24 +235,31 @@ function runInPage(ctx) {
219
235
 
220
236
  for (const { el } of group) {
221
237
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
222
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
238
+ const html = helpers.getOuterHtmlSnippet
239
+ ? helpers.getOuterHtmlSnippet(el)
240
+ : el.outerHTML || '';
223
241
 
224
242
  occurrences.push({
225
243
  selector: stableSelector,
226
244
  html,
227
245
  summary: normalizedName
228
- ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
229
- : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
246
+ ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
247
+ : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
230
248
  hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
231
249
  i18n: {
232
250
  summaryKey: normalizedName
233
- ? 'landmarkUnique_summary_cantTell_duplicateName'
234
- : 'landmarkUnique_summary_cantTell_bothUnnamed',
251
+ ? 'landmarkUnique_summary_cantTell_duplicateName'
252
+ : 'landmarkUnique_summary_cantTell_bothUnnamed',
235
253
  hintKey: 'landmarkUnique_hint_cantTell',
236
254
  params: { role }
237
255
  },
238
256
  data: {
239
- details: { reasonCode: 'LANDMARK_NOT_UNIQUE', role, name: normalizedName, groupSize: group.length }
257
+ details: {
258
+ reasonCode: 'LANDMARK_NOT_UNIQUE',
259
+ role,
260
+ name: normalizedName,
261
+ groupSize: group.length
262
+ }
240
263
  }
241
264
  });
242
265
  }
@@ -246,7 +269,12 @@ function runInPage(ctx) {
246
269
  if (!occurrences.length) {
247
270
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
248
271
  }
249
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
272
+ return {
273
+ ruleId: rule.ruleId,
274
+ outcome: 'cantTell',
275
+ severity: rule.defaultSeverity || 'minor',
276
+ occurrences
277
+ };
250
278
  }
251
279
 
252
280
  module.exports = { id, meta, runInPage };