@surea11y/core 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/CI_INTEGRATIONS.md +7 -7
  6. package/docs/ENGINE_OPTIONS.md +1 -1
  7. package/docs/I18N.md +12 -9
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/REPORT.md +1 -1
  11. package/package.json +50 -16
  12. package/src/baseline.js +0 -0
  13. package/src/checks/automatic/area-alt-present.js +4 -6
  14. package/src/checks/automatic/aria-allowed-attr.js +15 -51
  15. package/src/checks/automatic/aria-allowed-role.js +2 -0
  16. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  17. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  18. package/src/checks/automatic/aria-deprecated-role.js +4 -3
  19. package/src/checks/automatic/aria-hidden-body.js +6 -4
  20. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  21. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  22. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  23. package/src/checks/automatic/aria-required-attr.js +6 -7
  24. package/src/checks/automatic/aria-required-children.js +7 -10
  25. package/src/checks/automatic/aria-required-parent.js +20 -25
  26. package/src/checks/automatic/aria-role-name-present.js +2 -0
  27. package/src/checks/automatic/aria-roles-valid.js +2 -0
  28. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  29. package/src/checks/automatic/aria-valid-attr.js +2 -0
  30. package/src/checks/automatic/autocomplete-valid.js +2 -0
  31. package/src/checks/automatic/avoid-inline-spacing.js +3 -2
  32. package/src/checks/automatic/binary-control-name-present.js +2 -0
  33. package/src/checks/automatic/button-name-present.js +7 -7
  34. package/src/checks/automatic/bypass-blocks-present.js +9 -7
  35. package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
  36. package/src/checks/automatic/combobox-name-present.js +2 -0
  37. package/src/checks/automatic/contrast-computable.js +2 -0
  38. package/src/checks/automatic/contrast-enhanced.js +2 -0
  39. package/src/checks/automatic/contrast-minimum.js +2 -0
  40. package/src/checks/automatic/css-orientation-lock.js +21 -27
  41. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  42. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  43. package/src/checks/automatic/dialog-name-present.js +10 -10
  44. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  45. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  46. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  47. package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
  48. package/src/checks/automatic/form-control-single-label.js +6 -7
  49. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  50. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  51. package/src/checks/automatic/iframe-name-present.js +2 -0
  52. package/src/checks/automatic/iframe-title-unique.js +3 -1
  53. package/src/checks/automatic/img-alt-present.js +7 -9
  54. package/src/checks/automatic/input-image-alt-present.js +4 -6
  55. package/src/checks/automatic/label-in-name.js +15 -19
  56. package/src/checks/automatic/language-page-present.js +2 -0
  57. package/src/checks/automatic/link-in-text-block.js +2 -0
  58. package/src/checks/automatic/link-name-present.js +2 -0
  59. package/src/checks/automatic/list-children-valid.js +14 -24
  60. package/src/checks/automatic/listbox-name-present.js +2 -0
  61. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  62. package/src/checks/automatic/menuitem-name-present.js +2 -0
  63. package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
  64. package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
  65. package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
  66. package/src/checks/automatic/meter-name-present.js +4 -3
  67. package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
  68. package/src/checks/automatic/object-text-alternative-present.js +2 -0
  69. package/src/checks/automatic/option-name-present.js +2 -0
  70. package/src/checks/automatic/page-title-present.js +2 -0
  71. package/src/checks/automatic/progressbar-name-present.js +8 -10
  72. package/src/checks/automatic/role-img-alt-present.js +4 -4
  73. package/src/checks/automatic/searchbox-name-present.js +2 -0
  74. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  75. package/src/checks/automatic/slider-name-present.js +2 -0
  76. package/src/checks/automatic/spinbutton-name-present.js +2 -0
  77. package/src/checks/automatic/summary-name-present.js +2 -0
  78. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  79. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  80. package/src/checks/automatic/tab-name-present.js +2 -0
  81. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  82. package/src/checks/automatic/table-th-has-data-cells.js +2 -0
  83. package/src/checks/automatic/target-size-minimum.js +5 -0
  84. package/src/checks/automatic/td-has-header.js +24 -1
  85. package/src/checks/automatic/textbox-name-present.js +2 -0
  86. package/src/checks/automatic/tooltip-name-present.js +2 -0
  87. package/src/checks/automatic/treeitem-name-present.js +2 -0
  88. package/src/checks/automatic/valid-lang.js +2 -0
  89. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  90. package/src/checks/manual/accesskeys-manual.js +3 -1
  91. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  92. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  93. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  94. package/src/checks/manual/aria-text-manual.js +6 -5
  95. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  96. package/src/checks/manual/css-hidden-focus.js +184 -9
  97. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  98. package/src/checks/manual/empty-heading-manual.js +17 -17
  99. package/src/checks/manual/empty-table-header-manual.js +52 -25
  100. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  102. package/src/checks/manual/heading-order-manual.js +28 -1
  103. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  104. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  105. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  106. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  107. package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/label-title-only-manual.js +29 -22
  110. package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
  111. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
  112. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  113. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  114. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  115. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  116. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  117. package/src/checks/manual/landmark-unique-manual.js +37 -52
  118. package/src/checks/manual/link-name-quality-manual.js +2 -0
  119. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  120. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  121. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  122. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  123. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  124. package/src/checks/manual/p-as-heading-manual.js +2 -0
  125. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  126. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  127. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  128. package/src/checks/manual/region-manual.js +27 -36
  129. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  130. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  131. package/src/checks/manual/skip-link-manual.js +7 -6
  132. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  133. package/src/checks/manual/tabindex-manual.js +3 -1
  134. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  135. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  136. package/src/checks/manual/video-caption-manual.js +2 -0
  137. package/src/checks/manual-review.js +2 -0
  138. package/src/core.js +6772 -2158
  139. package/src/index.js +2 -0
  140. package/src/report.js +51 -9
  141. package/src/sarif.js +18 -3
  142. package/surea11y.browser.js +2731 -999
  143. package/bin/core.js +0 -473
  144. package/docs/CLI.md +0 -128
  145. package/src/catalogs/composites.wcag.js +0 -454
  146. package/src/checks/rules-and-tags.full.csv +0 -19
  147. package/src/checks/rules-and-tags.full.json +0 -259
  148. package/src/core/aria-helpers.js +0 -1211
  149. package/src/core/contrast-helpers.js +0 -1302
  150. package/src/core/dom-helpers.js +0 -4493
  151. package/src/core/dom-runner.js +0 -787
  152. package/src/core/frame-messaging.js +0 -261
  153. package/src/core/frame-scan.js +0 -190
  154. package/src/core/rollup-composites.js +0 -127
  155. package/src/core/rule-meta.js +0 -176
  156. package/src/coverage/wcag-facets.js +0 -1079
  157. package/src/coverage/wcag-version-map.js +0 -84
  158. package/src/i18n/en.js +0 -1228
  159. package/src/i18n/fr.js +0 -1185
  160. package/src/policy/contracts.js +0 -18
  161. package/src/policy/resolvePolicy.js +0 -59
  162. package/src/policy/schemas/engine-options.schema.json +0 -103
  163. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,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';
@@ -1,36 +1,30 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check empty-table-header
5
7
  * @atomic true
6
8
  * @summary Table header cells 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 <th> elements that don't carry a conflicting explicit role,
10
12
  * plus any element (native <th> or not) with role="columnheader" or
11
- * role="rowheader" — matches a widely-used reference engine's own
12
- * `empty-table-header` selector exactly (`th:not([role]), [role="columnheader"], [role="rowheader"]`,
13
- * verified directly against its rule descriptor): a <th> that explicitly
14
- * restates role="columnheader"/"rowheader" is still covered via the second
15
- * clause, but a <th role="presentation"> (no longer meaningfully a header)
16
- * is correctly excluded, and an ARIA-role-only header (e.g. a <div
17
- * role="columnheader"> in a role="grid"/role="table" widget) is caught too
18
- * — found missing entirely via the cross-engine diff tool 2026-07-23.
13
+ * role="rowheader" (`th:not([role]), [role="columnheader"], [role="rowheader"]`):
14
+ * a <th> that explicitly restates role="columnheader"/"rowheader" is still
15
+ * covered via the second clause, but a <th role="presentation"> (no longer
16
+ * meaningfully a header) is correctly excluded, and an ARIA-role-only header
17
+ * (e.g. a <div role="columnheader"> in a role="grid"/role="table" widget) is
18
+ * caught too.
19
19
  * @expectation
20
20
  * The header cell has visible text content. A <th> named only via
21
21
  * aria-label/aria-labelledby (no visible text) is ALSO flagged, not
22
- * treated as equivalent — real screen-reader/browser testing (found via
23
- * a live-DOM cross-engine run 2026-07-21, verified against
24
- * https://butterpep.com/table-header-naming.html and
25
- * https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/)
26
- * confirms aria-label support on <th> is genuinely inconsistent in
27
- * practice: NVDA+Firefox and iOS VoiceOver+Safari ignore it entirely
28
- * (only visible text is announced), JAWS+Chrome/IE11 also only announce
29
- * visible text in the header cell itself. Visible text is the one
30
- * mechanism confirmed to work across every tested combination. A widely-used
31
- * reference engine's own equivalent check (`has-visible-text` only, no aria-label/
32
- * aria-labelledby alternative — confirmed directly against its rule
33
- * descriptor) reaches the same conclusion.
22
+ * treated as equivalent — aria-label support on <th> is genuinely
23
+ * inconsistent in practice: NVDA+Firefox and iOS VoiceOver+Safari ignore
24
+ * it entirely (only visible text is announced), JAWS+Chrome/IE11 also only
25
+ * announce visible text in the header cell itself. Visible text is the one
26
+ * mechanism confirmed to work across every tested combination. See
27
+ * https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/.
34
28
  * @implementation-notes
35
29
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
36
30
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
@@ -72,8 +66,36 @@ function runInPage(ctx) {
72
66
  .trim();
73
67
  }
74
68
 
69
+ const isAccTreeEligible =
70
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
71
+
72
+ function isEligible(node) {
73
+ if (!isAccTreeEligible) return true;
74
+ try {
75
+ const r = isAccTreeEligible(node, ctx);
76
+ return !!(r && r.eligible);
77
+ } catch {
78
+ return true;
79
+ }
80
+ }
81
+
82
+ // Plain el.textContent includes text from aria-hidden descendants, which
83
+ // a real screen reader never announces. A <th> whose only text comes from
84
+ // an aria-hidden descendant (e.g. <th><span aria-hidden="true">Name</span
85
+ // ></th>) would otherwise be treated as having visible text, even though
86
+ // AT announces nothing for it.
75
87
  function getVisibleText(el) {
76
- return normalizeWs(el.textContent);
88
+ function walk(node) {
89
+ if (node.nodeType === 3) return node.nodeValue || '';
90
+ if (node.nodeType !== 1) return '';
91
+ if (!isEligible(node)) return '';
92
+ let text = '';
93
+ for (const child of node.childNodes || []) text += walk(child);
94
+ return text;
95
+ }
96
+ let text = '';
97
+ for (const child of el.childNodes || []) text += walk(child);
98
+ return normalizeWs(text);
77
99
  }
78
100
 
79
101
  function getAriaOnlyName(el) {
@@ -97,9 +119,8 @@ function runInPage(ctx) {
97
119
  return '';
98
120
  }
99
121
 
100
- // Matches a widely-used reference engine's own empty-table-header selector exactly: a <th> with
101
- // no conflicting explicit role, plus any element carrying an explicit
102
- // columnheader/rowheader role (native or not).
122
+ // A <th> with no conflicting explicit role, plus any element carrying an
123
+ // explicit columnheader/rowheader role (native or not).
103
124
  const selector = 'th:not([role]), [role="columnheader"], [role="rowheader"]';
104
125
  const nodes = helpers.queryAllSmart
105
126
  ? helpers.queryAllSmart(selector)
@@ -110,6 +131,12 @@ function runInPage(ctx) {
110
131
 
111
132
  for (const el of nodes) {
112
133
  if (!el) continue;
134
+
135
+ // A fully aria-hidden header cell isn't part of the AT-perceived
136
+ // table structure at all -- queryAllSmart's default hidden-content
137
+ // policy only excludes "hard" CSS-based hiding, not aria-hidden.
138
+ if (!isEligible(el)) continue;
139
+
113
140
  applicableCount += 1;
114
141
 
115
142
  if (getVisibleText(el)) continue;
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check focus-order-semantics
5
7
  * @atomic true
6
8
  * @summary Elements added to the tab order should have interactive semantics
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
  * Elements with an explicit `tabindex` of `0` or greater (in the tab
10
12
  * order) AND an explicit `role` attribute that is one of a curated set
@@ -16,8 +18,8 @@
16
18
  * announce, which is confusing for keyboard users who land on it and
17
19
  * get no indication of what activating it (if anything) would do.
18
20
  * @implementation-notes
19
- * - Not WCAG-normative by a widely-used reference engine's own classification — authored as an
20
- * advisory, cantTell-capped `type: 'manual'` rule.
21
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * `type: 'manual'` rule.
21
23
  * - The non-interactive role list is deliberately curated and
22
24
  * conservative (structural/document roles only) — legitimate custom
23
25
  * widget patterns using `tabindex` with a genuinely interactive role
@@ -25,6 +27,17 @@
25
27
  * `tabindex` and NO role at all are also not flagged: native semantics
26
28
  * or an intentionally generic custom-interactive pattern cannot be
27
29
  * distinguished from markup alone with the same confidence.
30
+ * - `region` is deliberately NOT in the non-interactive role list: a
31
+ * tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded pattern
32
+ * this engine's own `scrollable-region-focusable` check exists to
33
+ * RECOMMEND (a scrollable landmark with no other focusable content
34
+ * needs `tabindex="0"` to be keyboard-reachable at all) — flagging it
35
+ * here would be internally inconsistent with that sibling check. A
36
+ * `role="region"` is also commonly made tabbable on its own merits
37
+ * (e.g. a cookie-consent banner or notification/toast region a keyboard
38
+ * user should be able to reach directly, as in
39
+ * `<div role="region" tabindex="0">`). Scoped to `region` only;
40
+ * navigation/status/tabpanel remain flagged pending their own evidence.
28
41
  */
29
42
 
30
43
  const id = 'focus-order-semantics';
@@ -59,7 +72,6 @@ function runInPage(ctx) {
59
72
  'none',
60
73
  'img',
61
74
  'heading',
62
- 'region',
63
75
  'article',
64
76
  'banner',
65
77
  'contentinfo',
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check heading-order
5
7
  * @atomic true
6
8
  * @summary Heading levels must not skip a level going deeper
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever the page contains two or more heading elements
10
12
  * (native <h1>-<h6>, or explicit role="heading" with aria-level —
@@ -83,6 +85,31 @@ function runInPage(ctx) {
83
85
  for (const el of nodes) {
84
86
  if (!el || seen.has(el)) continue;
85
87
  seen.add(el);
88
+
89
+ // queryAllSmart's default hidden-content policy only excludes "hard"
90
+ // CSS-based hiding (display:none, visibility:hidden, etc.) -- it does
91
+ // NOT exclude aria-hidden, a softer/semantic removal from the
92
+ // accessibility tree that still leaves an element visually rendered.
93
+ // A heading order rule whose whole premise is "the document outline
94
+ // assistive technology users rely on" (see this rule's own header
95
+ // comment) must not let an aria-hidden heading participate in that
96
+ // outline at all: it's invisible to exactly the users this rule
97
+ // exists to protect. Without this check an aria-hidden heading is both
98
+ // flagged itself (though it isn't part of the AT-perceived outline)
99
+ // and can mask a real skip immediately after it, by advancing the
100
+ // "highest level reached so far" tracker on a level no AT user
101
+ // actually encountered.
102
+ if (helpers.isAccTreeEligible) {
103
+ const elig = (() => {
104
+ try {
105
+ return helpers.isAccTreeEligible(el, ctx);
106
+ } catch {
107
+ return { eligible: true, reasons: [] };
108
+ }
109
+ })();
110
+ if (elig && elig.eligible === false) continue;
111
+ }
112
+
86
113
  const level = getHeadingLevel(el);
87
114
  if (level > 0) headings.push({ el, level });
88
115
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check image-redundant-alt
5
7
  * @atomic true
6
8
  * @summary An image's alt text must not duplicate adjacent visible text
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 <img> elements with non-empty alt text whose immediate
10
12
  * parent element also has other visible text content (i.e. text nodes
@@ -57,12 +59,30 @@ function runInPage(ctx) {
57
59
  }
58
60
 
59
61
  function getOwnTextExcludingImg(parent, imgEl) {
62
+ const isAccTreeEligible =
63
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
64
+
60
65
  let text = '';
61
66
  for (const child of parent.childNodes || []) {
62
67
  if (child === imgEl) continue;
63
68
  if (child.nodeType === 3) {
64
69
  text += ' ' + (child.nodeValue || '');
65
70
  } else if (child.nodeType === 1 && child !== imgEl) {
71
+ // An aria-hidden sibling is never actually announced to assistive
72
+ // technology, so its text can't cause the "same words twice"
73
+ // double-announcement this rule exists to catch -- counting it
74
+ // anyway flags a redundancy that doesn't exist in what AT users
75
+ // actually hear.
76
+ if (isAccTreeEligible) {
77
+ const elig = (() => {
78
+ try {
79
+ return isAccTreeEligible(child, ctx);
80
+ } catch {
81
+ return { eligible: true, reasons: [] };
82
+ }
83
+ })();
84
+ if (elig && elig.eligible === false) continue;
85
+ }
66
86
  text += ' ' + (child.textContent || '');
67
87
  }
68
88
  }
@@ -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
  /**
@@ -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
  /**