@surea11y/core 1.1.1 → 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 (76) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +1 -1
  3. package/src/checks/automatic/aria-allowed-attr.js +2 -3
  4. package/src/checks/automatic/aria-allowed-role.js +2 -3
  5. package/src/checks/automatic/aria-braille-equivalent.js +3 -4
  6. package/src/checks/automatic/aria-conditional-attr.js +3 -4
  7. package/src/checks/automatic/aria-deprecated-role.js +2 -3
  8. package/src/checks/automatic/aria-prohibited-attr.js +2 -3
  9. package/src/checks/automatic/aria-prohibited-children.js +2 -3
  10. package/src/checks/automatic/aria-required-attr.js +2 -3
  11. package/src/checks/automatic/aria-required-children.js +2 -3
  12. package/src/checks/automatic/aria-required-parent.js +3 -4
  13. package/src/checks/automatic/aria-roles-valid.js +2 -3
  14. package/src/checks/automatic/aria-valid-attr-value.js +2 -3
  15. package/src/checks/automatic/aria-valid-attr.js +2 -3
  16. package/src/checks/automatic/autocomplete-valid.js +2 -3
  17. package/src/checks/automatic/avoid-inline-spacing.js +2 -3
  18. package/src/checks/automatic/binary-control-name-present.js +2 -3
  19. package/src/checks/automatic/button-name-present.js +14 -4
  20. package/src/checks/automatic/bypass-blocks-present.js +35 -7
  21. package/src/checks/automatic/combobox-name-present.js +2 -3
  22. package/src/checks/automatic/definition-list-children-valid.js +2 -3
  23. package/src/checks/automatic/deprecated-elements-not-used.js +2 -3
  24. package/src/checks/automatic/dialog-name-present.js +2 -3
  25. package/src/checks/automatic/dlitem-parent-valid.js +2 -3
  26. package/src/checks/automatic/form-control-single-label.js +2 -3
  27. package/src/checks/automatic/iframe-focusable-content.js +2 -3
  28. package/src/checks/automatic/iframe-name-present.js +2 -3
  29. package/src/checks/automatic/iframe-title-unique.js +2 -3
  30. package/src/checks/automatic/label-in-name.js +7 -8
  31. package/src/checks/automatic/link-in-text-block.js +2 -3
  32. package/src/checks/automatic/link-name-present.js +16 -4
  33. package/src/checks/automatic/list-children-valid.js +2 -3
  34. package/src/checks/automatic/listbox-name-present.js +2 -3
  35. package/src/checks/automatic/listitem-parent-valid.js +2 -3
  36. package/src/checks/automatic/menuitem-name-present.js +2 -3
  37. package/src/checks/automatic/meter-name-present.js +2 -3
  38. package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
  39. package/src/checks/automatic/option-name-present.js +2 -3
  40. package/src/checks/automatic/progressbar-name-present.js +2 -3
  41. package/src/checks/automatic/searchbox-name-present.js +2 -3
  42. package/src/checks/automatic/server-side-image-map-absent.js +2 -3
  43. package/src/checks/automatic/slider-name-present.js +2 -3
  44. package/src/checks/automatic/spinbutton-name-present.js +2 -3
  45. package/src/checks/automatic/summary-name-present.js +2 -3
  46. package/src/checks/automatic/tab-name-present.js +2 -3
  47. package/src/checks/automatic/table-headers-attr-valid.js +2 -3
  48. package/src/checks/automatic/table-th-has-data-cells.js +2 -3
  49. package/src/checks/automatic/td-has-header.js +2 -3
  50. package/src/checks/automatic/textbox-name-present.js +2 -3
  51. package/src/checks/automatic/tooltip-name-present.js +2 -3
  52. package/src/checks/automatic/treeitem-name-present.js +2 -3
  53. package/src/checks/automatic/valid-lang.js +2 -3
  54. package/src/checks/manual/accesskeys-manual.js +1 -7
  55. package/src/checks/manual/aria-checked-state-mismatch-manual.js +3 -4
  56. package/src/checks/manual/aria-text-manual.js +2 -3
  57. package/src/checks/manual/focus-order-semantics-manual.js +2 -3
  58. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
  59. package/src/checks/manual/image-redundant-alt-manual.js +2 -3
  60. package/src/checks/manual/label-title-only-manual.js +2 -3
  61. package/src/checks/manual/link-name-quality-manual.js +2 -3
  62. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
  63. package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
  64. package/src/checks/manual/p-as-heading-manual.js +2 -3
  65. package/src/checks/manual/page-has-heading-one-manual.js +31 -2
  66. package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
  67. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
  68. package/src/checks/manual/skip-link-manual.js +91 -9
  69. package/src/checks/manual/table-duplicate-name-manual.js +2 -3
  70. package/src/checks/manual/table-fake-caption-manual.js +2 -3
  71. package/src/checks/manual/video-caption-manual.js +2 -3
  72. package/src/core/dom-helpers.js +56 -3
  73. package/src/core/dom-runner.js +15 -0
  74. package/src/core.js +851 -481
  75. package/src/i18n/en.js +4 -2
  76. package/src/i18n/fr.js +4 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.1.2] - 2026-07-30
8
+
9
+ ### Fixed
10
+ - `accesskeys` no longer over-reports duplicate `accesskey` values when one copy is structurally/CSS hidden by default (for example collapsed or `display:none` menu replicas). Candidate collection now follows the shared helper visibility policy, so only currently eligible elements are grouped unless `engineOptions.includeHiddenElements: true` is explicitly set.
11
+ - `skip-link` no longer treats fragment-target existence alone as sufficient. It now also flags skip links whose target exists but is currently unusable (hidden from the accessibility tree), while keeping geometry-based target checks gated to environments that expose reliable layout metrics.
12
+ - `page-has-heading-one` and `bypass-blocks-present` credited a fully non-rendered `<h1>`/`<main>`/heading (inside a `display:none` ancestor, or otherwise removed from the accessibility tree via `visibility:hidden`/`[hidden]`/`aria-hidden`/`inert`) as satisfying the check, since both queried the raw DOM (`document.querySelectorAll`) with no visibility/accessibility-tree filtering. Found via the cross-engine comparisons project on CDC's flu page: its only `<h1>` sits inside a `display:none` ancestor — unreachable by sighted and screen reader users alike — and `page-has-heading-one` reported `notApplicable` where axe-core correctly fails. `bypass-blocks-present`'s `<main>`/heading conditions had the identical gap, wrongly returning `pass` for a page with zero actual bypass mechanisms. Both now filter candidates through the existing `isAccTreeEligible` helper, matching `landmark-one-main`'s established precedent; confirmed this does not regress genuinely screen-reader-accessible but visually-clipped/off-screen headings and landmarks (e.g. eBay's homepage `<h1>`, hidden via clip-path with no `aria-hidden`), which `isAccTreeEligible` correctly continues to credit.
13
+ - `button-name-present` and `link-name-present` credited a `<button>`/`<a href>` element's rendered content as its accessible name even when an explicit `role` overrode it to a role whose content represents a VALUE, not a NAME (`combobox`, `listbox`, `textbox`, `slider`, `spinbutton`, `progressbar`, `scrollbar` — name-from-author-only per the WAI-ARIA Accessible Name and Description Computation spec; mirrors axe-core's `controlValueRoles`, verified against its source). Both checks gated "is this a name-from-content candidate" on the native host tag alone, never checking whether `role` had overridden it. Found via the cross-engine comparisons project on Spotify's "Today's Top Hits" playlist page: `<button role="combobox">List</button>` (a "sort by" control, no `aria-label`/`aria-labelledby`) was credited with the name "List" — the combobox's currently selected *value*, not a label for what it is — and reported no issue at all, while axe-core's `button-name` correctly failed it. Both rules now exclude these value-roles from name-from-content; a programmatic name (`aria-label`/`aria-labelledby`/`title`/native `<label>`) still works normally. `combobox-name-present`, `listbox-name-present`, `textbox-name-present`, `spinbutton-name-present`, `progressbar-name-present`, `meter-name-present`, `searchbox-name-present`, `slider-name-present`, and `dialog-name-present` were audited against the same gap and were already correctly name-from-author-only.
14
+ - `createDomHelpers()`'s element-keyed caches (`outerHtmlCache`, `selectorCache`, etc.) were persisted on `window.__a11ycoreSharedCache` and only initialized once per `window`/`document`, not once per run. A window/document reused across separate `runDomRulesInPage()`/`runa11yCoreInPage()` calls — e.g. Jest's `jsdom` environment, which creates one `window` per test file — could read back a previous run's stale cached value for an element that persists by reference across runs (like `document.body`) while its content changed via an in-place mutation (`innerHTML = ...`) in between. Rule pass/fail outcomes were always computed correctly against the live DOM; only cached diagnostic data such as `occurrences[].html` (via `bypass-blocks-present`, reported in #2) could go stale. `runCore()` (`src/core/dom-runner.js`) now resets `window.__a11ycoreSharedCache` at the start of every run, keeping the intended within-a-run sharing while preventing leakage across runs.
15
+ - `buildSelector` could emit ambiguous selectors in multi-region scans when the target element was the last same-tag sibling, because the `:nth-of-type()` disambiguator could be omitted on that segment. Selector construction now consistently disambiguates those cases, so each occurrence maps back to the intended node.
16
+ - `queryAllSmart` could retain elements that are structurally hard-hidden when `isAccTreeEligible` short-circuited on an `inert` ancestor before reaching an outer `display:none`/`visibility:hidden`/`content-visibility:hidden` ancestor. It now performs a style-only DOM visibility fallback in that path and excludes these hard-hidden nodes by default, preserving `includeHiddenElements: true` opt-in behavior.
17
+
18
+ ### Changed
19
+ - Test coverage tightened for this release cycle: re-enabled and stabilized the previously skipped `role-img-text-alternative-present` i18n assertions (EN/FR exact strings and keys), and added regression coverage for the inert + outer hard-hidden filtering path in `queryAllSmart`.
20
+ - Removed a dead `root`/`safeRoot` second argument passed to `helpers.queryAllSmart`/`helpers.queryAll` across 67 rule files: both helpers only ever accepted a single selector argument and scope internally via the run's resolved `contextSelector` roots, so the extra argument was silently ignored in every call. No behavior change — `ctx.root` was always identical to the roots already baked into `helpers` at run start. Also fixed `docs/RULE_TEMPLATE.md`, the copy/paste template most likely responsible for the pattern spreading, which declared `safeRoot` but never even referenced it.
21
+
7
22
  ## [1.1.1] - 2026-07-29
8
23
 
9
24
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Lightweight DOM rules accessibility core with modular rules.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -98,8 +98,7 @@ const meta = {
98
98
  };
99
99
 
100
100
  function runInPage(ctx) {
101
- const { document, root, helpers, rule } = ctx;
102
- const safeRoot = root || document;
101
+ const { document, helpers, rule } = ctx;
103
102
 
104
103
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
105
104
  if (!ariaHelpers) {
@@ -163,7 +162,7 @@ function runInPage(ctx) {
163
162
  };
164
163
 
165
164
  const globalSet = new Set(GLOBAL_ATTRS);
166
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
165
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
167
166
 
168
167
  const occurrences = [];
169
168
  let applicableCount = 0;
@@ -46,15 +46,14 @@ 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 ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
53
52
  if (!ariaHelpers) {
54
53
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
55
54
  }
56
55
 
57
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
56
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
58
57
 
59
58
  const occurrences = [];
60
59
  let applicableCount = 0;
@@ -52,8 +52,7 @@ 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 trim(v) { return (v == null ? '' : String(v)).trim(); }
59
58
 
@@ -74,8 +73,8 @@ function runInPage(ctx) {
74
73
  }
75
74
 
76
75
  const nodes = helpers.queryAllSmart
77
- ? helpers.queryAllSmart('[aria-braillelabel], [aria-brailleroledescription]', safeRoot)
78
- : helpers.queryAll('[aria-braillelabel], [aria-brailleroledescription]', safeRoot);
76
+ ? helpers.queryAllSmart('[aria-braillelabel], [aria-brailleroledescription]')
77
+ : helpers.queryAll('[aria-braillelabel], [aria-brailleroledescription]');
79
78
 
80
79
  const occurrences = [];
81
80
  let applicableCount = 0;
@@ -53,16 +53,15 @@ 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
  function trim(v) { return (v == null ? '' : String(v)).trim(); }
60
59
 
61
60
  const TRUTHY_INVALID_VALUES = new Set(['true', 'grammar', 'spelling']);
62
61
 
63
62
  const nodes = helpers.queryAllSmart
64
- ? helpers.queryAllSmart('[aria-errormessage]', safeRoot)
65
- : helpers.queryAll('[aria-errormessage]', safeRoot);
63
+ ? helpers.queryAllSmart('[aria-errormessage]')
64
+ : helpers.queryAll('[aria-errormessage]');
66
65
 
67
66
  const occurrences = [];
68
67
  let applicableCount = 0;
@@ -45,15 +45,14 @@ 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 ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
52
51
  if (!ariaHelpers) {
53
52
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
54
53
  }
55
54
 
56
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
55
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
57
56
 
58
57
  const occurrences = [];
59
58
  let applicableCount = 0;
@@ -83,8 +83,7 @@ const meta = {
83
83
  };
84
84
 
85
85
  function runInPage(ctx) {
86
- const { document, root, helpers, rule } = ctx;
87
- const safeRoot = root || document;
86
+ const { document, helpers, rule } = ctx;
88
87
 
89
88
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
90
89
  if (!ariaHelpers) {
@@ -104,7 +103,7 @@ function runInPage(ctx) {
104
103
 
105
104
  const PROHIBITED_NAMING_ATTRS = ['aria-label', 'aria-labelledby'];
106
105
 
107
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
106
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
108
107
 
109
108
  const occurrences = [];
110
109
  let applicableCount = 0;
@@ -100,8 +100,7 @@ const meta = {
100
100
  };
101
101
 
102
102
  function runInPage(ctx) {
103
- const { document, root, helpers, rule } = ctx;
104
- const safeRoot = root || document;
103
+ const { document, helpers, rule } = ctx;
105
104
 
106
105
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
107
106
  if (!ariaHelpers) {
@@ -190,7 +189,7 @@ function runInPage(ctx) {
190
189
  }
191
190
  }
192
191
 
193
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
192
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
194
193
 
195
194
  const occurrences = [];
196
195
  let applicableCount = 0;
@@ -69,8 +69,7 @@ const meta = {
69
69
  };
70
70
 
71
71
  function runInPage(ctx) {
72
- const { document, root, helpers, rule } = ctx;
73
- const safeRoot = root || document;
72
+ const { document, helpers, rule } = ctx;
74
73
 
75
74
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
76
75
  if (!ariaHelpers) {
@@ -94,7 +93,7 @@ function runInPage(ctx) {
94
93
  return v != null && String(v).trim().toLowerCase() === 'true';
95
94
  }
96
95
 
97
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
96
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
98
97
 
99
98
  const occurrences = [];
100
99
  let applicableCount = 0;
@@ -80,8 +80,7 @@ const meta = {
80
80
  };
81
81
 
82
82
  function runInPage(ctx) {
83
- const { document, root, helpers, rule } = ctx;
84
- const safeRoot = root || document;
83
+ const { document, helpers, rule } = ctx;
85
84
 
86
85
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
87
86
  if (!ariaHelpers) {
@@ -112,7 +111,7 @@ function runInPage(ctx) {
112
111
  // ("runInPage MUST be self-contained").
113
112
  const CANDIDATE_SELECTOR = '[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
114
113
 
115
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
114
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
116
115
 
117
116
  const occurrences = [];
118
117
  let applicableCount = 0;
@@ -66,8 +66,7 @@ const meta = {
66
66
  };
67
67
 
68
68
  function runInPage(ctx) {
69
- const { document, root, helpers, rule } = ctx;
70
- const safeRoot = root || document;
69
+ const { document, helpers, rule } = ctx;
71
70
 
72
71
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
73
72
  if (!ariaHelpers) {
@@ -153,7 +152,7 @@ function runInPage(ctx) {
153
152
  const idTok = elId && String(elId).trim();
154
153
  if (!idTok) return false;
155
154
 
156
- const owners = helpers.queryAllSmart ? helpers.queryAllSmart('[aria-owns]', safeRoot) : helpers.queryAll('[aria-owns]', safeRoot);
155
+ const owners = helpers.queryAllSmart ? helpers.queryAllSmart('[aria-owns]') : helpers.queryAll('[aria-owns]');
157
156
  for (const owner of owners) {
158
157
  if (!owner || !owner.getAttribute) continue;
159
158
  const ownsAttr = owner.getAttribute('aria-owns') || '';
@@ -166,7 +165,7 @@ function runInPage(ctx) {
166
165
  return false;
167
166
  }
168
167
 
169
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
168
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
170
169
 
171
170
  const occurrences = [];
172
171
  let applicableCount = 0;
@@ -44,15 +44,14 @@ const meta = {
44
44
  };
45
45
 
46
46
  function runInPage(ctx) {
47
- const { document, root, helpers, rule } = ctx;
48
- const safeRoot = root || document;
47
+ const { document, helpers, rule } = ctx;
49
48
 
50
49
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
51
50
  if (!ariaHelpers) {
52
51
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
53
52
  }
54
53
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
54
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
56
55
 
57
56
  const occurrences = [];
58
57
  let applicableCount = 0;
@@ -57,15 +57,14 @@ const meta = {
57
57
  };
58
58
 
59
59
  function runInPage(ctx) {
60
- const { document, root, helpers, rule } = ctx;
61
- const safeRoot = root || document;
60
+ const { document, helpers, rule } = ctx;
62
61
 
63
62
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
64
63
  if (!ariaHelpers) {
65
64
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
66
65
  }
67
66
 
68
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
67
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
69
68
 
70
69
  const occurrences = [];
71
70
  let applicableCount = 0;
@@ -46,15 +46,14 @@ 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 ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
53
52
  if (!ariaHelpers) {
54
53
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
55
54
  }
56
55
 
57
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
56
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
58
57
 
59
58
  const occurrences = [];
60
59
  let applicableCount = 0;
@@ -52,8 +52,7 @@ 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
  // Declared inside runInPage — see scripts/build-core.js header
59
58
  // ("runInPage MUST be self-contained").
@@ -88,7 +87,7 @@ function runInPage(ctx) {
88
87
  return FIELD_NAMES.has(remaining[0]);
89
88
  }
90
89
 
91
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('input, select, textarea', safeRoot) : helpers.queryAll('input, select, textarea', safeRoot);
90
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('input, select, textarea') : helpers.queryAll('input, select, textarea');
92
91
 
93
92
  const occurrences = [];
94
93
  let applicableCount = 0;
@@ -46,12 +46,11 @@ 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 SPACING_PROPS = ['line-height', 'letter-spacing', 'word-spacing'];
53
52
 
54
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[style]', safeRoot) : helpers.queryAll('[style]', safeRoot);
53
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[style]') : helpers.queryAll('[style]');
55
54
 
56
55
  const occurrences = [];
57
56
  let applicableCount = 0;
@@ -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) {
@@ -159,7 +158,7 @@ function runInPage(ctx) {
159
158
  let applicableCount = 0;
160
159
 
161
160
  const selector = 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
162
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
161
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
163
162
 
164
163
 
165
164
  // Precompute label[for] associations once for speed/determinism.
@@ -25,8 +25,7 @@ const meta = {
25
25
  };
26
26
 
27
27
  function runInPage(ctx) {
28
- const { document, root, helpers, rule } = ctx;
29
- const safeRoot = root || document;
28
+ const { document, helpers, rule } = ctx;
30
29
 
31
30
  const occurrences = [];
32
31
  let applicableCount = 0;
@@ -63,7 +62,7 @@ function runInPage(ctx) {
63
62
  }
64
63
 
65
64
  const selector = 'button, input[type="button"], input[type="submit"], input[type="reset"], [role="button"]';
66
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
65
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
67
66
 
68
67
  for (const el of nodes) {
69
68
  // isAccTreeEligible returns { eligible, reasons }, not a boolean.
@@ -75,6 +74,7 @@ function runInPage(ctx) {
75
74
 
76
75
  const tag = (el.tagName || '').toLowerCase();
77
76
  const role = el.getAttribute ? el.getAttribute('role') : null;
77
+ const roleNorm = normalizeWs(role).toLowerCase();
78
78
 
79
79
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
80
80
 
@@ -90,7 +90,17 @@ function runInPage(ctx) {
90
90
  inputValueName = getInputButtonValueName(el);
91
91
  }
92
92
 
93
- const isContentNameCandidate = tag === 'button' || role === 'button';
93
+ // A native <button> (or [role="button"]) whose role has been overridden to
94
+ // one of these roles is no longer semantically a button — per the WAI-ARIA
95
+ // Accessible Name and Description Computation spec these roles are
96
+ // name-from-author-only, and their rendered content represents a VALUE,
97
+ // not a NAME (mirrors axe-core's controlValueRoles, verified against its
98
+ // source). Found on a real page (Spotify's "sort by" control): a
99
+ // <button role="combobox">List</button> where "List" is the combobox's
100
+ // currently selected value, not a label for what the combobox is —
101
+ // crediting it as the accessible name masked a real missing-name bug.
102
+ const VALUE_ROLES = ['textbox', 'progressbar', 'scrollbar', 'slider', 'spinbutton', 'combobox', 'listbox'];
103
+ const isContentNameCandidate = (tag === 'button' || role === 'button') && !VALUE_ROLES.includes(roleNorm);
94
104
  const contentName =
95
105
  (!trustedProgrammaticName && !inputValueName && isContentNameCandidate)
96
106
  ? getConservativeSubtreeText(el)
@@ -75,14 +75,43 @@ function runInPage(ctx) {
75
75
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
76
76
  }
77
77
 
78
- function hasMainLandmark() {
78
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
79
+
80
+ function isExposedToAt(el) {
81
+ if (!isAccTreeEligible) return true;
82
+ try {
83
+ const r = isAccTreeEligible(el, ctx);
84
+ if (typeof r === 'boolean') return r;
85
+ return !!(r && r.eligible);
86
+ } catch {
87
+ return true;
88
+ }
89
+ }
90
+
91
+ function queryAll(selector) {
79
92
  try {
80
- return !!document.querySelector('main, [role="main"]');
93
+ return helpers && typeof helpers.queryAllSmart === 'function'
94
+ ? helpers.queryAllSmart(selector)
95
+ : document.querySelectorAll(selector);
81
96
  } catch {
82
- return false;
97
+ return [];
83
98
  }
84
99
  }
85
100
 
101
+ // Filters through isAccTreeEligible (hidden/aria-hidden/display:none/inert
102
+ // don't count as "a bypass mechanism is present") -- see page-has-heading-
103
+ // one-manual.js's identical fix for the real-world trigger (CDC's flu
104
+ // page, 2026-07-30: its only <h1> sits inside a display:none ancestor,
105
+ // unreachable by sighted and screen reader users alike). A fully
106
+ // non-rendered <main>/heading was previously credited here too, wrongly
107
+ // returning `pass` for a page with zero actual bypass mechanisms.
108
+ function hasMainLandmark() {
109
+ for (const el of queryAll('main, [role="main"]')) {
110
+ if (el && isExposedToAt(el)) return true;
111
+ }
112
+ return false;
113
+ }
114
+
86
115
  function hasWorkingAnchorLink() {
87
116
  let links = [];
88
117
  try {
@@ -122,11 +151,10 @@ function runInPage(ctx) {
122
151
  }
123
152
 
124
153
  function hasHeading() {
125
- try {
126
- return !!document.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
127
- } catch {
128
- return false;
154
+ for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
155
+ if (el && isExposedToAt(el)) return true;
129
156
  }
157
+ return false;
130
158
  }
131
159
 
132
160
  const mainLandmark = hasMainLandmark();
@@ -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) {
@@ -159,7 +158,7 @@ function runInPage(ctx) {
159
158
  let applicableCount = 0;
160
159
 
161
160
  const selector = '[role="combobox"]';
162
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
161
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
163
162
 
164
163
  // Precompute label[for] map for combobox elements that are labelable
165
164
  // native form controls (e.g. <input role="combobox">).
@@ -54,14 +54,13 @@ const meta = {
54
54
  };
55
55
 
56
56
  function runInPage(ctx) {
57
- const { document, root, helpers, rule } = ctx;
58
- const safeRoot = root || document;
57
+ const { document, helpers, rule } = ctx;
59
58
 
60
59
  // Declared inside runInPage — see scripts/build-core.js header
61
60
  // ("runInPage MUST be self-contained").
62
61
  const PASSTHROUGH_TAGS = new Set(['dt', 'dd', 'script', 'template', 'style']);
63
62
 
64
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dl', safeRoot) : helpers.queryAll('dl', safeRoot);
63
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dl') : helpers.queryAll('dl');
65
64
 
66
65
  const occurrences = [];
67
66
  let applicableCount = 0;
@@ -48,10 +48,9 @@ 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
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('blink, marquee', safeRoot) : helpers.queryAll('blink, marquee', safeRoot);
53
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('blink, marquee') : helpers.queryAll('blink, marquee');
55
54
 
56
55
  const occurrences = [];
57
56
  let applicableCount = 0;
@@ -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) {
@@ -141,7 +140,7 @@ function runInPage(ctx) {
141
140
  let applicableCount = 0;
142
141
 
143
142
  const selector = '[role="dialog"],[role="alertdialog"]';
144
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
143
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
145
144
 
146
145
 
147
146
  function evaluate(el) {
@@ -42,10 +42,9 @@ const meta = {
42
42
  };
43
43
 
44
44
  function runInPage(ctx) {
45
- const { document, root, helpers, rule } = ctx;
46
- const safeRoot = root || document;
45
+ const { document, helpers, rule } = ctx;
47
46
 
48
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dt, dd', safeRoot) : helpers.queryAll('dt, dd', safeRoot);
47
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dt, dd') : helpers.queryAll('dt, dd');
49
48
 
50
49
  const occurrences = [];
51
50
  let applicableCount = 0;
@@ -46,11 +46,10 @@ 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 selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
53
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
52
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
54
53
 
55
54
  // Build a for-value -> label[] map once (avoids per-control dynamic
56
55
  // attribute-selector construction / CSS.escape, which is not guaranteed
@@ -55,8 +55,7 @@ const meta = {
55
55
  };
56
56
 
57
57
  function runInPage(ctx) {
58
- const { document, root, helpers, rule } = ctx;
59
- const safeRoot = root || document;
58
+ const { document, helpers, rule } = ctx;
60
59
 
61
60
  function hasFocusableCandidate(doc) {
62
61
  if (!doc || !doc.querySelectorAll) return false;
@@ -88,7 +87,7 @@ function runInPage(ctx) {
88
87
  return !Number.isNaN(n) && n < 0;
89
88
  }
90
89
 
91
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
90
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame') : helpers.queryAll('iframe, frame');
92
91
 
93
92
  const occurrences = [];
94
93
  let applicableCount = 0;
@@ -44,10 +44,9 @@ const meta = {
44
44
  };
45
45
 
46
46
  function runInPage(ctx) {
47
- const { document, root, helpers, rule } = ctx;
48
- const safeRoot = root || document;
47
+ const { document, helpers, rule } = ctx;
49
48
 
50
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
49
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame') : helpers.queryAll('iframe, frame');
51
50
 
52
51
  const occurrences = [];
53
52
  let applicableCount = 0;
@@ -47,10 +47,9 @@ 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
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
52
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame') : helpers.queryAll('iframe, frame');
54
53
 
55
54
  const groups = new Map(); // trimmed title -> elements[]
56
55
  let applicableCount = 0;