@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ 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
+
22
+ ## [1.1.1] - 2026-07-29
23
+
24
+ ### Changed
25
+ - **`engineOptions.includeHiddenElements` (default `false`)**: helper-driven rules now skip elements hidden by `display:none` (on the element or any ancestor), `visibility:hidden`/`collapse`, the `[hidden]` attribute, closed `<details>`, and other structurally-non-rendered content by default, matching the visibility-aware behavior of other established engines. Filtering happens upstream in the shared `queryAllSmart` helper, before a rule's own pass/fail logic runs, so it's a candidate-list exclusion, not a post-hoc annotation. Set `engineOptions.includeHiddenElements: true` to restore the previous behavior and evaluate hidden/collapsed subtrees anyway (e.g. to catch a markup defect, like a broken ARIA ID reference, before a `<dialog>` ever opens). 10 rule files whose own logic intentionally doesn't call the underlying eligibility check directly (static-markup-validity rules such as `aria-valid-attr`, `aria-valid-attr-value`, `aria-allowed-attr`, `aria-allowed-role`, `aria-prohibited-attr`, `table-headers-attr-valid`, `table-th-has-data-cells`, `deprecated-elements-not-used`, `iframe-title-unique`, `aria-checked-state-mismatch-manual`) still inherit this filtering through `queryAllSmart`; their doc comments were updated to say so. See `docs/ENGINE_OPTIONS.md` and `docs/LIMITATIONS.md`.
26
+
27
+ ### Fixed
28
+ - `docs/LIMITATIONS.md`: the `<dialog>`/UA-stylesheet-hidden-content note was stale — it claimed static-markup-validity checks still evaluate hidden content, which this release's default change makes no longer true. Corrected to describe the current default and how to opt back in.
29
+
7
30
  ## [1.1.0] - 2026-07-28
8
31
 
9
32
  ### Added
@@ -28,7 +51,7 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
28
51
  - README: the JSON output example referenced a nonexistent rule id (`link-name-quality`); corrected to the real id, `link-name-quality-manual`.
29
52
  - README: Quick Start code samples labeled the runner's four positional arguments as `url, ruleFilter, options, policy`; corrected to the actual names (`pageUrl, contextSelector, engineOptions, runOnly`) used consistently elsewhere in the docs.
30
53
 
31
- ## [1.1.1] - 2026-07-24
54
+ ## [1.0.0] - 2026-07-26
32
55
 
33
56
  ### Added
34
57
  - 125 rules (77 automatic/`fail`-capable, 48 manual/advisory) — see `docs/RULE_CATALOG.md` for the full list.
@@ -61,7 +84,7 @@ See `docs/LIMITATIONS.md` — structural (keyboard-trap detection, reflow-at-zoo
61
84
 
62
85
  ---
63
86
 
64
- ## How to add an entry
87
+ # How to add an entry
65
88
 
66
89
  When you ship a change worth calling out to consumers (not every commit):
67
90
  1. Add a bullet under `[Unreleased]`, in the right subsection (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`) — create the subsection if it doesn't exist yet for this cycle.
@@ -72,6 +72,7 @@ runDomRulesInPage(url, null, {
72
72
  ```js
73
73
  const engineOptions = {
74
74
  locale: 'en', // default 'en'; falls back to 'en' per-string if a key is missing in the requested locale
75
+ includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
75
76
  includeShadowDom: true, // default true — opt OUT with `false` to skip open shadow roots
76
77
  excludeSelectors: ['#cookie-banner', '.third-party-widget'], // array or comma-separated string
77
78
  timestamp: '2026-07-20T12:00:00Z', // optional — engine has no built-in clock, see OUTPUT_SCHEMA.md
@@ -114,6 +115,7 @@ const engineOptions = {
114
115
  | Option | Meaning |
115
116
  |---|---|
116
117
  | `locale` | Any string; resolution is per-string with graceful fallback (requested locale → `en` → the rule's literal English fallback text), so a partially-translated locale never produces missing text. See [`I18N.md`](./I18N.md) for current locale coverage. |
118
+ | `includeHiddenElements` | Default `false`: helper queries exclude elements hidden by structural/CSS mechanisms such as `display:none`, `[hidden]`, closed `<details>`, and hidden rendering-only host elements (with descendants excluded too). Set `true` to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). |
117
119
  | `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
118
120
  | `excludeSelectors` | Elements matching any of these selectors (and their descendants) are skipped entirely, for **every** rule — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. To exclude something from just one specific rule instead, use `rules[ruleId].excludeSelectors` below. |
119
121
  | `timestamp` | Passed straight through to the result's top-level `timestamp` field; the engine does not generate one itself (deterministic-by-design). |
@@ -13,7 +13,7 @@ surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at
13
13
  ## Environment-dependent — depends on how you run it
14
14
 
15
15
  - **jsdom (Node, no real browser) has no CSS layout engine.** Rules needing real geometry — most notably `target-size-minimum` (WCAG 2.5.8, needs real `getBoundingClientRect()`) — report `notApplicable` under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2) to get real findings from these rules.
16
- - **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec) are skipped by most tools' visibility-aware checks, including other established engines — but surea11y's static-markup-validity checks (like ARIA ID-reference validity) still evaluate them, since a markup defect is still a defect even before the dialog opens. This is a deliberate surea11y choice that can occasionally make it *more* thorough than other engines on hidden content, not a bug — found and confirmed during internal cross-engine verification.
16
+ - **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec), along with any other subtree hidden via `display:none`, `visibility:hidden`, `[hidden]`, or closed `<details>`, are **excluded from rule evaluation by default** — matching the visibility-aware behavior of other established engines. This is a deliberate default (`engineOptions.includeHiddenElements: false`), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set `engineOptions.includeHiddenElements: true` to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#engineoptions--the-rest) for the option and exactly which hiding mechanisms it covers.
17
17
  - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/core scan <url>`) specifically fetches static HTML only, with no JS execution — see [`CLI.md`](./CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. Other engines running inside an actual loaded browser tab see the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with either engine's rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
18
18
 
19
19
  ## Deliberately not attempted — judgment calls, not automatable safely
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.1.0",
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": {
@@ -70,7 +70,9 @@
70
70
  * menuitemcheckbox, menuitemradio. `aria-level` added to: tablist.
71
71
  * - `tree`'s `aria-readonly` removed: not in aria-query's resolved
72
72
  * props for `tree` (was an unverified carryover, not spec-backed).
73
- * - Not gated on isAccTreeEligible: this is a static markup property.
73
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
74
+ * property, while engine-level hidden-subtree filtering still applies
75
+ * unless engineOptions.includeHiddenElements is true.
74
76
  */
75
77
 
76
78
  const id = 'aria-allowed-attr';
@@ -96,8 +98,7 @@ const meta = {
96
98
  };
97
99
 
98
100
  function runInPage(ctx) {
99
- const { document, root, helpers, rule } = ctx;
100
- const safeRoot = root || document;
101
+ const { document, helpers, rule } = ctx;
101
102
 
102
103
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
103
104
  if (!ariaHelpers) {
@@ -161,7 +162,7 @@ function runInPage(ctx) {
161
162
  };
162
163
 
163
164
  const globalSet = new Set(GLOBAL_ATTRS);
164
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
165
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
165
166
 
166
167
  const occurrences = [];
167
168
  let applicableCount = 0;
@@ -18,7 +18,9 @@
18
18
  * - Deliberately scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
19
19
  * elements without an asserted constraint are treated as "no constraint"
20
20
  * (not flagged) rather than guessed at — see that table's header comment.
21
- * - Not gated on isAccTreeEligible: this is a static markup property.
21
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
22
+ * property, while engine-level hidden-subtree filtering still applies
23
+ * unless engineOptions.includeHiddenElements is true.
22
24
  */
23
25
 
24
26
  const id = 'aria-allowed-role';
@@ -44,15 +46,14 @@ const meta = {
44
46
  };
45
47
 
46
48
  function runInPage(ctx) {
47
- const { document, root, helpers, rule } = ctx;
48
- const safeRoot = root || document;
49
+ const { document, helpers, rule } = ctx;
49
50
 
50
51
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
51
52
  if (!ariaHelpers) {
52
53
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
53
54
  }
54
55
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
56
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
56
57
 
57
58
  const occurrences = [];
58
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;
@@ -55,7 +55,9 @@
55
55
  * `definition_role` page even demonstrates `aria-labelledby` usage on
56
56
  * it directly. A real, confirmed documentation bug on MDN's side, not
57
57
  * a gap here.
58
- * - Not gated on isAccTreeEligible: this is a static markup property.
58
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
59
+ * property, while engine-level hidden-subtree filtering still applies
60
+ * unless engineOptions.includeHiddenElements is true.
59
61
  */
60
62
 
61
63
  const id = 'aria-prohibited-attr';
@@ -81,8 +83,7 @@ const meta = {
81
83
  };
82
84
 
83
85
  function runInPage(ctx) {
84
- const { document, root, helpers, rule } = ctx;
85
- const safeRoot = root || document;
86
+ const { document, helpers, rule } = ctx;
86
87
 
87
88
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
88
89
  if (!ariaHelpers) {
@@ -102,7 +103,7 @@ function runInPage(ctx) {
102
103
 
103
104
  const PROHIBITED_NAMING_ATTRS = ['aria-label', 'aria-labelledby'];
104
105
 
105
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
106
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
106
107
 
107
108
  const occurrences = [];
108
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;
@@ -17,7 +17,9 @@
17
17
  * ID reference (list) that resolves to an existing element in the
18
18
  * document.
19
19
  * @implementation-notes
20
- * - Not gated on isAccTreeEligible: this is a static markup property.
20
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
21
+ * property, while engine-level hidden-subtree filtering still applies
22
+ * unless engineOptions.includeHiddenElements is true.
21
23
  * - ID-reference resolution (added 2026-07-20, see aria-helpers.js's
22
24
  * idExists) only flags idref-list attributes (aria-labelledby,
23
25
  * aria-describedby, aria-controls, aria-owns, etc.) when NONE of the
@@ -55,15 +57,14 @@ const meta = {
55
57
  };
56
58
 
57
59
  function runInPage(ctx) {
58
- const { document, root, helpers, rule } = ctx;
59
- const safeRoot = root || document;
60
+ const { document, helpers, rule } = ctx;
60
61
 
61
62
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
62
63
  if (!ariaHelpers) {
63
64
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
64
65
  }
65
66
 
66
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
67
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
67
68
 
68
69
  const occurrences = [];
69
70
  let applicableCount = 0;
@@ -18,7 +18,9 @@
18
18
  * - Distinct from aria-valid-attr-value (which validates the VALUE
19
19
  * of a recognized attribute) — this rule only validates the attribute
20
20
  * NAME.
21
- * - Not gated on isAccTreeEligible: this is a static markup property.
21
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
22
+ * property, while engine-level hidden-subtree filtering still applies
23
+ * unless engineOptions.includeHiddenElements is true.
22
24
  */
23
25
 
24
26
  const id = 'aria-valid-attr';
@@ -44,15 +46,14 @@ const meta = {
44
46
  };
45
47
 
46
48
  function runInPage(ctx) {
47
- const { document, root, helpers, rule } = ctx;
48
- const safeRoot = root || document;
49
+ const { document, helpers, rule } = ctx;
49
50
 
50
51
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
51
52
  if (!ariaHelpers) {
52
53
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
53
54
  }
54
55
 
55
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*', safeRoot) : helpers.queryAll('*', safeRoot);
56
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
56
57
 
57
58
  const occurrences = [];
58
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;