@surea11y/core 1.1.0 → 1.1.2

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 (78) hide show
  1. package/CHANGELOG.md +25 -2
  2. package/docs/ENGINE_OPTIONS.md +2 -0
  3. package/docs/LIMITATIONS.md +1 -1
  4. package/package.json +1 -1
  5. package/src/checks/automatic/aria-allowed-attr.js +5 -4
  6. package/src/checks/automatic/aria-allowed-role.js +5 -4
  7. package/src/checks/automatic/aria-braille-equivalent.js +3 -4
  8. package/src/checks/automatic/aria-conditional-attr.js +3 -4
  9. package/src/checks/automatic/aria-deprecated-role.js +2 -3
  10. package/src/checks/automatic/aria-prohibited-attr.js +5 -4
  11. package/src/checks/automatic/aria-prohibited-children.js +2 -3
  12. package/src/checks/automatic/aria-required-attr.js +2 -3
  13. package/src/checks/automatic/aria-required-children.js +2 -3
  14. package/src/checks/automatic/aria-required-parent.js +3 -4
  15. package/src/checks/automatic/aria-roles-valid.js +2 -3
  16. package/src/checks/automatic/aria-valid-attr-value.js +5 -4
  17. package/src/checks/automatic/aria-valid-attr.js +5 -4
  18. package/src/checks/automatic/autocomplete-valid.js +2 -3
  19. package/src/checks/automatic/avoid-inline-spacing.js +2 -3
  20. package/src/checks/automatic/binary-control-name-present.js +2 -3
  21. package/src/checks/automatic/button-name-present.js +14 -4
  22. package/src/checks/automatic/bypass-blocks-present.js +35 -7
  23. package/src/checks/automatic/combobox-name-present.js +2 -3
  24. package/src/checks/automatic/definition-list-children-valid.js +2 -3
  25. package/src/checks/automatic/deprecated-elements-not-used.js +5 -4
  26. package/src/checks/automatic/dialog-name-present.js +2 -3
  27. package/src/checks/automatic/dlitem-parent-valid.js +2 -3
  28. package/src/checks/automatic/form-control-single-label.js +2 -3
  29. package/src/checks/automatic/iframe-focusable-content.js +2 -3
  30. package/src/checks/automatic/iframe-name-present.js +2 -3
  31. package/src/checks/automatic/iframe-title-unique.js +5 -5
  32. package/src/checks/automatic/label-in-name.js +7 -8
  33. package/src/checks/automatic/link-in-text-block.js +2 -3
  34. package/src/checks/automatic/link-name-present.js +16 -4
  35. package/src/checks/automatic/list-children-valid.js +2 -3
  36. package/src/checks/automatic/listbox-name-present.js +2 -3
  37. package/src/checks/automatic/listitem-parent-valid.js +2 -3
  38. package/src/checks/automatic/menuitem-name-present.js +2 -3
  39. package/src/checks/automatic/meter-name-present.js +2 -3
  40. package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
  41. package/src/checks/automatic/option-name-present.js +2 -3
  42. package/src/checks/automatic/progressbar-name-present.js +2 -3
  43. package/src/checks/automatic/searchbox-name-present.js +2 -3
  44. package/src/checks/automatic/server-side-image-map-absent.js +2 -3
  45. package/src/checks/automatic/slider-name-present.js +2 -3
  46. package/src/checks/automatic/spinbutton-name-present.js +2 -3
  47. package/src/checks/automatic/summary-name-present.js +2 -3
  48. package/src/checks/automatic/tab-name-present.js +2 -3
  49. package/src/checks/automatic/table-headers-attr-valid.js +5 -4
  50. package/src/checks/automatic/table-th-has-data-cells.js +5 -4
  51. package/src/checks/automatic/td-has-header.js +2 -3
  52. package/src/checks/automatic/textbox-name-present.js +2 -3
  53. package/src/checks/automatic/tooltip-name-present.js +2 -3
  54. package/src/checks/automatic/treeitem-name-present.js +2 -3
  55. package/src/checks/automatic/valid-lang.js +2 -3
  56. package/src/checks/manual/accesskeys-manual.js +1 -7
  57. package/src/checks/manual/aria-checked-state-mismatch-manual.js +7 -6
  58. package/src/checks/manual/aria-text-manual.js +2 -3
  59. package/src/checks/manual/focus-order-semantics-manual.js +2 -3
  60. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
  61. package/src/checks/manual/image-redundant-alt-manual.js +2 -3
  62. package/src/checks/manual/label-title-only-manual.js +2 -3
  63. package/src/checks/manual/link-name-quality-manual.js +2 -3
  64. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
  65. package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
  66. package/src/checks/manual/p-as-heading-manual.js +2 -3
  67. package/src/checks/manual/page-has-heading-one-manual.js +31 -2
  68. package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
  69. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
  70. package/src/checks/manual/skip-link-manual.js +91 -9
  71. package/src/checks/manual/table-duplicate-name-manual.js +2 -3
  72. package/src/checks/manual/table-fake-caption-manual.js +2 -3
  73. package/src/checks/manual/video-caption-manual.js +2 -3
  74. package/src/core/dom-helpers.js +89 -3
  75. package/src/core/dom-runner.js +19 -0
  76. package/src/core.js +962 -481
  77. package/src/i18n/en.js +4 -2
  78. package/src/i18n/fr.js +4 -2
@@ -23,11 +23,10 @@ const meta = {
23
23
  };
24
24
 
25
25
  function runInPage(ctx) {
26
- const { document, root, helpers, rule } = ctx;
26
+ const { document, helpers, rule } = ctx;
27
27
  const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
28
  ? helpers.getEligibilityInfo
29
29
  : null;
30
- const safeRoot = root || document;
31
30
 
32
31
 
33
32
  function normalizeWs(s) {
@@ -91,7 +90,7 @@ function runInPage(ctx) {
91
90
  let applicableCount = 0;
92
91
 
93
92
  const selector = "[role=\"treeitem\"]";
94
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
93
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
95
94
 
96
95
 
97
96
  function hasName(el) {
@@ -47,12 +47,11 @@ const meta = {
47
47
  };
48
48
 
49
49
  function runInPage(ctx) {
50
- const { document, root, helpers, rule } = ctx;
51
- const safeRoot = root || document;
50
+ const { document, helpers, rule } = ctx;
52
51
 
53
52
  const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
54
53
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]', safeRoot) : helpers.queryAll('[lang]', safeRoot);
54
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]') : helpers.queryAll('[lang]');
56
55
 
57
56
  const occurrences = [];
58
57
  let applicableCount = 0;
@@ -41,13 +41,7 @@ const meta = {
41
41
 
42
42
  function runInPage(ctx) {
43
43
  const { document, helpers, rule } = ctx;
44
-
45
- let nodes = [];
46
- try {
47
- nodes = document.querySelectorAll('[accesskey]');
48
- } catch {
49
- nodes = [];
50
- }
44
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[accesskey]') : helpers.queryAll('[accesskey]');
51
45
 
52
46
  const groups = new Map(); // normalized key -> elements[]
53
47
  for (const el of nodes) {
@@ -42,8 +42,10 @@
42
42
  * - Any `aria-checked` value other than "true"/"false"/"mixed" (checkbox)
43
43
  * or "true"/"false" (radio) is treated as equivalent to "false" — also
44
44
  * matches that reference engine's own normalization exactly.
45
- * - Not gated on isAccTreeEligible: whether the accessible state matches
46
- * is a markup-correctness property independent of current visibility.
45
+ * - Not rule-gated on isAccTreeEligible: whether the accessible state
46
+ * matches is still a markup-correctness property, while engine-level
47
+ * hidden-subtree filtering applies unless engineOptions.includeHiddenElements
48
+ * is true.
47
49
  */
48
50
 
49
51
  const id = 'aria-checked-state-mismatch';
@@ -69,14 +71,13 @@ const meta = {
69
71
  };
70
72
 
71
73
  function runInPage(ctx) {
72
- const { document, root, helpers, rule } = ctx;
73
- const safeRoot = root || document;
74
+ const { document, helpers, rule } = ctx;
74
75
 
75
76
  function trim(v) { return (v == null ? '' : String(v)).trim(); }
76
77
 
77
78
  const nodes = helpers.queryAllSmart
78
- ? helpers.queryAllSmart('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]', safeRoot)
79
- : helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]', safeRoot);
79
+ ? helpers.queryAllSmart('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]')
80
+ : helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]');
80
81
 
81
82
  const occurrences = [];
82
83
  let applicableCount = 0;
@@ -46,13 +46,12 @@ const meta = {
46
46
  };
47
47
 
48
48
  function runInPage(ctx) {
49
- const { document, root, helpers, rule } = ctx;
50
- const safeRoot = root || document;
49
+ const { document, helpers, rule } = ctx;
51
50
 
52
51
  const FOCUSABLE_DESCENDANT_SELECTOR =
53
52
  'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
54
53
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]', safeRoot) : helpers.queryAll('[role="text"]', safeRoot);
54
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]') : helpers.queryAll('[role="text"]');
56
55
 
57
56
  const occurrences = [];
58
57
  let applicableCount = 0;
@@ -48,8 +48,7 @@ const meta = {
48
48
  };
49
49
 
50
50
  function runInPage(ctx) {
51
- const { document, root, helpers, rule } = ctx;
52
- const safeRoot = root || document;
51
+ const { document, helpers, rule } = ctx;
53
52
 
54
53
  const NON_INTERACTIVE_ROLES = new Set([
55
54
  'generic', 'group', 'text', 'presentation', 'none', 'img', 'heading',
@@ -59,7 +58,7 @@ function runInPage(ctx) {
59
58
  'definition', 'paragraph', 'caption', 'status', 'alert', 'log', 'tooltip'
60
59
  ]);
61
60
 
62
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]', safeRoot) : helpers.queryAll('[tabindex][role]', safeRoot);
61
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]') : helpers.queryAll('[tabindex][role]');
63
62
 
64
63
  const occurrences = [];
65
64
  let applicableCount = 0;
@@ -52,14 +52,13 @@ const meta = {
52
52
  };
53
53
 
54
54
  function runInPage(ctx) {
55
- const { document, root, helpers, rule } = ctx;
56
- const safeRoot = root || document;
55
+ const { document, helpers, rule } = ctx;
57
56
 
58
57
  function normName(s) {
59
58
  return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
60
59
  }
61
60
 
62
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
61
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
63
62
 
64
63
  const groups = new Map(); // normName -> [{ el, href }]
65
64
  let applicableCount = 0;
@@ -47,8 +47,7 @@ const meta = {
47
47
  };
48
48
 
49
49
  function runInPage(ctx) {
50
- const { document, root, helpers, rule } = ctx;
51
- const safeRoot = root || document;
50
+ const { document, helpers, rule } = ctx;
52
51
 
53
52
  function normalizeWs(s) {
54
53
  return String(s || '').replace(/\s+/g, ' ').trim();
@@ -67,7 +66,7 @@ function runInPage(ctx) {
67
66
  return normalizeWs(text);
68
67
  }
69
68
 
70
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]', safeRoot) : helpers.queryAll('img[alt]', safeRoot);
69
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]') : helpers.queryAll('img[alt]');
71
70
 
72
71
  const occurrences = [];
73
72
  let applicableCount = 0;
@@ -45,11 +45,10 @@ const meta = {
45
45
  };
46
46
 
47
47
  function runInPage(ctx) {
48
- const { document, root, helpers, rule } = ctx;
49
- const safeRoot = root || document;
48
+ const { document, helpers, rule } = ctx;
50
49
 
51
50
  const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
52
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
51
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
53
52
 
54
53
  const labelsByFor = new Map();
55
54
  const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
@@ -60,8 +60,7 @@ const meta = {
60
60
  };
61
61
 
62
62
  function runInPage(ctx) {
63
- const { document, root, helpers, rule } = ctx;
64
- const safeRoot = root || document;
63
+ const { document, helpers, rule } = ctx;
65
64
 
66
65
  const GENERIC_LINK_TEXT = new Set([
67
66
  'click here', 'here', 'click', 'more', 'more info', 'more information',
@@ -80,7 +79,7 @@ function runInPage(ctx) {
80
79
  }
81
80
 
82
81
  const selector = 'a[href]';
83
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
82
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
84
83
 
85
84
  const occurrences = [];
86
85
  let applicableCount = 0;
@@ -67,8 +67,7 @@ const meta = {
67
67
  };
68
68
 
69
69
  function runInPage(ctx) {
70
- const { document, root, helpers, rule } = ctx;
71
- const safeRoot = root || document;
70
+ const { document, helpers, rule } = ctx;
72
71
 
73
72
  const MOUSE_ONLY_ATTRS = [
74
73
  'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup',
@@ -79,7 +78,7 @@ function runInPage(ctx) {
79
78
  function trim(v) { return (v == null ? '' : String(v)).trim(); }
80
79
 
81
80
  const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
82
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
81
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
83
82
 
84
83
  const occurrences = [];
85
84
  let applicableCount = 0;
@@ -58,10 +58,9 @@ const meta = {
58
58
  };
59
59
 
60
60
  function runInPage(ctx) {
61
- const { document, root, helpers, rule } = ctx;
62
- const safeRoot = root || document;
61
+ const { document, helpers, rule } = ctx;
63
62
 
64
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]', safeRoot) : helpers.queryAll('audio[autoplay], video[autoplay]', safeRoot);
63
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]') : helpers.queryAll('audio[autoplay], video[autoplay]');
65
64
 
66
65
  const occurrences = [];
67
66
  let applicableCount = 0;
@@ -53,8 +53,7 @@ const meta = {
53
53
  };
54
54
 
55
55
  function runInPage(ctx) {
56
- const { document, root, helpers, rule } = ctx;
57
- const safeRoot = root || document;
56
+ const { document, helpers, rule } = ctx;
58
57
 
59
58
  const MAX_HEADING_LIKE_CHARS = 120;
60
59
  const MIN_FONT_SIZE_PX = 18;
@@ -106,7 +105,7 @@ function runInPage(ctx) {
106
105
  return Number.isFinite(px) ? px : 0;
107
106
  }
108
107
 
109
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p', safeRoot) : helpers.queryAll('p', safeRoot);
108
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p') : helpers.queryAll('p');
110
109
 
111
110
  const occurrences = [];
112
111
  let applicableCount = 0;
@@ -19,6 +19,18 @@
19
19
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
21
  * header comment for the shared rationale/precedent.
22
+ * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
23
+ * display:none/inert elements don't count as "the page has a heading
24
+ * one"), matching `landmark-one-main`'s own precedent. Found via a real
25
+ * page (CDC's flu page, 2026-07-30): its only `<h1>` sits inside a
26
+ * `display:none` ancestor — genuinely unreachable by sighted and screen
27
+ * reader users alike — and a raw `document.querySelectorAll` credited it
28
+ * anyway, reporting `notApplicable` where axe-core correctly fails. This
29
+ * does NOT regress purely-visually-clipped-but-AT-exposed headings (e.g.
30
+ * eBay's homepage `<h1>` hidden via clip-path/off-screen positioning,
31
+ * `visibility:visible`, no `aria-hidden`) — `isAccTreeEligible` only
32
+ * excludes elements actually removed from the accessibility tree, not
33
+ * ones merely clipped from the visual viewport.
22
34
  */
23
35
 
24
36
  const id = 'page-has-heading-one';
@@ -70,14 +82,31 @@ function runInPage(ctx) {
70
82
  return tag === 'h1';
71
83
  }
72
84
 
85
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
86
+
87
+ function isExposedToAt(el) {
88
+ if (!isAccTreeEligible) return true;
89
+ try {
90
+ const r = isAccTreeEligible(el, ctx);
91
+ if (typeof r === 'boolean') return r;
92
+ return !!(r && r.eligible);
93
+ } catch {
94
+ return true;
95
+ }
96
+ }
97
+
98
+ // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
99
+ // landmark-one-main-manual.js's identical precedent.
73
100
  let nodes = [];
74
101
  try {
75
- nodes = document.querySelectorAll('h1, [role]');
102
+ nodes = helpers && typeof helpers.queryAllSmart === 'function'
103
+ ? helpers.queryAllSmart('h1, [role]')
104
+ : document.querySelectorAll('h1, [role]');
76
105
  } catch {
77
106
  nodes = [];
78
107
  }
79
108
 
80
- const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el));
109
+ const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el) && isExposedToAt(el));
81
110
 
82
111
  if (hasH1) {
83
112
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
@@ -73,8 +73,7 @@ const meta = {
73
73
  };
74
74
 
75
75
  function runInPage(ctx) {
76
- const { document, root, helpers, rule } = ctx;
77
- const safeRoot = root || document;
76
+ const { document, helpers, rule } = ctx;
78
77
 
79
78
  // The full set of ARIA attributes marked `global: true` per the WAI-ARIA
80
79
  // spec (confirmed against a widely-used reference engine's own
@@ -91,8 +90,8 @@ function runInPage(ctx) {
91
90
  const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
92
91
 
93
92
  const nodes = helpers.queryAllSmart
94
- ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]', safeRoot)
95
- : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]', safeRoot);
93
+ ? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
94
+ : helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
96
95
 
97
96
  const occurrences = [];
98
97
  let applicableCount = 0;
@@ -64,8 +64,7 @@ const meta = {
64
64
  };
65
65
 
66
66
  function runInPage(ctx) {
67
- const { document, root, helpers, rule } = ctx;
68
- const safeRoot = root || document;
67
+ const { document, helpers, rule } = ctx;
69
68
 
70
69
  function safeComputedStyle(el) {
71
70
  try {
@@ -110,7 +109,7 @@ function runInPage(ctx) {
110
109
  }
111
110
 
112
111
  const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
113
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR, safeRoot) : helpers.queryAll(CANDIDATE_SELECTOR, safeRoot);
112
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR) : helpers.queryAll(CANDIDATE_SELECTOR);
114
113
 
115
114
  const occurrences = [];
116
115
  let applicableCount = 0;
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * @check skip-link
5
5
  * @atomic true
6
- * @summary A "skip" link must resolve to a real, focusable target
6
+ * @summary A "skip" link must resolve to a real, usable target
7
7
  * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
8
  * @applicability
9
9
  * Applies to <a href="#fragment"> elements whose accessible name
@@ -18,8 +18,11 @@
18
18
  * implementation-notes) — this only widens the known-convention list.
19
19
  * @expectation
20
20
  * The link's fragment resolves to a real element in the document
21
- * (via a matching id, or a legacy <a name="...">). A skip link whose
22
- * target does not exist silently does nothing when activated.
21
+ * (via a matching id, or a legacy <a name="...">), and that target is
22
+ * currently usable (not hidden from the accessibility tree; and, when
23
+ * browser geometry is available, not zero-area/no-rects). A skip link
24
+ * whose target is missing or effectively unusable does not provide a
25
+ * reliable bypass destination.
23
26
  * @implementation-notes
24
27
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
25
28
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
@@ -33,8 +36,8 @@
33
36
  const id = 'skip-link';
34
37
 
35
38
  const meta = {
36
- title: 'Skip link must have a resolvable target',
37
- description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real element in the document.',
39
+ title: 'Skip link must have a resolvable, usable target',
40
+ description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
38
41
  i18n: {
39
42
  titleKey: 'skipLink_title',
40
43
  descriptionKey: 'skipLink_description'
@@ -51,8 +54,7 @@ const meta = {
51
54
  };
52
55
 
53
56
  function runInPage(ctx) {
54
- const { document, root, helpers, rule } = ctx;
55
- const safeRoot = root || document;
57
+ const { document, helpers, rule } = ctx;
56
58
 
57
59
  function normalizeWs(s) {
58
60
  return String(s || '').replace(/\s+/g, ' ').trim();
@@ -79,7 +81,31 @@ function runInPage(ctx) {
79
81
  return normalizeWs(el.textContent);
80
82
  }
81
83
 
82
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
84
+ function hasReliableGeometrySupport() {
85
+ const probe = document.documentElement || document.body || null;
86
+ if (!probe || !probe.getClientRects || !probe.getBoundingClientRect) return false;
87
+ try {
88
+ const rects = probe.getClientRects();
89
+ const rectCount = rects ? rects.length : 0;
90
+ const r = probe.getBoundingClientRect();
91
+ const w = r && Number.isFinite(r.width) ? r.width : 0;
92
+ const h = r && Number.isFinite(r.height) ? r.height : 0;
93
+ return rectCount > 0 && (w > 0 || h > 0);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function toEligibility(info) {
100
+ return {
101
+ eligible: !!(info && info.eligible),
102
+ reasons: info && Array.isArray(info.reasons) ? info.reasons.slice(0) : []
103
+ };
104
+ }
105
+
106
+ const geometrySupported = hasReliableGeometrySupport();
107
+
108
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
83
109
 
84
110
  const occurrences = [];
85
111
  let applicableCount = 0;
@@ -117,7 +143,63 @@ function runInPage(ctx) {
117
143
  }
118
144
  }
119
145
 
120
- if (target) continue;
146
+ if (target) {
147
+ const accEligibility = toEligibility(
148
+ helpers.getEligibilityInfo
149
+ ? helpers.getEligibilityInfo(target, ctx, { targetSet: 'acc' })
150
+ : (helpers.isAccTreeEligible ? helpers.isAccTreeEligible(target, ctx) : { eligible: true, reasons: [] })
151
+ );
152
+
153
+ let geometryEligibility = null;
154
+ let geometryReasonCode = null;
155
+ if (geometrySupported && helpers.isDomVisibleEligible) {
156
+ geometryEligibility = toEligibility(
157
+ helpers.isDomVisibleEligible(target, ctx, { visibilityMode: 'styleAndGeometry', ignoreOpacity: true })
158
+ );
159
+ if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('noClientRects')) {
160
+ geometryReasonCode = 'NO_CLIENT_RECTS';
161
+ } else if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('zeroArea')) {
162
+ geometryReasonCode = 'ZERO_AREA_TARGET';
163
+ }
164
+ }
165
+
166
+ const unusableByAcc = !accEligibility.eligible;
167
+ const unusableByGeometry = !!geometryReasonCode;
168
+ if (!unusableByAcc && !unusableByGeometry) continue;
169
+
170
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
171
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
172
+
173
+ occurrences.push({
174
+ selector: stableSelector,
175
+ html,
176
+ summary: 'This skip link points to a target that exists but is not currently usable.',
177
+ hint: 'Point this skip link to a target that is exposed and usable as a navigation destination.',
178
+ i18n: {
179
+ summaryKey: 'skipLink_summary_unusableTarget_cantTell',
180
+ hintKey: 'skipLink_hint_unusableTarget_cantTell',
181
+ params: { href }
182
+ },
183
+ data: {
184
+ details: {
185
+ reasonCode: 'SKIP_LINK_TARGET_UNUSABLE',
186
+ href,
187
+ unusableReasonCode: unusableByAcc ? 'ACC_TREE_INELIGIBLE' : geometryReasonCode,
188
+ targetSelector: helpers.buildSelector ? helpers.buildSelector(target) : null,
189
+ geometryCheckEnabled: geometrySupported
190
+ },
191
+ visibilityFilter: {
192
+ targetSet: 'acc',
193
+ accEligible: accEligibility.eligible,
194
+ reasons: accEligibility.reasons
195
+ },
196
+ targetGeometry: geometryEligibility
197
+ ? { eligible: geometryEligibility.eligible, reasons: geometryEligibility.reasons }
198
+ : { eligible: null, reasons: [] }
199
+ }
200
+ });
201
+ continue;
202
+ }
121
203
 
122
204
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
123
205
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
@@ -43,14 +43,13 @@ const meta = {
43
43
  };
44
44
 
45
45
  function runInPage(ctx) {
46
- const { document, root, helpers, rule } = ctx;
47
- const safeRoot = root || document;
46
+ const { document, helpers, rule } = ctx;
48
47
 
49
48
  function normalizeWs(s) {
50
49
  return String(s || '').replace(/\s+/g, ' ').trim();
51
50
  }
52
51
 
53
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]', safeRoot) : helpers.queryAll('table[summary]', safeRoot);
52
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]') : helpers.queryAll('table[summary]');
54
53
 
55
54
  const occurrences = [];
56
55
  let applicableCount = 0;
@@ -51,12 +51,11 @@ const meta = {
51
51
  };
52
52
 
53
53
  function runInPage(ctx) {
54
- const { document, root, helpers, rule } = ctx;
55
- const safeRoot = root || document;
54
+ const { document, helpers, rule } = ctx;
56
55
 
57
56
  function trim(v) { return (v == null ? '' : String(v)).trim(); }
58
57
 
59
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table', safeRoot) : helpers.queryAll('table', safeRoot);
58
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
60
59
 
61
60
  const occurrences = [];
62
61
  let applicableCount = 0;
@@ -51,10 +51,9 @@ const meta = {
51
51
  };
52
52
 
53
53
  function runInPage(ctx) {
54
- const { document, root, helpers, rule } = ctx;
55
- const safeRoot = root || document;
54
+ const { document, helpers, rule } = ctx;
56
55
 
57
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video', safeRoot) : helpers.queryAll('video', safeRoot);
56
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video') : helpers.queryAll('video');
58
57
 
59
58
  const occurrences = [];
60
59
  let applicableCount = 0;
@@ -115,6 +115,10 @@ function createDomHelpers(opts) {
115
115
  })();
116
116
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
117
117
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
118
+ // Default off: by default, helper queries skip structurally/CSS-hidden
119
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
120
+ // opt out with includeHiddenElements:true.
121
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
118
122
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
119
123
 
120
124
  // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
@@ -1081,8 +1085,55 @@ function createDomHelpers(opts) {
1081
1085
  return results;
1082
1086
  }
1083
1087
 
1088
+ const HARD_HIDDEN_REASONS = new Set([
1089
+ 'displayNone',
1090
+ 'hiddenAttr',
1091
+ 'detailsClosed',
1092
+ 'templateContent',
1093
+ 'nonRenderedElement',
1094
+ 'inputHidden',
1095
+ 'visibilityHidden',
1096
+ 'contentVisibilityHidden'
1097
+ ]);
1098
+
1084
1099
  function queryAllSmart(sel) {
1085
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1100
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1101
+
1102
+ // Global hidden-content policy: skip nodes that are fully excluded from
1103
+ // rendered visibility by default (unless includeHiddenElements:true).
1104
+ if (!includeHiddenElements) {
1105
+ list = list.filter((el) => {
1106
+ try {
1107
+ const vis = isAccTreeEligible(el);
1108
+ if (!vis || vis.eligible !== false) return true;
1109
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
1110
+ for (const r of reasons) {
1111
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
1112
+ }
1113
+
1114
+ // `isAccTreeEligible` can short-circuit on an inert ancestor
1115
+ // before it reaches an outer hard-hidden ancestor (e.g.
1116
+ // display:none wrapper). In that case the node is still
1117
+ // structurally hidden and should be excluded by the default
1118
+ // hidden-content policy.
1119
+ if (reasons.includes('inert')) {
1120
+ const domVis = isDomVisibleEligible(el, null, {
1121
+ visibilityMode: 'styleOnly',
1122
+ disableGeometry: true,
1123
+ ignoreOpacity: true
1124
+ });
1125
+ const domReasons = Array.isArray(domVis && domVis.reasons) ? domVis.reasons : [];
1126
+ for (const r of domReasons) {
1127
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
1128
+ }
1129
+ }
1130
+ return true;
1131
+ } catch {
1132
+ return true;
1133
+ }
1134
+ });
1135
+ }
1136
+
1086
1137
  return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
1087
1138
  }
1088
1139
 
@@ -3652,6 +3703,36 @@ function createDomHelpers(opts) {
3652
3703
  let node = el;
3653
3704
  let safety = 0;
3654
3705
 
3706
+ // Only apply the "stop climbing once we reach a contextSelector-
3707
+ // matched root" shortcut when there's a single (or no) matched
3708
+ // root -- resolveContextRoots() falls back to `[documentElement]`
3709
+ // when no contextSelector is given, so this is the overwhelmingly
3710
+ // common case and behaves exactly as before.
3711
+ //
3712
+ // With MULTIPLE matched roots (multi-region contextSelector
3713
+ // scans), stopping there without recording anything about which
3714
+ // root produced an ambiguous, non-unique selector string for two
3715
+ // structurally-identical regions -- a real, confirmed bug (found
3716
+ // 2026-07-29 via the cross-engine comparisons project): two
3717
+ // wrapper <div>s, each containing two identical ".widget"
3718
+ // sections scanned via `contextSelector: '.widget'`, produced the
3719
+ // *same* selector string ("section:nth-of-type(1) > div > div >
3720
+ // button") for the equivalent button in each wrapper --
3721
+ // resolving to 2 elements instead of 1 when queried, and pointing
3722
+ // at the wrong one for at least one of the two occurrences. The
3723
+ // existing `el.matches(candidate)` safety check below couldn't
3724
+ // catch this: it only verifies THIS element matches the string,
3725
+ // never that the string is unique document-wide.
3726
+ //
3727
+ // Fix: when multiple roots are in play, don't stop early --
3728
+ // keep climbing (same as the always-correct no-contextSelector
3729
+ // path) until finding a genuinely unique anchor or reaching the
3730
+ // true document root, which is always singular. That restores
3731
+ // the invariant the final safety-check comment below relies on,
3732
+ // rather than needing a separate (more expensive) document-wide
3733
+ // uniqueness re-check.
3734
+ const stopAtMatchedRoot = roots.length <= 1;
3735
+
3655
3736
  while (node && node.nodeType === 1 && safety++ < 20) {
3656
3737
  let anchor = null;
3657
3738
 
@@ -3691,7 +3772,7 @@ function createDomHelpers(opts) {
3691
3772
  parts.unshift(nthOfType(node));
3692
3773
  }
3693
3774
 
3694
- if (!node.parentElement || roots.includes(node)) break;
3775
+ if (!node.parentElement || (stopAtMatchedRoot && roots.includes(node))) break;
3695
3776
  node = node.parentElement;
3696
3777
  }
3697
3778
 
@@ -3710,7 +3791,12 @@ function createDomHelpers(opts) {
3710
3791
  // position relative to its own parent via `>` (child, not
3711
3792
  // descendant) combinators, so a correctly-matching chain can
3712
3793
  // only resolve to one element short of a malformed document
3713
- // (e.g. two <html> roots). Re-deriving that guarantee via a
3794
+ // (e.g. two <html> roots) -- true as long as the walk above
3795
+ // never stops short of a genuinely unique anchor/root, which is
3796
+ // exactly what `stopAtMatchedRoot` now guarantees (see its own
3797
+ // comment above; a multi-root contextSelector scan stopping
3798
+ // early used to violate this invariant silently). Re-deriving
3799
+ // that guarantee via a
3714
3800
  // document-wide :nth-of-type scan was measured to cost O(total
3715
3801
  // same-tag siblings) per call — pathological on pages with many
3716
3802
  // flat, unidentified siblings (e.g. hundreds of unlabeled