@surea11y/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,4085 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * DOM helpers used by checks (ctx.helpers).
5
+ *
6
+ * Kernel philosophy (A11yCore helpers contract)
7
+ * ---------------------------------------------
8
+ * These helpers exist to keep checks:
9
+ * - **Atomic** (checks decide outcomes; helpers provide facts),
10
+ * - **Deterministic** (no randomness, time, locale, or non-deterministic iteration),
11
+ * - **Serializable** (inlined into generated core.js; no Node-only APIs),
12
+ * - **Standards-aligned** (helpers are mechanism-aware where requirements depend on element/type).
13
+ *
14
+ * Design principles:
15
+ * 1) Helpers return **structured "Info" objects** (facts + mechanism + flags), not verdicts.
16
+ * - Rules produce outcomes: pass/fail/cantTell/notApplicable.
17
+ * 2) Prefer **mechanism-first** semantics:
18
+ * - Example: <img> requires an `alt` attribute (even if aria-label exists). Helpers surface that:
19
+ * `{ present:false, flags:['name-present-but-alt-missing'] }` when `alt` is missing.
20
+ * 3) Keep "kernel" helpers small, stable, and reusable across domains. If/when domain helpers emerge
21
+ * (tables/media/forms), they should build on these kernel primitives.
22
+ *
23
+ * Kernel helpers included (A → F):
24
+ * A) eligibility: isAccTreeEligible (existing), getEligibilityInfo (new)
25
+ * B) name/description: getAccessibleNameInfo, getAccessibleDescriptionInfo (new)
26
+ * C) text alternatives: getTextAlternativeInfo (new)
27
+ * D) role/focusability: getRoleInfo, getFocusableInfo (new)
28
+ * E) IDREF resolution: resolveIdRefs, getTextFromIdRefs (new)
29
+ * F) selector/snippet: buildSelector/buildSimpleSelector/getOuterHtmlSnippet (existing)
30
+ */
31
+
32
+ const {createContrastHelpers} = require('./contrast-helpers');
33
+ const {createAriaHelpers} = require('./aria-helpers');
34
+
35
+ function normalizeSelectorList(value) {
36
+ if (!value) return [];
37
+ if (Array.isArray(value)) return value.map(String).map((s) => s.trim()).filter(Boolean);
38
+ if (typeof value === 'string') {
39
+ // allow "#a,#b" or "#a, #b"
40
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
41
+ }
42
+ return [];
43
+ }
44
+
45
+ /**
46
+ * Resolves a raw contextSelector (string | string[] | null) to the
47
+ * normalized selector value (`ctxSelector`) plus the actual root elements to
48
+ * scan (`roots`, deduped, in resolution order), falling back to
49
+ * documentElement/body/html when nothing matches. Extracted out of
50
+ * dom-runner.js's runCore so frame-scan.js can discover which child
51
+ * <iframe>/<frame> elements fall within the same scan scope, without
52
+ * duplicating this resolution logic a second time.
53
+ */
54
+ function resolveContextRoots(document, contextSelector) {
55
+ const ctxSelector =
56
+ Array.isArray(contextSelector)
57
+ ? (() => {
58
+ const list = contextSelector
59
+ .map((s) => (typeof s === 'string' ? s.trim() : ''))
60
+ .filter(Boolean);
61
+ return list.length ? list : null;
62
+ })()
63
+ : (typeof contextSelector === 'string' && contextSelector.trim())
64
+ ? contextSelector.trim()
65
+ : null;
66
+
67
+ let roots = [];
68
+ {
69
+ const selectorList = Array.isArray(ctxSelector) ? ctxSelector : (ctxSelector ? [ctxSelector] : []);
70
+ const seen = new Set();
71
+ for (const sel of selectorList) {
72
+ let matches = [];
73
+ try {
74
+ matches = document.querySelectorAll(sel);
75
+ } catch {
76
+ matches = [];
77
+ }
78
+ for (const el of matches) {
79
+ if (el && !seen.has(el)) {
80
+ seen.add(el);
81
+ roots.push(el);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ if (!roots.length) {
87
+ const fallback =
88
+ document.documentElement ||
89
+ document.body ||
90
+ document.querySelector('html');
91
+ if (fallback) roots = [fallback];
92
+ }
93
+
94
+ return { ctxSelector, roots };
95
+ }
96
+
97
+ function createDomHelpers(opts) {
98
+ const document = opts && opts.document ? opts.document : null;
99
+ const window = opts && opts.window ? opts.window : null;
100
+ // Some engine paths may not pass opts.window; recover it from document when possible.
101
+ const realmWindow =
102
+ window ||
103
+ (document && document.defaultView) ||
104
+ null;
105
+ // opts.root accepts either a single element (back-compat -- every
106
+ // existing call site, including every test, passes one) or an array of
107
+ // elements (multi-region contextSelector support, dom-runner.js). Every
108
+ // internal consumer below works off `roots` (always an array, possibly
109
+ // empty) rather than assuming a single element.
110
+ const roots = (() => {
111
+ const r = opts && opts.root;
112
+ if (Array.isArray(r)) return r.filter((x) => x && typeof x === 'object');
113
+ if (r && typeof r === 'object') return [r];
114
+ return [];
115
+ })();
116
+ // Default on: opt OUT with `includeShadowDom: false`, not opt in.
117
+ const includeShadowDom = !(opts && opts.includeShadowDom === false);
118
+ const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
119
+
120
+ // Selector-related caches (selector uniqueness index, per-element built
121
+ // selector strings) depend on includeShadowDom/excludeSelectors, since
122
+ // those change which elements are considered when checking uniqueness.
123
+ // The underlying storage is shared across createDomHelpers() calls on
124
+ // the same window/document (see __domSharedCache below), so a run with
125
+ // different options must not read/write another run's cached selectors.
126
+ // This key partitions those caches per effective option set.
127
+ const __selectorOptsKey = (includeShadowDom ? 'sd1' : 'sd0') + '|' + excludeSelectors.slice().sort().join(',');
128
+
129
+ // -------------------------------------------------------------------------
130
+ // Per-run shared caches (DOM helpers)
131
+ // Stored on the realm window when possible so multiple helper instances
132
+ // within the same run share caches deterministically.
133
+ // -------------------------------------------------------------------------
134
+ var __domSharedCache = {};
135
+ var __selectorCache = null;
136
+ var __outerHtmlCache = null;
137
+ var __idLookupDocCache = null; // Map<string, Element|null>
138
+ var __idLookupRootCache = null; // Map<string, Element|null>
139
+ var __idRefCacheByRoot = null; // WeakMap<object, Map<string, {refs, missing, flags, partsLen}>>
140
+ var __idRefReverseIndexByScope = null; // WeakMap<object, Map<string, Set<Element>>>
141
+ var __uniqIndexByScope = null; // WeakMap<object, object> (selector uniqueness index per scope)
142
+ var __shadowRootsByRoot = null; // WeakMap<object, Array<object>> (cached open shadow roots per root)
143
+
144
+ // Shared recursion-depth guard across the mutually-recursive naming
145
+ // functions (computeIdRefTargetTextAlternative <-> getContentNameInfo <->
146
+ // getAccessibleNameInfo, via aria-labelledby targets that themselves
147
+ // contain elements with their own aria-labelledby). Each per-call
148
+ // `visited` Set only guards against cycles *within* a single top-level
149
+ // getTextFromIdRefs() invocation; a cross-function hop (e.g. resolving a
150
+ // labelledby target's content, which contains a descendant with its own
151
+ // aria-labelledby) starts a *fresh* visited Set and would defeat that
152
+ // guard on a genuine circular reference. This counter bounds the total
153
+ // combined call depth regardless of which function is on the stack.
154
+ var __nameComputationDepth = 0;
155
+ var __NAME_COMPUTATION_MAX_DEPTH = 40;
156
+
157
+ // -------------------------------------------------------------------------
158
+ // Optional per-run performance counters (debug/benchmark only)
159
+ // -------------------------------------------------------------------------
160
+ const __perfEnabled = !!(opts && opts.perfStats);
161
+ const __perf = __perfEnabled ? {enabled: true, counters: Object.create(null)} : null;
162
+
163
+ function __perfInc(key, n) {
164
+ if (!__perfEnabled || !__perf) return;
165
+ const k = String(key);
166
+ const add = n == null ? 1 : (Number(n) || 0);
167
+ __perf.counters[k] = (__perf.counters[k] || 0) + add;
168
+ }
169
+
170
+ function getPerfStats() {
171
+ if (!__perfEnabled || !__perf) return {enabled: false, counters: {}};
172
+ // Return a shallow copy to prevent accidental mutation by callers
173
+ return {enabled: true, counters: {...__perf.counters}};
174
+ }
175
+
176
+ function resetPerfStats() {
177
+ if (!__perfEnabled || !__perf) return;
178
+ __perf.counters = Object.create(null);
179
+ }
180
+
181
+
182
+ // -------------------------------------------------------------------------
183
+ // Shared escaping helpers (reduce per-call allocations, deterministic)
184
+ // -------------------------------------------------------------------------
185
+ const __w = realmWindow || window;
186
+ const __cssEscapeSafe = (s) => {
187
+ try {
188
+ return __w && __w.CSS && typeof __w.CSS.escape === 'function' ? __w.CSS.escape(String(s)) : String(s);
189
+ } catch {
190
+ return String(s);
191
+ }
192
+ };
193
+ // Spec-accurate CSS.escape() fallback (CSSOM "serialize an identifier"
194
+ // algorithm) for environments without a native window.CSS.escape — this
195
+ // is jsdom's actual situation (confirmed: window.CSS.escape is
196
+ // undefined there), so this fallback is not a rare edge case, it's the
197
+ // one actually exercised on every selector this engine ever builds.
198
+ // The previous fallback (a flat "escape any disallowed character"
199
+ // regex) didn't handle CSS identifiers that START with a digit or a
200
+ // hyphen+digit — a real pattern on real sites (UUID-style element IDs,
201
+ // e.g. Nike's homepage: id="13cbc70d-ca70-4938-9150-5abddc780c24").
202
+ // An unescaped leading digit makes the resulting selector fragment
203
+ // (e.g. "#13cbc70d-...") invalid CSS, which made buildSelectorUncached's
204
+ // own el.matches(candidate) verification throw (silently caught),
205
+ // degrading the selector to buildSimpleSelector's bare-tag-name
206
+ // fallback — losing all positional/uniqueness information for every
207
+ // element anchored under that ancestor.
208
+ function __cssEscapeIdentFallback(value) {
209
+ const string = String(value);
210
+ const length = string.length;
211
+ const firstCodeUnit = string.charCodeAt(0);
212
+ if (length === 1 && firstCodeUnit === 0x002d) return '\\' + string;
213
+ let result = '';
214
+ for (let index = 0; index < length; index++) {
215
+ const codeUnit = string.charCodeAt(index);
216
+ if (codeUnit === 0x0000) {
217
+ result += '\uFFFD';
218
+ continue;
219
+ }
220
+ if (
221
+ (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
222
+ codeUnit === 0x007f ||
223
+ (index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
224
+ (index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)
225
+ ) {
226
+ result += '\\' + codeUnit.toString(16) + ' ';
227
+ continue;
228
+ }
229
+ if (
230
+ codeUnit >= 0x0080 ||
231
+ codeUnit === 0x002d ||
232
+ codeUnit === 0x005f ||
233
+ (codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
234
+ (codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
235
+ (codeUnit >= 0x0061 && codeUnit <= 0x007a)
236
+ ) {
237
+ result += string.charAt(index);
238
+ continue;
239
+ }
240
+ result += '\\' + string.charAt(index);
241
+ }
242
+ return result;
243
+ }
244
+ const __cssEscapeIdent = (s) => {
245
+ try {
246
+ if (__w && __w.CSS && typeof __w.CSS.escape === 'function') return __w.CSS.escape(String(s));
247
+ } catch {
248
+ }
249
+ return __cssEscapeIdentFallback(s);
250
+ };
251
+ const __escapeAttrValue = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
252
+
253
+
254
+ // --- eligibility utilities ---
255
+ const isElement = (n) => !!n && n.nodeType === 1;
256
+ const computedStyle = (el) => {
257
+ // Per-run memoization scoped by *helper scope* (root/document), to ensure
258
+ // style caching does not bleed across helper instances with different roots.
259
+ // This aligns with eligibility cache scoping semantics locked by checks.
260
+ const scope = __getScopeObj();
261
+
262
+ let map = null;
263
+
264
+ try {
265
+ if (__computedStyleCacheByScope && scope && el && typeof el === 'object') {
266
+ map = __computedStyleCacheByScope.get(scope) || null;
267
+ if (map && map.has(el)) {
268
+ __perfInc('computedStyle.hit');
269
+ const c = map.get(el);
270
+ return c && typeof c === 'object' ? c : {};
271
+ }
272
+ }
273
+ } catch { /* ignore */
274
+ }
275
+
276
+ __perfInc('computedStyle.miss');
277
+ let cs = {};
278
+ try {
279
+ const w = realmWindow || window;
280
+ cs = w && w.getComputedStyle ? w.getComputedStyle(el) : (el && el.style) || {};
281
+ } catch {
282
+ cs = {};
283
+ }
284
+
285
+ try {
286
+ if (__computedStyleCacheByScope && scope && el && typeof el === 'object') {
287
+ if (!map) {
288
+ map = __computedStyleCacheByScope.get(scope) || null;
289
+ if (!map) {
290
+ map = new WeakMap();
291
+ __computedStyleCacheByScope.set(scope, map);
292
+ }
293
+ }
294
+ map.set(el, cs);
295
+ }
296
+ } catch {
297
+ __perfInc('computedStyle.nocache');
298
+ }
299
+
300
+ return cs && typeof cs === 'object' ? cs : {};
301
+ };
302
+
303
+ const getOpenModalDialogs = () => {
304
+ // Per-run memoization of open modal dialogs (document-scoped).
305
+ // Safe under engine constraints (no DOM mutation during a run); deterministic.
306
+ if (!document || !document.querySelectorAll) return [];
307
+ if (!__openModalDialogsByDoc) {
308
+ __perfInc('modalDialogs.nocache');
309
+ }
310
+ try {
311
+ if (__openModalDialogsByDoc) {
312
+ const cached = __openModalDialogsByDoc.get(document);
313
+ if (cached) {
314
+ __perfInc('modalDialogs.hit');
315
+ return cached;
316
+ }
317
+ __perfInc('modalDialogs.miss');
318
+ }
319
+ } catch {
320
+ __perfInc('modalDialogs.nocache');
321
+ }
322
+
323
+ let list = [];
324
+ try {
325
+ const nl = document.querySelectorAll('dialog[open][aria-modal="true"]');
326
+ // Preserve document order, avoid Array.from allocation where possible.
327
+ for (const el of nl) list.push(el);
328
+ } catch {
329
+ list = [];
330
+ }
331
+
332
+ try {
333
+ if (__openModalDialogsByDoc) __openModalDialogsByDoc.set(document, list);
334
+ } catch { /* ignore */
335
+ }
336
+
337
+ return list;
338
+ };
339
+
340
+ // Flat-tree (composed) parent: a distributed/slotted node's real rendered
341
+ // parent is the <slot> it's assigned to, NOT its own light-DOM parentNode
342
+ // (parentNode is unaffected by slot assignment and stays truthy for any
343
+ // normally-connected slotted element — checking it first, as an earlier
344
+ // version of this helper did, means the assignedSlot branch never fires
345
+ // for the common case of a real, connected slotted child, silently
346
+ // treating it as if it rendered under its light-DOM parent instead of
347
+ // the shadow-tree container it's actually distributed into). assignedSlot
348
+ // must be checked first; parentNode only applies to nodes that aren't
349
+ // currently distributed through a slot. Once climbing reaches a
350
+ // ShadowRoot itself (parentNode is null there), `.host` is the shadow
351
+ // host element directly — NOT `getRootNode({composed:true})`, which
352
+ // resolves all the way to the top-level document, skipping past the
353
+ // immediate shadow boundary this function is trying to climb out of one
354
+ // level at a time.
355
+ const composedParent = (n) => {
356
+ if (!n) return null;
357
+ if (n.assignedSlot) return n.assignedSlot;
358
+ if (n.parentNode) return n.parentNode;
359
+ return n.host || null;
360
+ };
361
+ const ancestorsIncludingSelf = (n) => {
362
+ if (!n) return [];
363
+ // Cache ancestor chains per node, per run, to avoid repeated composed-parent walks.
364
+ // Deterministic: purely memoized within the current run, no cross-run persistence.
365
+ try {
366
+ if (__ancestorsIncludingSelfCache && typeof __ancestorsIncludingSelfCache.get === 'function') {
367
+ const cached = __ancestorsIncludingSelfCache.get(n);
368
+ if (cached) {
369
+ __perfInc('ancestorsIncludingSelf.hit');
370
+ return cached;
371
+ }
372
+ __perfInc('ancestorsIncludingSelf.miss');
373
+ const out = [];
374
+ let cur = n, guard = 0;
375
+ while (cur && guard++ < 200) {
376
+ out.push(cur);
377
+ cur = composedParent(cur);
378
+ }
379
+ __ancestorsIncludingSelfCache.set(n, out);
380
+ return out;
381
+ }
382
+ } catch { /* fall through */
383
+ }
384
+
385
+ __perfInc('ancestorsIncludingSelf.nocache');
386
+ const out = [];
387
+ let cur = n, guard = 0;
388
+ while (cur && guard++ < 200) {
389
+ out.push(cur);
390
+ cur = composedParent(cur);
391
+ }
392
+ return out;
393
+ };
394
+
395
+ function getClosestMap(el) {
396
+ try {
397
+ if (!isElement(el)) return null;
398
+ return el.closest ? el.closest('map') : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ function hasBlockingInert(node) {
405
+ // Default behavior: inert anywhere in ancestorsIncludingSelf blocks.
406
+ if (!isElement(node)) return false;
407
+
408
+ const tag = (node.tagName || '').toLowerCase();
409
+ const isArea = tag === 'area';
410
+
411
+ let mapEl = null;
412
+ if (isArea) mapEl = getClosestMap(node);
413
+
414
+ const chain = ancestorsIncludingSelf(node);
415
+
416
+ for (const a of chain) {
417
+ if (!isElement(a)) continue;
418
+
419
+ // Exception: for <area>, inert on itself or on its <map> does NOT block
420
+ if (isArea) {
421
+ if (a === node) continue; // ignore <area inert>
422
+ if (mapEl && a === mapEl) continue; // ignore <map inert>
423
+ }
424
+
425
+ if (a.hasAttribute && a.hasAttribute('inert')) return true;
426
+ }
427
+ return false;
428
+ }
429
+
430
+ const trim = (v) => (v == null ? '' : String(v)).trim();
431
+
432
+ const getAttr = (el, name) => {
433
+ try {
434
+ return el && el.getAttribute ? el.getAttribute(name) : null;
435
+ } catch {
436
+ return null;
437
+ }
438
+ };
439
+
440
+ function parseTabIndex(el) {
441
+ const raw = getAttr(el, 'tabindex');
442
+ const t = trim(raw);
443
+ if (raw == null || t === '') return {has: false, value: null, valid: false};
444
+ const n = Number(t);
445
+ if (Number.isNaN(n)) return {has: true, value: null, valid: false};
446
+ return {has: true, value: n, valid: true};
447
+ }
448
+
449
+ function getPlatformFocusability(el) {
450
+ // Per-run memoization (WeakMap<Element, Result>)
451
+ try {
452
+ if (__focusabilityCache && el && typeof el === 'object' && __focusabilityCache.has(el)) {
453
+ __perfInc('focusability.hit');
454
+ const c = __focusabilityCache.get(el);
455
+ if (c && typeof c === 'object') {
456
+ return {
457
+ focusable: !!c.focusable,
458
+ tabbable: !!c.tabbable,
459
+ mechanism: c.mechanism || 'none',
460
+ flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
461
+ };
462
+ }
463
+ }
464
+ } catch {
465
+ }
466
+
467
+ __perfInc('focusability.miss');
468
+ let result = null;
469
+
470
+ if (!isElement(el)) {
471
+ result = {focusable: false, tabbable: false, mechanism: 'none', flags: ['notElement']};
472
+ } else if (hasBlockingInert(el)) {
473
+ result = {focusable: false, tabbable: false, mechanism: 'none', flags: ['inert']};
474
+ } else {
475
+ const flags = [];
476
+ const disabled = !!(el.matches && el.matches(':disabled'));
477
+ if (disabled) {
478
+ result = {focusable: false, tabbable: false, mechanism: 'disabled', flags: ['disabled']};
479
+ } else {
480
+ const ti = parseTabIndex(el);
481
+ if (ti.has) {
482
+ if (!ti.valid) result = {
483
+ focusable: false,
484
+ tabbable: false,
485
+ mechanism: 'tabindex',
486
+ flags: ['tabindex-invalid']
487
+ };
488
+ else if (ti.value < 0) result = {
489
+ focusable: true,
490
+ tabbable: false,
491
+ mechanism: 'tabindex',
492
+ flags: ['tabindex-negative']
493
+ };
494
+ else result = {
495
+ focusable: true,
496
+ tabbable: true,
497
+ mechanism: 'tabindex',
498
+ flags: ['tabindex-nonnegative']
499
+ };
500
+ } else {
501
+ // native focusability
502
+ const native = isPlatformFocusable(el); // uses your existing boolean logic
503
+ if (native) result = {focusable: true, tabbable: true, mechanism: 'native', flags};
504
+ else result = {focusable: false, tabbable: false, mechanism: 'none', flags};
505
+ }
506
+ }
507
+ }
508
+
509
+ try {
510
+ if (__focusabilityCache && el && typeof el === 'object') {
511
+ __focusabilityCache.set(el, {
512
+ focusable: !!result.focusable,
513
+ tabbable: !!result.tabbable,
514
+ mechanism: result.mechanism || 'none',
515
+ flags: Array.isArray(result.flags) ? result.flags.slice(0) : []
516
+ });
517
+ }
518
+ } catch {
519
+ }
520
+
521
+ return {
522
+ focusable: !!result.focusable,
523
+ tabbable: !!result.tabbable,
524
+ mechanism: result.mechanism || 'none',
525
+ flags: Array.isArray(result.flags) ? result.flags.slice(0) : []
526
+ };
527
+ }
528
+
529
+ // --- attribute ---
530
+ function getAttributeInfo(el, attr) {
531
+ const flags = [];
532
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
533
+
534
+ const attrValue = trim(getAttr(el, attr));
535
+ if (!attrValue) return {present: false, value: '', mechanism: attr, flags: ['empty']};
536
+
537
+ return {present: true, value: attrValue, mechanism: attr, flags};
538
+ }
539
+
540
+ // --- ARIA name primitives (reusable across checks) ---
541
+ function getAriaLabelInfo(el) {
542
+ const flags = [];
543
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
544
+
545
+ const ariaLabel = trim(getAttr(el, 'aria-label'));
546
+ if (!ariaLabel) return {present: false, value: '', mechanism: 'aria-label', flags: ['empty']};
547
+
548
+ return {present: true, value: ariaLabel, mechanism: 'aria-label', flags};
549
+ }
550
+
551
+ function getAriaLabelledByInfo(el, _ctx, opts) {
552
+ const flags = [];
553
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
554
+
555
+ const ariaLabelledBy = trim(getAttr(el, 'aria-labelledby'));
556
+ if (!ariaLabelledBy) return {present: false, value: '', mechanism: 'aria-labelledby', flags: ['missing']};
557
+
558
+ const t = getTextFromIdRefs(ariaLabelledBy, _ctx, opts);
559
+ for (const f of t.flags) flags.push(f);
560
+
561
+ if (!t.text) flags.push('empty');
562
+
563
+ return {
564
+ present: !!t.text,
565
+ value: t.text || '',
566
+ mechanism: 'aria-labelledby',
567
+ refsCount: t.refsCount,
568
+ missing: t.missing ? t.missing.slice(0) : [],
569
+ flags
570
+ };
571
+ }
572
+
573
+ /**
574
+ * getAriaNameInfo: ARIA-only name, with correct precedence.
575
+ * aria-labelledby (if non-empty) wins over aria-label.
576
+ */
577
+ function getAriaNameInfo(el, _ctx, opts) {
578
+ const flags = [];
579
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
580
+
581
+ const lb = getAriaLabelledByInfo(el, _ctx, opts);
582
+ if (lb.present && lb.value) return {
583
+ present: true,
584
+ value: lb.value,
585
+ mechanism: 'aria-labelledby',
586
+ flags: flags.concat(lb.flags || [])
587
+ };
588
+
589
+ const al = getAriaLabelInfo(el);
590
+ if (al.present && al.value) return {
591
+ present: true,
592
+ value: al.value,
593
+ mechanism: 'aria-label',
594
+ flags: flags.concat(al.flags || [])
595
+ };
596
+
597
+ // If aria-labelledby existed but was empty/unresolvable, preserve that info in flags.
598
+ if (trim(getAttr(el, 'aria-labelledby'))) flags.push('aria-labelledby-empty-or-unresolvable');
599
+ if (getAttr(el, 'aria-label') != null && !trim(getAttr(el, 'aria-label'))) flags.push('aria-label-empty');
600
+
601
+ return {present: false, value: '', mechanism: 'none', flags};
602
+ }
603
+
604
+ // Landmark-role naming (nav/main/region/banner/contentinfo/etc.): these roles don't derive
605
+ // a name from content (unlike a button/link), so per the accname spec their only sources are
606
+ // aria-label, aria-labelledby, then a title-attribute fallback. Was duplicated ad hoc across 7
607
+ // landmark rule files (landmark-unique, landmark-no-duplicate-banner/-contentinfo,
608
+ // landmark-banner/-main/-contentinfo-is-top-level, region), each its own local
609
+ // getAccessibleLandmarkName -- some using getAriaLabelledByInfo's target-name resolution,
610
+ // others a raw ref.textContent copy predating that fix, and NONE checking title at all.
611
+ // Confirmed via a real page (2026-07-22, live-DOM corpus): DuckDuckGo's homepage has two
612
+ // <nav>s distinguished only by title="navigation" on one of them -- a widely-used reference
613
+ // engine's landmark-unique correctly treats them as uniquely named (verified directly against
614
+ // that engine's own runtime, not assumed), while every one of the 7 local copies saw both as unnamed and flagged a false
615
+ // duplicate. One shared, correct implementation replaces all 7 copies.
616
+ function getLandmarkNameInfo(el, ctx) {
617
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
618
+
619
+ const aria = getAriaNameInfo(el, ctx);
620
+ if (aria.present && aria.value) return aria;
621
+
622
+ const title = trim(getAttr(el, 'title'));
623
+ if (title) return {present: true, value: title, mechanism: 'title', flags: (aria.flags || [])};
624
+
625
+ return {present: false, value: '', mechanism: 'none', flags: (aria.flags || []).concat(title === '' && getAttr(el, 'title') != null ? ['title-empty'] : [])};
626
+ }
627
+
628
+ const lower = (v) => trim(v).toLowerCase();
629
+
630
+ const safeDocGetById = (id) => {
631
+ const key = trim(id);
632
+ if (!key) return null;
633
+
634
+ // Shared cache (per run)
635
+ try {
636
+ if (__idLookupDocCache && __idLookupDocCache.has(key)) {
637
+ __perfInc('idLookup.doc.hit');
638
+ return __idLookupDocCache.get(key) || null;
639
+ }
640
+ } catch {
641
+ }
642
+
643
+ __perfInc('idLookup.doc.miss');
644
+ let el = null;
645
+ try {
646
+ if (document && document.getElementById) el = document.getElementById(key);
647
+ } catch {
648
+ el = null;
649
+ }
650
+
651
+ try {
652
+ if (__idLookupDocCache) __idLookupDocCache.set(key, el || null);
653
+ } catch {
654
+ }
655
+
656
+ return el || null;
657
+ };
658
+
659
+ const safeRootQueryById = (id) => {
660
+ // Best-effort for cases where root is not the document (e.g. shadow root-like, fragment roots).
661
+ // Note: IDs are document-global in HTML, but test harnesses may use scoped roots.
662
+ // With multi-region contextSelector, tries each root in turn and
663
+ // returns the first match -- IDs are meant to be document-unique
664
+ // anyway, so at most one root should ever actually contain it.
665
+ const key = trim(id);
666
+ if (!key) return null;
667
+ if (!roots.length) return null;
668
+
669
+ try {
670
+ const cacheKey = '#' + key;
671
+ if (__idLookupRootCache && __idLookupRootCache.has(cacheKey)) {
672
+ __perfInc('idLookup.root.hit');
673
+ return __idLookupRootCache.get(cacheKey) || null;
674
+ }
675
+ } catch {
676
+ }
677
+
678
+ __perfInc('idLookup.root.miss');
679
+ let el = null;
680
+ for (const r of roots) {
681
+ if (!r || !r.querySelector) continue;
682
+ try {
683
+ el = r.querySelector('#' + key);
684
+ } catch {
685
+ el = null;
686
+ }
687
+ if (el) break;
688
+ }
689
+
690
+ try {
691
+ const cacheKey = '#' + key;
692
+ if (__idLookupRootCache) __idLookupRootCache.set(cacheKey, el || null);
693
+ } catch {
694
+ }
695
+
696
+ return el || null;
697
+ };
698
+
699
+ function inClosedDetailsContent(node) {
700
+ try {
701
+ if (!isElement(node)) return false;
702
+ const summary = node.closest && node.closest('summary');
703
+ if (summary && summary.contains(node)) return false;
704
+ const details = node.closest && node.closest('details');
705
+ if (details && !details.hasAttribute('open')) return true;
706
+ } catch {
707
+ }
708
+ return false;
709
+ }
710
+
711
+ function isPlatformFocusable(el) {
712
+ if (!isElement(el) || hasBlockingInert(el)) return false;
713
+ const tag = (el.tagName || '').toLowerCase();
714
+ const type = (el.getAttribute && (el.getAttribute('type') || '').toLowerCase()) || '';
715
+ const disabled = !!(el.matches && el.matches(':disabled'));
716
+ if (disabled) return false;
717
+
718
+ if (tag === 'a') {
719
+ const href = el.getAttribute && el.getAttribute('href');
720
+ if (href && href.trim()) return true;
721
+ }
722
+ if (tag === 'area') {
723
+ // Engine policy: treat <area> as focusable when it's part of a *used* image map.
724
+ const map = getClosestMap(el);
725
+ if (map) {
726
+ const rawName = (map.getAttribute && (map.getAttribute('name') || map.getAttribute('id') || '')).trim();
727
+ if (rawName && document && document.querySelector) {
728
+ const esc = __cssEscapeSafe;
729
+ const n = esc(rawName);
730
+
731
+ // Be practical: accept both "#name" and "name", and ignore case.
732
+ const sels = [
733
+ `img[usemap="#${n}" i]`,
734
+ `img[usemap="${n}" i]`
735
+ ];
736
+
737
+ for (const sel of sels) {
738
+ try {
739
+ if (document.querySelector(sel)) return true;
740
+ } catch {
741
+ }
742
+ }
743
+ }
744
+ }
745
+ }
746
+ if (tag === 'input') {
747
+ if (type !== 'hidden') return true;
748
+ }
749
+ if (tag === 'select' || tag === 'textarea' || tag === 'button' || tag === 'summary') return true;
750
+ if (el.hasAttribute && el.hasAttribute('contenteditable')) {
751
+ // contenteditable="false" explicitly disables the editing host
752
+ // and does not by itself add the element to the tab order.
753
+ const ceVal = lower(getAttr(el, 'contenteditable'));
754
+ if (ceVal !== 'false') return true;
755
+ }
756
+
757
+ const tabindex = el.getAttribute && el.getAttribute('tabindex');
758
+ if (tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(tabindex))) return true;
759
+
760
+ return false;
761
+ }
762
+
763
+
764
+ function getIdRefReverseIndex(scopeObj) {
765
+ // Reverse index: id token -> referencing elements (aria-labelledby / aria-describedby)
766
+ // Built once per scope per run. Deterministic: querySelectorAll order is document order.
767
+ if (!scopeObj || !scopeObj.querySelectorAll) return null;
768
+
769
+ if (!__idRefReverseIndexByScope) {
770
+ __perfInc('idrefReverseIndex.nocache');
771
+ return null;
772
+ }
773
+
774
+ try {
775
+ const cached = __idRefReverseIndexByScope.get(scopeObj);
776
+ if (cached) {
777
+ __perfInc('idrefReverseIndex.hit');
778
+ return cached;
779
+ }
780
+ } catch {
781
+ __perfInc('idrefReverseIndex.nocache');
782
+ return null;
783
+ }
784
+
785
+ __perfInc('idrefReverseIndex.miss');
786
+
787
+ const idx = new Map();
788
+ let refs = [];
789
+ try {
790
+ refs = Array.from(scopeObj.querySelectorAll('[aria-labelledby],[aria-describedby]'));
791
+ } catch {
792
+ refs = [];
793
+ }
794
+
795
+ for (const el of refs) {
796
+ if (!isElement(el)) continue;
797
+
798
+ // Parse tokens deterministically
799
+ const lb = trim(getAttr(el, 'aria-labelledby'));
800
+ const db = trim(getAttr(el, 'aria-describedby'));
801
+
802
+ // Avoid pushing same element twice for the same token when both attrs contain it.
803
+ const pushed = new Set();
804
+
805
+ if (lb) {
806
+ const parts = lb.split(/\s+/).filter(Boolean);
807
+ for (const t of parts) {
808
+ const tok = trim(t);
809
+ if (!tok || pushed.has(tok)) continue;
810
+ pushed.add(tok);
811
+ const arr = idx.get(tok);
812
+ if (arr) arr.push(el);
813
+ else idx.set(tok, [el]);
814
+ }
815
+ }
816
+
817
+ if (db) {
818
+ const parts = db.split(/\s+/).filter(Boolean);
819
+ for (const t of parts) {
820
+ const tok = trim(t);
821
+ if (!tok || pushed.has(tok)) continue;
822
+ pushed.add(tok);
823
+ const arr = idx.get(tok);
824
+ if (arr) arr.push(el);
825
+ else idx.set(tok, [el]);
826
+ }
827
+ }
828
+ }
829
+
830
+ try {
831
+ __idRefReverseIndexByScope.set(scopeObj, idx);
832
+ __perfInc('idrefReverseIndex.build');
833
+ } catch {
834
+ // ignore cache set errors
835
+ }
836
+
837
+ return idx;
838
+ }
839
+
840
+ function isReferencedByVisibleIdRef(node) {
841
+ if (!document || !isElement(node)) return false;
842
+ const id = node.getAttribute && node.getAttribute('id');
843
+ const idTok = id && id.trim ? id.trim() : '';
844
+ if (!idTok) return false;
845
+
846
+ // Prefer reverse-index lookup (single build per run) over repeated querySelectorAll per node.
847
+ const idx = getIdRefReverseIndex(document);
848
+ if (idx && typeof idx.get === 'function') {
849
+ let refs = null;
850
+ try {
851
+ refs = idx.get(idTok) || null;
852
+ } catch {
853
+ refs = null;
854
+ }
855
+ if (refs && refs.length) {
856
+ for (const ref of refs) {
857
+ if (!isElement(ref)) continue;
858
+ const elig = isAccTreeEligible(ref); // safe recursion
859
+ if (elig && elig.eligible) return true;
860
+ }
861
+ return false;
862
+ }
863
+ // If index exists but no references, short-circuit.
864
+ return false;
865
+ }
866
+
867
+ // Fallback to querySelectorAll when cache is unavailable.
868
+ const esc = __cssEscapeSafe;
869
+ const idSel = esc(idTok);
870
+ let refs = [];
871
+ try {
872
+ refs = [
873
+ ...Array.from(document.querySelectorAll('[aria-labelledby~="' + idSel + '"]')),
874
+ ...Array.from(document.querySelectorAll('[aria-describedby~="' + idSel + '"]')),
875
+ ];
876
+ } catch {
877
+ refs = [];
878
+ }
879
+ for (const ref of refs) {
880
+ if (!isElement(ref)) continue;
881
+ const elig = isAccTreeEligible(ref); // safe recursion
882
+ if (elig && elig.eligible) return true;
883
+ }
884
+ return false;
885
+ }
886
+
887
+
888
+ function isExcluded(el) {
889
+ if (!excludeSelectors.length || !el || !el.closest) return false;
890
+ try {
891
+ return excludeSelectors.some((sel) => !!el.closest(sel));
892
+ } catch {
893
+ return false;
894
+ }
895
+ }
896
+
897
+ function queryAll(sel) {
898
+ if (!roots.length) return [];
899
+ const out = [];
900
+ const seen = new Set();
901
+ // Per root: self-match first (matching the original single-root
902
+ // ordering), then descendants, deduped across all roots -- matters
903
+ // when multiple contextSelector regions overlap/nest, so an element
904
+ // reachable from more than one root is only ever reported once.
905
+ for (const r of roots) {
906
+ if (!r) continue;
907
+ // querySelectorAll never returns its own context node, only
908
+ // descendants — so an attribute/role selector can never match
909
+ // `r` itself this way. In the default (unscoped) case `r` is
910
+ // `document.documentElement` (the <html> element), meaning every
911
+ // rule using this helper was structurally blind to an issue
912
+ // asserted directly on <html> (e.g. `<html role="...">`,
913
+ // `[lang]`, any `[aria-*]`) — not a narrow, rule-specific gap.
914
+ // Found via a real page: news24.com's South Africa homepage,
915
+ // `<html role="document">`, which a widely-used reference engine correctly flags but
916
+ // this engine's aria-allowed-role couldn't reach at all, no
917
+ // matter how correct its ALLOWED_ROLES_BY_ELEMENT entry was.
918
+ if (r.nodeType === 1 && typeof r.matches === 'function' && !seen.has(r)) {
919
+ try {
920
+ if (r.matches(sel)) {
921
+ seen.add(r);
922
+ out.push(r);
923
+ }
924
+ } catch {
925
+ }
926
+ }
927
+ try {
928
+ const list = r.querySelectorAll(sel);
929
+ for (const el of list) {
930
+ if (el && !seen.has(el)) {
931
+ seen.add(el);
932
+ out.push(el);
933
+ }
934
+ }
935
+ } catch {
936
+ // skip this root, keep results from the others
937
+ }
938
+ }
939
+ return out;
940
+ }
941
+
942
+ function queryAllDeep(sel) {
943
+ if (!roots.length) return [];
944
+ // Performance note:
945
+ // Avoid the old "querySelectorAll('*')" approach which is O(N) per shadow host
946
+ // and explodes on huge DOMs. Instead, walk shadow roots only and run the selector
947
+ // in each root once. This keeps work proportional to the number of shadow roots.
948
+ const results = [];
949
+ const seen = new Set();
950
+ const visitedRoots = new Set();
951
+
952
+ const pushMatches = (scope) => {
953
+ if (!scope || !scope.querySelectorAll) return;
954
+ let els = [];
955
+ try {
956
+ els = scope.querySelectorAll(sel);
957
+ } catch {
958
+ els = [];
959
+ }
960
+ // NodeList is iterable; avoid Array.from to reduce allocations.
961
+ for (const el of els) {
962
+ if (el && !seen.has(el) && !isExcluded(el)) {
963
+ seen.add(el);
964
+ results.push(el);
965
+ }
966
+ }
967
+ // Same root-self-match gap as queryAll above: querySelectorAll
968
+ // never returns `scope` itself, so a shadow-root host element
969
+ // (or the top-level <html> root) matching `sel` directly would
970
+ // otherwise be invisible here too.
971
+ if (scope.nodeType === 1 && typeof scope.matches === 'function' && !seen.has(scope) && !isExcluded(scope)) {
972
+ try {
973
+ if (scope.matches(sel)) {
974
+ seen.add(scope);
975
+ results.push(scope);
976
+ }
977
+ } catch {
978
+ }
979
+ }
980
+ };
981
+
982
+ const collectShadowRoots = (scope) => {
983
+ if (!scope || !scope.querySelectorAll) return [];
984
+
985
+ // Cache shadow root discovery per root to avoid repeated querySelectorAll('*') walks.
986
+ // IMPORTANT: do not cache when excludeSelectors is non-empty (different helpers may differ).
987
+ if (!excludeSelectors.length && __shadowRootsByRoot) {
988
+ try {
989
+ const cached = __shadowRootsByRoot.get(scope);
990
+ if (cached) {
991
+ __perfInc('shadowRoots.hit');
992
+ return cached;
993
+ }
994
+ __perfInc('shadowRoots.miss');
995
+
996
+ let hosts = [];
997
+ try {
998
+ hosts = scope.querySelectorAll('*');
999
+ } catch {
1000
+ hosts = [];
1001
+ }
1002
+
1003
+ const roots = [];
1004
+ for (const el of hosts) {
1005
+ if (!el || el.nodeType !== 1) continue;
1006
+ const sr = el.shadowRoot;
1007
+ if (sr) roots.push(sr);
1008
+ }
1009
+
1010
+ try {
1011
+ __shadowRootsByRoot.set(scope, roots);
1012
+ } catch {
1013
+ __perfInc('shadowRoots.nocache');
1014
+ }
1015
+ return roots;
1016
+ } catch {
1017
+ __perfInc('shadowRoots.nocache');
1018
+ // fall through to uncached path
1019
+ }
1020
+ } else {
1021
+ __perfInc('shadowRoots.nocache');
1022
+ }
1023
+
1024
+ // Uncached path (preserves excludeSelectors filtering semantics).
1025
+ let hosts = [];
1026
+ try {
1027
+ hosts = scope.querySelectorAll('*');
1028
+ } catch {
1029
+ hosts = [];
1030
+ }
1031
+ const roots = [];
1032
+ for (const el of hosts) {
1033
+ if (!el || el.nodeType !== 1) continue;
1034
+ if (isExcluded(el)) continue;
1035
+ const sr = el.shadowRoot;
1036
+ if (sr) roots.push(sr);
1037
+ }
1038
+ return roots;
1039
+ };
1040
+
1041
+ // Seed the BFS queue with every resolved context root (not just one)
1042
+ // -- the existing shadow-root discovery loop below already
1043
+ // generalizes to multiple starting points without further changes,
1044
+ // since it was already a growing queue, not a single fixed root.
1045
+ const q = roots.slice();
1046
+ for (let qi = 0; qi < q.length; qi++) {
1047
+ const curRoot = q[qi];
1048
+ if (!curRoot || visitedRoots.has(curRoot)) continue;
1049
+ visitedRoots.add(curRoot);
1050
+
1051
+ pushMatches(curRoot);
1052
+
1053
+ const childShadowRoots = collectShadowRoots(curRoot);
1054
+ for (const sr of childShadowRoots) q.push(sr);
1055
+ }
1056
+
1057
+ return results;
1058
+ }
1059
+
1060
+ function queryAllSmart(sel) {
1061
+ const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1062
+ return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
1063
+ }
1064
+
1065
+ // -------------------------------------------------------------------------
1066
+ // Per-run shared caches (DOM helpers)
1067
+ // -------------------------------------------------------------------------
1068
+ try {
1069
+ const w =
1070
+ realmWindow ||
1071
+ (document && document.defaultView) ||
1072
+ (typeof global !== 'undefined' && global.window ? global.window : null);
1073
+
1074
+ if (w) {
1075
+ if (!w.__a11ycoreSharedCache) w.__a11ycoreSharedCache = {};
1076
+ if (!w.__a11ycoreSharedCache.dom) w.__a11ycoreSharedCache.dom = {};
1077
+ __domSharedCache = w.__a11ycoreSharedCache.dom;
1078
+ }
1079
+ } catch {
1080
+ __domSharedCache = {};
1081
+ }
1082
+
1083
+ // Selector cache (per element), partitioned by __selectorOptsKey since
1084
+ // built selectors depend on includeShadowDom/excludeSelectors.
1085
+ try {
1086
+ __selectorCache = __domSharedCache.selectorCache instanceof Map
1087
+ ? __domSharedCache.selectorCache
1088
+ : (__domSharedCache.selectorCache = new Map());
1089
+ } catch {
1090
+ __selectorCache = null;
1091
+ }
1092
+
1093
+ function __getSelectorCacheForOpts() {
1094
+ if (!__selectorCache) return null;
1095
+ try {
1096
+ let wm = __selectorCache.get(__selectorOptsKey);
1097
+ if (!(wm instanceof WeakMap)) {
1098
+ wm = new WeakMap();
1099
+ __selectorCache.set(__selectorOptsKey, wm);
1100
+ }
1101
+ return wm;
1102
+ } catch {
1103
+ return null;
1104
+ }
1105
+ }
1106
+
1107
+ try {
1108
+ __outerHtmlCache = __domSharedCache.outerHtmlCache instanceof WeakMap
1109
+ ? __domSharedCache.outerHtmlCache
1110
+ : (__domSharedCache.outerHtmlCache = new WeakMap());
1111
+ } catch {
1112
+ __outerHtmlCache = null;
1113
+ }
1114
+
1115
+ // ID lookups: cache getElementById / root.querySelector(#id) results within a run
1116
+ try {
1117
+ __idLookupDocCache = __domSharedCache.idLookupDocCache instanceof Map
1118
+ ? __domSharedCache.idLookupDocCache
1119
+ : (__domSharedCache.idLookupDocCache = new Map());
1120
+ } catch {
1121
+ __idLookupDocCache = null;
1122
+ }
1123
+
1124
+ try {
1125
+ __idLookupRootCache = __domSharedCache.idLookupRootCache instanceof Map
1126
+ ? __domSharedCache.idLookupRootCache
1127
+ : (__domSharedCache.idLookupRootCache = new Map());
1128
+ } catch {
1129
+ __idLookupRootCache = null;
1130
+ }
1131
+
1132
+ // IDREF resolution: cache resolveIdRefs results (root-scoped) within a run
1133
+ try {
1134
+ __idRefCacheByRoot = __domSharedCache.idRefCacheByRoot instanceof WeakMap
1135
+ ? __domSharedCache.idRefCacheByRoot
1136
+ : (__domSharedCache.idRefCacheByRoot = new WeakMap());
1137
+ } catch {
1138
+ __idRefCacheByRoot = null;
1139
+ }
1140
+
1141
+ // Reverse index for aria-labelledby/aria-describedby -> id token
1142
+ try {
1143
+ __idRefReverseIndexByScope = __domSharedCache.idRefReverseIndexByScope instanceof WeakMap
1144
+ ? __domSharedCache.idRefReverseIndexByScope
1145
+ : (__domSharedCache.idRefReverseIndexByScope = new WeakMap());
1146
+ } catch {
1147
+ __idRefReverseIndexByScope = null;
1148
+ }
1149
+
1150
+ // Selector uniqueness index (per scope) within a run
1151
+ try {
1152
+ __uniqIndexByScope = __domSharedCache.uniqIndexByScope instanceof WeakMap
1153
+ ? __domSharedCache.uniqIndexByScope
1154
+ : (__domSharedCache.uniqIndexByScope = new WeakMap());
1155
+ } catch {
1156
+ __uniqIndexByScope = null;
1157
+ }
1158
+
1159
+ // Shadow root discovery cache (per root) within a run.
1160
+ // Only used when excludeSelectors is empty to avoid cross-helper bleed.
1161
+ try {
1162
+ __shadowRootsByRoot = __domSharedCache.shadowRootsByRoot instanceof WeakMap
1163
+ ? __domSharedCache.shadowRootsByRoot
1164
+ : (__domSharedCache.shadowRootsByRoot = new WeakMap());
1165
+ } catch {
1166
+ __shadowRootsByRoot = null;
1167
+ }
1168
+
1169
+
1170
+ // -------------------------------------------------------------------------
1171
+ // Additional per-run caches (eligibility / focusability / labeling)
1172
+ // -------------------------------------------------------------------------
1173
+ let __ancestorsIncludingSelfCache = null;
1174
+ let __eligibilityAccCache = null;
1175
+ let __eligibilityDomCacheByMode = null; // Map<string, WeakMap<Element, Result>>
1176
+ let __focusabilityCache = null;
1177
+ let __visibilityHintsCache = null; // WeakMap<Element, {hints:Array<string>, metrics:object}>
1178
+ let __computedStyleCacheByScope = null; // WeakMap<object, WeakMap<Element, CSSStyleDeclaration|object>>
1179
+ let __openModalDialogsByDoc = null; // WeakMap<Document, Array<Element>>
1180
+ let __ancestorBlockerAccByScope = null; // WeakMap<object, WeakMap<Element, {struct:string|null, css:string|null}>>
1181
+ let __ancestorBlockerDomByScope = null; // WeakMap<object, WeakMap<Element, {struct, css, cssKnown, visibility, contentVisHidden, opacity}>>
1182
+ let __ancestorBlockerDomStructFinalByScope = null; // WeakMap<object, WeakMap<Element, string|null>> (final structural blocker per element per scope)
1183
+ let __labelAssociationCache = null;
1184
+ let __labelMethodCache = null;
1185
+ let __labelForIndexByDoc = null; // WeakMap<Document, Map<string, {exists:boolean, text:string}>> (label[for] by id cache)
1186
+ let __accessibleNameCacheByKey = null; // Map<string, WeakMap<Element, Info>>
1187
+ let __accessibleDescCacheByKey = null; // Map<string, WeakMap<Element, Info>>
1188
+
1189
+ try {
1190
+ __ancestorsIncludingSelfCache = __domSharedCache.ancestorsIncludingSelfCache instanceof WeakMap
1191
+ ? __domSharedCache.ancestorsIncludingSelfCache
1192
+ : (__domSharedCache.ancestorsIncludingSelfCache = new WeakMap());
1193
+ } catch {
1194
+ __ancestorsIncludingSelfCache = null;
1195
+ }
1196
+
1197
+ try {
1198
+ __eligibilityAccCache = __domSharedCache.eligibilityAccCache instanceof WeakMap
1199
+ ? __domSharedCache.eligibilityAccCache
1200
+ : (__domSharedCache.eligibilityAccCache = new WeakMap());
1201
+ } catch {
1202
+ __eligibilityAccCache = null;
1203
+ }
1204
+
1205
+ try {
1206
+ __eligibilityDomCacheByMode = __domSharedCache.eligibilityDomCacheByMode instanceof Map
1207
+ ? __domSharedCache.eligibilityDomCacheByMode
1208
+ : (__domSharedCache.eligibilityDomCacheByMode = new Map());
1209
+ } catch {
1210
+ __eligibilityDomCacheByMode = null;
1211
+ }
1212
+
1213
+ try {
1214
+ __focusabilityCache = __domSharedCache.focusabilityCache instanceof WeakMap
1215
+ ? __domSharedCache.focusabilityCache
1216
+ : (__domSharedCache.focusabilityCache = new WeakMap());
1217
+ } catch {
1218
+ __focusabilityCache = null;
1219
+ }
1220
+
1221
+ try {
1222
+ __visibilityHintsCache = __domSharedCache.visibilityHintsCache instanceof WeakMap
1223
+ ? __domSharedCache.visibilityHintsCache
1224
+ : (__domSharedCache.visibilityHintsCache = new WeakMap());
1225
+ } catch {
1226
+ __visibilityHintsCache = null;
1227
+ }
1228
+
1229
+ try {
1230
+ __computedStyleCacheByScope = __domSharedCache.computedStyleCacheByScope instanceof WeakMap
1231
+ ? __domSharedCache.computedStyleCacheByScope
1232
+ : (__domSharedCache.computedStyleCacheByScope = new WeakMap());
1233
+ } catch {
1234
+ __computedStyleCacheByScope = null;
1235
+ }
1236
+
1237
+
1238
+ try {
1239
+ __openModalDialogsByDoc = __domSharedCache.openModalDialogsByDoc instanceof WeakMap
1240
+ ? __domSharedCache.openModalDialogsByDoc
1241
+ : (__domSharedCache.openModalDialogsByDoc = new WeakMap());
1242
+ } catch {
1243
+ __openModalDialogsByDoc = null;
1244
+ }
1245
+
1246
+ try {
1247
+ __ancestorBlockerAccByScope = __domSharedCache.ancestorBlockerAccByScope instanceof WeakMap
1248
+ ? __domSharedCache.ancestorBlockerAccByScope
1249
+ : (__domSharedCache.ancestorBlockerAccByScope = new WeakMap());
1250
+ } catch {
1251
+ __ancestorBlockerAccByScope = null;
1252
+ }
1253
+
1254
+ try {
1255
+ __ancestorBlockerDomByScope = __domSharedCache.ancestorBlockerDomByScope instanceof WeakMap
1256
+ ? __domSharedCache.ancestorBlockerDomByScope
1257
+ : (__domSharedCache.ancestorBlockerDomByScope = new WeakMap());
1258
+ } catch {
1259
+ __ancestorBlockerDomByScope = null;
1260
+ }
1261
+
1262
+ try {
1263
+ __ancestorBlockerDomStructFinalByScope = __domSharedCache.ancestorBlockerDomStructFinalByScope instanceof WeakMap
1264
+ ? __domSharedCache.ancestorBlockerDomStructFinalByScope
1265
+ : (__domSharedCache.ancestorBlockerDomStructFinalByScope = new WeakMap());
1266
+ } catch {
1267
+ __ancestorBlockerDomStructFinalByScope = null;
1268
+ }
1269
+
1270
+
1271
+ try {
1272
+ __labelAssociationCache = __domSharedCache.labelAssociationCache instanceof WeakMap
1273
+ ? __domSharedCache.labelAssociationCache
1274
+ : (__domSharedCache.labelAssociationCache = new WeakMap());
1275
+ } catch {
1276
+ __labelAssociationCache = null;
1277
+ }
1278
+
1279
+ try {
1280
+ __labelMethodCache = __domSharedCache.labelMethodCache instanceof WeakMap
1281
+ ? __domSharedCache.labelMethodCache
1282
+ : (__domSharedCache.labelMethodCache = new WeakMap());
1283
+ } catch {
1284
+ __labelMethodCache = null;
1285
+ }
1286
+
1287
+
1288
+ try {
1289
+ __labelForIndexByDoc = __domSharedCache.labelForIndexByDoc instanceof WeakMap
1290
+ ? __domSharedCache.labelForIndexByDoc
1291
+ : (__domSharedCache.labelForIndexByDoc = new WeakMap());
1292
+ } catch {
1293
+ __labelForIndexByDoc = null;
1294
+ }
1295
+
1296
+ try {
1297
+ __accessibleNameCacheByKey = __domSharedCache.accessibleNameCacheByKey instanceof Map
1298
+ ? __domSharedCache.accessibleNameCacheByKey
1299
+ : (__domSharedCache.accessibleNameCacheByKey = new Map());
1300
+ } catch {
1301
+ __accessibleNameCacheByKey = null;
1302
+ }
1303
+
1304
+ try {
1305
+ __accessibleDescCacheByKey = __domSharedCache.accessibleDescCacheByKey instanceof Map
1306
+ ? __domSharedCache.accessibleDescCacheByKey
1307
+ : (__domSharedCache.accessibleDescCacheByKey = new Map());
1308
+ } catch {
1309
+ __accessibleDescCacheByKey = null;
1310
+ }
1311
+
1312
+
1313
+ function __getScopeObj() {
1314
+ // Purely a cache-partition key -- doesn't need to BE a real scan
1315
+ // scope, just a value that's stable for this run and distinct across
1316
+ // runs with a different root set. `roots` itself (the array) is a
1317
+ // stable reference for the whole run when there's more than one.
1318
+ if (roots.length === 1) return roots[0];
1319
+ if (roots.length > 1) return roots;
1320
+ return (document && typeof document === 'object') ? document : null;
1321
+ }
1322
+
1323
+
1324
+ function __getLabelForByIdCache(nameKey) {
1325
+ // Document-scoped cache for `document.querySelector('label[for="..."]')`.
1326
+ // Keeps test semantics (first lookup uses querySelector) while eliminating repeated lookups.
1327
+ if (!document || !document.querySelector) return null;
1328
+ if (!__labelForIndexByDoc) {
1329
+ __perfInc('labelForById.nocache');
1330
+ return null;
1331
+ }
1332
+
1333
+ try {
1334
+ const nk = nameKey == null ? '__default__' : String(nameKey);
1335
+ let byKey = __labelForIndexByDoc.get(document);
1336
+ if (!(byKey instanceof Map)) {
1337
+ __perfInc('labelForById.miss');
1338
+ byKey = new Map();
1339
+ __labelForIndexByDoc.set(document, byKey);
1340
+ __perfInc('labelForById.build');
1341
+ }
1342
+ const existing = byKey.get(nk);
1343
+ if (existing && existing instanceof Map) {
1344
+ __perfInc('labelForById.hit');
1345
+ return existing;
1346
+ }
1347
+ __perfInc('labelForById.miss');
1348
+ const map = new Map();
1349
+ byKey.set(nk, map);
1350
+ __perfInc('labelForById.build');
1351
+ return map;
1352
+ } catch {
1353
+ __perfInc('labelForById.nocache');
1354
+ return null;
1355
+ }
1356
+ }
1357
+
1358
+ function __lookupLabelForId(id, nameKey) {
1359
+ const key = trim(id);
1360
+ if (!key) return null;
1361
+
1362
+ const map = __getLabelForByIdCache(nameKey);
1363
+ if (map) {
1364
+ if (map.has(key)) return map.get(key) || null;
1365
+ // compute and store
1366
+ let entry = null;
1367
+ try {
1368
+ const sel = 'label[for="' + key.replace(/\\/g, '\\\\').replace(/"/g, '\\\"') + '"]';
1369
+ const label = document.querySelector(sel);
1370
+ if (label && isElement(label)) {
1371
+ let t = '';
1372
+ try {
1373
+ t = trim(label.textContent);
1374
+ } catch {
1375
+ t = '';
1376
+ }
1377
+ entry = {exists: true, text: t};
1378
+ } else {
1379
+ entry = {exists: false, text: ''};
1380
+ }
1381
+ } catch {
1382
+ entry = {exists: false, text: ''};
1383
+ }
1384
+ try {
1385
+ map.set(key, entry);
1386
+ } catch {
1387
+ }
1388
+ return entry && entry.exists ? entry : null;
1389
+ }
1390
+
1391
+ // No cache available: fallback to direct querySelector
1392
+ try {
1393
+ const sel = 'label[for="' + key.replace(/\\/g, '\\\\').replace(/"/g, '\\\"') + '"]';
1394
+ const label = document.querySelector(sel);
1395
+ if (label && isElement(label)) {
1396
+ let t = '';
1397
+ try {
1398
+ t = trim(label.textContent);
1399
+ } catch {
1400
+ t = '';
1401
+ }
1402
+ return {exists: true, text: t};
1403
+ }
1404
+ } catch {
1405
+ }
1406
+ return null;
1407
+ }
1408
+
1409
+ function __getEligibilityAccCacheForScope() {
1410
+ const scopeObj = __getScopeObj();
1411
+ if (!scopeObj || !__domSharedCache) return null;
1412
+ try {
1413
+ const wmByScope =
1414
+ __domSharedCache.eligibilityAccCacheByScope instanceof WeakMap
1415
+ ? __domSharedCache.eligibilityAccCacheByScope
1416
+ : (__domSharedCache.eligibilityAccCacheByScope = new WeakMap());
1417
+
1418
+ let perScope = wmByScope.get(scopeObj);
1419
+ if (!(perScope instanceof WeakMap)) {
1420
+ perScope = new WeakMap();
1421
+ wmByScope.set(scopeObj, perScope);
1422
+ }
1423
+ return perScope;
1424
+ } catch {
1425
+ return null;
1426
+ }
1427
+ }
1428
+
1429
+ function __getEligibilityDomCacheForScope(modeKey) {
1430
+ const scopeObj = __getScopeObj();
1431
+ if (!scopeObj || !__domSharedCache) return null;
1432
+ try {
1433
+ const wmByScope =
1434
+ __domSharedCache.eligibilityDomCacheByScope instanceof WeakMap
1435
+ ? __domSharedCache.eligibilityDomCacheByScope
1436
+ : (__domSharedCache.eligibilityDomCacheByScope = new WeakMap());
1437
+
1438
+ let perScopeMap = wmByScope.get(scopeObj);
1439
+ if (!(perScopeMap instanceof Map)) {
1440
+ perScopeMap = new Map();
1441
+ wmByScope.set(scopeObj, perScopeMap);
1442
+ }
1443
+
1444
+ let perMode = perScopeMap.get(modeKey);
1445
+ if (!(perMode instanceof WeakMap)) {
1446
+ perMode = new WeakMap();
1447
+ perScopeMap.set(modeKey, perMode);
1448
+ }
1449
+ return perMode;
1450
+ } catch {
1451
+ return null;
1452
+ }
1453
+ }
1454
+
1455
+
1456
+ function __getAncestorBlockerAccCacheForScope() {
1457
+ const scopeObj = __getScopeObj();
1458
+ if (!scopeObj || !__ancestorBlockerAccByScope) return null;
1459
+ try {
1460
+ let perScope = __ancestorBlockerAccByScope.get(scopeObj);
1461
+ if (!(perScope instanceof WeakMap)) {
1462
+ perScope = new WeakMap();
1463
+ __ancestorBlockerAccByScope.set(scopeObj, perScope);
1464
+ }
1465
+ return perScope;
1466
+ } catch {
1467
+ return null;
1468
+ }
1469
+ }
1470
+
1471
+ function __getAncestorBlockerDomCacheForScope() {
1472
+ const scopeObj = __getScopeObj();
1473
+ if (!scopeObj || !__ancestorBlockerDomByScope) return null;
1474
+ try {
1475
+ let perScope = __ancestorBlockerDomByScope.get(scopeObj);
1476
+ if (!(perScope instanceof WeakMap)) {
1477
+ perScope = new WeakMap();
1478
+ __ancestorBlockerDomByScope.set(scopeObj, perScope);
1479
+ }
1480
+ return perScope;
1481
+ } catch {
1482
+ return null;
1483
+ }
1484
+ }
1485
+
1486
+ function __getAncestorBlockerDomStructFinalCacheForScope() {
1487
+ const scopeObj = __getScopeObj();
1488
+ if (!scopeObj || !__ancestorBlockerDomStructFinalByScope) return null;
1489
+ try {
1490
+ let perScope = __ancestorBlockerDomStructFinalByScope.get(scopeObj);
1491
+ if (!(perScope instanceof WeakMap)) {
1492
+ perScope = new WeakMap();
1493
+ __ancestorBlockerDomStructFinalByScope.set(scopeObj, perScope);
1494
+ }
1495
+ return perScope;
1496
+ } catch {
1497
+ return null;
1498
+ }
1499
+ }
1500
+
1501
+
1502
+ function __getDomEligibilityModeKey(opts) {
1503
+ const mode = opts && opts.visibilityMode === 'styleAndGeometry' ? 'styleAndGeometry' : 'styleOnly';
1504
+ const disableGeometry = !!(opts && opts.disableGeometry === true);
1505
+ return mode + '|' + (disableGeometry ? 'dg1' : 'dg0');
1506
+ }
1507
+
1508
+ function __getNameOptsKey(opts) {
1509
+ // Only include options that affect this helper's output.
1510
+ const disallowContents = !!(opts && opts.disallowContents === true);
1511
+ const maxRefs = opts && opts.maxRefs != null ? (Number(opts.maxRefs) | 0) : -1;
1512
+ return (disallowContents ? 'dc1' : 'dc0') + '|mr' + String(maxRefs);
1513
+ }
1514
+
1515
+ function __getDescOptsKey(opts) {
1516
+ const allowTitle = !!(opts && opts.allowTitle === true);
1517
+ const maxRefs = opts && opts.maxRefs != null ? (Number(opts.maxRefs) | 0) : -1;
1518
+ return (allowTitle ? 'at1' : 'at0') + '|mr' + String(maxRefs);
1519
+ }
1520
+
1521
+ function getOuterHtmlSnippet(el) {
1522
+ if (!el || typeof el !== 'object') return '';
1523
+ try {
1524
+ if (__outerHtmlCache && __outerHtmlCache.has(el)) {
1525
+ __perfInc('outerHtml.hit');
1526
+ return __outerHtmlCache.get(el) || '';
1527
+ }
1528
+ } catch {
1529
+ }
1530
+
1531
+ __perfInc('outerHtml.miss');
1532
+
1533
+ let out = '';
1534
+ try {
1535
+ const html = el.outerHTML || '';
1536
+ if (html.length > 2000) out = html.slice(0, 2000) + '…';
1537
+ else out = html;
1538
+ } catch {
1539
+ out = '';
1540
+ }
1541
+
1542
+ try {
1543
+ if (__outerHtmlCache && el && typeof el === 'object') __outerHtmlCache.set(el, out);
1544
+ } catch {
1545
+ }
1546
+ return out;
1547
+ }
1548
+
1549
+ // --- Accessibility-tree eligibility (ordered checks) ---
1550
+ function isAccTreeEligible(node) {
1551
+ // Cache is per-scope (root/document) to avoid cross-run leakage.
1552
+ const __accCache = __getEligibilityAccCacheForScope();
1553
+ const __ancBlockCache = __getAncestorBlockerAccCacheForScope();
1554
+
1555
+ if (!isElement(node)) {
1556
+ return {eligible: false, reasons: ['notElement']};
1557
+ }
1558
+
1559
+ try {
1560
+ if (__accCache && node && typeof node === 'object' && __accCache.has(node)) {
1561
+ const c = __accCache.get(node);
1562
+ if (c && typeof c === 'object') {
1563
+ return {
1564
+ eligible: !!c.eligible,
1565
+ reasons: Array.isArray(c.reasons) ? c.reasons.slice(0) : []
1566
+ };
1567
+ }
1568
+ }
1569
+ } catch {
1570
+ }
1571
+
1572
+ const reasons = [];
1573
+
1574
+ function __cacheAndReturn(res) {
1575
+ const out = {
1576
+ eligible: !!(res && res.eligible),
1577
+ reasons: (res && Array.isArray(res.reasons)) ? res.reasons.slice(0) : []
1578
+ };
1579
+ try {
1580
+ if (__accCache && node && typeof node === 'object') {
1581
+ __accCache.set(node, {eligible: out.eligible, reasons: out.reasons.slice(0)});
1582
+ }
1583
+ } catch {
1584
+ }
1585
+ return out;
1586
+ }
1587
+
1588
+ const chain = ancestorsIncludingSelf(node);
1589
+
1590
+ // 1) HTML/DOM hiding
1591
+ for (const a of chain) {
1592
+ if (!isElement(a)) continue;
1593
+
1594
+ // Ancestor structural blockers are scope-cached (per run) to avoid repeated checks.
1595
+ let struct = null;
1596
+ try {
1597
+ if (__ancBlockCache && __ancBlockCache.has(a)) {
1598
+ __perfInc('ancestorBlockerAcc.struct.hit');
1599
+ const cached = __ancBlockCache.get(a);
1600
+ struct = cached && cached.struct ? String(cached.struct) : null;
1601
+ } else {
1602
+ __perfInc('ancestorBlockerAcc.struct.miss');
1603
+ const tn = (a.tagName || '').toLowerCase();
1604
+ if (a.hasAttribute && a.hasAttribute('hidden')) struct = 'hiddenAttr';
1605
+ else if (tn === 'template') struct = 'templateContent';
1606
+ else if (tn === 'script' || tn === 'style' || tn === 'meta' || tn === 'link' || tn === 'noscript') struct = 'nonRenderedElement';
1607
+ else if (tn === 'input') {
1608
+ const t = (a.getAttribute && (a.getAttribute('type') || '').toLowerCase()) || '';
1609
+ if (t === 'hidden') struct = 'inputHidden';
1610
+ }
1611
+ try {
1612
+ try {
1613
+ if (__ancBlockCache) {
1614
+ const prev = __ancBlockCache.has(a) ? (__ancBlockCache.get(a) || null) : null;
1615
+ __ancBlockCache.set(a, {
1616
+ struct,
1617
+ css: prev && prev.css ? prev.css : null,
1618
+ cssKnown: prev && prev.cssKnown === true ? true : false
1619
+ });
1620
+ }
1621
+ } catch {
1622
+ __perfInc('ancestorBlockerAcc.struct.nocache');
1623
+ }
1624
+ } catch {
1625
+ __perfInc('ancestorBlockerAcc.struct.nocache');
1626
+ }
1627
+ }
1628
+ } catch { /* ignore */
1629
+ }
1630
+
1631
+ if (struct) return __cacheAndReturn({eligible: false, reasons: [struct]});
1632
+ }
1633
+ if (inClosedDetailsContent(node)) return __cacheAndReturn({eligible: false, reasons: ['detailsClosed']});
1634
+
1635
+ // 2) Inertness / modality
1636
+ if (hasBlockingInert(node)) {
1637
+ return __cacheAndReturn({eligible: false, reasons: ['inert']});
1638
+ }
1639
+ // Modal dialog (best effort)
1640
+ try {
1641
+ const openModals = getOpenModalDialogs();
1642
+ if (openModals.length) {
1643
+ let inside = false;
1644
+ for (const d of openModals) {
1645
+ if (d && d.contains && d.contains(node)) {
1646
+ inside = true;
1647
+ break;
1648
+ }
1649
+ }
1650
+ if (!inside) return __cacheAndReturn({eligible: false, reasons: ['modalInert']});
1651
+ }
1652
+ } catch {
1653
+ }
1654
+
1655
+ // 3) CSS rendering suppression
1656
+ // display:none is NOT inherited: if ANY ancestor (or self) has
1657
+ // display:none, the whole subtree is unrendered no matter what a
1658
+ // descendant's own display is, so this must be resolved via an
1659
+ // ancestor walk that breaks on the first blocker found.
1660
+ for (const a of chain) {
1661
+ if (!isElement(a)) continue;
1662
+
1663
+ // <area> is a non-rendered element; some DOMs report display:none for it.
1664
+ // Don’t treat the *area itself* as ineligible based on computed style.
1665
+ if (a === node) {
1666
+ const tn = (a.tagName || '').toLowerCase();
1667
+ if (tn === 'area') continue;
1668
+ }
1669
+
1670
+ // Cache ancestor CSS blockers (display) per scope.
1671
+ let cssBlock = null;
1672
+ let cssKnown = false;
1673
+ try {
1674
+ if (__ancBlockCache && __ancBlockCache.has(a)) {
1675
+ const cached = __ancBlockCache.get(a);
1676
+ if (cached && cached.cssKnown === true) {
1677
+ __perfInc('ancestorBlockerAcc.css.hit');
1678
+ cssKnown = true;
1679
+ cssBlock = cached.css ? String(cached.css) : null;
1680
+ } else {
1681
+ __perfInc('ancestorBlockerAcc.css.miss');
1682
+ }
1683
+ } else {
1684
+ __perfInc('ancestorBlockerAcc.css.miss');
1685
+ }
1686
+ } catch {
1687
+ }
1688
+
1689
+ if (!cssKnown) {
1690
+ const cs = computedStyle(a);
1691
+ cssBlock = (cs && cs.display === 'none') ? 'displayNone' : null;
1692
+
1693
+ try {
1694
+ if (__ancBlockCache) {
1695
+ const prev = __ancBlockCache.has(a) ? (__ancBlockCache.get(a) || null) : null;
1696
+ __ancBlockCache.set(a, {
1697
+ struct: prev && prev.struct ? prev.struct : null,
1698
+ css: cssBlock || null,
1699
+ cssKnown: true
1700
+ });
1701
+ }
1702
+ } catch {
1703
+ __perfInc('ancestorBlockerAcc.css.nocache');
1704
+ }
1705
+ }
1706
+
1707
+ if (cssBlock === 'displayNone') return __cacheAndReturn({eligible: false, reasons: ['displayNone']});
1708
+ }
1709
+
1710
+ // visibility IS inherited (and thus invertible): a descendant with an
1711
+ // explicit visibility:visible re-renders even under a
1712
+ // visibility:hidden ancestor. The fully resolved, post-inheritance
1713
+ // value is already reflected in the target node's own computed
1714
+ // style, so this is checked on `node` directly rather than by
1715
+ // walking ancestors (which would incorrectly treat visibility like
1716
+ // the non-inherited `display` property above).
1717
+ {
1718
+ const tn = (node.tagName || '').toLowerCase();
1719
+ if (tn !== 'area') {
1720
+ const cs = computedStyle(node);
1721
+ if (cs && (cs.visibility === 'hidden' || cs.visibility === 'collapse')) {
1722
+ return __cacheAndReturn({eligible: false, reasons: ['visibilityHidden']});
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ // 4) ARIA subtree hiding with exceptions with exceptions
1728
+ let ariaHidden = false;
1729
+ for (const a of chain) {
1730
+ if (!isElement(a)) continue;
1731
+ const v = a.getAttribute && a.getAttribute('aria-hidden');
1732
+ if (v != null && String(v).trim().toLowerCase() === 'true') {
1733
+ ariaHidden = true;
1734
+ break;
1735
+ }
1736
+ }
1737
+ if (ariaHidden) {
1738
+ const idref = isReferencedByVisibleIdRef(node);
1739
+
1740
+ // IDREF exception stays
1741
+ if (idref) return __cacheAndReturn({eligible: true, reasons: ['ariaHiddenOverriddenIdref']});
1742
+
1743
+ // Only *explicit* tabbable focus (tabindex >= 0) overrides aria-hidden by default.
1744
+ // Native focusability alone does not override aria-hidden EXCEPT for specific
1745
+ // mechanisms where the engine must still evaluate (e.g. <area> in a *used* map,
1746
+ // and <input type="image">).
1747
+ const ti = parseTabIndex(node);
1748
+ if (ti.has && ti.valid && ti.value >= 0) {
1749
+ return __cacheAndReturn({eligible: true, reasons: ['ariaHiddenOverriddenTabbable']});
1750
+ }
1751
+
1752
+ // Programmatic focus (explicit tabindex < 0) does NOT override eligibility.
1753
+ if (ti.has && ti.valid && ti.value < 0) {
1754
+ return __cacheAndReturn({eligible: false, reasons: ['ariaHiddenProgrammaticFocusExcluded']});
1755
+ }
1756
+
1757
+ // Exception: allow aria-hidden override for mechanisms where the engine must
1758
+ // still evaluate required labeling/alt checks. Keep this narrowly scoped.
1759
+ const tag = (node.tagName || '').toLowerCase();
1760
+ const type = tag === 'input'
1761
+ ? ((node.getAttribute && (node.getAttribute('type') || '').toLowerCase()) || '')
1762
+ : '';
1763
+
1764
+ // Native form controls are tabbable by default (even without tabindex)
1765
+ // and are targeted by labeling checks.
1766
+ const isNativeFormControl =
1767
+ tag === 'select' ||
1768
+ tag === 'textarea' ||
1769
+ (tag === 'input' && type !== 'hidden'); // includes type=image
1770
+
1771
+ // Other elements that are natively tabbable by default (no explicit
1772
+ // tabindex required): <button>, <summary>, and <a>/<area> with a
1773
+ // non-empty href. Real browsers keep these in the tab order
1774
+ // regardless of aria-hidden — this is exactly the "aria-hidden on a
1775
+ // focusable element" anti-pattern that aria-hidden-focus.js itself
1776
+ // detects as a violation, so the eligibility model must evaluate
1777
+ // these too rather than silently excluding them. getPlatformFocusability
1778
+ // (via isPlatformFocusable) already checks the href/disabled/inert
1779
+ // conditions correctly for each of these tags.
1780
+ const isOtherNativelyFocusable =
1781
+ tag === 'button' ||
1782
+ tag === 'summary' ||
1783
+ tag === 'a';
1784
+
1785
+ if (tag === 'area' || isNativeFormControl || isOtherNativelyFocusable) {
1786
+ const f2 = getPlatformFocusability(node);
1787
+ if (f2 && f2.tabbable) {
1788
+ return __cacheAndReturn({eligible: true, reasons: ['ariaHiddenOverriddenTabbable']});
1789
+ }
1790
+ }
1791
+
1792
+ return __cacheAndReturn({eligible: false, reasons: ['ariaHidden']});
1793
+ }
1794
+
1795
+ // 5/6 handled implicitly; 7 already covered
1796
+ return __cacheAndReturn({eligible: true, reasons});
1797
+ }
1798
+
1799
+ function isDomVisibleEligible(node, _ctx, opts) {
1800
+ const reasons = [];
1801
+ const out = (visible, reasonsArr, metrics) => ({
1802
+ eligible: !!visible,
1803
+ reasons: reasonsArr.slice(0),
1804
+ metrics: metrics && typeof metrics === 'object' ? {...metrics} : {}
1805
+ });
1806
+
1807
+ if (!isElement(node)) return out(false, ['notElement'], {});
1808
+
1809
+ const __modeKey = __getDomEligibilityModeKey(opts);
1810
+ const __domCache = __getEligibilityDomCacheForScope(__modeKey);
1811
+
1812
+ const __ancBlockDomCache = __getAncestorBlockerDomCacheForScope();
1813
+ const __ancBlockStructFinalCache = __getAncestorBlockerDomStructFinalCacheForScope();
1814
+
1815
+ try {
1816
+ if (__domCache && node && typeof node === 'object' && __domCache.has(node)) {
1817
+ const c = __domCache.get(node);
1818
+ if (c && typeof c === 'object') {
1819
+ return {
1820
+ eligible: !!c.eligible,
1821
+ reasons: Array.isArray(c.reasons) ? c.reasons.slice(0) : [],
1822
+ metrics: c.metrics && typeof c.metrics === 'object' ? {...c.metrics} : {}
1823
+ };
1824
+ }
1825
+ }
1826
+ } catch {
1827
+ }
1828
+
1829
+ function __cacheAndReturn(res) {
1830
+ const outRes = {
1831
+ eligible: !!(res && res.eligible),
1832
+ reasons: (res && Array.isArray(res.reasons)) ? res.reasons.slice(0) : [],
1833
+ metrics: (res && res.metrics && typeof res.metrics === 'object') ? {...res.metrics} : {}
1834
+ };
1835
+ try {
1836
+ if (__domCache && node && typeof node === 'object') {
1837
+ __domCache.set(node, {
1838
+ eligible: outRes.eligible,
1839
+ reasons: outRes.reasons.slice(0),
1840
+ metrics: {...outRes.metrics}
1841
+ });
1842
+ }
1843
+ } catch {
1844
+ }
1845
+ return outRes;
1846
+ }
1847
+
1848
+ // 1) HTML hiding
1849
+ // Final short-circuit: reuse structural blocker result for this node when already known.
1850
+ try {
1851
+ if (__ancBlockStructFinalCache && __ancBlockStructFinalCache.has(node)) {
1852
+ __perfInc('ancestorBlockerDom.structFinal.hit');
1853
+ const r = __ancBlockStructFinalCache.get(node);
1854
+ const rr = (r != null && r !== '') ? String(r) : null;
1855
+ if (rr) return __cacheAndReturn(out(false, [rr], {}));
1856
+ } else {
1857
+ __perfInc('ancestorBlockerDom.structFinal.miss');
1858
+ }
1859
+ } catch {
1860
+ }
1861
+
1862
+ const chain = ancestorsIncludingSelf(node);
1863
+ const __domStructSeen = [];
1864
+ for (const a of chain) {
1865
+ if (!isElement(a)) continue;
1866
+
1867
+ __domStructSeen.push(a);
1868
+
1869
+ // If an ancestor already has a final structural blocker cached,
1870
+ // short-circuit immediately (this is what the test expects).
1871
+ try {
1872
+ if (__ancBlockStructFinalCache && __ancBlockStructFinalCache.has(a)) {
1873
+ __perfInc('ancestorBlockerDom.structFinal.hit');
1874
+ const r = __ancBlockStructFinalCache.get(a);
1875
+ const rr = (r != null && r !== '') ? String(r) : null;
1876
+ if (rr) {
1877
+ // Propagate to nodes we've seen on this walk (including `node`)
1878
+ try {
1879
+ for (const s of __domStructSeen) {
1880
+ if (!__ancBlockStructFinalCache.has(s)) __ancBlockStructFinalCache.set(s, rr);
1881
+ }
1882
+ } catch {
1883
+ }
1884
+ return __cacheAndReturn(out(false, [rr], {}));
1885
+ }
1886
+ }
1887
+ } catch {
1888
+ }
1889
+
1890
+ // Cached structural blockers (per scope) to short-circuit shared ancestor checks.
1891
+ let struct = null;
1892
+ try {
1893
+ if (__ancBlockDomCache && __ancBlockDomCache.has(a)) {
1894
+ __perfInc('ancestorBlockerDom.struct.hit');
1895
+ const cached = __ancBlockDomCache.get(a);
1896
+ struct = cached && cached.struct ? String(cached.struct) : null;
1897
+ } else {
1898
+ __perfInc('ancestorBlockerDom.struct.miss');
1899
+ const tn = (a.tagName || '').toLowerCase();
1900
+ if (a.hasAttribute && a.hasAttribute('hidden')) struct = 'hiddenAttr';
1901
+ else if (tn === 'template') struct = 'templateContent';
1902
+ else if (tn === 'script' || tn === 'style' || tn === 'meta' || tn === 'link' || tn === 'noscript') struct = 'nonRenderedElement';
1903
+ else if (tn === 'input') {
1904
+ const t = (a.getAttribute && (a.getAttribute('type') || '').toLowerCase()) || '';
1905
+ if (t === 'hidden') struct = 'inputHidden';
1906
+ }
1907
+ try {
1908
+ if (__ancBlockDomCache) {
1909
+ const prev = __ancBlockDomCache.has(a) ? (__ancBlockDomCache.get(a) || null) : null;
1910
+ __ancBlockDomCache.set(a, {
1911
+ struct,
1912
+ css: prev && prev.css ? prev.css : null,
1913
+ cssKnown: prev && prev.cssKnown === true ? true : false,
1914
+ visibility: prev && prev.visibility ? prev.visibility : null,
1915
+ contentVisHidden: prev && prev.contentVisHidden === true ? true : null,
1916
+ opacity: prev && typeof prev.opacity === 'number' ? prev.opacity : null
1917
+ });
1918
+ }
1919
+ } catch {
1920
+ __perfInc('ancestorBlockerDom.struct.nocache');
1921
+ }
1922
+
1923
+ }
1924
+ } catch {
1925
+ }
1926
+
1927
+ if (struct) {
1928
+ try {
1929
+ if (__ancBlockStructFinalCache) {
1930
+ for (const s of __domStructSeen) {
1931
+ if (!__ancBlockStructFinalCache.has(s)) __ancBlockStructFinalCache.set(s, struct);
1932
+ }
1933
+ }
1934
+ } catch {
1935
+ }
1936
+ return __cacheAndReturn(out(false, [struct], {}));
1937
+ }
1938
+ }
1939
+
1940
+ try {
1941
+ if (__ancBlockStructFinalCache) {
1942
+ for (const s of __domStructSeen) {
1943
+ if (!__ancBlockStructFinalCache.has(s)) __ancBlockStructFinalCache.set(s, null);
1944
+ }
1945
+ }
1946
+ } catch {
1947
+ }
1948
+
1949
+ // Closed <details> hides content visually
1950
+ if (inClosedDetailsContent(node)) return __cacheAndReturn(out(false, ['detailsClosed'], {}));
1951
+
1952
+ const visibilityMode =
1953
+ opts && opts.visibilityMode === 'pointer'
1954
+ ? 'pointer'
1955
+ : (opts && opts.visibilityMode === 'styleAndGeometry'
1956
+ ? 'styleAndGeometry'
1957
+ : 'styleOnly');
1958
+
1959
+ // CSS visibility is inherited, so the target node's own computed
1960
+ // style already reflects the fully-resolved (post-inheritance)
1961
+ // value. Checked here, before the opacity accumulation walk below,
1962
+ // so an element that is BOTH opacity:0 AND visibility:hidden (a
1963
+ // common hover/JS-reveal dropdown pattern — confirmed on a real
1964
+ // site, Getty's global nav dropdowns) is correctly reported as
1965
+ // 'visibilityHidden' rather than only 'opacityZero'. Reporting only
1966
+ // 'opacityZero' matters because callers that deliberately treat
1967
+ // opacity:0 as "still in-scope" (e.g. aria-hidden-focus, which must
1968
+ // not exclude opacity-based hiding) would otherwise see no other
1969
+ // blocking reason and wrongly conclude the element is focusable,
1970
+ // even though visibility:hidden alone already removes it from the
1971
+ // tab order in real browsers.
1972
+ {
1973
+ const nodeCs = computedStyle(node);
1974
+ if (nodeCs && (nodeCs.visibility === 'hidden' || nodeCs.visibility === 'collapse')) {
1975
+ return __cacheAndReturn(out(false, ['visibilityHidden'], {visibility: nodeCs.visibility}));
1976
+ }
1977
+ }
1978
+
1979
+ // 2) CSS visibility suppression + opacity chain
1980
+ //
1981
+ // Two passes over the SAME ancestor chain, deliberately NOT
1982
+ // interleaved: display:none (and content-visibility:hidden) are
1983
+ // absolute, un-overridable blocks — there is no CSS mechanism for a
1984
+ // descendant to un-hide itself from a display:none ancestor, unlike
1985
+ // visibility:hidden (invertible) or opacity (never a hard block by
1986
+ // this function's own design — see callers like aria-hidden-focus
1987
+ // that deliberately keep opacity:0 in-scope). A single interleaved
1988
+ // loop that returns on the FIRST blocking condition found while
1989
+ // walking outward from the target would let a CLOSER ancestor's
1990
+ // opacity:0 short-circuit before a FARTHER ancestor's display:none
1991
+ // is ever reached — silently hiding the stronger, unconditional
1992
+ // block behind the weaker, filterable one. Found via a real site:
1993
+ // BuzzFeed's carousel slides are aria-hidden with opacity:0 (by
1994
+ // design, for a fade transition) AND nested several levels inside a
1995
+ // responsive wrapper that is display:none at the simulated
1996
+ // viewport width — the opacity:0 on the closer ancestor was
1997
+ // masking the display:none on the farther one, wrongly reporting
1998
+ // only 'opacityZero' (which aria-hidden-focus filters out as
1999
+ // still-in-scope) and missing the real, unconditional
2000
+ // non-rendering. Pass 1 here checks every ancestor for a hard
2001
+ // structural CSS block first, with no early exit for opacity; pass
2002
+ // 2 (below) computes the accumulated opacity only once no hard
2003
+ // block was found anywhere in the chain.
2004
+ const __cssInfoByAncestor = new Map();
2005
+
2006
+ for (const a of chain) {
2007
+ if (!isElement(a)) continue;
2008
+
2009
+ let cssBlock = null;
2010
+ let cssKnown = false;
2011
+
2012
+ let cachedVisibility = null;
2013
+ let cachedContentVisHidden = null;
2014
+ let cachedOpacity = null;
2015
+ let cachedPointerEventsNone = null;
2016
+ let cachedPointerEventsKnown = false;
2017
+ let cs = null;
2018
+
2019
+ try {
2020
+ if (__ancBlockDomCache && __ancBlockDomCache.has(a)) {
2021
+ const cached = __ancBlockDomCache.get(a);
2022
+ if (cached) {
2023
+ // cssKnown means "we already computed display/visibility/content-visibility once"
2024
+ if (cached.cssKnown === true) {
2025
+ __perfInc('ancestorBlockerDom.css.hit');
2026
+ cssKnown = true;
2027
+ cssBlock = cached.css ? String(cached.css) : null;
2028
+ } else {
2029
+ __perfInc('ancestorBlockerDom.css.miss');
2030
+ }
2031
+
2032
+ cachedVisibility = cached.visibility != null ? String(cached.visibility) : null;
2033
+ cachedContentVisHidden = cached.contentVisHidden === true ? true : null;
2034
+ cachedOpacity = (cached && typeof cached.opacity === 'number' && Number.isFinite(cached.opacity))
2035
+ ? cached.opacity
2036
+ : null;
2037
+
2038
+ cachedPointerEventsNone = cached.pointerEventsNone === true ? true : null;
2039
+ cachedPointerEventsKnown = cached.pointerEventsKnown === true ? true : false;
2040
+ }
2041
+ } else {
2042
+ __perfInc('ancestorBlockerDom.css.miss');
2043
+ }
2044
+ } catch {
2045
+ }
2046
+
2047
+ // Compute CSS blockers (and maybe opacity) only when needed
2048
+ if (!cssKnown && cachedContentVisHidden !== true) {
2049
+ cs = computedStyle(a);
2050
+
2051
+ if (cs && cs.display === 'none') cssBlock = 'displayNone';
2052
+ else if (cs && (cs.visibility === 'hidden' || cs.visibility === 'collapse')) {
2053
+ cssBlock = 'visibilityHidden';
2054
+ cachedVisibility = cs.visibility;
2055
+ } else if (cs && cs.contentVisibility === 'hidden') {
2056
+ cssBlock = 'contentVisibilityHidden';
2057
+ cachedContentVisHidden = true;
2058
+ }
2059
+
2060
+ // NEW: parse opacity once and cache it (even if cssBlock is null)
2061
+ if (cachedOpacity == null) {
2062
+ try {
2063
+ const raw = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
2064
+ const parsed = Number.parseFloat(raw);
2065
+ if (Number.isFinite(parsed)) cachedOpacity = parsed;
2066
+ } catch {
2067
+ }
2068
+ }
2069
+
2070
+ // Pointer reachability: pointer-events:none blocks hit-testing
2071
+ if (visibilityMode === 'pointer' && !cachedPointerEventsKnown) {
2072
+ try {
2073
+ const pe = cs && cs.pointerEvents != null ? String(cs.pointerEvents).trim() : '';
2074
+ cachedPointerEventsKnown = true;
2075
+ if (pe === 'none') cachedPointerEventsNone = true;
2076
+ } catch {
2077
+ }
2078
+ }
2079
+
2080
+ try {
2081
+ if (__ancBlockDomCache) {
2082
+ const prev = __ancBlockDomCache.has(a) ? (__ancBlockDomCache.get(a) || null) : null;
2083
+ __ancBlockDomCache.set(a, {
2084
+ struct: prev && prev.struct ? prev.struct : null,
2085
+ css: cssBlock || null,
2086
+ cssKnown: true,
2087
+ visibility: cachedVisibility || (prev && prev.visibility ? prev.visibility : null),
2088
+ contentVisHidden: cachedContentVisHidden === true ? true : (prev && prev.contentVisHidden === true ? true : null),
2089
+ opacity: cachedOpacity == null ? (prev && typeof prev.opacity === 'number' ? prev.opacity : null) : cachedOpacity,
2090
+ pointerEventsNone: cachedPointerEventsNone === true ? true : (prev && prev.pointerEventsNone === true ? true : null),
2091
+ pointerEventsKnown: cachedPointerEventsKnown === true ? true : (prev && prev.pointerEventsKnown === true ? true : false)
2092
+ });
2093
+ }
2094
+ } catch {
2095
+ __perfInc('ancestorBlockerDom.css.nocache');
2096
+ }
2097
+ }
2098
+
2099
+ __cssInfoByAncestor.set(a, {cssBlock, cachedOpacity, cachedPointerEventsNone, cachedPointerEventsKnown});
2100
+
2101
+ if (cssBlock === 'displayNone') return __cacheAndReturn(out(false, ['displayNone'], {}));
2102
+ // NOTE: unlike display:none, CSS visibility is inherited and thus
2103
+ // invertible — a descendant with an explicit visibility:visible
2104
+ // re-renders even under a visibility:hidden ancestor. So an
2105
+ // ancestor's visibility:hidden must NOT short-circuit this walk;
2106
+ // the target node's own fully-resolved visibility is checked
2107
+ // once, after the loop (see below).
2108
+ if (cssBlock === 'contentVisibilityHidden') {
2109
+ return __cacheAndReturn(out(false, ['contentVisibilityHidden'], {}));
2110
+ }
2111
+ }
2112
+
2113
+ let opacityProduct = 1;
2114
+ for (const a of chain) {
2115
+ if (!isElement(a)) continue;
2116
+
2117
+ const info = __cssInfoByAncestor.get(a) || {};
2118
+ let cachedOpacity = info.cachedOpacity;
2119
+ let cachedPointerEventsNone = info.cachedPointerEventsNone;
2120
+ let cachedPointerEventsKnown = info.cachedPointerEventsKnown;
2121
+ let cs = null;
2122
+
2123
+ if (visibilityMode === 'pointer') {
2124
+ // pointer-events:none prevents the element from receiving pointer interactions
2125
+ if (cachedPointerEventsKnown === true && cachedPointerEventsNone === true) {
2126
+ return __cacheAndReturn(out(false, ['pointerEventsNone'], {}));
2127
+ }
2128
+
2129
+ if (cachedPointerEventsKnown !== true) {
2130
+ try {
2131
+ if (!cs) cs = computedStyle(a);
2132
+ const pe = cs && cs.pointerEvents != null ? String(cs.pointerEvents).trim() : '';
2133
+ cachedPointerEventsKnown = true;
2134
+ if (pe === 'none') cachedPointerEventsNone = true;
2135
+
2136
+ // Write back pointer-events status without disturbing other fields
2137
+ try {
2138
+ if (__ancBlockDomCache) {
2139
+ const prev = __ancBlockDomCache.has(a) ? (__ancBlockDomCache.get(a) || null) : null;
2140
+ if (prev) {
2141
+ __ancBlockDomCache.set(a, {
2142
+ struct: prev.struct || null,
2143
+ css: prev.css || null,
2144
+ cssKnown: prev.cssKnown === true ? true : false,
2145
+ visibility: prev.visibility || null,
2146
+ contentVisHidden: prev.contentVisHidden === true ? true : null,
2147
+ opacity: typeof prev.opacity === 'number' ? prev.opacity : null,
2148
+ pointerEventsNone: cachedPointerEventsNone === true ? true : null,
2149
+ pointerEventsKnown: cachedPointerEventsKnown === true ? true : false
2150
+ });
2151
+ } else {
2152
+ __ancBlockDomCache.set(a, {
2153
+ struct: null,
2154
+ css: null,
2155
+ cssKnown: false,
2156
+ visibility: null,
2157
+ contentVisHidden: null,
2158
+ opacity: null,
2159
+ pointerEventsNone: cachedPointerEventsNone === true ? true : null,
2160
+ pointerEventsKnown: cachedPointerEventsKnown === true ? true : false
2161
+ });
2162
+ }
2163
+ }
2164
+ } catch {}
2165
+ } catch {}
2166
+ }
2167
+
2168
+ if (cachedPointerEventsNone === true) {
2169
+ return __cacheAndReturn(out(false, ['pointerEventsNone'], {}));
2170
+ }
2171
+ }
2172
+
2173
+ // If opacity isn't cached yet, compute once and write it back even when cssBlock was cached.
2174
+ // This prevents repeated computedStyle(a) calls across many isDomVisibleEligible() invocations.
2175
+ if (cachedOpacity == null) {
2176
+ try {
2177
+ if (!cs) cs = computedStyle(a);
2178
+ const raw = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
2179
+ const parsed = Number.parseFloat(raw);
2180
+ if (Number.isFinite(parsed)) {
2181
+ cachedOpacity = parsed;
2182
+
2183
+ // Write back to cache without disturbing other fields
2184
+ try {
2185
+ if (__ancBlockDomCache) {
2186
+ const prev = __ancBlockDomCache.has(a) ? (__ancBlockDomCache.get(a) || null) : null;
2187
+ if (prev) {
2188
+ __ancBlockDomCache.set(a, {
2189
+ struct: prev.struct || null,
2190
+ css: prev.css || null,
2191
+ visibility: prev.visibility || null,
2192
+ contentVisHidden: prev.contentVisHidden === true ? true : null,
2193
+ opacity: cachedOpacity,
2194
+ pointerEventsNone: prev.pointerEventsNone === true ? true : null,
2195
+ pointerEventsKnown: prev.pointerEventsKnown === true ? true : false
2196
+ });
2197
+ } else {
2198
+ // No prior cache entry for this ancestor and no hard
2199
+ // structural block was found for it in pass 1 above
2200
+ // (pass 1 would have returned early otherwise), so
2201
+ // struct/css/visibility/contentVisHidden are all
2202
+ // known-null here.
2203
+ __ancBlockDomCache.set(a, {
2204
+ struct: null,
2205
+ css: null,
2206
+ visibility: null,
2207
+ contentVisHidden: null,
2208
+ opacity: cachedOpacity,
2209
+ pointerEventsNone: null,
2210
+ pointerEventsKnown: false
2211
+ });
2212
+ }
2213
+ }
2214
+ } catch {}
2215
+ }
2216
+ } catch {}
2217
+ }
2218
+
2219
+ // opacity handling (visual)
2220
+ const op = cachedOpacity != null ? cachedOpacity : 1;
2221
+ opacityProduct *= op;
2222
+ // Allow callers to ignore opacity-based invisibility (still focusable).
2223
+ const ignoreOpacity = !!(opts && opts.ignoreOpacity === true);
2224
+
2225
+ if (!ignoreOpacity && visibilityMode !== 'pointer' && opacityProduct <= 0.0001) {
2226
+ return __cacheAndReturn(out(false, ['opacityZero'], { opacity: opacityProduct }));
2227
+ }
2228
+ }
2229
+
2230
+ // 3) Layout/geometry (optional)
2231
+ const useGeometry =
2232
+ visibilityMode === 'pointer'
2233
+ ? !(opts && opts.disableGeometry === true)
2234
+ : (visibilityMode === 'styleAndGeometry' && !(opts && opts.disableGeometry === true));
2235
+
2236
+ if (useGeometry) {
2237
+ try {
2238
+ if (node.getClientRects) {
2239
+ const rects = node.getClientRects();
2240
+ const rectCount = rects ? rects.length : 0;
2241
+
2242
+ if (!rectCount) {
2243
+ return __cacheAndReturn(out(false, ['noClientRects'], {rectCount: 0}));
2244
+ }
2245
+
2246
+ const r = node.getBoundingClientRect ? node.getBoundingClientRect() : null;
2247
+ const w = r && Number.isFinite(r.width) ? r.width : 0;
2248
+ const h = r && Number.isFinite(r.height) ? r.height : 0;
2249
+
2250
+ if (w <= 0 || h <= 0) {
2251
+ return __cacheAndReturn(out(false, ['zeroArea'], {rectCount, width: w, height: h}));
2252
+ }
2253
+
2254
+ return __cacheAndReturn(out(true, reasons, {
2255
+ rectCount,
2256
+ width: w,
2257
+ height: h,
2258
+ opacity: opacityProduct
2259
+ }));
2260
+ }
2261
+ } catch {
2262
+ // ignore geometry failures; fall back to style-only eligibility
2263
+ }
2264
+ }
2265
+
2266
+ return __cacheAndReturn(out(true, reasons, {opacity: opacityProduct}));
2267
+ }
2268
+
2269
+ function getEligibilityInfo(node, _ctx, opts) {
2270
+ const targetSet = opts && (opts.targetSet === 'acc' || opts.targetSet === 'dom') ? opts.targetSet : 'dom';
2271
+ const r = targetSet === 'dom' ? isDomVisibleEligible(node, _ctx, opts) : isAccTreeEligible(node);
2272
+ return {
2273
+ eligible: !!(r && r.eligible),
2274
+ reasons: (r && Array.isArray(r.reasons)) ? r.reasons.slice(0) : [],
2275
+ targetSet,
2276
+ accEligible: targetSet === 'acc' ? !!(r && r.eligible) : null,
2277
+ };
2278
+ }
2279
+
2280
+ // E) IDREF helpers
2281
+ function resolveIdRefs(idrefString, _ctx, opts) {
2282
+ const raw = trim(idrefString);
2283
+ if (!raw) return {refs: [], missing: [], flags: ['empty']};
2284
+
2285
+ // Normalize whitespace for stable cache keys
2286
+ const parts = raw.split(/\s+/).filter(Boolean);
2287
+ const normKey = parts.join(' ');
2288
+
2289
+ // Root-scoped cache map
2290
+ let cacheMap = null;
2291
+ if (__idRefCacheByRoot) {
2292
+ const scopeObj = __getScopeObj();
2293
+ if (scopeObj) {
2294
+ try {
2295
+ cacheMap = __idRefCacheByRoot.get(scopeObj) || null;
2296
+ if (!cacheMap) {
2297
+ cacheMap = new Map();
2298
+ __idRefCacheByRoot.set(scopeObj, cacheMap);
2299
+ }
2300
+ } catch {
2301
+ cacheMap = null;
2302
+ }
2303
+ }
2304
+ }
2305
+
2306
+ // Cached base result is *untruncated* (opts.maxRefs applied per call)
2307
+ if (cacheMap) {
2308
+ try {
2309
+ const cached = cacheMap.get(normKey);
2310
+ if (cached && cached.refs && cached.missing && cached.flags) {
2311
+ const baseRefs = Array.isArray(cached.refs) ? cached.refs.slice(0) : [];
2312
+ const baseMissing = Array.isArray(cached.missing) ? cached.missing.slice(0) : [];
2313
+ const baseFlags = Array.isArray(cached.flags) ? cached.flags.slice(0) : [];
2314
+
2315
+ // Apply deterministic truncation if requested
2316
+ if (opts && opts.maxRefs && baseRefs.length > opts.maxRefs) {
2317
+ baseRefs.length = Math.max(0, Number(opts.maxRefs) | 0);
2318
+ baseFlags.push('truncated');
2319
+ }
2320
+
2321
+ __perfInc('idref.resolve.hit');
2322
+ return {refs: baseRefs, missing: baseMissing, flags: baseFlags};
2323
+ }
2324
+ } catch {
2325
+ // cache read errors should never throw
2326
+ }
2327
+ }
2328
+
2329
+ __perfInc(cacheMap ? 'idref.resolve.miss' : 'idref.resolve.nocache');
2330
+ // Compute base result
2331
+ const refs = [];
2332
+ const missing = [];
2333
+ const seen = new Set();
2334
+
2335
+ for (const id of parts) {
2336
+ const key = trim(id);
2337
+ if (!key) continue;
2338
+
2339
+ let el = safeDocGetById(key);
2340
+ if (!el) el = safeRootQueryById(key);
2341
+
2342
+ if (!el || !isElement(el)) {
2343
+ missing.push(key);
2344
+ continue;
2345
+ }
2346
+ if (seen.has(el)) continue;
2347
+ seen.add(el);
2348
+ refs.push(el);
2349
+ }
2350
+
2351
+ const flags = [];
2352
+ if (missing.length) flags.push('idref-missing');
2353
+ if (parts.length !== refs.length + missing.length) flags.push('deduped'); // indicates repeats
2354
+
2355
+ // Store untruncated base result
2356
+ if (cacheMap) {
2357
+ try {
2358
+ cacheMap.set(normKey, {
2359
+ refs: refs.slice(0),
2360
+ missing: missing.slice(0),
2361
+ flags: flags.slice(0),
2362
+ partsLen: parts.length
2363
+ });
2364
+ } catch {
2365
+ // ignore cache write errors
2366
+ }
2367
+ }
2368
+
2369
+ // Apply deterministic truncation per call
2370
+ if (opts && opts.maxRefs && refs.length > opts.maxRefs) {
2371
+ refs.length = Math.max(0, Number(opts.maxRefs) | 0);
2372
+ flags.push('truncated');
2373
+ }
2374
+
2375
+ return {refs, missing, flags};
2376
+ }
2377
+
2378
+ // Native "name is derived from value/alt" mechanisms, used when resolving
2379
+ // an IDREF *target*'s own text alternative (see computeIdRefTargetTextAlternative).
2380
+ function __getElementValueLikeName(el) {
2381
+ if (!isElement(el)) return '';
2382
+ const tag = (el.tagName || '').toLowerCase();
2383
+
2384
+ if (tag === 'img' || tag === 'area') {
2385
+ const alt = getAttr(el, 'alt');
2386
+ if (alt != null) {
2387
+ const t = trim(alt);
2388
+ if (t) return t;
2389
+ }
2390
+ return '';
2391
+ }
2392
+
2393
+ if (tag === 'input') {
2394
+ const type = lower(getAttr(el, 'type') || 'text');
2395
+ if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image') {
2396
+ const v = getAttr(el, 'value');
2397
+ if (v != null) {
2398
+ const t = trim(v);
2399
+ if (t) return t;
2400
+ }
2401
+ if (type === 'submit') return 'Submit';
2402
+ if (type === 'reset') return 'Reset';
2403
+ }
2404
+ }
2405
+
2406
+ return '';
2407
+ }
2408
+
2409
+ // Recursively computes an IDREF-referenced node's own text alternative,
2410
+ // per the Accessible Name and Description Computation spec (resolving a
2411
+ // reference re-applies the name-computation algorithm to the target, it
2412
+ // does not just read raw textContent — see getContentNameInfo for why
2413
+ // raw textContent misses image alt text and other attribute-sourced
2414
+ // names on descendants). `visited` guards against cycles reachable via
2415
+ // direct aria-labelledby chains (e.g. two elements labelling each
2416
+ // other); `__nameComputationDepth` additionally bounds the combined
2417
+ // depth across this function and getContentNameInfo/
2418
+ // getAccessibleNameInfo, since a hop through a target's *content* (a
2419
+ // descendant with its own aria-labelledby) starts a fresh `visited` Set
2420
+ // and would otherwise defeat that per-call guard on a genuine cycle.
2421
+ function computeIdRefTargetTextAlternative(el, visited, _ctx, opts) {
2422
+ if (!isElement(el)) return '';
2423
+ if (visited.has(el)) return '';
2424
+ visited.add(el);
2425
+ if (__nameComputationDepth >= __NAME_COMPUTATION_MAX_DEPTH) return '';
2426
+
2427
+ // Establish opts.includeHidden exactly once per aria-labelledby/aria-describedby
2428
+ // traversal, from the top-level referenced target's own hidden state -- mirrors
2429
+ // a widely-used reference engine's prepareContext, which only computes context.includeHidden when it's
2430
+ // still undefined and never overwrites it on recursive calls, so the whole
2431
+ // referenced subtree (nested labelledby chains included) shares one decision. See
2432
+ // getContentNameInfo's collect() for what this bypasses and why (real bug found via
2433
+ // Discord's live-DOM footer nav, 2026-07-23).
2434
+ let effOpts = opts;
2435
+ if (!opts || opts.includeHidden === undefined) {
2436
+ let hidden = false;
2437
+ try {
2438
+ const elig = isAccTreeEligible(el);
2439
+ hidden = !(elig && elig.eligible);
2440
+ } catch {
2441
+ hidden = false;
2442
+ }
2443
+ effOpts = Object.assign({}, opts, {includeHidden: hidden});
2444
+ }
2445
+
2446
+ __nameComputationDepth += 1;
2447
+ try {
2448
+ const ariaLabel = trim(getAttr(el, 'aria-label'));
2449
+ if (ariaLabel) return ariaLabel;
2450
+
2451
+ const labelledBy = trim(getAttr(el, 'aria-labelledby'));
2452
+ if (labelledBy) {
2453
+ const parts = labelledBy.split(/\s+/).filter(Boolean);
2454
+ const texts = [];
2455
+ for (const id of parts) {
2456
+ let ref = safeDocGetById(id);
2457
+ if (!ref) ref = safeRootQueryById(id);
2458
+ if (ref && isElement(ref)) {
2459
+ const t = computeIdRefTargetTextAlternative(ref, visited, _ctx, effOpts);
2460
+ if (t) texts.push(t);
2461
+ }
2462
+ }
2463
+ const joined = trim(texts.join(' '));
2464
+ if (joined) return joined;
2465
+ }
2466
+
2467
+ const valueLike = __getElementValueLikeName(el);
2468
+ if (valueLike) return valueLike;
2469
+
2470
+ const contentInfo = getContentNameInfo(el, _ctx, effOpts);
2471
+ if (contentInfo && contentInfo.present && contentInfo.value) return contentInfo.value;
2472
+
2473
+ const title = trim(getAttr(el, 'title'));
2474
+ if (title) return title;
2475
+
2476
+ return '';
2477
+ } finally {
2478
+ __nameComputationDepth -= 1;
2479
+ }
2480
+ }
2481
+
2482
+ function getTextFromIdRefs(idrefString, _ctx, opts) {
2483
+ const r = resolveIdRefs(idrefString, _ctx, opts);
2484
+ const texts = [];
2485
+ const visited = new Set();
2486
+ for (const el of r.refs) {
2487
+ try {
2488
+ const t = computeIdRefTargetTextAlternative(el, visited, _ctx, opts);
2489
+ if (t) texts.push(t);
2490
+ } catch {
2491
+ }
2492
+ }
2493
+ const text = trim(texts.join(' '));
2494
+ const flags = r.flags.slice(0);
2495
+ if (!text && r.refs.length) flags.push('resolved-empty-text');
2496
+ return {text, refsCount: r.refs.length, missing: r.missing.slice(0), flags};
2497
+ }
2498
+
2499
+ function isIdRefEligibleTarget(node) {
2500
+ // IDREF policy: include hidden/aria-hidden/collapsed targets,
2501
+ // exclude only inertness or non-composed.
2502
+ if (!isElement(node)) return {eligible: false, reasons: ['notElement']};
2503
+
2504
+ // NOTE: `root` is not an eligibility boundary for IDREF targets.
2505
+
2506
+ if (hasBlockingInert(node)) return {eligible: false, reasons: ['inert']};
2507
+
2508
+ return {eligible: true, reasons: []};
2509
+ }
2510
+
2511
+ function getTextFromIdRefsIdrefEligible(idrefString, _ctx, opts) {
2512
+ const r = resolveIdRefs(idrefString, _ctx, opts);
2513
+
2514
+ const texts = [];
2515
+ const excluded = []; // [{ id, reasons }]
2516
+ const visited = new Set();
2517
+ for (const el of r.refs) {
2518
+ const elig = isIdRefEligibleTarget(el);
2519
+ if (!elig.eligible) {
2520
+ const id = trim(el.getAttribute && el.getAttribute('id'));
2521
+ excluded.push({id: id || null, reasons: elig.reasons.slice(0)});
2522
+ continue;
2523
+ }
2524
+ try {
2525
+ const t = computeIdRefTargetTextAlternative(el, visited, _ctx, opts);
2526
+ if (t) texts.push(t);
2527
+ } catch {
2528
+ }
2529
+ }
2530
+
2531
+ const text = trim(texts.join(' '));
2532
+ const flags = r.flags.slice(0);
2533
+ if (!text && r.refs.length) flags.push('resolved-empty-text');
2534
+
2535
+ if (excluded.length) flags.push('idref-excluded');
2536
+
2537
+ return {
2538
+ text,
2539
+ refsCount: r.refs.length,
2540
+ missing: r.missing.slice(0),
2541
+ excluded,
2542
+ flags
2543
+ };
2544
+ }
2545
+
2546
+ // B) Accessible name / description helpers (mechanism-first, but scoped & deterministic)
2547
+ // Computes a wrapping/explicit <label>'s own text for the purpose of
2548
+ // naming ONE specific control inside it, excluding that control's own
2549
+ // subtree (matches HTML-AAM's "label text minus embedded control
2550
+ // content" and a widely-used reference engine's implicit-evaluate/explicit-evaluate, which do
2551
+ // the same exclusion via a startNode/inControlContext flag on their own
2552
+ // subtreeText recursion). Deliberately does NOT call back into
2553
+ // getAccessibleNameInfo/getContentNameInfo for descendants — only img
2554
+ // alt (getTextAlternativeInfo), aria-label (getAriaLabelInfo), and
2555
+ // aria-labelledby (getAriaLabelledByInfo) on descendants, all of which
2556
+ // are leaf-safe with respect to <label> lookups. This is intentional:
2557
+ // getAccessibleNameInfo calls this function, and getContentNameInfo's
2558
+ // own descendant walk calls getAccessibleNameInfo — if this function
2559
+ // routed back through either of those instead, a control nested inside
2560
+ // its own naming <label> (the exact case this exists to handle) would
2561
+ // recurse forever between "what's my name" and "what's my label's
2562
+ // content."
2563
+ function getLabelSubtreeNameInfo(labelEl, excludeEl, _ctx, opts) {
2564
+ if (!isElement(labelEl)) return {present: false, value: '', mechanism: 'none', flags: []};
2565
+
2566
+ const parts = [];
2567
+ let guardCount = 0;
2568
+
2569
+ function isImageLikeNode(node) {
2570
+ const tag = lower(node.tagName);
2571
+ const type = tag === 'input' ? lower(getAttr(node, 'type')) : '';
2572
+ return tag === 'img' || tag === 'area' || (tag === 'input' && type === 'image');
2573
+ }
2574
+
2575
+ function walk(node) {
2576
+ if (node === excludeEl) return; // exclude the target control's own subtree
2577
+ guardCount += 1;
2578
+ if (guardCount > 5000) return;
2579
+
2580
+ if (node.nodeType === 3) {
2581
+ const t = trim(node.nodeValue);
2582
+ if (t) parts.push(t);
2583
+ return;
2584
+ }
2585
+ if (!isElement(node)) return;
2586
+
2587
+ let eligible = true;
2588
+ try {
2589
+ const r = isAccTreeEligible(node);
2590
+ eligible = !!(r && r.eligible);
2591
+ } catch {
2592
+ eligible = true;
2593
+ }
2594
+ if (!eligible) return;
2595
+
2596
+ const al = getAriaLabelInfo(node);
2597
+ if (al && al.present && al.value) {
2598
+ parts.push(al.value);
2599
+ return;
2600
+ }
2601
+ const alb = getAriaLabelledByInfo(node, _ctx, opts);
2602
+ if (alb && alb.present && alb.value) {
2603
+ parts.push(alb.value);
2604
+ return;
2605
+ }
2606
+
2607
+ if (isImageLikeNode(node)) {
2608
+ const alt = getTextAlternativeInfo(node, _ctx, opts);
2609
+ if (alt && alt.present && alt.value) parts.push(alt.value);
2610
+ return;
2611
+ }
2612
+
2613
+ const kids = node.childNodes ? Array.from(node.childNodes) : [];
2614
+ for (const kid of kids) walk(kid);
2615
+ }
2616
+
2617
+ try {
2618
+ const kids = labelEl.childNodes ? Array.from(labelEl.childNodes) : [];
2619
+ for (const kid of kids) walk(kid);
2620
+ } catch {
2621
+ }
2622
+
2623
+ const value = trim(parts.join(' ').replace(/\s+/g, ' '));
2624
+ return {present: !!value, value, mechanism: 'label', flags: value ? [] : ['empty']};
2625
+ }
2626
+
2627
+ function getAccessibleNameInfo(el, _ctx, opts) {
2628
+ const flags = [];
2629
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
2630
+
2631
+ const key = __getNameOptsKey(opts);
2632
+ try {
2633
+ if (__accessibleNameCacheByKey && __accessibleNameCacheByKey.has(key)) {
2634
+ const wm = __accessibleNameCacheByKey.get(key);
2635
+ if (wm && wm instanceof WeakMap && wm.has(el)) {
2636
+ const c = wm.get(el);
2637
+ if (c && typeof c === 'object') {
2638
+ __perfInc('accessibleName.hit');
2639
+ return {
2640
+ present: !!c.present,
2641
+ value: c.value == null ? '' : String(c.value),
2642
+ mechanism: c.mechanism || 'none',
2643
+ flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
2644
+ };
2645
+ }
2646
+ }
2647
+ }
2648
+ } catch {
2649
+ }
2650
+ __perfInc('accessibleName.miss');
2651
+
2652
+ const aria = getAriaNameInfo(el, _ctx, opts);
2653
+ if (aria && aria.present && aria.value) {
2654
+ const out = {
2655
+ present: true,
2656
+ value: aria.value,
2657
+ mechanism: aria.mechanism,
2658
+ flags: flags.concat(aria.flags || [])
2659
+ };
2660
+ try {
2661
+ if (__accessibleNameCacheByKey) {
2662
+ const wm = __accessibleNameCacheByKey.get(key) || (__accessibleNameCacheByKey.set(key, new WeakMap()), __accessibleNameCacheByKey.get(key));
2663
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2664
+ present: !!out.present,
2665
+ value: out.value,
2666
+ mechanism: out.mechanism,
2667
+ flags: out.flags.slice(0)
2668
+ });
2669
+ }
2670
+ } catch {
2671
+ }
2672
+ return out;
2673
+ }
2674
+ if (aria && aria.flags && aria.flags.length) {
2675
+ for (const f of aria.flags) flags.push(f);
2676
+ }
2677
+
2678
+ // Native <label> association via the HTML `.labels` API, which
2679
+ // resolves BOTH `<label for="...">` and wrapping `<label>...</label>`
2680
+ // in one call, for any genuinely labelable element (button, input,
2681
+ // meter, output, progress, select, textarea — `.labels` is simply
2682
+ // absent/undefined on anything else, so this never over-triggers).
2683
+ // Found via a real page: DeviantArt's settings toggles wrap a
2684
+ // description div and an unlabeled icon-only <button aria-pressed>
2685
+ // in one <label> — a spec-valid naming mechanism (HTML lists
2686
+ // <button> as labelable) that a widely-used reference engine's
2687
+ // button-name rule already checks (its implicit-label/explicit-label checks) but this engine
2688
+ // wasn't checking at all, since the id-based lookup below only ever
2689
+ // handled explicit for="" and this button has no id to begin with.
2690
+ try {
2691
+ if (el.labels && el.labels.length) {
2692
+ for (const labelEl of Array.from(el.labels)) {
2693
+ const info = getLabelSubtreeNameInfo(labelEl, el, _ctx, opts);
2694
+ if (info.present && info.value) {
2695
+ const out = {present: true, value: info.value, mechanism: 'label', flags};
2696
+ try {
2697
+ if (__accessibleNameCacheByKey) {
2698
+ const wm = __accessibleNameCacheByKey.get(key) || (__accessibleNameCacheByKey.set(key, new WeakMap()), __accessibleNameCacheByKey.get(key));
2699
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2700
+ present: true,
2701
+ value: out.value,
2702
+ mechanism: out.mechanism,
2703
+ flags: out.flags.slice(0)
2704
+ });
2705
+ }
2706
+ } catch {
2707
+ }
2708
+ return out;
2709
+ }
2710
+ }
2711
+ }
2712
+ } catch {
2713
+ }
2714
+
2715
+ // Explicit <label for="..."> or wrapping <label> (common and deterministic for form controls)
2716
+ // (fallback for elements where `.labels` isn't natively available,
2717
+ // e.g. a non-native-labelable element like <div role="button" id="x">
2718
+ // still explicitly pointed at by <label for="x">.)
2719
+ const id = trim(getAttr(el, 'id'));
2720
+ if (id) {
2721
+ // Prefer indexed lookup (1 build per run) over repeated querySelector per element.
2722
+ const entry = __lookupLabelForId(id, key);
2723
+ if (entry && entry.exists) {
2724
+ const lt = entry.text || '';
2725
+ if (lt) {
2726
+ const out = {present: true, value: lt, mechanism: 'label', flags};
2727
+ try {
2728
+ if (__accessibleNameCacheByKey) {
2729
+ const wm = __accessibleNameCacheByKey.get(key) || (__accessibleNameCacheByKey.set(key, new WeakMap()), __accessibleNameCacheByKey.get(key));
2730
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2731
+ present: true,
2732
+ value: out.value,
2733
+ mechanism: out.mechanism,
2734
+ flags: out.flags.slice(0)
2735
+ });
2736
+ }
2737
+ } catch {
2738
+ }
2739
+ return out;
2740
+ }
2741
+ // If label exists but is empty, fall through (matches prior behavior: empty label doesn't produce a name).
2742
+ }
2743
+ }
2744
+
2745
+
2746
+ // POLICY NOTE (2026-07-23, revisit if ever reconsidered): title is accepted here as a
2747
+ // last-resort accessible-name source, matching HTML-AAM/accname and a widely-used
2748
+ // reference engine's own behavior (confirmed by reading that engine's source -- several
2749
+ // of its rules explicitly accept a
2750
+ // non-empty title, e.g. image-alt's non-empty-title check). This is a deliberate,
2751
+ // spec-compliant choice, not an oversight -- but title is a genuinely weak mechanism in
2752
+ // practice (no touch/mobile exposure, inconsistent screen-reader support, no visible
2753
+ // affordance for sighted users), and this is the shared function nearly every
2754
+ // accessible-name-dependent rule in the engine goes through. Kept for spec/reference-engine-parity
2755
+ // rather than removed outright; flagged here so it isn't silently load-bearing.
2756
+ const title = trim(getAttr(el, 'title'));
2757
+ if (title) {
2758
+ flags.push('title-used');
2759
+ const out = {present: true, value: title, mechanism: 'title', flags};
2760
+ try {
2761
+ if (__accessibleNameCacheByKey) {
2762
+ const wm = __accessibleNameCacheByKey.get(key) || (__accessibleNameCacheByKey.set(key, new WeakMap()), __accessibleNameCacheByKey.get(key));
2763
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2764
+ present: true,
2765
+ value: out.value,
2766
+ mechanism: out.mechanism,
2767
+ flags: out.flags.slice(0)
2768
+ });
2769
+ }
2770
+ } catch {
2771
+ }
2772
+ return out;
2773
+ }
2774
+
2775
+ const out = {present: false, value: '', mechanism: 'none', flags};
2776
+ try {
2777
+ if (__accessibleNameCacheByKey) {
2778
+ const wm = __accessibleNameCacheByKey.get(key) || (__accessibleNameCacheByKey.set(key, new WeakMap()), __accessibleNameCacheByKey.get(key));
2779
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2780
+ present: false,
2781
+ value: '',
2782
+ mechanism: 'none',
2783
+ flags: out.flags.slice(0)
2784
+ });
2785
+ }
2786
+ } catch {
2787
+ }
2788
+ return out;
2789
+ }
2790
+
2791
+ function getAccessibleDescriptionInfo(el, _ctx, opts) {
2792
+ const flags = [];
2793
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
2794
+
2795
+ const key = __getDescOptsKey(opts);
2796
+ try {
2797
+ if (__accessibleDescCacheByKey && __accessibleDescCacheByKey.has(key)) {
2798
+ const wm = __accessibleDescCacheByKey.get(key);
2799
+ if (wm && wm instanceof WeakMap && wm.has(el)) {
2800
+ const c = wm.get(el);
2801
+ if (c && typeof c === 'object') {
2802
+ __perfInc('accessibleDesc.hit');
2803
+ return {
2804
+ present: !!c.present,
2805
+ value: c.value == null ? '' : String(c.value),
2806
+ mechanism: c.mechanism || 'none',
2807
+ flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
2808
+ };
2809
+ }
2810
+ }
2811
+ }
2812
+ } catch {
2813
+ }
2814
+ __perfInc('accessibleDesc.miss');
2815
+
2816
+ const describedBy = trim(getAttr(el, 'aria-describedby'));
2817
+ if (describedBy) {
2818
+ const t = getTextFromIdRefs(describedBy, _ctx, opts);
2819
+ for (const f of t.flags) flags.push(f);
2820
+ if (t.text) {
2821
+ const out = {present: true, value: t.text, mechanism: 'aria-describedby', flags};
2822
+ try {
2823
+ if (__accessibleDescCacheByKey) {
2824
+ const wm = __accessibleDescCacheByKey.get(key) || (__accessibleDescCacheByKey.set(key, new WeakMap()), __accessibleDescCacheByKey.get(key));
2825
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2826
+ present: true,
2827
+ value: out.value,
2828
+ mechanism: out.mechanism,
2829
+ flags: out.flags.slice(0)
2830
+ });
2831
+ }
2832
+ } catch {
2833
+ }
2834
+ return out;
2835
+ }
2836
+ flags.push('empty');
2837
+ }
2838
+
2839
+ const allowTitle = !!(opts && opts.allowTitle === true);
2840
+ if (allowTitle) {
2841
+ const title = trim(getAttr(el, 'title'));
2842
+ if (title) {
2843
+ flags.push('title-used');
2844
+ const out = {present: true, value: title, mechanism: 'title', flags};
2845
+ try {
2846
+ if (__accessibleDescCacheByKey) {
2847
+ const wm = __accessibleDescCacheByKey.get(key) || (__accessibleDescCacheByKey.set(key, new WeakMap()), __accessibleDescCacheByKey.get(key));
2848
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2849
+ present: true,
2850
+ value: out.value,
2851
+ mechanism: out.mechanism,
2852
+ flags: out.flags.slice(0)
2853
+ });
2854
+ }
2855
+ } catch {
2856
+ }
2857
+ return out;
2858
+ }
2859
+ }
2860
+
2861
+ const out = {present: false, value: '', mechanism: 'none', flags};
2862
+ try {
2863
+ if (__accessibleDescCacheByKey) {
2864
+ const wm = __accessibleDescCacheByKey.get(key) || (__accessibleDescCacheByKey.set(key, new WeakMap()), __accessibleDescCacheByKey.get(key));
2865
+ if (wm && wm instanceof WeakMap) wm.set(el, {
2866
+ present: false,
2867
+ value: '',
2868
+ mechanism: 'none',
2869
+ flags: out.flags.slice(0)
2870
+ });
2871
+ }
2872
+ } catch {
2873
+ }
2874
+ return out;
2875
+ }
2876
+
2877
+ // <canvas> fallback content is the element's *children*, not just its
2878
+ // rendered text — a documented HTML5 technique is an equivalent <img
2879
+ // alt="..."> (or similarly self-describing element) inside <canvas>.
2880
+ // textContent alone misses that, since alt text isn't part of it.
2881
+ function __hasMeaningfulCanvasFallbackDescendant(container) {
2882
+ try {
2883
+ if (!container || !container.querySelectorAll) return false;
2884
+
2885
+ const imgs = container.querySelectorAll('img[alt]');
2886
+ for (const img of imgs) {
2887
+ if (trim(img.getAttribute && img.getAttribute('alt'))) return true;
2888
+ }
2889
+
2890
+ const areas = container.querySelectorAll('area[alt]');
2891
+ for (const area of areas) {
2892
+ if (trim(area.getAttribute && area.getAttribute('alt'))) return true;
2893
+ }
2894
+
2895
+ const named = container.querySelectorAll('[aria-label]');
2896
+ for (const n of named) {
2897
+ if (trim(n.getAttribute && n.getAttribute('aria-label'))) return true;
2898
+ }
2899
+
2900
+ return false;
2901
+ } catch {
2902
+ return false;
2903
+ }
2904
+ }
2905
+
2906
+ // C) Text alternative helper (mechanism-aware by element/type)
2907
+ function getTextAlternativeInfo(el, _ctx, opts) {
2908
+ const flags = [];
2909
+ if (!isElement(el)) {
2910
+ return {
2911
+ present: false,
2912
+ value: '',
2913
+ mechanism: 'unsupported',
2914
+ requiredMechanism: 'unknown',
2915
+ flags: ['notElement']
2916
+ };
2917
+ }
2918
+
2919
+ const tag = lower(el.tagName);
2920
+ const type = tag === 'input' ? lower(getAttr(el, 'type')) : '';
2921
+
2922
+ const isImageLike =
2923
+ tag === 'img' ||
2924
+ tag === 'area' ||
2925
+ (tag === 'input' && type === 'image');
2926
+
2927
+ if (isImageLike) {
2928
+ const altRaw = getAttr(el, 'alt');
2929
+ const altPresent = altRaw != null;
2930
+ const altText = trim(altRaw);
2931
+
2932
+ if (altPresent) {
2933
+ if (!altText) flags.push('alt-empty');
2934
+ return {
2935
+ present: true,
2936
+ value: altText,
2937
+ mechanism: 'alt',
2938
+ requiredMechanism: 'alt',
2939
+ flags
2940
+ };
2941
+ }
2942
+
2943
+ // Missing alt is a real issue even if an accessible name exists.
2944
+ const name = getAccessibleNameInfo(el, _ctx, opts);
2945
+ if (name && name.present && name.value) flags.push('name-present-but-alt-missing');
2946
+ flags.push('alt-missing');
2947
+
2948
+ return {
2949
+ present: false,
2950
+ value: name && name.present ? (name.value || '') : '',
2951
+ mechanism: name && name.present ? 'accessible-name' : 'none',
2952
+ requiredMechanism: 'alt',
2953
+ flags: flags.concat((name && name.flags) ? name.flags.slice(0) : [])
2954
+ };
2955
+ }
2956
+
2957
+ if (tag === 'canvas') {
2958
+ const fallbackText = trim(el.textContent || '');
2959
+ if (fallbackText || __hasMeaningfulCanvasFallbackDescendant(el)) {
2960
+ return {
2961
+ present: true,
2962
+ value: fallbackText || 'fallback-content',
2963
+ mechanism: 'canvas-fallback',
2964
+ requiredMechanism: 'fallback-or-name',
2965
+ flags
2966
+ };
2967
+ }
2968
+
2969
+ // <canvas> is not a labelable element (no browser computes an
2970
+ // accessible name from <label for="...">), so only ARIA naming
2971
+ // (and title, as a generic last-resort accname source) count —
2972
+ // unlike getAccessibleNameInfo, which also accepts native
2973
+ // <label> associations.
2974
+ const aria = getAriaNameInfo(el, _ctx, opts);
2975
+ if (aria && aria.present && aria.value) {
2976
+ return {
2977
+ present: true,
2978
+ value: aria.value,
2979
+ mechanism: aria.mechanism || 'aria',
2980
+ requiredMechanism: 'fallback-or-name',
2981
+ flags: flags.concat(aria.flags ? aria.flags.slice(0) : [])
2982
+ };
2983
+ }
2984
+
2985
+ const title = trim(getAttr(el, 'title'));
2986
+ if (title) {
2987
+ return {
2988
+ present: true,
2989
+ value: title,
2990
+ mechanism: 'title',
2991
+ requiredMechanism: 'fallback-or-name',
2992
+ flags: flags.concat(['title-used'])
2993
+ };
2994
+ }
2995
+
2996
+ return {
2997
+ present: false,
2998
+ value: '',
2999
+ mechanism: 'none',
3000
+ requiredMechanism: 'fallback-or-name',
3001
+ flags: flags.concat(aria && aria.flags ? aria.flags.slice(0) : [])
3002
+ };
3003
+ }
3004
+
3005
+ return {
3006
+ present: false,
3007
+ value: '',
3008
+ mechanism: 'unsupported',
3009
+ requiredMechanism: 'unknown',
3010
+ flags: ['unsupported-element']
3011
+ };
3012
+ }
3013
+
3014
+ // C.1) "Name from content" — recursive accname-aligned content-name computation.
3015
+ //
3016
+ // Rationale: the accname spec's "name from content" step (2F) is recursive —
3017
+ // for each child node, use that CHILD's own accessible name if it has one
3018
+ // (aria-label/aria-labelledby/native <label>/title, or `alt` for image-like
3019
+ // elements) rather than only concatenating literal text nodes. A naive
3020
+ // TreeWalker(SHOW_TEXT)-only walk (the pattern previously duplicated across
3021
+ // the *-name-present rule family) misses any descendant that gets its name
3022
+ // from an attribute rather than visible text — the single most common
3023
+ // real-world case being `<a href="..."><img alt="Company Name"></a>` (a
3024
+ // logo link) and `<button><span role="img" aria-label="Close"></span></button>`
3025
+ // (an icon-only button). Both were confirmed to false-positive under the
3026
+ // old text-node-only approach before this helper was added.
3027
+ function getContentNameInfo(el, _ctx, opts) {
3028
+ const flags = [];
3029
+ if (!isElement(el)) return {present: false, value: '', mechanism: 'unsupported', flags: ['notElement']};
3030
+
3031
+ // Shares __nameComputationDepth with computeIdRefTargetTextAlternative
3032
+ // — see that function's header comment for why a single per-call
3033
+ // guard isn't enough on its own.
3034
+ if (__nameComputationDepth >= __NAME_COMPUTATION_MAX_DEPTH) {
3035
+ return {present: false, value: '', mechanism: 'none', flags: ['depth-limit']};
3036
+ }
3037
+
3038
+ const maxNodes = (opts && Number.isFinite(opts.maxContentNodes)) ? Math.max(1, opts.maxContentNodes) : 5000;
3039
+ let visitedCount = 0;
3040
+ let truncated = false;
3041
+
3042
+ function isImageLikeNode(node) {
3043
+ const tag = lower(node.tagName);
3044
+ const type = tag === 'input' ? lower(getAttr(node, 'type')) : '';
3045
+ return tag === 'img' || tag === 'area' || (tag === 'input' && type === 'image');
3046
+ }
3047
+
3048
+ function collect(node, parts) {
3049
+ if (truncated) return;
3050
+ visitedCount += 1;
3051
+ if (visitedCount > maxNodes) {
3052
+ truncated = true;
3053
+ if (flags.indexOf('truncated') === -1) flags.push('truncated');
3054
+ return;
3055
+ }
3056
+
3057
+ if (node.nodeType === 3) {
3058
+ const t = trim(node.nodeValue);
3059
+ if (t) parts.push(t);
3060
+ return;
3061
+ }
3062
+
3063
+ if (!isElement(node)) return;
3064
+
3065
+ // Skip anything not exposed to the accessibility tree (hidden,
3066
+ // aria-hidden, display:none, inert, etc.) — same scope as
3067
+ // isAccTreeEligible, so a hidden descendant never contributes.
3068
+ //
3069
+ // Exception: opts.includeHidden (set by computeIdRefTargetTextAlternative
3070
+ // when the aria-labelledby/aria-describedby TARGET itself is hidden) skips
3071
+ // this check entirely except for genuinely non-rendered tags. Per the accname
3072
+ // spec, a directly-referenced target's own hidden state doesn't block name
3073
+ // computation, and per a widely-used reference engine's own
3074
+ // prepareContext/context.includeHidden (verified by reading that engine's source
3075
+ // directly), that bypass covers the target's whole
3076
+ // subtree, not just the target element itself -- confirmed via a real page
3077
+ // (Discord's footer, 2026-07-23): four <nav aria-labelledby="...">, each
3078
+ // referencing a CSS-hidden (display:none, a responsive/interaction-gated
3079
+ // dropdown toggle) heading with genuinely distinct text ("Product"/"Company"/
3080
+ // "Resources"/"Policies"). surea11y's own isAccTreeEligible correctly treats
3081
+ // each toggle's hidden text as ineligible on its own terms (nothing wrong with
3082
+ // that check in isolation), but applying it while resolving what a
3083
+ // labelledby-referencing element is NAMED disagreed with that reference engine's real,
3084
+ // spec-aligned "pass" (distinct names) -- surea11y saw all four as unnamed and collapsed them
3085
+ // into one false "not unique" cluster (landmark-unique, and any other
3086
+ // rule resolving an aria-labelledby name through a hidden target, e.g. dialog/
3087
+ // tab/menuitem-name-present).
3088
+ if (opts && opts.includeHidden) {
3089
+ const tag = lower(node.tagName);
3090
+ if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') return;
3091
+ } else {
3092
+ let eligible = true;
3093
+ try {
3094
+ const eligRes = isAccTreeEligible(node);
3095
+ eligible = !!(eligRes && eligRes.eligible);
3096
+ } catch {
3097
+ eligible = true;
3098
+ }
3099
+ if (!eligible) return;
3100
+ }
3101
+
3102
+ if (isImageLikeNode(node)) {
3103
+ // aria-labelledby/aria-label take priority over alt per the
3104
+ // accname spec (HTML-AAM) — checked first. Found via a real
3105
+ // page (eBay's product-card links): an <img alt=""
3106
+ // aria-labelledby="..."> pointing to real product-title text
3107
+ // was contributing nothing to the parent <a>'s content name,
3108
+ // since getTextAlternativeInfo alone only ever looks at alt
3109
+ // (by design, for the separate "is alt present" question
3110
+ // img-alt-present cares about) and an empty-but-present alt
3111
+ // short-circuited before aria-labelledby was ever checked.
3112
+ const ariaName = getAccessibleNameInfo(node, _ctx, opts);
3113
+ if (ariaName && ariaName.present && ariaName.value) {
3114
+ parts.push(ariaName.value);
3115
+ if (flags.indexOf('descendant-name-used:image-aria') === -1) flags.push('descendant-name-used:image-aria');
3116
+ return;
3117
+ }
3118
+ const alt = getTextAlternativeInfo(node, _ctx, opts);
3119
+ if (alt && alt.present && alt.value) {
3120
+ parts.push(alt.value);
3121
+ if (flags.indexOf('descendant-alt-used') === -1) flags.push('descendant-alt-used');
3122
+ }
3123
+ return; // image-like elements have no meaningful children to recurse into
3124
+ }
3125
+
3126
+ const ownName = getAccessibleNameInfo(node, _ctx, opts);
3127
+ if (ownName && ownName.present && ownName.value) {
3128
+ parts.push(ownName.value);
3129
+ const tag = `descendant-name-used:${ownName.mechanism || 'unknown'}`;
3130
+ if (flags.indexOf(tag) === -1) flags.push(tag);
3131
+ return; // this descendant speaks for itself; don't also use its content
3132
+ }
3133
+
3134
+ // A <slot>'s own childNodes are its FALLBACK content only —
3135
+ // rendered solely when nothing is assigned to it. When real
3136
+ // content IS distributed into it, that's what's actually
3137
+ // rendered/exposed to the accessibility tree, and it lives
3138
+ // elsewhere in the light DOM, not as this node's children.
3139
+ // Found via a real page (Shoelace's <sl-button>, whose shadow
3140
+ // root renders <a part="base"><slot name="prefix">...
3141
+ // <slot part="label">...<slot name="suffix">...</a> — walking
3142
+ // the slots' own (empty) childNodes found nothing, when the
3143
+ // button's real accessible name ("Follow") was a plain light-DOM
3144
+ // text node assigned to the unnamed middle slot).
3145
+ if (lower(node.tagName) === 'slot' && typeof node.assignedNodes === 'function') {
3146
+ let assigned = [];
3147
+ try {
3148
+ assigned = node.assignedNodes({flatten: true}) || [];
3149
+ } catch {
3150
+ assigned = [];
3151
+ }
3152
+ const kids = assigned.length ? assigned : (node.childNodes ? Array.from(node.childNodes) : []);
3153
+ for (const kid of kids) {
3154
+ collect(kid, parts);
3155
+ if (truncated) break;
3156
+ }
3157
+ return;
3158
+ }
3159
+
3160
+ const kids = node.childNodes ? Array.from(node.childNodes) : [];
3161
+ for (const kid of kids) {
3162
+ collect(kid, parts);
3163
+ if (truncated) break;
3164
+ }
3165
+ }
3166
+
3167
+ const parts = [];
3168
+ __nameComputationDepth += 1;
3169
+ try {
3170
+ const topKids = el.childNodes ? Array.from(el.childNodes) : [];
3171
+ for (const kid of topKids) {
3172
+ collect(kid, parts);
3173
+ if (truncated) break;
3174
+ }
3175
+ } finally {
3176
+ __nameComputationDepth -= 1;
3177
+ }
3178
+
3179
+ const value = trim(parts.join(' ').replace(/\s+/g, ' '));
3180
+ return {
3181
+ present: !!value,
3182
+ value,
3183
+ mechanism: value ? 'content' : 'none',
3184
+ flags
3185
+ };
3186
+ }
3187
+
3188
+ // D) Role + focusability helpers
3189
+ function getRoleInfo(el, _ctx, opts) {
3190
+ const flags = [];
3191
+ if (!isElement(el)) return {role: '', source: 'none', flags: ['notElement']};
3192
+
3193
+ const explicit = trim(getAttr(el, 'role'));
3194
+ if (explicit) {
3195
+ const v = explicit;
3196
+ const low = v.toLowerCase();
3197
+ if (low === 'presentation' || low === 'none') flags.push('presentation');
3198
+ // Minimal sanity: role token should not contain spaces beyond role list; keep deterministic
3199
+ if (/\s/.test(v)) flags.push('multiple-roles');
3200
+ return {role: v, source: 'explicit', flags};
3201
+ }
3202
+
3203
+ const allowImplicit = !(opts && opts.disallowImplicit === true);
3204
+ if (!allowImplicit) return {role: '', source: 'none', flags};
3205
+
3206
+ const tag = lower(el.tagName);
3207
+ const type = tag === 'input' ? lower(getAttr(el, 'type')) : '';
3208
+ const href = tag === 'a' || tag === 'area' ? trim(getAttr(el, 'href')) : '';
3209
+
3210
+ // Minimal implicit mapping (expand later if needed, but keep stable and small).
3211
+ let role = '';
3212
+ if ((tag === 'a' || tag === 'area') && href) role = 'link';
3213
+ else if (tag === 'button') role = 'button';
3214
+ else if (tag === 'summary') role = 'button';
3215
+ else if (tag === 'input') {
3216
+ if (type === 'checkbox') role = 'checkbox';
3217
+ else if (type === 'radio') role = 'radio';
3218
+ else if (type === 'range') role = 'slider';
3219
+ else if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image') role = 'button';
3220
+ else if (type !== 'hidden') role = 'textbox';
3221
+ } else if (tag === 'select') role = 'combobox';
3222
+ else if (tag === 'textarea') role = 'textbox';
3223
+
3224
+ if (role) return {role, source: 'implicit', flags};
3225
+ return {role: '', source: 'none', flags};
3226
+ }
3227
+
3228
+ function getFocusableInfo(el, _ctx, opts) {
3229
+ // Allocation-minimal merge: avoid chained concat() which creates intermediate arrays.
3230
+ if (!isElement(el)) return {focusable: false, tabbable: false, mechanism: 'none', flags: ['notElement']};
3231
+
3232
+ const pf = getPlatformFocusability(el); // returns focusable + tabbable + mechanism + flags
3233
+
3234
+ // Merge flags deterministically (stable order: local flags, then pf.flags)
3235
+ const outFlags = [];
3236
+ // (No local flags today; keep structure for forward compatibility without extra allocations.)
3237
+ if (pf && Array.isArray(pf.flags) && pf.flags.length) {
3238
+ for (let i = 0; i < pf.flags.length; i++) outFlags.push(pf.flags[i]);
3239
+ }
3240
+
3241
+ return {
3242
+ focusable: !!(pf && pf.focusable),
3243
+ tabbable: !!(pf && pf.tabbable),
3244
+ mechanism: (pf && pf.mechanism) || 'none',
3245
+ flags: outFlags
3246
+ };
3247
+ }
3248
+
3249
+ function getVisibilityHintsInfo(el, _ctx, opts) {
3250
+ // Deterministic, style-only visibility hints for triage.
3251
+ // Does NOT decide eligibility; checks decide outcomes.
3252
+ // Uses computedStyle() which is already scope-cached.
3253
+
3254
+ if (!isElement(el)) return {hints: [], metrics: {}, flags: ['notElement']};
3255
+
3256
+ // Cache per element per run
3257
+ try {
3258
+ if (__visibilityHintsCache && __visibilityHintsCache.has(el)) {
3259
+ __perfInc('visibilityHints.hit');
3260
+ const c = __visibilityHintsCache.get(el);
3261
+ if (c && typeof c === 'object') {
3262
+ return {
3263
+ hints: Array.isArray(c.hints) ? c.hints.slice(0) : [],
3264
+ metrics: c.metrics && typeof c.metrics === 'object' ? {...c.metrics} : {},
3265
+ flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
3266
+ };
3267
+ }
3268
+ }
3269
+ } catch {
3270
+ // ignore
3271
+ }
3272
+
3273
+ __perfInc('visibilityHints.miss');
3274
+
3275
+ const hints = [];
3276
+ const metrics = {};
3277
+ const flags = [];
3278
+
3279
+ const cs = computedStyle(el) || {};
3280
+
3281
+ // opacity:0
3282
+ try {
3283
+ const raw = cs.opacity != null ? String(cs.opacity).trim() : '';
3284
+ const op = raw ? Number.parseFloat(raw) : 1;
3285
+ if (Number.isFinite(op)) metrics.opacity = op;
3286
+ if (Number.isFinite(op) && op <= 0.0001) hints.push('opacityZero');
3287
+ } catch {
3288
+ flags.push('opacity-parse-failed');
3289
+ }
3290
+
3291
+ // clip / clip-path
3292
+ try {
3293
+ const clip = cs.clip != null ? String(cs.clip).trim() : '';
3294
+ const clipPath = cs.clipPath != null ? String(cs.clipPath).trim() : '';
3295
+
3296
+ const clipLow = clip.toLowerCase();
3297
+ const clipPathLow = clipPath.toLowerCase();
3298
+
3299
+ if (clipLow && clipLow !== 'auto') {
3300
+ // Detect common visually-hidden: rect(0,0,0,0)
3301
+ const norm = clipLow.replace(/\s+/g, '');
3302
+ if (norm.indexOf('rect(') !== -1 && norm.indexOf('rect(0') !== -1) hints.push('clipped');
3303
+ }
3304
+
3305
+ if (clipPathLow && clipPathLow !== 'none') {
3306
+ // Detect common visually-hidden: inset(50%) / inset(100%)
3307
+ if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('50%') !== -1 || clipPathLow.indexOf('100%') !== -1)) {
3308
+ hints.push('clipped');
3309
+ }
3310
+ }
3311
+
3312
+ if (clip) metrics.clip = clip;
3313
+ if (clipPath) metrics.clipPath = clipPath;
3314
+ } catch {
3315
+ flags.push('clip-parse-failed');
3316
+ }
3317
+
3318
+ // zero-size + overflow hidden/clip
3319
+ try {
3320
+ const wv = cs.width != null ? String(cs.width).trim() : '';
3321
+ const hv = cs.height != null ? String(cs.height).trim() : '';
3322
+ const ov = cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
3323
+
3324
+ metrics.width = wv || null;
3325
+ metrics.height = hv || null;
3326
+ metrics.overflow = ov || null;
3327
+
3328
+ const isZeroW = wv === '0px' || wv === '0';
3329
+ const isZeroH = hv === '0px' || hv === '0';
3330
+ const hidesOverflow = ov === 'hidden' || ov === 'clip';
3331
+ if ((isZeroW || isZeroH) && hidesOverflow) hints.push('zeroSizeOverflowHidden');
3332
+ } catch {
3333
+ flags.push('size-parse-failed');
3334
+ }
3335
+
3336
+ // offscreen heuristic (string-based; no geometry)
3337
+ try {
3338
+ const pos = cs.position != null ? String(cs.position).trim().toLowerCase() : '';
3339
+ const left = cs.left != null ? String(cs.left).trim().toLowerCase() : '';
3340
+ const top = cs.top != null ? String(cs.top).trim().toLowerCase() : '';
3341
+ const ti = cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
3342
+
3343
+ metrics.position = pos || null;
3344
+ metrics.left = left || null;
3345
+ metrics.top = top || null;
3346
+ metrics.textIndent = ti || null;
3347
+
3348
+ const parsePx = (s) => {
3349
+ if (!s || s === 'auto') return null;
3350
+ const m = String(s).match(/-?\d+(\.\d+)?/);
3351
+ if (!m) return null;
3352
+ const n = Number.parseFloat(m[0]);
3353
+ return Number.isFinite(n) ? n : null;
3354
+ };
3355
+
3356
+ const l = parsePx(left);
3357
+ const t = parsePx(top);
3358
+ const ind = parsePx(ti);
3359
+
3360
+ if (pos === 'absolute' || pos === 'fixed') {
3361
+ if ((l != null && l <= -5000) || (t != null && t <= -5000)) hints.push('offscreen');
3362
+ }
3363
+ if (ind != null && ind <= -5000) hints.push('offscreen');
3364
+ } catch {
3365
+ flags.push('offscreen-parse-failed');
3366
+ }
3367
+
3368
+ // Dedupe hints, stable order
3369
+ const order = ['opacityZero', 'offscreen', 'clipped', 'zeroSizeOverflowHidden'];
3370
+ const seen = new Set();
3371
+ const stable = [];
3372
+ for (const k of order) {
3373
+ if (hints.indexOf(k) !== -1 && !seen.has(k)) {
3374
+ seen.add(k);
3375
+ stable.push(k);
3376
+ }
3377
+ }
3378
+
3379
+ const out = {hints: stable, metrics, flags};
3380
+
3381
+ try {
3382
+ if (__visibilityHintsCache) {
3383
+ __visibilityHintsCache.set(el, {
3384
+ hints: stable.slice(0),
3385
+ metrics: {...metrics},
3386
+ flags: flags.slice(0)
3387
+ });
3388
+ }
3389
+ } catch {
3390
+ __perfInc('visibilityHints.nocache');
3391
+ }
3392
+
3393
+ return out;
3394
+ }
3395
+
3396
+ // Back-compat: keep existing helper but implement via new name helper.
3397
+ function hasAccessibleName(el) {
3398
+ const info = getAccessibleNameInfo(el);
3399
+ return !!(info && info.present && trim(info.value));
3400
+ }
3401
+
3402
+ function createSelectorUniqIndex() {
3403
+ const idCount = new Map();
3404
+ const testIdCount = new Map(); // data-testid + data-test + data-cy + data-qa
3405
+ const nameCount = new Map(); // key: tag|name
3406
+ const ariaLabelCount = new Map(); // key: tag|aria-label
3407
+ const roleAriaLabelCount = new Map(); // key: role|aria-label
3408
+
3409
+ const sel = '[id],[data-testid],[data-test],[data-cy],[data-qa],[name],[aria-label],[role]';
3410
+ let nodes;
3411
+ if (typeof queryAllSmart === 'function') {
3412
+ nodes = queryAllSmart(sel) || [];
3413
+ } else {
3414
+ // Defensive fallback (queryAllSmart is always defined in this
3415
+ // module, so this branch is not expected to run) -- loop every
3416
+ // resolved root rather than assuming a single scope element.
3417
+ nodes = [];
3418
+ const seen = new Set();
3419
+ for (const r of roots) {
3420
+ if (!r || !r.querySelectorAll) continue;
3421
+ for (const el of r.querySelectorAll(sel)) {
3422
+ if (el && !seen.has(el)) { seen.add(el); nodes.push(el); }
3423
+ }
3424
+ }
3425
+ if (!nodes.length && !roots.length && document) {
3426
+ nodes = Array.from(document.querySelectorAll(sel));
3427
+ }
3428
+ }
3429
+
3430
+ const inc = (map, key) => map.set(key, (map.get(key) || 0) + 1);
3431
+
3432
+ for (const el of nodes) {
3433
+ if (!el || el.nodeType !== 1) continue;
3434
+
3435
+ const tag = (el.tagName || '').toLowerCase();
3436
+
3437
+ const elementId = el.getAttribute('id');
3438
+ if (elementId && elementId.trim()) inc(idCount, elementId.trim());
3439
+
3440
+ for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
3441
+ const v = el.getAttribute(a);
3442
+ if (v && v.trim()) inc(testIdCount, a + '=' + v.trim());
3443
+ }
3444
+
3445
+ const name = el.getAttribute('name');
3446
+ if (name && name.trim() && tag) inc(nameCount, tag + '|' + name.trim());
3447
+
3448
+ const aria = el.getAttribute('aria-label');
3449
+ if (aria && aria.trim() && tag) inc(ariaLabelCount, tag + '|' + aria.trim());
3450
+
3451
+ const role = el.getAttribute('role');
3452
+ if (role && role.trim() && aria && aria.trim()) {
3453
+ inc(roleAriaLabelCount, role.trim() + '|' + aria.trim());
3454
+ }
3455
+ }
3456
+
3457
+ return {idCount, testIdCount, nameCount, ariaLabelCount, roleAriaLabelCount};
3458
+ }
3459
+
3460
+ function buildSimpleSelector(el, fallbackTag) {
3461
+ try {
3462
+ if (!el || el.nodeType !== 1) return fallbackTag || 'html';
3463
+
3464
+ const tag = (el.tagName || fallbackTag || 'html').toLowerCase();
3465
+
3466
+ const cssEscapeIdent = __cssEscapeIdent;
3467
+
3468
+ const escapeAttrValue = __escapeAttrValue;
3469
+
3470
+ const elementId = el.getAttribute && el.getAttribute('id');
3471
+ if (elementId && elementId.trim()) return '#' + cssEscapeIdent(elementId.trim());
3472
+
3473
+ for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
3474
+ const v = el.getAttribute && el.getAttribute(a);
3475
+ if (v && v.trim()) return '[' + a + '="' + escapeAttrValue(v.trim()) + '"]';
3476
+ }
3477
+
3478
+ const name = el.getAttribute && el.getAttribute('name');
3479
+ if (name && name.trim()) return tag + '[name="' + escapeAttrValue(name.trim()) + '"]';
3480
+
3481
+ return tag;
3482
+ } catch {
3483
+ return fallbackTag || 'html';
3484
+ }
3485
+ }
3486
+
3487
+ function getUniqIndex() {
3488
+ const scopeObj = __getScopeObj();
3489
+ if (!scopeObj || !__uniqIndexByScope) {
3490
+ __perfInc('uniqIndex.nocache');
3491
+ // Fallback: build per call (should be rare; determinism preserved)
3492
+ return createSelectorUniqIndex();
3493
+ }
3494
+
3495
+ // Partitioned by __selectorOptsKey: the index's counts depend on
3496
+ // includeShadowDom/excludeSelectors (via queryAllSmart), so a scope
3497
+ // reused across runs with different options must not share indices.
3498
+ let perScope = null;
3499
+ try {
3500
+ perScope = __uniqIndexByScope.get(scopeObj);
3501
+ if (!(perScope instanceof Map)) {
3502
+ perScope = new Map();
3503
+ __uniqIndexByScope.set(scopeObj, perScope);
3504
+ }
3505
+ } catch {
3506
+ __perfInc('uniqIndex.nocache');
3507
+ return createSelectorUniqIndex();
3508
+ }
3509
+
3510
+ const cached = perScope.get(__selectorOptsKey);
3511
+ if (cached) {
3512
+ __perfInc('uniqIndex.hit');
3513
+ return cached;
3514
+ }
3515
+
3516
+ __perfInc('uniqIndex.miss');
3517
+ const idx = createSelectorUniqIndex();
3518
+ try {
3519
+ perScope.set(__selectorOptsKey, idx);
3520
+ } catch { /* ignore */
3521
+ }
3522
+ __perfInc('uniqIndex.build');
3523
+ return idx;
3524
+ }
3525
+
3526
+ function buildSelectorUncached(el) {
3527
+ const escapeAttrValue = __escapeAttrValue;
3528
+ try {
3529
+ if (!el || el.nodeType !== 1) return 'html';
3530
+
3531
+ const cssEscape = __cssEscapeIdent;
3532
+
3533
+ const idx = getUniqIndex();
3534
+ const tag = (el.tagName || '').toLowerCase();
3535
+
3536
+ const uniqueIdSel = () => {
3537
+ const elementId = el.getAttribute('id');
3538
+ if (!elementId || !elementId.trim()) return null;
3539
+ const v = elementId.trim();
3540
+ if (idx && (idx.idCount.get(v) || 0) === 1) return '#' + cssEscape(v);
3541
+ return null;
3542
+ };
3543
+
3544
+ const uniqueTestSel = () => {
3545
+ for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
3546
+ const v = el.getAttribute(a);
3547
+ if (!v || !v.trim()) continue;
3548
+ const key = a + '=' + v.trim();
3549
+ if (idx && (idx.testIdCount.get(key) || 0) === 1) {
3550
+ return '[' + a + '="' + escapeAttrValue(v.trim()) + '"]';
3551
+ }
3552
+ }
3553
+ return null;
3554
+ };
3555
+
3556
+ const uniqueNameSel = () => {
3557
+ const v = el.getAttribute('name');
3558
+ if (!v || !v.trim() || !tag) return null;
3559
+ const key = tag + '|' + v.trim();
3560
+ if (idx && (idx.nameCount.get(key) || 0) === 1) return tag + '[name="' + escapeAttrValue(v.trim()) + '"]';
3561
+ return null;
3562
+ };
3563
+
3564
+ const uniqueAriaSel = () => {
3565
+ const v = el.getAttribute('aria-label');
3566
+ if (!v || !v.trim() || !tag) return null;
3567
+ const key = tag + '|' + v.trim();
3568
+ if (idx && (idx.ariaLabelCount.get(key) || 0) === 1) return tag + '[aria-label="' + escapeAttrValue(v.trim()) + '"]';
3569
+ return null;
3570
+ };
3571
+
3572
+ const uniqueRoleAriaSel = () => {
3573
+ const role = el.getAttribute('role');
3574
+ const aria = el.getAttribute('aria-label');
3575
+ if (!role || !role.trim() || !aria || !aria.trim()) return null;
3576
+ const key = role.trim() + '|' + aria.trim();
3577
+ if (idx && (idx.roleAriaLabelCount.get(key) || 0) === 1) {
3578
+ return '[role="' + escapeAttrValue(role.trim()) + '"][aria-label="' + escapeAttrValue(aria.trim()) + '"]';
3579
+ }
3580
+ return null;
3581
+ };
3582
+
3583
+ const direct =
3584
+ uniqueIdSel() ||
3585
+ uniqueTestSel() ||
3586
+ uniqueRoleAriaSel() ||
3587
+ uniqueNameSel() ||
3588
+ uniqueAriaSel();
3589
+
3590
+ if (direct) return direct;
3591
+
3592
+ const parts = [];
3593
+
3594
+ function nthOfType(node) {
3595
+ const t = (node.tagName || '').toLowerCase() || '*';
3596
+ const p = node.parentElement;
3597
+ if (!p) return t;
3598
+
3599
+ let i = 1;
3600
+ let sib = node.previousElementSibling;
3601
+ while (sib) {
3602
+ if ((sib.tagName || '').toLowerCase() === t) i++;
3603
+ sib = sib.previousElementSibling;
3604
+ }
3605
+
3606
+ // A same-tag sibling before this node (i > 1) already means
3607
+ // an unqualified tag selector would be ambiguous — no need
3608
+ // to also scan forward in that case. Only scan
3609
+ // nextElementSibling when this node is the first of its tag
3610
+ // among its siblings, to catch the case where the
3611
+ // disambiguating sibling comes after it instead.
3612
+ let hasSame = i > 1;
3613
+ if (!hasSame) {
3614
+ sib = node.nextElementSibling;
3615
+ while (sib) {
3616
+ if ((sib.tagName || '').toLowerCase() === t) {
3617
+ hasSame = true;
3618
+ break;
3619
+ }
3620
+ sib = sib.nextElementSibling;
3621
+ }
3622
+ }
3623
+ return hasSame ? t + ':nth-of-type(' + i + ')' : t;
3624
+ }
3625
+
3626
+ let node = el;
3627
+ let safety = 0;
3628
+
3629
+ while (node && node.nodeType === 1 && safety++ < 20) {
3630
+ let anchor = null;
3631
+
3632
+ if (node !== el) {
3633
+ const t = (node.tagName || '').toLowerCase();
3634
+ const id = node.getAttribute('id');
3635
+ if (id && id.trim() && idx && (idx.idCount.get(id.trim()) || 0) === 1) anchor = '#' + cssEscape(id.trim());
3636
+ if (!anchor) {
3637
+ for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
3638
+ const v = node.getAttribute(a);
3639
+ if (v && v.trim() && idx && (idx.testIdCount.get(a + '=' + v.trim()) || 0) === 1) {
3640
+ anchor = '[' + a + '="' + escapeAttrValue(v.trim()) + '"]';
3641
+ break;
3642
+ }
3643
+ }
3644
+ }
3645
+ if (!anchor) {
3646
+ const name = node.getAttribute('name');
3647
+ if (name && name.trim() && t && idx && (idx.nameCount.get(t + '|' + name.trim()) || 0) === 1) {
3648
+ anchor = t + '[name="' + escapeAttrValue(name.trim()) + '"]';
3649
+ }
3650
+ }
3651
+ if (!anchor) {
3652
+ const aria = node.getAttribute('aria-label');
3653
+ if (aria && aria.trim() && t && idx && (idx.ariaLabelCount.get(t + '|' + aria.trim()) || 0) === 1) {
3654
+ anchor = t + '[aria-label="' + escapeAttrValue(aria.trim()) + '"]';
3655
+ }
3656
+ }
3657
+ }
3658
+
3659
+ if (node === el) {
3660
+ parts.unshift(nthOfType(node));
3661
+ } else if (anchor) {
3662
+ parts.unshift(anchor);
3663
+ break;
3664
+ } else {
3665
+ parts.unshift(nthOfType(node));
3666
+ }
3667
+
3668
+ if (!node.parentElement || roots.includes(node)) break;
3669
+ node = node.parentElement;
3670
+ }
3671
+
3672
+ const candidate = parts.join(' > ') || (tag || 'html');
3673
+
3674
+ // Verify the constructed selector string actually resolves to
3675
+ // `el` per the CSS engine's own semantics — a real safety net,
3676
+ // since some selector engines (observed in jsdom) disagree with
3677
+ // this function's own :nth-of-type sibling counting in edge
3678
+ // cases. `el.matches(candidate)` checks exactly that (does the
3679
+ // engine agree this element satisfies the string we built) at a
3680
+ // cost bounded by el's own ancestor-chain depth.
3681
+ //
3682
+ // This intentionally does NOT re-verify global uniqueness via a
3683
+ // whole-document query: every path segment above pins an exact
3684
+ // position relative to its own parent via `>` (child, not
3685
+ // descendant) combinators, so a correctly-matching chain can
3686
+ // only resolve to one element short of a malformed document
3687
+ // (e.g. two <html> roots). Re-deriving that guarantee via a
3688
+ // document-wide :nth-of-type scan was measured to cost O(total
3689
+ // same-tag siblings) per call — pathological on pages with many
3690
+ // flat, unidentified siblings (e.g. hundreds of unlabeled
3691
+ // <img>s), while contributing no realistic additional safety.
3692
+ try {
3693
+ if (el && typeof el.matches === 'function' && el.matches(candidate)) return candidate;
3694
+ } catch {
3695
+ }
3696
+
3697
+ return buildSimpleSelector(el, tag || 'html');
3698
+ } catch {
3699
+ return 'html';
3700
+ }
3701
+ }
3702
+
3703
+ function buildSelector(el) {
3704
+ const cache = __getSelectorCacheForOpts();
3705
+ try {
3706
+ if (cache && el && typeof el === 'object' && cache.has(el)) {
3707
+ __perfInc('selector.hit');
3708
+ return cache.get(el) || 'html';
3709
+ }
3710
+ } catch {
3711
+ }
3712
+ __perfInc('selector.miss');
3713
+ const sel = buildSelectorUncached(el);
3714
+ try {
3715
+ if (cache && el && typeof el === 'object') cache.set(el, sel);
3716
+ } catch {
3717
+ }
3718
+ return sel;
3719
+ }
3720
+
3721
+ // Sibling-index path from documentElement's descendants down to `el`
3722
+ // ([] if `el` IS the documentElement); null if `el` is falsy or detached
3723
+ // in a way that makes indexing impossible. A more robust element-identity
3724
+ // mechanism than a CSS selector string alone (survives some DOM changes
3725
+ // a selector wouldn't -- e.g. an id/class rename), at the cost of not
3726
+ // being usable as a real CSS selector itself. Deliberately mirrors the
3727
+ // same algorithm used by this project's (external) cross-engine
3728
+ // result-matching tooling exactly, rather than requiring it --
3729
+ // this file must stay self-contained (embedded into the generated
3730
+ // runtime via .toString(), no module requires survive that), so a
3731
+ // correctness fix to the algorithm must be applied to both copies.
3732
+ function structuralPath(el) {
3733
+ if (!el || typeof el !== 'object') return null;
3734
+ const path = [];
3735
+ let node = el;
3736
+ try {
3737
+ while (node && node.parentElement) {
3738
+ const parent = node.parentElement;
3739
+ const idx = Array.prototype.indexOf.call(parent.children, node);
3740
+ if (idx < 0) return null;
3741
+ path.unshift(idx);
3742
+ node = parent;
3743
+ }
3744
+ } catch {
3745
+ return null;
3746
+ }
3747
+ return path;
3748
+ }
3749
+
3750
+ // Occurrence-level structural path: prefers the actual element reference
3751
+ // (exact, no re-resolution risk) and only falls back to re-resolving via
3752
+ // the occurrence's own selector when no element reference was kept --
3753
+ // the same technique the cross-engine live-DOM adapters already use to
3754
+ // recover an element from a reported selector, with the same accepted
3755
+ // caveat (a non-unique selector could resolve to a different element
3756
+ // than originally intended -- already documented as "structural-path
3757
+ // collisions" for the cross-engine tooling).
3758
+ function buildStructuralPath(node, selector) {
3759
+ if (node && typeof node === 'object') {
3760
+ const p = structuralPath(node);
3761
+ if (p) return p;
3762
+ }
3763
+ if (selector && typeof selector === 'string' && document && typeof document.querySelector === 'function') {
3764
+ let el = null;
3765
+ try {
3766
+ el = document.querySelector(selector);
3767
+ } catch {
3768
+ el = null;
3769
+ }
3770
+ if (el) return structuralPath(el);
3771
+ }
3772
+ return null;
3773
+ }
3774
+
3775
+ function getNonEmptyTitle(el) {
3776
+ if (!getAttributeInfo) return null;
3777
+ try {
3778
+ const info = getAttributeInfo(el, 'title');
3779
+ const v = info && info.present ? trim(info.value) : '';
3780
+ return v ? v : null;
3781
+ } catch {
3782
+ return null;
3783
+ }
3784
+ }
3785
+
3786
+ function isPlaceholderCapable(el) {
3787
+ // Per HTML, `placeholder` is only a name/hint source for text-entry
3788
+ // input types and <textarea> — browsers/AT ignore it on other input
3789
+ // types (checkbox, radio, range, color, date, file, ...) and on
3790
+ // <select>, so it must not be treated as an accessible-name source
3791
+ // for those.
3792
+ try {
3793
+ if (!isElement(el)) return false;
3794
+ const tag = (el.tagName || '').toLowerCase();
3795
+ if (tag === 'textarea') return true;
3796
+ if (tag !== 'input') return false;
3797
+ const type = (el.getAttribute && (el.getAttribute('type') || 'text') || 'text').toLowerCase().trim();
3798
+ const t = type || 'text';
3799
+ return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === 'number';
3800
+ } catch {
3801
+ return false;
3802
+ }
3803
+ }
3804
+
3805
+ function getNonEmptyPlaceholder(el) {
3806
+ if (!getAttributeInfo) return null;
3807
+ if (!isPlaceholderCapable(el)) return null;
3808
+ try {
3809
+ const info = getAttributeInfo(el, 'placeholder');
3810
+ const v = info && info.present ? trim(info.value) : '';
3811
+ return v ? v : null;
3812
+ } catch {
3813
+ return null;
3814
+ }
3815
+ }
3816
+
3817
+ // A <label> contributes a name to its associated control either via its
3818
+ // own aria-label/aria-labelledby (checked first, same precedence any
3819
+ // element's accessible name gives ARIA over content — verified against
3820
+ // a real page: <label aria-label="Toggle Navigation"><svg
3821
+ // aria-hidden="true">...</svg></label> names its control "Toggle
3822
+ // Navigation" even though the label's only child content is aria-
3823
+ // hidden) or, failing that, its rendered content (getContentNameInfo,
3824
+ // which already excludes aria-hidden/display:none/inert descendants —
3825
+ // e.g. <label><input><span aria-hidden="true">Accept</span></label>
3826
+ // gives the control no name despite the DOM association existing).
3827
+ function labelContributesAccessibleName(lab) {
3828
+ try {
3829
+ const aria = getAriaNameInfo(lab, null, {});
3830
+ if (aria && aria.present && trim(aria.value)) return true;
3831
+ } catch {
3832
+ }
3833
+ try {
3834
+ const info = getContentNameInfo(lab, null, {});
3835
+ return !!(info && info.present && trim(info.value));
3836
+ } catch {
3837
+ return true; // conservative on error: don't newly fail
3838
+ }
3839
+ }
3840
+
3841
+ function hasLabelAssociation(el) {
3842
+ // Deterministic, stable subset:
3843
+ // - <label for="id">
3844
+ // - wrapping <label> ... <input> ...
3845
+ // A structural association alone isn't enough — see
3846
+ // labelContributesAccessibleName above for what counts.
3847
+ if (!isElement(el)) return false;
3848
+
3849
+ try {
3850
+ if (__labelAssociationCache && el && typeof el === 'object' && __labelAssociationCache.has(el)) {
3851
+ __perfInc('labelAssociation.hit');
3852
+ return !!__labelAssociationCache.get(el);
3853
+ }
3854
+ } catch {
3855
+ }
3856
+
3857
+ __perfInc('labelAssociation.miss');
3858
+ let out = false;
3859
+ let associatedLabels = [];
3860
+
3861
+ // Prefer the native `.labels` API — resolves both wrapping <label>
3862
+ // and <label for="id"> association in one call, as real elements.
3863
+ try {
3864
+ if (el && 'labels' in el && el.labels && el.labels.length) {
3865
+ associatedLabels = Array.prototype.slice.call(el.labels);
3866
+ }
3867
+ } catch {
3868
+ }
3869
+
3870
+ if (!associatedLabels.length) {
3871
+ // Fallback for environments without a working `.labels` API:
3872
+ // structural-only (pre-existing behavior, no content check —
3873
+ // __lookupLabelForId's cache doesn't retain an element ref).
3874
+ const id = trim(getAttr(el, 'id'));
3875
+ if (id) {
3876
+ const entry = __lookupLabelForId(id, '__default__');
3877
+ if (entry && entry.exists) out = true;
3878
+ }
3879
+
3880
+ if (!out && el.closest) {
3881
+ try {
3882
+ const wrap = el.closest('label');
3883
+ if (wrap && isElement(wrap)) associatedLabels = [wrap];
3884
+ } catch {
3885
+ }
3886
+ }
3887
+ }
3888
+
3889
+ if (!out && associatedLabels.length) {
3890
+ out = associatedLabels.some(labelContributesAccessibleName);
3891
+ }
3892
+
3893
+ try {
3894
+ if (__labelAssociationCache && el && typeof el === 'object') __labelAssociationCache.set(el, !!out);
3895
+ } catch {
3896
+ }
3897
+
3898
+ return out;
3899
+ }
3900
+
3901
+ function getLabelMethod(el, _ctx, opts) {
3902
+ // returns { method, value } where value is best-effort text, deterministically trimmed
3903
+ if (!isElement(el)) return {method: 'none', value: null};
3904
+
3905
+ try {
3906
+ if (__labelMethodCache && el && typeof el === 'object' && __labelMethodCache.has(el)) {
3907
+ __perfInc('labelMethod.hit');
3908
+ const c = __labelMethodCache.get(el);
3909
+ if (c && typeof c === 'object') {
3910
+ return {method: c.method || 'none', value: c.value == null ? null : String(c.value)};
3911
+ }
3912
+ }
3913
+ } catch {
3914
+ }
3915
+
3916
+ __perfInc('labelMethod.miss');
3917
+ let out = {method: 'none', value: null};
3918
+
3919
+ if (hasLabelAssociation(el)) out = {method: 'label', value: null};
3920
+ else if (getAriaLabelledByInfo) {
3921
+ try {
3922
+ const info = getAriaLabelledByInfo(el, _ctx, {maxRefs: 8});
3923
+ const v = info && info.present ? trim(info.value) : '';
3924
+ if (v) out = {method: 'aria-labelledby', value: v};
3925
+ } catch {
3926
+ }
3927
+ }
3928
+
3929
+ if (out.method === 'none' && getAriaLabelInfo) {
3930
+ try {
3931
+ const info = getAriaLabelInfo(el);
3932
+ const v = info && info.present ? trim(info.value) : '';
3933
+ if (v) out = {method: 'aria-label', value: v};
3934
+ } catch {
3935
+ }
3936
+ }
3937
+
3938
+ if (out.method === 'none') {
3939
+ const titleV = getNonEmptyTitle(el);
3940
+ if (titleV) out = {method: 'title', value: titleV};
3941
+ }
3942
+
3943
+ if (out.method === 'none') {
3944
+ const phV = getNonEmptyPlaceholder(el);
3945
+ if (phV) out = {method: 'placeholder', value: phV};
3946
+ }
3947
+
3948
+ try {
3949
+ if (__labelMethodCache && el && typeof el === 'object') {
3950
+ __labelMethodCache.set(el, {method: out.method, value: out.value});
3951
+ }
3952
+ } catch {
3953
+ }
3954
+
3955
+ return out;
3956
+ }
3957
+
3958
+ function getLabelStrength(method) {
3959
+ // policy choice; this is deterministic and tweakable
3960
+ if (method === 'label' || method === 'aria-labelledby') return 'strong';
3961
+ if (method === 'aria-label') return 'medium';
3962
+ if (method === 'title' || method === 'placeholder') return 'weak';
3963
+ return 'none';
3964
+ }
3965
+
3966
+ function reportOccurrence(node, partial) {
3967
+ const o = (partial && typeof partial === 'object' && !Array.isArray(partial)) ? { ...partial } : {};
3968
+ // Attach the node for engine-side finalization. This must be removed later before returning results.
3969
+ o.__node = node || null;
3970
+ return o;
3971
+ }
3972
+
3973
+ let __contrastSharedCache = {};
3974
+ try {
3975
+ // In Node/JSDOM checks, the harness sets global.window/global.document.
3976
+ // The engine may instantiate helpers per rule without passing opts.window,
3977
+ // so we must be able to recover the stable realm window to share caches.
3978
+ const w =
3979
+ realmWindow ||
3980
+ (document && document.defaultView) ||
3981
+ (typeof global !== 'undefined' && global.window ? global.window : null);
3982
+
3983
+ if (w) {
3984
+ if (!w.__a11ycoreSharedCache) w.__a11ycoreSharedCache = {};
3985
+ if (!w.__a11ycoreSharedCache.contrast) w.__a11ycoreSharedCache.contrast = {};
3986
+ __contrastSharedCache = w.__a11ycoreSharedCache.contrast;
3987
+ }
3988
+ } catch {
3989
+ __contrastSharedCache = {};
3990
+ }
3991
+
3992
+ const __contrastShared = {
3993
+ trim,
3994
+ computedStyle,
3995
+ composedParent,
3996
+ buildSimpleSelector,
3997
+ __contrastSharedCache
3998
+ };
3999
+
4000
+ const contrast = createContrastHelpers(
4001
+ {window: realmWindow || window, document, root: roots, includeShadowDom, excludeSelectors},
4002
+ __contrastShared
4003
+ );
4004
+
4005
+ // Expose shared cache to checks (deterministic, in-memory only)
4006
+ contrast.sharedCache = __contrastShared.__contrastSharedCache;
4007
+
4008
+ const aria = createAriaHelpers(
4009
+ {window: realmWindow || window, document, root: roots},
4010
+ {trim}
4011
+ );
4012
+
4013
+ return {
4014
+ // Existing query/snippet utilities
4015
+ queryAll,
4016
+ queryAllDeep,
4017
+ queryAllSmart,
4018
+ getOuterHtmlSnippet,
4019
+ buildSimpleSelector,
4020
+ buildSelector,
4021
+ buildStructuralPath,
4022
+
4023
+ // Existing (back-compat)
4024
+ hasAccessibleName,
4025
+ isExcluded,
4026
+ isAccTreeEligible,
4027
+ isDomVisibleEligible,
4028
+
4029
+ // Eligibility info wrapper
4030
+ getEligibilityInfo,
4031
+
4032
+ // IDREF primitives
4033
+ resolveIdRefs,
4034
+ getTextFromIdRefs,
4035
+ getTextFromIdRefsIdrefEligible,
4036
+
4037
+ // ARIA-only name primitives (new)
4038
+ getAriaLabelInfo,
4039
+ getAriaLabelledByInfo,
4040
+ getAriaNameInfo,
4041
+
4042
+ // Landmark-role naming (aria-label -> aria-labelledby -> title; no content fallback --
4043
+ // see getLandmarkNameInfo's own header comment for why this replaced 7 duplicated copies)
4044
+ getLandmarkNameInfo,
4045
+
4046
+ // Name / description
4047
+ getAccessibleNameInfo,
4048
+ getAccessibleDescriptionInfo,
4049
+
4050
+ // Text alternatives
4051
+ getTextAlternativeInfo,
4052
+
4053
+ // Recursive "name from content" (accname-aligned; see getContentNameInfo header comment)
4054
+ getContentNameInfo,
4055
+
4056
+ // Role / focusability
4057
+ getRoleInfo,
4058
+ getFocusableInfo,
4059
+ getVisibilityHintsInfo,
4060
+
4061
+ getAttributeInfo,
4062
+
4063
+ getLabelMethod, getLabelStrength,
4064
+
4065
+ // Flat-tree ancestor walk (assignedSlot-aware, then shadow host) —
4066
+ // see this function's own definition above for why assignedSlot
4067
+ // must win over parentNode.
4068
+ composedParent,
4069
+
4070
+ // Perf counters (only populated when opts.perfStats === true)
4071
+ getPerfStats,
4072
+ resetPerfStats,
4073
+
4074
+ reportOccurrence,
4075
+
4076
+ contrast,
4077
+ aria
4078
+ };
4079
+ }
4080
+
4081
+ module.exports = {
4082
+ normalizeSelectorList,
4083
+ resolveContextRoots,
4084
+ createDomHelpers
4085
+ };