@surea11y/core 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +87 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/CI_INTEGRATIONS.md +7 -7
  7. package/docs/ENGINE_OPTIONS.md +1 -1
  8. package/docs/I18N.md +12 -9
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +1 -1
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_CATALOG.md +6 -6
  13. package/package.json +52 -16
  14. package/src/baseline.js +0 -0
  15. package/src/checks/automatic/area-alt-present.js +4 -6
  16. package/src/checks/automatic/aria-allowed-attr.js +663 -134
  17. package/src/checks/automatic/aria-allowed-role.js +2 -0
  18. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  19. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  20. package/src/checks/automatic/aria-deprecated-role.js +107 -38
  21. package/src/checks/automatic/aria-hidden-body.js +6 -4
  22. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  23. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  24. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  25. package/src/checks/automatic/aria-required-attr.js +6 -7
  26. package/src/checks/automatic/aria-required-children.js +7 -10
  27. package/src/checks/automatic/aria-required-parent.js +20 -25
  28. package/src/checks/automatic/aria-role-name-present.js +2 -0
  29. package/src/checks/automatic/aria-roles-valid.js +33 -6
  30. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  31. package/src/checks/automatic/aria-valid-attr.js +2 -0
  32. package/src/checks/automatic/autocomplete-valid.js +39 -1
  33. package/src/checks/automatic/avoid-inline-spacing.js +181 -20
  34. package/src/checks/automatic/binary-control-name-present.js +13 -3
  35. package/src/checks/automatic/button-name-present.js +54 -21
  36. package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
  37. package/src/checks/automatic/combobox-name-present.js +10 -1
  38. package/src/checks/automatic/contrast-computable.js +2 -0
  39. package/src/checks/automatic/contrast-enhanced.js +2 -0
  40. package/src/checks/automatic/contrast-minimum.js +2 -0
  41. package/src/checks/automatic/css-orientation-lock.js +21 -27
  42. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  43. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  44. package/src/checks/automatic/dialog-name-present.js +18 -11
  45. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  46. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  47. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  48. package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
  49. package/src/checks/automatic/form-control-single-label.js +110 -43
  50. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  51. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  52. package/src/checks/automatic/iframe-name-present.js +2 -0
  53. package/src/checks/automatic/iframe-title-unique.js +3 -1
  54. package/src/checks/automatic/img-alt-present.js +23 -18
  55. package/src/checks/automatic/input-image-alt-present.js +101 -52
  56. package/src/checks/automatic/label-in-name.js +97 -27
  57. package/src/checks/automatic/language-page-present.js +7 -1
  58. package/src/checks/automatic/link-in-text-block.js +2 -0
  59. package/src/checks/automatic/link-name-present.js +52 -17
  60. package/src/checks/automatic/list-children-valid.js +14 -24
  61. package/src/checks/automatic/listbox-name-present.js +10 -1
  62. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  63. package/src/checks/automatic/menuitem-name-present.js +10 -1
  64. package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
  65. package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
  66. package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
  67. package/src/checks/automatic/meter-name-present.js +12 -4
  68. package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
  69. package/src/checks/automatic/object-text-alternative-present.js +16 -7
  70. package/src/checks/automatic/option-name-present.js +10 -1
  71. package/src/checks/automatic/page-title-present.js +2 -0
  72. package/src/checks/automatic/progressbar-name-present.js +16 -11
  73. package/src/checks/automatic/role-img-alt-present.js +4 -4
  74. package/src/checks/automatic/searchbox-name-present.js +10 -1
  75. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  76. package/src/checks/automatic/slider-name-present.js +13 -2
  77. package/src/checks/automatic/spinbutton-name-present.js +10 -1
  78. package/src/checks/automatic/summary-name-present.js +10 -1
  79. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  80. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  81. package/src/checks/automatic/tab-name-present.js +10 -1
  82. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  83. package/src/checks/automatic/table-th-has-data-cells.js +69 -6
  84. package/src/checks/automatic/target-size-minimum.js +5 -0
  85. package/src/checks/automatic/td-has-header.js +24 -1
  86. package/src/checks/automatic/textbox-name-present.js +10 -1
  87. package/src/checks/automatic/tooltip-name-present.js +10 -1
  88. package/src/checks/automatic/treeitem-name-present.js +10 -1
  89. package/src/checks/automatic/valid-lang.js +18 -3
  90. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  91. package/src/checks/manual/accesskeys-manual.js +3 -1
  92. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  93. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  94. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  95. package/src/checks/manual/aria-text-manual.js +6 -5
  96. package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
  97. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  98. package/src/checks/manual/css-hidden-focus.js +184 -9
  99. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  100. package/src/checks/manual/empty-heading-manual.js +17 -17
  101. package/src/checks/manual/empty-table-header-manual.js +52 -25
  102. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  103. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  104. package/src/checks/manual/heading-order-manual.js +28 -1
  105. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  106. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  107. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
  110. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  111. package/src/checks/manual/label-title-only-manual.js +29 -22
  112. package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
  113. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
  114. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  115. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  116. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  117. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  118. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  119. package/src/checks/manual/landmark-unique-manual.js +37 -52
  120. package/src/checks/manual/link-name-quality-manual.js +2 -0
  121. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  122. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  123. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  124. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  125. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  126. package/src/checks/manual/p-as-heading-manual.js +2 -0
  127. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  128. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  129. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  130. package/src/checks/manual/region-manual.js +27 -36
  131. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  132. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  133. package/src/checks/manual/skip-link-manual.js +7 -6
  134. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  135. package/src/checks/manual/tabindex-manual.js +3 -1
  136. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  137. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  138. package/src/checks/manual/video-caption-manual.js +2 -0
  139. package/src/checks/manual-review.js +2 -0
  140. package/src/core.js +11820 -3317
  141. package/src/index.js +2 -0
  142. package/src/report.js +51 -9
  143. package/src/sarif.js +20 -5
  144. package/surea11y.browser.js +4943 -1388
  145. package/bin/core.js +0 -473
  146. package/docs/CLI.md +0 -128
  147. package/src/catalogs/composites.wcag.js +0 -454
  148. package/src/checks/automatic/bypass-blocks-present.js +0 -215
  149. package/src/checks/rules-and-tags.full.csv +0 -19
  150. package/src/checks/rules-and-tags.full.json +0 -259
  151. package/src/core/aria-helpers.js +0 -1211
  152. package/src/core/contrast-helpers.js +0 -1302
  153. package/src/core/dom-helpers.js +0 -4493
  154. package/src/core/dom-runner.js +0 -787
  155. package/src/core/frame-messaging.js +0 -261
  156. package/src/core/frame-scan.js +0 -190
  157. package/src/core/rollup-composites.js +0 -127
  158. package/src/core/rule-meta.js +0 -176
  159. package/src/coverage/wcag-facets.js +0 -1079
  160. package/src/coverage/wcag-version-map.js +0 -84
  161. package/src/i18n/en.js +0 -1228
  162. package/src/i18n/fr.js +0 -1185
  163. package/src/policy/contracts.js +0 -18
  164. package/src/policy/resolvePolicy.js +0 -59
  165. package/src/policy/schemas/engine-options.schema.json +0 -103
  166. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -0,0 +1,279 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check bypass-blocks-present
7
+ * @atomic true
8
+ * @summary The page must provide at least one mechanism to bypass repeated blocks of content
9
+ * @standard WCAG 2.2
10
+ * @sc 2.4.1
11
+ * @applicability
12
+ * Always applicable to any HTML document with a <body> element —
13
+ * "bypass blocks" is a whole-page concern, matching
14
+ * aria-hidden-body / page-title-present's pattern of
15
+ * evaluating the document directly rather than a scoped root.
16
+ * @expectation
17
+ * At least one of the following recognized WCAG 2.4.1 techniques is
18
+ * present:
19
+ * (a) a main landmark (<main> or [role="main"]) — technique ARIA11: a
20
+ * screen reader user can jump straight to it, bypassing everything
21
+ * before it (nav, header, repeated blocks) in one step;
22
+ * (b) a working same-page anchor link — technique G1/G123: an
23
+ * <a href="#id"> (or legacy <a name="id">) whose target resolves to
24
+ * a real element in the link's own tree (light DOM or the same shadow
25
+ * root). Deliberately NOT required to be positioned before a <nav> or
26
+ * be keyboard-focus-order-first — see implementation notes;
27
+ * (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
28
+ * H69: heading navigation is itself a standards-recognized bypass
29
+ * mechanism (e.g. a screen reader's "jump by heading" command).
30
+ * @implementation-notes
31
+ * - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
32
+ * `fail`). When a recognized mechanism is found the page has nothing to
33
+ * review here → `notApplicable` (matching page-has-heading-one-manual /
34
+ * skip-link-manual's "nothing to flag" convention). When none is found we
35
+ * return `cantTell` — "we could not detect a bypass mechanism, please
36
+ * verify" — rather than a hard `fail`. The absence of a *detectable*
37
+ * mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
38
+ * several reasons the engine cannot resolve from a single static snapshot:
39
+ * • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
40
+ * content "repeated on multiple Web pages"; whether any block is
41
+ * actually repeated across the site is not knowable from one document,
42
+ * so a page that legitimately needs no bypass mechanism would be
43
+ * indistinguishable from one that omits a required one.
44
+ * • Transient accessibility-tree state. When a modal dialog is open the
45
+ * rest of the page is routinely made `inert` or `aria-hidden="true"`,
46
+ * so the page's real <main>/headings are (correctly) filtered out by
47
+ * isAccTreeEligible for the duration of that state and only the dialog
48
+ * is exposed — a snapshot taken then would see "no mechanism" though
49
+ * the page has one once the dialog closes. The same applies to content
50
+ * that is display:none until revealed by script (tabs, accordions, an
51
+ * unmounted SPA view).
52
+ * Both cases would produce false positives under a hard `fail`, which this
53
+ * engine reserves for high-confidence violations; `cantTell` routes them to
54
+ * human review instead. This mirrors how other tools treat 2.4.1 (e.g. axe
55
+ * marks the no-mechanism case "needs review" via reviewOnFail rather than
56
+ * failing it), and why no ACT rule hard-fails 2.4.1 by presence alone.
57
+ * - This rule intentionally checks presence, not position, for the
58
+ * same-page-anchor condition (b): a full bypass algorithm is heuristic
59
+ * (see ROADMAP.md's Tier 1a note on why this rule was
60
+ * deferred from the rest of that batch), and getting DOM-order /
61
+ * keyboard-focus-order positioning exactly right without introducing
62
+ * false positives is materially harder than the rest of Tier 1a. Being
63
+ * lenient about condition (b) can only make us *miss* a review prompt
64
+ * (a page whose only anchor link isn't a real skip mechanism, e.g. a
65
+ * "back to top" link) — never raise a spurious one.
66
+ * - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
67
+ * shadow-DOM-aware (when the run enables includeShadowDom) and applies the
68
+ * engine's hidden-content policy. The same-page-anchor target is resolved
69
+ * in the link's own root (`getRootNode()` — the document, or the shadow
70
+ * root the link lives in) before falling back to the document, so a skip
71
+ * link encapsulated in a web component is credited the same as one in the
72
+ * light DOM. (Previously the anchor path used raw
73
+ * `document.querySelectorAll`/`getElementById`, which never pierced shadow
74
+ * roots — a genuine gap now closed.)
75
+ */
76
+
77
+ const id = 'bypass-blocks-present';
78
+
79
+ const meta = {
80
+ title: 'Page must provide a way to bypass repeated blocks',
81
+ description:
82
+ 'Checks that the page has at least one recognized WCAG 2.4.1 bypass-blocks mechanism: a main landmark, a working same-page anchor link, or a heading.',
83
+ i18n: {
84
+ titleKey: 'bypassBlocksPresent_title',
85
+ descriptionKey: 'bypassBlocksPresent_description'
86
+ },
87
+ helpUrl: null,
88
+ tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', 'manual'],
89
+ wcagSc: ['2.4.1'],
90
+ normativeMappings: [
91
+ {
92
+ standard: 'WCAG',
93
+ version: '2.2',
94
+ requirement: '2.4.1',
95
+ title: 'Bypass Blocks',
96
+ conformanceLevel: 'A'
97
+ }
98
+ ],
99
+ defaultSeverity: 'moderate',
100
+ category: 'operable',
101
+ type: 'manual',
102
+ defaultConfidence: 'medium',
103
+ coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
104
+ };
105
+
106
+ // This check is inherently whole-document (does the PAGE have this
107
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
108
+ // scoped this run narrower than the whole document, or when
109
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
110
+ function applicability(ctx) {
111
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
112
+ }
113
+
114
+ function runInPage(ctx) {
115
+ const { document, helpers, rule } = ctx;
116
+
117
+ const body = document && document.body ? document.body : null;
118
+ if (!body) {
119
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
120
+ }
121
+
122
+ const isAccTreeEligible =
123
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
124
+
125
+ function isExposedToAt(el) {
126
+ if (!isAccTreeEligible) return true;
127
+ try {
128
+ const r = isAccTreeEligible(el, ctx);
129
+ if (typeof r === 'boolean') return r;
130
+ return !!(r && r.eligible);
131
+ } catch {
132
+ return true;
133
+ }
134
+ }
135
+
136
+ function queryAll(selector) {
137
+ try {
138
+ return helpers && typeof helpers.queryAllSmart === 'function'
139
+ ? helpers.queryAllSmart(selector)
140
+ : document.querySelectorAll(selector);
141
+ } catch {
142
+ return [];
143
+ }
144
+ }
145
+
146
+ // Filters through isAccTreeEligible (hidden/aria-hidden/display:none/inert
147
+ // don't count as "a bypass mechanism is present") -- see
148
+ // page-has-heading-one-manual.js for the identical real-world trigger
149
+ // (e.g. a page whose only <h1> sits inside a display:none ancestor,
150
+ // unreachable by sighted and screen reader users alike). A fully
151
+ // non-rendered <main>/heading must not be credited here, since that would
152
+ // wrongly treat a page with zero currently-exposed bypass mechanisms as
153
+ // having one.
154
+ function hasMainLandmark() {
155
+ for (const el of queryAll('main, [role="main"]')) {
156
+ if (el && isExposedToAt(el)) return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ // Resolve a fragment id (or legacy <a name>) inside a specific root node
162
+ // (a Document or a ShadowRoot). Both expose getElementById; querySelector
163
+ // is used for the legacy anchor-name fallback.
164
+ function resolveInRoot(root, fragment) {
165
+ if (!root) return null;
166
+ let target;
167
+ try {
168
+ target = typeof root.getElementById === 'function' ? root.getElementById(fragment) : null;
169
+ } catch {
170
+ target = null;
171
+ }
172
+ if (target) return target;
173
+ try {
174
+ target =
175
+ typeof root.querySelector === 'function'
176
+ ? root.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]')
177
+ : null;
178
+ } catch {
179
+ target = null;
180
+ }
181
+ return target;
182
+ }
183
+
184
+ // Shadow-DOM-aware: gather anchors via queryAllSmart (pierces shadow roots
185
+ // when includeShadowDom is enabled, and drops hard-hidden links), and
186
+ // resolve each fragment in the link's own root before falling back to the
187
+ // document. This credits a skip link encapsulated in a web component the
188
+ // same way as one authored in the light DOM.
189
+ function hasWorkingAnchorLink() {
190
+ let links;
191
+ try {
192
+ links =
193
+ helpers && typeof helpers.queryAllSmart === 'function'
194
+ ? helpers.queryAllSmart('a[href]')
195
+ : document.querySelectorAll('a[href]');
196
+ } catch {
197
+ links = [];
198
+ }
199
+ for (const a of links) {
200
+ if (!a || !a.getAttribute) continue;
201
+ const href = String(a.getAttribute('href') || '').trim();
202
+ if (href.length < 2 || href.charAt(0) !== '#') continue;
203
+ let fragment = href.slice(1);
204
+ try {
205
+ fragment = decodeURIComponent(fragment);
206
+ } catch {
207
+ // keep raw fragment if it isn't a valid percent-encoded string
208
+ }
209
+ fragment = fragment.trim();
210
+ if (!fragment) continue;
211
+
212
+ let root = document;
213
+ try {
214
+ if (typeof a.getRootNode === 'function') {
215
+ const r = a.getRootNode();
216
+ if (r) root = r;
217
+ }
218
+ } catch {
219
+ root = document;
220
+ }
221
+
222
+ let target = resolveInRoot(root, fragment);
223
+ if (!target && root !== document) {
224
+ target = resolveInRoot(document, fragment);
225
+ }
226
+ if (target) return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ function hasHeading() {
232
+ for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
233
+ if (el && isExposedToAt(el)) return true;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ const mainLandmark = hasMainLandmark();
239
+ const anchorLink = mainLandmark ? false : hasWorkingAnchorLink();
240
+ const heading = mainLandmark || anchorLink ? false : hasHeading();
241
+
242
+ // A recognized mechanism is present -> nothing to review on this page.
243
+ if (mainLandmark || anchorLink || heading) {
244
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
245
+ }
246
+
247
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
248
+ const html = helpers.getOuterHtmlSnippet
249
+ ? helpers.getOuterHtmlSnippet(body)
250
+ : (body.outerHTML || '').slice(0, 200);
251
+
252
+ const occurrences = [
253
+ {
254
+ selector: stableSelector,
255
+ html,
256
+ summary:
257
+ 'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
258
+ hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden — e.g. while a modal dialog makes the page inert — or provided on a per-site basis; this needs human confirmation.)',
259
+ i18n: {
260
+ summaryKey: 'bypassBlocksPresent_summary_cantTell',
261
+ hintKey: 'bypassBlocksPresent_hint_cantTell',
262
+ params: {}
263
+ },
264
+ data: {
265
+ details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
266
+ visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
267
+ }
268
+ }
269
+ ];
270
+
271
+ return {
272
+ ruleId: rule.ruleId,
273
+ outcome: 'cantTell',
274
+ severity: rule.defaultSeverity || 'moderate',
275
+ occurrences
276
+ };
277
+ }
278
+
279
+ module.exports = { id, meta, runInPage, applicability };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -194,6 +196,163 @@ function runInPage(ctx) {
194
196
  }
195
197
  }
196
198
 
199
+ function getDeepActiveElement(docRef) {
200
+ let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
201
+ let guard = 0;
202
+ while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
203
+ cur = cur.shadowRoot.activeElement;
204
+ }
205
+ return cur;
206
+ }
207
+
208
+ function focusElementSafe(el) {
209
+ if (!el || typeof el.focus !== 'function') return false;
210
+ try {
211
+ el.focus({ preventScroll: true });
212
+ return true;
213
+ } catch {
214
+ try {
215
+ el.focus();
216
+ return true;
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+ }
222
+
223
+ function runFocusObservationWindow(docRef, fn) {
224
+ const w =
225
+ docRef && docRef.defaultView
226
+ ? docRef.defaultView
227
+ : typeof window !== 'undefined'
228
+ ? window
229
+ : null;
230
+ if (!w || typeof fn !== 'function') return;
231
+
232
+ const originalSetTimeout = typeof w.setTimeout === 'function' ? w.setTimeout.bind(w) : null;
233
+ const originalRequestAnimationFrame =
234
+ typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null;
235
+ const originalQueueMicrotask =
236
+ typeof w.queueMicrotask === 'function' ? w.queueMicrotask.bind(w) : null;
237
+
238
+ const queuedMicrotasks = [];
239
+ const queuedRaf = [];
240
+ const queuedTimers = [];
241
+ let fakeTimerId = 1;
242
+
243
+ const patchedSetTimeout = function (cb, delay) {
244
+ const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
245
+ if (typeof cb === 'function' && d <= 200) {
246
+ const args = [];
247
+ for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
248
+ queuedTimers.push({ delay: d, cb: () => cb.apply(w, args) });
249
+ return fakeTimerId++;
250
+ }
251
+ if (originalSetTimeout) return originalSetTimeout.apply(w, arguments);
252
+ return fakeTimerId++;
253
+ };
254
+
255
+ const patchedRaf = function (cb) {
256
+ if (typeof cb === 'function') {
257
+ queuedRaf.push(cb);
258
+ return fakeTimerId++;
259
+ }
260
+ if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(w, arguments);
261
+ return fakeTimerId++;
262
+ };
263
+
264
+ const patchedQueueMicrotask = function (cb) {
265
+ if (typeof cb === 'function') queuedMicrotasks.push(cb);
266
+ };
267
+
268
+ try {
269
+ if (originalSetTimeout) w.setTimeout = patchedSetTimeout;
270
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = patchedRaf;
271
+ if (originalQueueMicrotask) w.queueMicrotask = patchedQueueMicrotask;
272
+ fn();
273
+
274
+ let guard = 0;
275
+ while (
276
+ (queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
277
+ guard++ < 100
278
+ ) {
279
+ while (queuedMicrotasks.length) {
280
+ const mt = queuedMicrotasks.shift();
281
+ try {
282
+ mt();
283
+ } catch {}
284
+ }
285
+ while (queuedRaf.length) {
286
+ const rf = queuedRaf.shift();
287
+ try {
288
+ rf(16);
289
+ } catch {}
290
+ }
291
+ if (queuedTimers.length) {
292
+ queuedTimers.sort((a, b) => a.delay - b.delay);
293
+ const tt = queuedTimers.shift();
294
+ try {
295
+ tt.cb();
296
+ } catch {}
297
+ }
298
+ }
299
+ } finally {
300
+ if (originalSetTimeout) w.setTimeout = originalSetTimeout;
301
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = originalRequestAnimationFrame;
302
+ if (originalQueueMicrotask) w.queueMicrotask = originalQueueMicrotask;
303
+ }
304
+ }
305
+
306
+ function probeImmediateFocusRedirect(candidate) {
307
+ if (!candidate || typeof candidate.addEventListener !== 'function') return null;
308
+
309
+ let focusedByEvent = false;
310
+ const onFocusCapture = () => {
311
+ focusedByEvent = true;
312
+ };
313
+ try {
314
+ candidate.addEventListener('focus', onFocusCapture, true);
315
+ } catch {}
316
+
317
+ const before = getDeepActiveElement(document);
318
+ let focused = false;
319
+ runFocusObservationWindow(document, () => {
320
+ focused = focusElementSafe(candidate);
321
+ });
322
+
323
+ try {
324
+ candidate.removeEventListener('focus', onFocusCapture, true);
325
+ } catch {}
326
+ if (!focused || !focusedByEvent) return null;
327
+
328
+ const after = getDeepActiveElement(document);
329
+
330
+ if (before && before !== after) {
331
+ focusElementSafe(before);
332
+ }
333
+
334
+ if (!after || after === candidate) return null;
335
+ const redirectedTag = (() => {
336
+ try {
337
+ return lower(after.tagName || '');
338
+ } catch {
339
+ return '';
340
+ }
341
+ })();
342
+ const redirectedId = (() => {
343
+ try {
344
+ return trim(after.getAttribute && after.getAttribute('id'));
345
+ } catch {
346
+ return '';
347
+ }
348
+ })();
349
+ return {
350
+ redirected: true,
351
+ redirectedToTag: redirectedTag || null,
352
+ redirectedToId: redirectedId || null
353
+ };
354
+ }
355
+
197
356
  function isTabbable(el, info) {
198
357
  const f = info || getFocusableInfoSafe(el);
199
358
  return !!(f && f.focusable && f.tabbable);
@@ -226,6 +385,8 @@ function runInPage(ctx) {
226
385
  }
227
386
 
228
387
  const occurrences = [];
388
+ const maxRuntimeProbeCount = 3;
389
+ let runtimeProbeCount = 0;
229
390
 
230
391
  for (const el of candidates) {
231
392
  if (!el || !el.getAttribute) continue;
@@ -251,18 +412,32 @@ function runInPage(ctx) {
251
412
  const hintsArr = [];
252
413
  for (const k of hintOrder) if (hints.includes(k)) hintsArr.push(k);
253
414
 
415
+ const runtimeProbe =
416
+ runtimeProbeCount < maxRuntimeProbeCount ? probeImmediateFocusRedirect(el) : null;
417
+ if (runtimeProbeCount < maxRuntimeProbeCount) runtimeProbeCount += 1;
418
+ const downgradedToRedirectReview = !!(runtimeProbe && runtimeProbe.redirected);
419
+
254
420
  const baseOccurrence = {
255
- summary: `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
256
- hint: 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
257
- i18n: {
258
- summaryKey: 'cssHidden_focus_summary_cantTell',
259
- hintKey: 'cssHidden_focus_hint_cantTell',
260
- params: { element: tagName, visibilityHints: hintsArr.join(',') }
261
- },
421
+ summary: downgradedToRedirectReview
422
+ ? `Focusable ${tagName} appears visually hidden but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
423
+ : `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
424
+ hint: downgradedToRedirectReview
425
+ ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on visually hidden focus targets.'
426
+ : 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
427
+ i18n: downgradedToRedirectReview
428
+ ? null
429
+ : {
430
+ summaryKey: 'cssHidden_focus_summary_cantTell',
431
+ hintKey: 'cssHidden_focus_hint_cantTell',
432
+ params: { element: tagName, visibilityHints: hintsArr.join(',') }
433
+ },
262
434
  data: {
263
435
  details: {
264
- reasonCode: 'cssHiddenTabbable_needsFocusStateVerification',
265
- metrics: { visibilityHints: hintsArr.slice(0) }
436
+ reasonCode: downgradedToRedirectReview
437
+ ? 'cssHiddenTabbable_runtimeRedirect_needsReview'
438
+ : 'cssHiddenTabbable_needsFocusStateVerification',
439
+ metrics: { visibilityHints: hintsArr.slice(0) },
440
+ runtimeProbe: runtimeProbe || null
266
441
  }
267
442
  }
268
443
  };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -154,23 +156,26 @@ function runInPage(ctx) {
154
156
 
155
157
  // Only resolve IDREF text if aria-labelledby is present and aria-label is not already sufficient
156
158
  let labelledByText = '';
157
- let hasLabelledByMechanism = false;
158
-
159
- if (!ariaLabel && ariaLabelledBy) {
160
- hasLabelledByMechanism = true;
161
159
 
162
- if (getTextFromIdRefs) {
163
- try {
164
- const t = getTextFromIdRefs(ariaLabelledBy, ctx);
165
- labelledByText = trim(t && t.text);
166
- // If it resolves to empty, still treat as a mechanism present for manual review.
167
- } catch {
168
- labelledByText = '';
169
- }
160
+ if (!ariaLabel && ariaLabelledBy && getTextFromIdRefs) {
161
+ try {
162
+ const t = getTextFromIdRefs(ariaLabelledBy, ctx);
163
+ labelledByText = trim(t && t.text);
164
+ } catch {
165
+ labelledByText = '';
170
166
  }
171
167
  }
172
168
 
173
- const hasNameMechanism = !!(ariaLabel || title || hasLabelledByMechanism);
169
+ // A broken/empty-resolving aria-labelledby (e.g. pointing at a
170
+ // nonexistent id) is not a "detected" text alternative to review the
171
+ // QUALITY of -- there's no text here at all, and this element's
172
+ // sibling automatic rule (embed-text-alternative-present) already
173
+ // reports it as a fail (no accessible name). Like object-/svg-/
174
+ // canvas-text-alternative-quality-manual, require aria-labelledby to
175
+ // actually resolve to non-empty text rather than merely being present
176
+ // as an attribute; a present-but-broken aria-labelledby has no text
177
+ // alternative to review.
178
+ const hasNameMechanism = !!(ariaLabel || title || labelledByText);
174
179
  if (!hasNameMechanism) continue;
175
180
 
176
181
  const details = {
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check empty-heading
5
7
  * @atomic true
6
8
  * @summary Heading elements must not be empty
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to elements with a heading role: native <h1>-<h6>, or any
10
12
  * element with explicit role="heading" (unless overridden by another
@@ -19,23 +21,21 @@
19
21
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
23
  * header comment for the shared rationale/precedent.
22
- * - This is also the reconciliation point for the "heading-name-present"
23
- * gap surfaced by the 2026-07-19 ACT-rules research pass (see
24
- * ROADMAP.md "Tier 5 candidates"): that gap was already closed by this
25
- * pre-existing rule under a different name, not a real gap. Fixed two
26
- * real false-positive bugs found while confirming that (2026-07-19):
27
- * `title` wasn't accepted as a naming fallback, and hidden/aria-hidden/
28
- * display:none headings weren't excluded (not gated on
29
- * `isAccTreeEligible`), so an empty heading no AT user could ever
30
- * reach was still flagged.
31
- * - 2026-07-21: the subtree-text walker's own hand-rolled descendant
32
- * handling (aria-label/aria-labelledby only) never checked an `<img>`
33
- * descendant's `alt` text — found via Party City's
34
- * `<h1><a><div><img alt="..."></div></a></h1>` logo header, a false
35
- * "empty heading" cantTell. Replaced with the shared, accname-aligned
24
+ * - This is also the reconciliation point for the ACT-rules
25
+ * "heading-name-present" requirement (see ROADMAP.md "Tier 5
26
+ * candidates"): already covered by this pre-existing rule under a
27
+ * different name, not a separate gap. `title` is accepted as a naming
28
+ * fallback, and hidden/aria-hidden/display:none headings are excluded
29
+ * (gated on `isAccTreeEligible`), so an empty heading no AT user could
30
+ * ever reach is not flagged.
31
+ * - Descendant name resolution uses the shared, accname-aligned
36
32
  * `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
37
- * from content" implementation the 19 `-name-present` rules already use
38
- * — instead of maintaining a third, narrower copy of this logic here.
33
+ * from content" implementation the 19 `-name-present` rules already
34
+ * use — rather than a narrower hand-rolled walker, so an `<img alt="...">`
35
+ * descendant's alt text (e.g. a
36
+ * `<h1><a><div><img alt="..."></div></a></h1>` logo header) is correctly
37
+ * picked up as the heading's name instead of producing a false "empty
38
+ * heading" cantTell.
39
39
  */
40
40
 
41
41
  const id = 'empty-heading';