@surea11y/core 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.1.1] - 2026-07-29
8
+
9
+ ### Changed
10
+ - **`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`.
11
+
12
+ ### Fixed
13
+ - `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.
14
+
7
15
  ## [1.1.0] - 2026-07-28
8
16
 
9
17
  ### Added
@@ -28,7 +36,7 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
28
36
  - README: the JSON output example referenced a nonexistent rule id (`link-name-quality`); corrected to the real id, `link-name-quality-manual`.
29
37
  - 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
38
 
31
- ## [1.1.1] - 2026-07-24
39
+ ## [1.0.0] - 2026-07-26
32
40
 
33
41
  ### Added
34
42
  - 125 rules (77 automatic/`fail`-capable, 48 manual/advisory) — see `docs/RULE_CATALOG.md` for the full list.
@@ -61,7 +69,7 @@ See `docs/LIMITATIONS.md` — structural (keyboard-trap detection, reflow-at-zoo
61
69
 
62
70
  ---
63
71
 
64
- ## How to add an entry
72
+ # How to add an entry
65
73
 
66
74
  When you ship a change worth calling out to consumers (not every commit):
67
75
  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.1",
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';
@@ -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';
@@ -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';
@@ -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
@@ -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';
@@ -17,10 +17,12 @@
17
17
  * rule has no partial-pass case, matching a widely-used reference engine's `blink`/`marquee`
18
18
  * rules (which report only when the element is found).
19
19
  * @implementation-notes
20
- * - Not gated on isAccTreeEligible: presence in markup is itself the
20
+ * - Not rule-gated on isAccTreeEligible: presence in markup is itself the
21
21
  * violation, independent of visibility (moving/blinking content inside a
22
22
  * hidden ancestor could still become visible later without a code
23
23
  * change, so hiding it today does not remove the underlying defect).
24
+ * Engine-level hidden-subtree filtering still applies unless
25
+ * engineOptions.includeHiddenElements is true.
24
26
  */
25
27
 
26
28
  const id = 'deprecated-elements-not-used';
@@ -19,8 +19,9 @@
19
19
  * - Compares the title ATTRIBUTE specifically, not the full computed
20
20
  * accessible name (aria-label could legitimately differ in wording even
21
21
  * when title happens to collide) — matches a widely-used reference engine's frame-title-unique.
22
- * - Not gated on isAccTreeEligible: a duplicate title is a static markup
23
- * property independent of the frame's current visibility.
22
+ * - Not rule-gated on isAccTreeEligible: duplicate titles are a static
23
+ * markup property. Engine-level hidden-subtree filtering still applies
24
+ * unless engineOptions.includeHiddenElements is true.
24
25
  */
25
26
 
26
27
  const id = 'iframe-title-unique';
@@ -16,7 +16,9 @@
16
16
  * - One occurrence per offending cell (not per bad token), listing every
17
17
  * invalid reference — matches a widely-used reference engine's
18
18
  * td-headers-attr reporting granularity.
19
- * - Not gated on isAccTreeEligible: this is a static markup property.
19
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
20
+ * property, while engine-level hidden-subtree filtering still applies
21
+ * unless engineOptions.includeHiddenElements is true.
20
22
  */
21
23
 
22
24
  const id = 'table-headers-attr-valid';
@@ -28,7 +28,9 @@
28
28
  * if some particular <th> in it doesn't actually describe any cell
29
29
  * (false negative, not a false positive — acceptable under this
30
30
  * engine's philosophy).
31
- * - Not gated on isAccTreeEligible: this is a static markup property.
31
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
32
+ * property, while engine-level hidden-subtree filtering still applies
33
+ * unless engineOptions.includeHiddenElements is true.
32
34
  */
33
35
 
34
36
  const id = 'table-th-has-data-cells';
@@ -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';
@@ -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,37 @@ 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
+ ]);
1097
+
1084
1098
  function queryAllSmart(sel) {
1085
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1099
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1100
+
1101
+ // Global hidden-content policy: skip nodes that are fully excluded from
1102
+ // rendered visibility by default (unless includeHiddenElements:true).
1103
+ if (!includeHiddenElements) {
1104
+ list = list.filter((el) => {
1105
+ try {
1106
+ const vis = isAccTreeEligible(el);
1107
+ if (!vis || vis.eligible !== false) return true;
1108
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
1109
+ for (const r of reasons) {
1110
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
1111
+ }
1112
+ return true;
1113
+ } catch {
1114
+ return true;
1115
+ }
1116
+ });
1117
+ }
1118
+
1086
1119
  return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
1087
1120
  }
1088
1121
 
@@ -48,6 +48,9 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
48
48
 
49
49
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
50
50
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
51
+ // Default off: hidden/collapsed content is excluded from rule evaluation
52
+ // unless the caller explicitly opts in.
53
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
51
54
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
52
55
 
53
56
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -63,6 +66,7 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
63
66
  window,
64
67
  root: roots,
65
68
  includeShadowDom,
69
+ includeHiddenElements,
66
70
  excludeSelectors,
67
71
  // Optional perf counters (bench/debug only). Deterministic and per-run.
68
72
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
package/src/core.js CHANGED
@@ -10967,6 +10967,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
10967
10967
  })();
10968
10968
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
10969
10969
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
10970
+ // Default off: by default, helper queries skip structurally/CSS-hidden
10971
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
10972
+ // opt out with includeHiddenElements:true.
10973
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
10970
10974
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
10971
10975
 
10972
10976
  // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
@@ -11933,8 +11937,37 @@ const createDomHelpers = (function createDomHelpers(opts) {
11933
11937
  return results;
11934
11938
  }
11935
11939
 
11940
+ const HARD_HIDDEN_REASONS = new Set([
11941
+ 'displayNone',
11942
+ 'hiddenAttr',
11943
+ 'detailsClosed',
11944
+ 'templateContent',
11945
+ 'nonRenderedElement',
11946
+ 'inputHidden',
11947
+ 'visibilityHidden'
11948
+ ]);
11949
+
11936
11950
  function queryAllSmart(sel) {
11937
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
11951
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
11952
+
11953
+ // Global hidden-content policy: skip nodes that are fully excluded from
11954
+ // rendered visibility by default (unless includeHiddenElements:true).
11955
+ if (!includeHiddenElements) {
11956
+ list = list.filter((el) => {
11957
+ try {
11958
+ const vis = isAccTreeEligible(el);
11959
+ if (!vis || vis.eligible !== false) return true;
11960
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
11961
+ for (const r of reasons) {
11962
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
11963
+ }
11964
+ return true;
11965
+ } catch {
11966
+ return true;
11967
+ }
11968
+ });
11969
+ }
11970
+
11938
11971
  return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
11939
11972
  }
11940
11973
 
@@ -15129,6 +15162,9 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15129
15162
 
15130
15163
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
15131
15164
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
15165
+ // Default off: hidden/collapsed content is excluded from rule evaluation
15166
+ // unless the caller explicitly opts in.
15167
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
15132
15168
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
15133
15169
 
15134
15170
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -15144,6 +15180,7 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15144
15180
  window,
15145
15181
  root: roots,
15146
15182
  includeShadowDom,
15183
+ includeHiddenElements,
15147
15184
  excludeSelectors,
15148
15185
  // Optional perf counters (bench/debug only). Deterministic and per-run.
15149
15186
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
@@ -43056,6 +43093,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
43056
43093
  })();
43057
43094
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
43058
43095
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
43096
+ // Default off: by default, helper queries skip structurally/CSS-hidden
43097
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
43098
+ // opt out with includeHiddenElements:true.
43099
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
43059
43100
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
43060
43101
 
43061
43102
  // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
@@ -44022,8 +44063,37 @@ const createDomHelpers = (function createDomHelpers(opts) {
44022
44063
  return results;
44023
44064
  }
44024
44065
 
44066
+ const HARD_HIDDEN_REASONS = new Set([
44067
+ 'displayNone',
44068
+ 'hiddenAttr',
44069
+ 'detailsClosed',
44070
+ 'templateContent',
44071
+ 'nonRenderedElement',
44072
+ 'inputHidden',
44073
+ 'visibilityHidden'
44074
+ ]);
44075
+
44025
44076
  function queryAllSmart(sel) {
44026
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
44077
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
44078
+
44079
+ // Global hidden-content policy: skip nodes that are fully excluded from
44080
+ // rendered visibility by default (unless includeHiddenElements:true).
44081
+ if (!includeHiddenElements) {
44082
+ list = list.filter((el) => {
44083
+ try {
44084
+ const vis = isAccTreeEligible(el);
44085
+ if (!vis || vis.eligible !== false) return true;
44086
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
44087
+ for (const r of reasons) {
44088
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
44089
+ }
44090
+ return true;
44091
+ } catch {
44092
+ return true;
44093
+ }
44094
+ });
44095
+ }
44096
+
44027
44097
  return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
44028
44098
  }
44029
44099
 
@@ -47218,6 +47288,9 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47218
47288
 
47219
47289
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
47220
47290
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
47291
+ // Default off: hidden/collapsed content is excluded from rule evaluation
47292
+ // unless the caller explicitly opts in.
47293
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
47221
47294
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
47222
47295
 
47223
47296
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -47233,6 +47306,7 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47233
47306
  window,
47234
47307
  root: roots,
47235
47308
  includeShadowDom,
47309
+ includeHiddenElements,
47236
47310
  excludeSelectors,
47237
47311
  // Optional perf counters (bench/debug only). Deterministic and per-run.
47238
47312
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
@@ -75100,6 +75174,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
75100
75174
  })();
75101
75175
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
75102
75176
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
75177
+ // Default off: by default, helper queries skip structurally/CSS-hidden
75178
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
75179
+ // opt out with includeHiddenElements:true.
75180
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
75103
75181
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
75104
75182
 
75105
75183
  // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
@@ -76066,8 +76144,37 @@ const createDomHelpers = (function createDomHelpers(opts) {
76066
76144
  return results;
76067
76145
  }
76068
76146
 
76147
+ const HARD_HIDDEN_REASONS = new Set([
76148
+ 'displayNone',
76149
+ 'hiddenAttr',
76150
+ 'detailsClosed',
76151
+ 'templateContent',
76152
+ 'nonRenderedElement',
76153
+ 'inputHidden',
76154
+ 'visibilityHidden'
76155
+ ]);
76156
+
76069
76157
  function queryAllSmart(sel) {
76070
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
76158
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
76159
+
76160
+ // Global hidden-content policy: skip nodes that are fully excluded from
76161
+ // rendered visibility by default (unless includeHiddenElements:true).
76162
+ if (!includeHiddenElements) {
76163
+ list = list.filter((el) => {
76164
+ try {
76165
+ const vis = isAccTreeEligible(el);
76166
+ if (!vis || vis.eligible !== false) return true;
76167
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
76168
+ for (const r of reasons) {
76169
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
76170
+ }
76171
+ return true;
76172
+ } catch {
76173
+ return true;
76174
+ }
76175
+ });
76176
+ }
76177
+
76071
76178
  return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
76072
76179
  }
76073
76180
 
@@ -79262,6 +79369,9 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79262
79369
 
79263
79370
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
79264
79371
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
79372
+ // Default off: hidden/collapsed content is excluded from rule evaluation
79373
+ // unless the caller explicitly opts in.
79374
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
79265
79375
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
79266
79376
 
79267
79377
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -79277,6 +79387,7 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79277
79387
  window,
79278
79388
  root: roots,
79279
79389
  includeShadowDom,
79390
+ includeHiddenElements,
79280
79391
  excludeSelectors,
79281
79392
  // Optional perf counters (bench/debug only). Deterministic and per-run.
79282
79393
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)