@surea11y/core 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +87 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/CI_INTEGRATIONS.md +7 -7
  7. package/docs/ENGINE_OPTIONS.md +1 -1
  8. package/docs/I18N.md +12 -9
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +1 -1
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_CATALOG.md +6 -6
  13. package/package.json +52 -16
  14. package/src/baseline.js +0 -0
  15. package/src/checks/automatic/area-alt-present.js +4 -6
  16. package/src/checks/automatic/aria-allowed-attr.js +663 -134
  17. package/src/checks/automatic/aria-allowed-role.js +2 -0
  18. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  19. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  20. package/src/checks/automatic/aria-deprecated-role.js +107 -38
  21. package/src/checks/automatic/aria-hidden-body.js +6 -4
  22. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  23. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  24. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  25. package/src/checks/automatic/aria-required-attr.js +6 -7
  26. package/src/checks/automatic/aria-required-children.js +7 -10
  27. package/src/checks/automatic/aria-required-parent.js +20 -25
  28. package/src/checks/automatic/aria-role-name-present.js +2 -0
  29. package/src/checks/automatic/aria-roles-valid.js +33 -6
  30. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  31. package/src/checks/automatic/aria-valid-attr.js +2 -0
  32. package/src/checks/automatic/autocomplete-valid.js +39 -1
  33. package/src/checks/automatic/avoid-inline-spacing.js +181 -20
  34. package/src/checks/automatic/binary-control-name-present.js +13 -3
  35. package/src/checks/automatic/button-name-present.js +54 -21
  36. package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
  37. package/src/checks/automatic/combobox-name-present.js +10 -1
  38. package/src/checks/automatic/contrast-computable.js +2 -0
  39. package/src/checks/automatic/contrast-enhanced.js +2 -0
  40. package/src/checks/automatic/contrast-minimum.js +2 -0
  41. package/src/checks/automatic/css-orientation-lock.js +21 -27
  42. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  43. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  44. package/src/checks/automatic/dialog-name-present.js +18 -11
  45. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  46. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  47. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  48. package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
  49. package/src/checks/automatic/form-control-single-label.js +110 -43
  50. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  51. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  52. package/src/checks/automatic/iframe-name-present.js +2 -0
  53. package/src/checks/automatic/iframe-title-unique.js +3 -1
  54. package/src/checks/automatic/img-alt-present.js +23 -18
  55. package/src/checks/automatic/input-image-alt-present.js +101 -52
  56. package/src/checks/automatic/label-in-name.js +97 -27
  57. package/src/checks/automatic/language-page-present.js +7 -1
  58. package/src/checks/automatic/link-in-text-block.js +2 -0
  59. package/src/checks/automatic/link-name-present.js +52 -17
  60. package/src/checks/automatic/list-children-valid.js +14 -24
  61. package/src/checks/automatic/listbox-name-present.js +10 -1
  62. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  63. package/src/checks/automatic/menuitem-name-present.js +10 -1
  64. package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
  65. package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
  66. package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
  67. package/src/checks/automatic/meter-name-present.js +12 -4
  68. package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
  69. package/src/checks/automatic/object-text-alternative-present.js +16 -7
  70. package/src/checks/automatic/option-name-present.js +10 -1
  71. package/src/checks/automatic/page-title-present.js +2 -0
  72. package/src/checks/automatic/progressbar-name-present.js +16 -11
  73. package/src/checks/automatic/role-img-alt-present.js +4 -4
  74. package/src/checks/automatic/searchbox-name-present.js +10 -1
  75. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  76. package/src/checks/automatic/slider-name-present.js +13 -2
  77. package/src/checks/automatic/spinbutton-name-present.js +10 -1
  78. package/src/checks/automatic/summary-name-present.js +10 -1
  79. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  80. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  81. package/src/checks/automatic/tab-name-present.js +10 -1
  82. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  83. package/src/checks/automatic/table-th-has-data-cells.js +69 -6
  84. package/src/checks/automatic/target-size-minimum.js +5 -0
  85. package/src/checks/automatic/td-has-header.js +24 -1
  86. package/src/checks/automatic/textbox-name-present.js +10 -1
  87. package/src/checks/automatic/tooltip-name-present.js +10 -1
  88. package/src/checks/automatic/treeitem-name-present.js +10 -1
  89. package/src/checks/automatic/valid-lang.js +18 -3
  90. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  91. package/src/checks/manual/accesskeys-manual.js +3 -1
  92. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  93. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  94. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  95. package/src/checks/manual/aria-text-manual.js +6 -5
  96. package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
  97. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  98. package/src/checks/manual/css-hidden-focus.js +184 -9
  99. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  100. package/src/checks/manual/empty-heading-manual.js +17 -17
  101. package/src/checks/manual/empty-table-header-manual.js +52 -25
  102. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  103. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  104. package/src/checks/manual/heading-order-manual.js +28 -1
  105. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  106. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  107. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
  110. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  111. package/src/checks/manual/label-title-only-manual.js +29 -22
  112. package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
  113. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
  114. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  115. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  116. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  117. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  118. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  119. package/src/checks/manual/landmark-unique-manual.js +37 -52
  120. package/src/checks/manual/link-name-quality-manual.js +2 -0
  121. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  122. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  123. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  124. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  125. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  126. package/src/checks/manual/p-as-heading-manual.js +2 -0
  127. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  128. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  129. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  130. package/src/checks/manual/region-manual.js +27 -36
  131. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  132. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  133. package/src/checks/manual/skip-link-manual.js +7 -6
  134. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  135. package/src/checks/manual/tabindex-manual.js +3 -1
  136. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  137. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  138. package/src/checks/manual/video-caption-manual.js +2 -0
  139. package/src/checks/manual-review.js +2 -0
  140. package/src/core.js +11820 -3317
  141. package/src/index.js +2 -0
  142. package/src/report.js +51 -9
  143. package/src/sarif.js +20 -5
  144. package/surea11y.browser.js +4943 -1388
  145. package/bin/core.js +0 -473
  146. package/docs/CLI.md +0 -128
  147. package/src/catalogs/composites.wcag.js +0 -454
  148. package/src/checks/automatic/bypass-blocks-present.js +0 -215
  149. package/src/checks/rules-and-tags.full.csv +0 -19
  150. package/src/checks/rules-and-tags.full.json +0 -259
  151. package/src/core/aria-helpers.js +0 -1211
  152. package/src/core/contrast-helpers.js +0 -1302
  153. package/src/core/dom-helpers.js +0 -4493
  154. package/src/core/dom-runner.js +0 -787
  155. package/src/core/frame-messaging.js +0 -261
  156. package/src/core/frame-scan.js +0 -190
  157. package/src/core/rollup-composites.js +0 -127
  158. package/src/core/rule-meta.js +0 -176
  159. package/src/coverage/wcag-facets.js +0 -1079
  160. package/src/coverage/wcag-version-map.js +0 -84
  161. package/src/i18n/en.js +0 -1228
  162. package/src/i18n/fr.js +0 -1185
  163. package/src/policy/contracts.js +0 -18
  164. package/src/policy/resolvePolicy.js +0 -59
  165. package/src/policy/schemas/engine-options.schema.json +0 -103
  166. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,1302 +0,0 @@
1
- 'use strict';
2
-
3
- function createContrastHelpers(opts, shared) {
4
- const window = opts && opts.window ? opts.window : null;
5
-
6
- const trim = shared.trim;
7
- const computedStyle = shared.computedStyle;
8
- const composedParent = shared.composedParent;
9
- const buildSimpleSelector = shared.buildSimpleSelector;
10
-
11
- const clamp01 = (n) => {
12
- const x = Number(n);
13
- if (Number.isNaN(x)) return 0;
14
- if (x < 0) return 0;
15
- if (x > 1) return 1;
16
- return x;
17
- };
18
-
19
- const clamp255 = (n) => {
20
- const x = Number(n);
21
- if (Number.isNaN(x)) return 0;
22
- if (x < 0) return 0;
23
- if (x > 255) return 255;
24
- return x;
25
- };
26
-
27
- // -------- Shared per-run caches (shared.__contrastSharedCache lifetime is per engine run) --------
28
-
29
- function __getSharedWeakMapCache(propName) {
30
- try {
31
- const sc = shared && shared.__contrastSharedCache ? shared.__contrastSharedCache : null;
32
- if (!sc) return null;
33
- const existing = sc[propName];
34
- if (existing && typeof existing.get === 'function' && typeof existing.set === 'function')
35
- return existing;
36
- const wm = new WeakMap();
37
- sc[propName] = wm;
38
- return wm;
39
- } catch (_e) {
40
- return null;
41
- }
42
- }
43
-
44
- function __getSharedTextScanCache() {
45
- try {
46
- const sc = shared && shared.__contrastSharedCache ? shared.__contrastSharedCache : null;
47
- if (!sc) return null;
48
-
49
- if (
50
- sc.__textScanCache &&
51
- typeof sc.__textScanCache.get === 'function' &&
52
- typeof sc.__textScanCache.set === 'function' &&
53
- typeof sc.__textScanCache.has === 'function'
54
- ) {
55
- return sc.__textScanCache;
56
- }
57
-
58
- try {
59
- Object.defineProperty(sc, '__textScanCache', {
60
- value: new Map(),
61
- writable: false,
62
- enumerable: false,
63
- configurable: true
64
- });
65
- } catch {
66
- sc.__textScanCache = new Map();
67
- }
68
- return sc.__textScanCache;
69
- } catch {
70
- return null;
71
- }
72
- }
73
-
74
- function __getSharedColorParseCache() {
75
- try {
76
- const sc = shared && shared.__contrastSharedCache ? shared.__contrastSharedCache : null;
77
- if (!sc) return null;
78
-
79
- if (sc.__colorParseCache && typeof sc.__colorParseCache.get === 'function') {
80
- return sc.__colorParseCache;
81
- }
82
- const m = new Map();
83
- sc.__colorParseCache = m;
84
- return m;
85
- } catch (_e) {
86
- return null;
87
- }
88
- }
89
-
90
- // -------- Computed style memoization (per element, per run) --------
91
-
92
- const __localComputedStyleCache = new WeakMap();
93
- const __computedStyleCache =
94
- __getSharedWeakMapCache('__computedStyleCache') || __localComputedStyleCache;
95
-
96
- function __contrastComputedStyle(el) {
97
- try {
98
- if (!el || el.nodeType !== 1) return computedStyle(el);
99
- if (__computedStyleCache.has(el)) return __computedStyleCache.get(el);
100
- const cs = computedStyle(el);
101
- __computedStyleCache.set(el, cs);
102
- return cs;
103
- } catch {
104
- // Always no-throw: return empty object on any failure
105
- try {
106
- const cs = computedStyle(el);
107
- if (el && el.nodeType === 1) __computedStyleCache.set(el, cs);
108
- return cs;
109
- } catch {
110
- return {};
111
- }
112
- }
113
- }
114
-
115
- // Cache common booleans per element (per run)
116
- const __localHasBgImgCache = new WeakMap();
117
- const __localHasBlendModeCache = new WeakMap();
118
- const __localHasFilterCache = new WeakMap();
119
- const __hasBgImgCache = __getSharedWeakMapCache('__hasBgImgCache') || __localHasBgImgCache;
120
- const __hasBlendModeCache =
121
- __getSharedWeakMapCache('__hasBlendModeCache') || __localHasBlendModeCache;
122
- const __hasFilterCache = __getSharedWeakMapCache('__hasFilterCache') || __localHasFilterCache;
123
-
124
- // -------- Visibility mode resolution for getTextScan --------
125
-
126
- function __getVisibilityMode(engineOptions) {
127
- const m =
128
- engineOptions && typeof engineOptions.visibilityMode === 'string'
129
- ? engineOptions.visibilityMode
130
- : '';
131
- return m || 'styleOnly';
132
- }
133
-
134
- function __resolveVisibilityMode(ctx, engineOptions, d, w) {
135
- // If getTextScan was called with a direct string (unlikely, but safe)
136
- if (typeof engineOptions === 'string') return engineOptions;
137
-
138
- const candidates = [
139
- engineOptions,
140
-
141
- // common shapes
142
- ctx && ctx.engineOptions,
143
- ctx && ctx.options && ctx.options.engineOptions,
144
- ctx && ctx.options,
145
- ctx && ctx.opts && ctx.opts.engineOptions,
146
- ctx && ctx.opts,
147
-
148
- // policy layering shapes
149
- ctx && ctx.policyOverrides && ctx.policyOverrides.engineOptions,
150
- ctx && ctx.policyOverrides,
151
- ctx && ctx.policy && ctx.policy.engineOptions,
152
- ctx && ctx.policy,
153
-
154
- // sometimes hoisted
155
- ctx,
156
-
157
- // opts passed into createContrastHelpers
158
- opts && opts.engineOptions,
159
- opts && opts.options && opts.options.engineOptions,
160
- opts && opts.options,
161
- opts && opts.opts && opts.opts.engineOptions,
162
- opts && opts.opts,
163
-
164
- opts && opts.policyOverrides && opts.policyOverrides.engineOptions,
165
- opts && opts.policyOverrides,
166
- opts && opts.policy && opts.policy.engineOptions,
167
- opts && opts.policy,
168
-
169
- // globals sometimes used by runners
170
- w && w.__a11ycoreEngineOptions,
171
- d && d.__a11ycoreEngineOptions
172
- ];
173
-
174
- for (const c of candidates) {
175
- if (!c || typeof c !== 'object') continue;
176
-
177
- if (typeof c.visibilityMode === 'string') return c.visibilityMode;
178
-
179
- if (
180
- c.engineOptions &&
181
- typeof c.engineOptions === 'object' &&
182
- typeof c.engineOptions.visibilityMode === 'string'
183
- ) {
184
- return c.engineOptions.visibilityMode;
185
- }
186
-
187
- if (
188
- c.options &&
189
- typeof c.options === 'object' &&
190
- c.options.engineOptions &&
191
- typeof c.options.engineOptions === 'object' &&
192
- typeof c.options.engineOptions.visibilityMode === 'string'
193
- ) {
194
- return c.options.engineOptions.visibilityMode;
195
- }
196
-
197
- if (
198
- c.policy &&
199
- typeof c.policy === 'object' &&
200
- c.policy.engineOptions &&
201
- typeof c.policy.engineOptions === 'object' &&
202
- typeof c.policy.engineOptions.visibilityMode === 'string'
203
- ) {
204
- return c.policy.engineOptions.visibilityMode;
205
- }
206
-
207
- if (
208
- c.policyOverrides &&
209
- typeof c.policyOverrides === 'object' &&
210
- c.policyOverrides.engineOptions &&
211
- typeof c.policyOverrides.engineOptions === 'object' &&
212
- typeof c.policyOverrides.engineOptions.visibilityMode === 'string'
213
- ) {
214
- return c.policyOverrides.engineOptions.visibilityMode;
215
- }
216
- }
217
-
218
- return 'styleOnly';
219
- }
220
-
221
- function __asEligibilityBool(v) {
222
- if (typeof v === 'boolean') return v;
223
- if (v && typeof v === 'object' && typeof v.eligible === 'boolean') return v.eligible;
224
- return !!v;
225
- }
226
-
227
- // WCAG 1.4.3/1.4.6 "Incidental" exception: text that is part of an inactive
228
- // (disabled) user interface component has no contrast requirement. Walk the
229
- // ancestor chain (not just the text's immediate parent) so text nested inside
230
- // a disabled control, e.g. <button disabled><span>Label</span></button>, is
231
- // still recognized as inactive.
232
- function isInactiveUiComponent(el) {
233
- let node = el;
234
- let depth = 0;
235
- while (node && node.nodeType === 1 && depth++ < 100) {
236
- try {
237
- if (typeof node.matches === 'function' && node.matches(':disabled')) return true;
238
- } catch {}
239
- try {
240
- const ad = node.getAttribute
241
- ? String(node.getAttribute('aria-disabled') || '')
242
- .trim()
243
- .toLowerCase()
244
- : '';
245
- if (ad === 'true') return true;
246
- } catch {}
247
- node = node.parentElement;
248
- }
249
- return false;
250
- }
251
-
252
- function getTextScan(ctx, helpers, engineOptions) {
253
- try {
254
- const d = (ctx && ctx.document) || (opts && opts.document) || null;
255
-
256
- const w = (ctx && ctx.window) || (d && d.defaultView) || window || null;
257
-
258
- const rawMode = __resolveVisibilityMode(ctx, engineOptions, d, w);
259
-
260
- const visibilityMode =
261
- __getVisibilityMode({ visibilityMode: rawMode }) === 'styleAndGeometry'
262
- ? 'styleAndGeometry'
263
- : 'styleOnly';
264
-
265
- if (!d || typeof d.createTreeWalker !== 'function') {
266
- return { eligibleTextCount: 0, elements: [], visibilityMode };
267
- }
268
-
269
- const cache = __getSharedTextScanCache();
270
- const cacheKey = `visibilityMode=${visibilityMode}`;
271
- if (cache && cache.has(cacheKey)) return cache.get(cacheKey);
272
-
273
- // ctx.root is an array with multi-region contextSelector support
274
- // (dom-runner.js resolves it that way now) but back-compat with
275
- // any caller still passing a single element directly.
276
- const walkRootsRaw =
277
- ctx && ctx.root
278
- ? Array.isArray(ctx.root)
279
- ? ctx.root
280
- : [ctx.root]
281
- : [d.body || d.documentElement || d];
282
- const walkRoots = walkRootsRaw
283
- .map((wr) => (wr && wr.nodeType === 9 ? wr.body || wr.documentElement || wr : wr))
284
- .filter(Boolean);
285
-
286
- const SHOW_TEXT =
287
- w && w.NodeFilter && typeof w.NodeFilter.SHOW_TEXT === 'number'
288
- ? w.NodeFilter.SHOW_TEXT
289
- : 4;
290
-
291
- const isNonEmptyText = (t) => t != null && /\S/.test(String(t));
292
-
293
- const elToCount = new WeakMap();
294
- const elements = [];
295
- let eligibleTextCount = 0;
296
-
297
- const eligCache = new WeakMap();
298
- const inactiveCache = new WeakMap();
299
-
300
- const isVisibleEligible = (el) => {
301
- if (!helpers || typeof helpers.isDomVisibleEligible !== 'function') return true;
302
- if (eligCache.has(el)) return eligCache.get(el);
303
-
304
- let ok;
305
- try {
306
- const r = helpers.isDomVisibleEligible(el, ctx, { visibilityMode });
307
- ok = __asEligibilityBool(r);
308
- } catch {
309
- ok = false;
310
- }
311
-
312
- eligCache.set(el, ok);
313
- return ok;
314
- };
315
-
316
- const isInactive = (el) => {
317
- if (inactiveCache.has(el)) return inactiveCache.get(el);
318
- let inactive;
319
- try {
320
- inactive = isInactiveUiComponent(el);
321
- } catch {
322
- inactive = false;
323
- }
324
- inactiveCache.set(el, inactive);
325
- return inactive;
326
- };
327
-
328
- let node = null;
329
- let guard = 0;
330
- // Guards against double-counting text nodes reachable from more
331
- // than one root when contextSelector regions overlap/nest (a
332
- // single TreeWalker never revisits a node, but running one
333
- // walker per root could otherwise walk the same subtree twice).
334
- const visitedTextNodes = new Set();
335
-
336
- for (const walkRoot of walkRoots) {
337
- if (guard >= 500000) break;
338
- let walker;
339
- try {
340
- walker = d.createTreeWalker(walkRoot, SHOW_TEXT, null);
341
- } catch {
342
- continue;
343
- }
344
-
345
- while ((node = walker.nextNode()) && guard++ < 500000) {
346
- if (visitedTextNodes.has(node)) continue;
347
- visitedTextNodes.add(node);
348
-
349
- const text = node && node.nodeValue;
350
- if (!isNonEmptyText(text)) continue;
351
-
352
- const el =
353
- node.parentElement ||
354
- (node.parentNode && node.parentNode.nodeType === 1 ? node.parentNode : null);
355
-
356
- if (!el) continue;
357
- // Respect subtree exclusions from engineOptions.excludeSelectors
358
- try {
359
- if (helpers && typeof helpers.isExcluded === 'function' && helpers.isExcluded(el))
360
- continue;
361
- } catch {}
362
- if (!isVisibleEligible(el)) continue;
363
- if (isInactive(el)) continue;
364
-
365
- eligibleTextCount++;
366
-
367
- const prev = elToCount.get(el);
368
- if (prev === undefined) {
369
- elToCount.set(el, 1);
370
- elements.push(el);
371
- } else {
372
- elToCount.set(el, prev + 1);
373
- }
374
- }
375
- }
376
-
377
- // <input type="submit"|"button"|"reset">'s visible label is
378
- // rendered from its `value` attribute, not a DOM text node, so
379
- // it's structurally invisible to the SHOW_TEXT walk above (void
380
- // elements can't have text-node children at all). Found
381
- // 2026-08-01: these inputs were silently skipped by both
382
- // contrast-minimum and contrast-enhanced regardless of contrast
383
- // mode, confirmed on a real page (progressive.com's
384
- // `<input type="submit" value="Get a quote">`, a genuine
385
- // AAA-level failure surea11y never even considered a
386
- // candidate). Same eligibility gates as the real
387
- // text-node path above, applied to the input element itself.
388
- const visitedValueInputs = new Set();
389
- for (const walkRoot of walkRoots) {
390
- let candidates;
391
- try {
392
- candidates = walkRoot.querySelectorAll(
393
- 'input[type="submit" i], input[type="button" i], input[type="reset" i]'
394
- );
395
- } catch {
396
- continue;
397
- }
398
- for (const el of candidates) {
399
- if (visitedValueInputs.has(el)) continue;
400
- visitedValueInputs.add(el);
401
-
402
- const value = el.getAttribute ? el.getAttribute('value') : el.value;
403
- if (!isNonEmptyText(value)) continue;
404
-
405
- try {
406
- if (helpers && typeof helpers.isExcluded === 'function' && helpers.isExcluded(el))
407
- continue;
408
- } catch {}
409
- if (!isVisibleEligible(el)) continue;
410
- if (isInactive(el)) continue;
411
-
412
- eligibleTextCount++;
413
-
414
- const prev = elToCount.get(el);
415
- if (prev === undefined) {
416
- elToCount.set(el, 1);
417
- elements.push(el);
418
- } else {
419
- elToCount.set(el, prev + 1);
420
- }
421
- }
422
- }
423
-
424
- const out = Object.freeze({
425
- eligibleTextCount,
426
- visibilityMode,
427
- elements: Object.freeze(
428
- elements.map((el) => Object.freeze({ el, textCount: elToCount.get(el) || 0 }))
429
- )
430
- });
431
-
432
- if (cache) cache.set(cacheKey, out);
433
- return out;
434
- } catch {
435
- return { eligibleTextCount: 0, elements: [], visibilityMode: 'styleOnly' };
436
- }
437
- }
438
-
439
- // -------- Formatting helpers --------
440
-
441
- function toHex2(n) {
442
- const x = clamp255(n);
443
- const s = x.toString(16).toLowerCase();
444
- return s.length === 1 ? '0' + s : s;
445
- }
446
-
447
- function rgbToHex(rgb) {
448
- try {
449
- if (!rgb || typeof rgb !== 'object') return '';
450
- return '#' + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b);
451
- } catch {
452
- return '';
453
- }
454
- }
455
-
456
- // 96px/in, 72pt/in => 1px = 0.75pt
457
- function pxToPt(px) {
458
- const x = parseFloat(px);
459
- if (!Number.isFinite(x)) return '';
460
- return (x * 0.75).toFixed(1);
461
- }
462
-
463
- function fontWeightLabel(fontWeightNum) {
464
- const w = Number(fontWeightNum);
465
- if (Number.isFinite(w) && w >= 700) return 'bold';
466
- return 'normal';
467
- }
468
-
469
- function round2(n) {
470
- const x = Number(n);
471
- if (!Number.isFinite(x)) return '0.00';
472
- return (Math.round(x * 100) / 100).toFixed(2);
473
- }
474
-
475
- function rgbaToString(rgba) {
476
- if (!rgba || typeof rgba !== 'object') return '';
477
- const r = clamp255(rgba.r);
478
- const g = clamp255(rgba.g);
479
- const b = clamp255(rgba.b);
480
- const a = clamp01(rgba.a);
481
- return `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})`;
482
- }
483
-
484
- function parsePx(value) {
485
- if (value == null) return null;
486
-
487
- if (typeof value === 'number' && Number.isFinite(value)) return value;
488
-
489
- const s = String(value).trim().toLowerCase();
490
- if (!s) return null;
491
-
492
- const n = parseFloat(s);
493
- if (!Number.isFinite(n)) return null;
494
-
495
- if (s.endsWith('px')) return n;
496
- if (s.endsWith('pt')) return n * (96 / 72);
497
-
498
- if (s.endsWith('rem')) return n * 16;
499
- if (s.endsWith('em')) return n * 16;
500
- if (s.endsWith('%')) return (n / 100) * 16;
501
-
502
- return n;
503
- }
504
-
505
- function normalizeFontWeight(v) {
506
- const s = trim(v).toLowerCase();
507
- if (!s) return 400;
508
- if (s === 'normal') return 400;
509
- if (s === 'bold' || s === 'bolder') return 700;
510
- if (s === 'lighter') return 300;
511
- const n = Number.parseInt(s, 10);
512
- return Number.isFinite(n) ? n : 400;
513
- }
514
-
515
- function isLargeText(fontSizePx, fontWeightNum) {
516
- const size = parseFloat(fontSizePx);
517
- const w = Number(fontWeightNum);
518
- if (!Number.isFinite(size)) return false;
519
- if (size >= 24) return true;
520
- if (size >= 18.6667 && Number.isFinite(w) && w >= 700) return true;
521
- return false;
522
- }
523
-
524
- function requiredRatio(level, large) {
525
- const l = String(level || '').toUpperCase();
526
- if (l === 'AAA') return large ? 4.5 : 7.0;
527
- return large ? 3.0 : 4.5;
528
- }
529
-
530
- // -------- CSS color parsing + memoization --------
531
-
532
- const __localColorParseCache = new Map();
533
- const __colorParseCache = __getSharedColorParseCache() || __localColorParseCache;
534
-
535
- function __normalizeCssColorCacheKey(input) {
536
- const raw = input == null ? '' : String(input);
537
- let s = trim(raw).toLowerCase();
538
- if (!s) return '';
539
- s = s.replace(/\s+/g, ' ');
540
- s = s.replace(/\s*,\s*/g, ',');
541
- s = s.replace(/\(\s+/g, '(');
542
- s = s.replace(/\s+\)/g, ')');
543
- s = s.replace(/\s*\/\s*/g, '/');
544
- // Only strip whitespace BEFORE a '%' (e.g. "50 %" -> "50%"). Stripping
545
- // trailing whitespace too (the original /\s*%\s*/ pattern) ate the
546
- // space that separates adjacent percentage-suffixed channels in the
547
- // modern space-separated syntax (e.g. "rgb(100% 0% 0%)" collapsed to
548
- // "rgb(100%0%0%)", one fused token instead of three), silently
549
- // breaking that whole syntax variant.
550
- s = s.replace(/\s*%/g, '%');
551
- return s;
552
- }
553
-
554
- function __parseCssColorToRgbaUncached(input) {
555
- const s = trim(input).toLowerCase();
556
- if (!s) return null;
557
- if (s === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
558
-
559
- if (s[0] === '#') {
560
- const hex = s.slice(1);
561
- const isHex = /^[0-9a-f]+$/i.test(hex);
562
- if (!isHex) return null;
563
-
564
- const hexToInt = (h) => Number.parseInt(h, 16);
565
-
566
- try {
567
- if (hex.length === 3) {
568
- const r = hexToInt(hex[0] + hex[0]);
569
- const g = hexToInt(hex[1] + hex[1]);
570
- const b = hexToInt(hex[2] + hex[2]);
571
- return { r, g, b, a: 1 };
572
- }
573
- if (hex.length === 4) {
574
- const r = hexToInt(hex[0] + hex[0]);
575
- const g = hexToInt(hex[1] + hex[1]);
576
- const b = hexToInt(hex[2] + hex[2]);
577
- const a = hexToInt(hex[3] + hex[3]) / 255;
578
- return { r, g, b, a: clamp01(a) };
579
- }
580
- if (hex.length === 6) {
581
- const r = hexToInt(hex.slice(0, 2));
582
- const g = hexToInt(hex.slice(2, 4));
583
- const b = hexToInt(hex.slice(4, 6));
584
- return { r, g, b, a: 1 };
585
- }
586
- if (hex.length === 8) {
587
- const r = hexToInt(hex.slice(0, 2));
588
- const g = hexToInt(hex.slice(2, 4));
589
- const b = hexToInt(hex.slice(4, 6));
590
- const a = hexToInt(hex.slice(6, 8)) / 255;
591
- return { r, g, b, a: clamp01(a) };
592
- }
593
- } catch {}
594
- return null;
595
- }
596
-
597
- const m = s.match(/^rgba?\((.*)\)$/);
598
-
599
- // Modern space-separated: rgb(0 0 0 / 0.5)
600
- if (m && m[1] && m[1].indexOf(',') === -1) {
601
- const body = trim(m[1]);
602
- const parts2 = body.split('/').map((x) => trim(x));
603
- const rgbPart = parts2[0] || '';
604
- const aPart = parts2[1] || '';
605
-
606
- const rgbNums = rgbPart
607
- .split(/\s+/)
608
- .map((x) => trim(x))
609
- .filter(Boolean);
610
- if (rgbNums.length >= 3) {
611
- const parseChannel2 = (t) => {
612
- if (!t) return null;
613
- if (t.endsWith('%')) {
614
- const p = Number.parseFloat(t);
615
- if (!Number.isFinite(p)) return null;
616
- return clamp255(Math.round((p / 100) * 255));
617
- }
618
- const n = Number.parseFloat(t);
619
- if (!Number.isFinite(n)) return null;
620
- return clamp255(Math.round(n));
621
- };
622
-
623
- const r = parseChannel2(rgbNums[0]);
624
- const g = parseChannel2(rgbNums[1]);
625
- const b = parseChannel2(rgbNums[2]);
626
- if (r == null || g == null || b == null) return null;
627
-
628
- let a = 1;
629
- if (aPart) {
630
- if (aPart.endsWith('%')) {
631
- const p = Number.parseFloat(aPart);
632
- if (Number.isFinite(p)) a = clamp01(p / 100);
633
- } else {
634
- const n = Number.parseFloat(aPart);
635
- if (Number.isFinite(n)) a = clamp01(n);
636
- }
637
- }
638
-
639
- return { r, g, b, a };
640
- }
641
- }
642
-
643
- // Comma-separated: rgb(0,0,0) / rgba(0,0,0,0.5)
644
- if (m && m[1]) {
645
- const parts = m[1].split(',').map((x) => trim(x));
646
- if (parts.length < 3) return null;
647
-
648
- const parseChannel = (t) => {
649
- if (!t) return null;
650
- if (t.endsWith('%')) {
651
- const p = Number.parseFloat(t);
652
- if (!Number.isFinite(p)) return null;
653
- return clamp255(Math.round((p / 100) * 255));
654
- }
655
- const n = Number.parseFloat(t);
656
- if (!Number.isFinite(n)) return null;
657
- return clamp255(Math.round(n));
658
- };
659
-
660
- const r = parseChannel(parts[0]);
661
- const g = parseChannel(parts[1]);
662
- const b = parseChannel(parts[2]);
663
- if (r == null || g == null || b == null) return null;
664
-
665
- let a = 1;
666
- if (parts.length >= 4) {
667
- const t = parts[3];
668
- if (t && t.endsWith('%')) {
669
- const p = Number.parseFloat(t);
670
- if (Number.isFinite(p)) a = clamp01(p / 100);
671
- } else {
672
- const n = Number.parseFloat(t);
673
- if (Number.isFinite(n)) a = clamp01(n);
674
- }
675
- }
676
- return { r, g, b, a };
677
- }
678
-
679
- // Fallback: let the platform parse named/system colors.
680
- // Useful in jsdom/browsers where computed styles may return keywords like "black" or "CanvasText".
681
- try {
682
- const w = window || null;
683
- const d = w && w.document ? w.document : null;
684
- if (
685
- w &&
686
- d &&
687
- typeof d.createElement === 'function' &&
688
- typeof w.getComputedStyle === 'function'
689
- ) {
690
- const probe = d.createElement('span');
691
- // Avoid layout/paint side effects
692
- probe.style.position = 'absolute';
693
- probe.style.left = '-9999px';
694
- probe.style.top = '-9999px';
695
- probe.style.opacity = '0';
696
- probe.style.color = String(input);
697
- const parent = d.body || d.documentElement;
698
- if (parent && typeof parent.appendChild === 'function') parent.appendChild(probe);
699
-
700
- let computed = '';
701
- try {
702
- computed = (w.getComputedStyle(probe) && w.getComputedStyle(probe).color) || '';
703
- } catch (_e) {
704
- computed = '';
705
- }
706
-
707
- try {
708
- if (probe && probe.parentNode) probe.parentNode.removeChild(probe);
709
- } catch (_e) {}
710
-
711
- const normalized = __normalizeCssColorCacheKey(computed);
712
- if (normalized && normalized !== __normalizeCssColorCacheKey(input)) {
713
- // Reuse the parser on the computed rgb()/rgba() string.
714
- const parsed = __parseCssColorToRgbaUncached(normalized);
715
- if (parsed) return parsed;
716
- }
717
- }
718
- } catch (_e) {}
719
-
720
- return null;
721
- }
722
-
723
- function parseCssColorToRgba(input) {
724
- const key = __normalizeCssColorCacheKey(input);
725
- if (!key) return null;
726
- if (__colorParseCache.has(key)) return __colorParseCache.get(key);
727
-
728
- const out = __parseCssColorToRgbaUncached(key);
729
- __colorParseCache.set(key, out);
730
- return out;
731
- }
732
-
733
- // -------- Color math --------
734
-
735
- function compositeRgba(src, dst) {
736
- const s = src && typeof src === 'object' ? src : { r: 0, g: 0, b: 0, a: 0 };
737
- const d = dst && typeof dst === 'object' ? dst : { r: 0, g: 0, b: 0, a: 0 };
738
-
739
- const as = clamp01(s.a);
740
- const ad = clamp01(d.a);
741
-
742
- const outA = as + ad * (1 - as);
743
- if (outA <= 0) return { r: 0, g: 0, b: 0, a: 0 };
744
-
745
- const rs = clamp255(s.r);
746
- const gs = clamp255(s.g);
747
- const bs = clamp255(s.b);
748
-
749
- const rd = clamp255(d.r);
750
- const gd = clamp255(d.g);
751
- const bd = clamp255(d.b);
752
-
753
- const outR = (rs * as + rd * ad * (1 - as)) / outA;
754
- const outG = (gs * as + gd * ad * (1 - as)) / outA;
755
- const outB = (bs * as + bd * ad * (1 - as)) / outA;
756
-
757
- return {
758
- r: clamp255(Math.round(outR)),
759
- g: clamp255(Math.round(outG)),
760
- b: clamp255(Math.round(outB)),
761
- a: clamp01(outA)
762
- };
763
- }
764
-
765
- function srgbToLinear(c) {
766
- const cs = Number(c) / 255;
767
- if (cs <= 0.03928) return cs / 12.92;
768
- return Math.pow((cs + 0.055) / 1.055, 2.4);
769
- }
770
-
771
- function relativeLuminance(rgb) {
772
- const r = srgbToLinear(clamp255(rgb.r));
773
- const g = srgbToLinear(clamp255(rgb.g));
774
- const b = srgbToLinear(clamp255(rgb.b));
775
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
776
- }
777
-
778
- function contrastRatio(fgRgb, bgRgb) {
779
- const L1 = relativeLuminance(fgRgb);
780
- const L2 = relativeLuminance(bgRgb);
781
- const lighter = Math.max(L1, L2);
782
- const darker = Math.min(L1, L2);
783
- return (lighter + 0.05) / (darker + 0.05);
784
- }
785
-
786
- function truncateCssValue(v, maxLen) {
787
- const s = trim(v);
788
- const n = (Number(maxLen) | 0) > 10 ? Number(maxLen) | 0 : 80;
789
- if (s.length <= n) return s;
790
- return s.slice(0, n - 3) + '...';
791
- }
792
-
793
- function hasBackgroundImageOrGradient(style) {
794
- try {
795
- const v = style && style.backgroundImage;
796
- return !!(v && String(v).trim() && String(v).trim().toLowerCase() !== 'none');
797
- } catch {
798
- return false;
799
- }
800
- }
801
-
802
- function hasBlendMode(style) {
803
- try {
804
- const v = style && style.mixBlendMode;
805
- return !!(v && String(v).trim() && String(v).trim().toLowerCase() !== 'normal');
806
- } catch {
807
- return false;
808
- }
809
- }
810
-
811
- function hasFilter(style) {
812
- try {
813
- const f = style && style.filter;
814
- const bf = style && style.backdropFilter;
815
- const fOn = f && String(f).trim().toLowerCase() !== 'none';
816
- const bfOn = bf && String(bf).trim().toLowerCase() !== 'none';
817
- return !!(fOn || bfOn);
818
- } catch {
819
- return false;
820
- }
821
- }
822
-
823
- function __hasBackgroundImageOrGradientEl(el, cs) {
824
- try {
825
- if (!el || el.nodeType !== 1) return hasBackgroundImageOrGradient(cs);
826
- if (__hasBgImgCache.has(el)) return __hasBgImgCache.get(el);
827
- const v = hasBackgroundImageOrGradient(cs);
828
- __hasBgImgCache.set(el, v);
829
- return v;
830
- } catch {
831
- return false;
832
- }
833
- }
834
-
835
- function __hasBlendModeEl(el, cs) {
836
- try {
837
- if (!el || el.nodeType !== 1) return hasBlendMode(cs);
838
- if (__hasBlendModeCache.has(el)) return __hasBlendModeCache.get(el);
839
- const v = hasBlendMode(cs);
840
- __hasBlendModeCache.set(el, v);
841
- return v;
842
- } catch {
843
- return false;
844
- }
845
- }
846
-
847
- function __hasFilterEl(el, cs) {
848
- try {
849
- if (!el || el.nodeType !== 1) return hasFilter(cs);
850
- if (__hasFilterCache.has(el)) return __hasFilterCache.get(el);
851
- const v = hasFilter(cs);
852
- __hasFilterCache.set(el, v);
853
- return v;
854
- } catch {
855
- return false;
856
- }
857
- }
858
-
859
- // -------- Opacity product memoization --------
860
-
861
- const __localOpacityProductCache = new WeakMap();
862
- const __opacityProductCache =
863
- __getSharedWeakMapCache('__opacityProductCache') || __localOpacityProductCache;
864
-
865
- function computeOpacityProduct(el) {
866
- try {
867
- if (!el || (typeof el !== 'object' && typeof el !== 'function')) return 1;
868
- if (__opacityProductCache.has(el)) return __opacityProductCache.get(el);
869
-
870
- let prod = 1;
871
- let cur = el;
872
- let guard = 0;
873
- while (cur && guard++ < 200) {
874
- if (cur.nodeType !== 1) {
875
- cur = composedParent(cur);
876
- continue;
877
- }
878
- const cs = __contrastComputedStyle(cur);
879
- const o = clamp01(Number.parseFloat(cs && cs.opacity != null ? cs.opacity : '1'));
880
- prod *= o;
881
- cur = composedParent(cur);
882
- if (prod <= 0) break;
883
- }
884
-
885
- const out = clamp01(prod);
886
- __opacityProductCache.set(el, out);
887
- return out;
888
- } catch (_e) {
889
- return 1;
890
- }
891
- }
892
-
893
- // -------- Effective foreground/background memoization --------
894
-
895
- const __localEffectiveForegroundCache = new WeakMap();
896
- const __effectiveForegroundCache =
897
- __getSharedWeakMapCache('__effectiveForegroundCache') || __localEffectiveForegroundCache;
898
-
899
- function computeEffectiveForeground(el) {
900
- try {
901
- if (el && __effectiveForegroundCache.has(el)) return __effectiveForegroundCache.get(el);
902
- } catch (_e) {}
903
-
904
- const cs = __contrastComputedStyle(el);
905
- const c = parseCssColorToRgba(cs && cs.color);
906
- if (!c) {
907
- const out = { rgba: null, alpha: 0, opacityProduct: computeOpacityProduct(el) };
908
- try {
909
- if (el) __effectiveForegroundCache.set(el, out);
910
- } catch (_e) {}
911
- return out;
912
- }
913
-
914
- const op = computeOpacityProduct(el);
915
- const out = {
916
- rgba: { r: c.r, g: c.g, b: c.b, a: clamp01(c.a * op) },
917
- alpha: clamp01(c.a * op),
918
- opacityProduct: op
919
- };
920
- try {
921
- if (el) __effectiveForegroundCache.set(el, out);
922
- } catch (_e) {}
923
- return out;
924
- }
925
-
926
- const __localEffectiveBackgroundCache = new WeakMap();
927
- const __effectiveBackgroundCache =
928
- __getSharedWeakMapCache('__effectiveBackgroundCache') || __localEffectiveBackgroundCache;
929
-
930
- function __bgCacheKey(opts2) {
931
- const contrast =
932
- opts2 && opts2.contrast && typeof opts2.contrast === 'object' ? opts2.contrast : {};
933
- const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
934
- const rootCanvasFallback =
935
- typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
936
- ? contrast.rootCanvasFallback.trim()
937
- : '#ffffff';
938
-
939
- // Cache key must include any input that can affect computed background.
940
- // Note: we only cache when collectStack is false.
941
- return `${mode}|${rootCanvasFallback}`;
942
- }
943
-
944
- function computeEffectiveBackground(el, opts2) {
945
- const __bgKey = __bgCacheKey(opts2);
946
- const __collectStack = !!(opts2 && opts2.collectStack);
947
-
948
- // Only cache when stack collection is off
949
- if (!__collectStack) {
950
- try {
951
- if (el && __effectiveBackgroundCache.has(el)) {
952
- const m = __effectiveBackgroundCache.get(el);
953
- if (m && typeof m.get === 'function' && m.has(__bgKey)) return m.get(__bgKey);
954
- }
955
- } catch (_e) {}
956
- }
957
-
958
- const contrast =
959
- opts2 && opts2.contrast && typeof opts2.contrast === 'object' ? opts2.contrast : {};
960
- const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
961
- const rootCanvasFallback =
962
- typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
963
- ? contrast.rootCanvasFallback.trim()
964
- : '#ffffff';
965
-
966
- const collectStack = !!(opts2 && opts2.collectStack);
967
- const stack = collectStack ? [] : null;
968
-
969
- let acc = { r: 0, g: 0, b: 0, a: 0 };
970
- let cur = el;
971
- let guard = 0;
972
-
973
- while (cur && guard++ < 200) {
974
- if (cur.nodeType !== 1) {
975
- cur = composedParent(cur);
976
- continue;
977
- }
978
-
979
- const cs = __contrastComputedStyle(cur);
980
- const bg = parseCssColorToRgba(cs && cs.backgroundColor);
981
- const op = clamp01(Number.parseFloat(cs && cs.opacity != null ? cs.opacity : '1'));
982
-
983
- if (bg) {
984
- const layer = { r: bg.r, g: bg.g, b: bg.b, a: clamp01(bg.a) };
985
- if (collectStack) {
986
- stack.push({
987
- selector: __getSimpleSelectorCached(cur, (cur.tagName || '').toLowerCase() || 'html'),
988
- bg: { r: layer.r, g: layer.g, b: layer.b, a: layer.a },
989
- opacity: op
990
- });
991
- }
992
- // This ancestor's own background sits BEHIND everything
993
- // already accumulated from its descendants (acc is painted
994
- // over it here).
995
- acc = compositeRgba(acc, layer);
996
- }
997
-
998
- // CSS opacity on an ancestor scales the *entire rendered
999
- // subtree* (its own background plus everything already
1000
- // accumulated from descendants) as one compositing group
1001
- // against whatever is further out — not just that ancestor's
1002
- // own background layer. Applying it here (after folding in
1003
- // this ancestor's own bg) keeps that correct even when
1004
- // accumulated alpha already reached 1 from an inner opaque
1005
- // layer, which is why there is no longer an early
1006
- // `acc.a >= 1` exit: a still-unvisited outer ancestor's
1007
- // opacity can still reduce that alpha.
1008
- if (op < 1) {
1009
- acc = { r: acc.r, g: acc.g, b: acc.b, a: clamp01(acc.a * op) };
1010
- }
1011
-
1012
- cur = composedParent(cur);
1013
- }
1014
-
1015
- let out;
1016
- const allowAssumptions = mode === 'auditorAssist';
1017
-
1018
- if (acc.a < 1) {
1019
- if (allowAssumptions) {
1020
- // If the root is not opaque, apply an explicit canvas fallback.
1021
- const fb = parseCssColorToRgba(rootCanvasFallback) || { r: 255, g: 255, b: 255, a: 1 };
1022
- const fbOpaque = { r: fb.r, g: fb.g, b: fb.b, a: 1 };
1023
-
1024
- acc = compositeRgba(fbOpaque, acc);
1025
-
1026
- out = {
1027
- ok: true,
1028
- rgba: { r: acc.r, g: acc.g, b: acc.b, a: 1 },
1029
- alpha: 1,
1030
- stack: stack || [],
1031
- reasonCode: null,
1032
- assumptionsApplied: ['ROOT_CANVAS_FALLBACK'],
1033
- assumedRootCanvasColor: rootCanvasFallback
1034
- };
1035
- } else {
1036
- out = {
1037
- ok: false,
1038
- rgba: acc,
1039
- alpha: acc.a,
1040
- stack: stack || [],
1041
- reasonCode: 'BACKGROUND_NOT_OPAQUE_AT_ROOT'
1042
- };
1043
- }
1044
- } else {
1045
- out = { ok: true, rgba: acc, alpha: acc.a, stack: stack || [], reasonCode: null };
1046
- }
1047
-
1048
- if (!__collectStack && el) {
1049
- try {
1050
- let m = __effectiveBackgroundCache.get(el);
1051
- if (!m) {
1052
- m = new Map();
1053
- __effectiveBackgroundCache.set(el, m);
1054
- }
1055
- m.set(__bgKey, out);
1056
- } catch (_e) {}
1057
- }
1058
-
1059
- return out;
1060
- }
1061
-
1062
- // -------- Selector memoization --------
1063
-
1064
- const __localSimpleSelectorCache = new WeakMap();
1065
- const __simpleSelectorCache =
1066
- __getSharedWeakMapCache('__simpleSelectorCache') || __localSimpleSelectorCache;
1067
-
1068
- function __getSimpleSelectorCached(el, fallbackTag) {
1069
- try {
1070
- if (!el || el.nodeType !== 1) return '';
1071
- if (__simpleSelectorCache.has(el)) return __simpleSelectorCache.get(el) || '';
1072
- const s = buildSimpleSelector(el, fallbackTag);
1073
- __simpleSelectorCache.set(el, s || '');
1074
- return s || '';
1075
- } catch (_e) {
1076
- return '';
1077
- }
1078
- }
1079
-
1080
- function classifyBackgroundImageValue(bgImageValue) {
1081
- try {
1082
- const v = bgImageValue == null ? '' : String(bgImageValue).trim();
1083
- if (!v) return 'unknown';
1084
- const s = v.toLowerCase();
1085
- if (s === 'none') return 'unknown';
1086
-
1087
- // Multiple layers can be comma-separated; we keep it simple + deterministic:
1088
- // if any layer has url()/image-set() => image
1089
- // if any layer has *gradient( => gradient
1090
- const hasGradient = /gradient\s*\(/i.test(s);
1091
- const hasUrl = /\burl\s*\(/i.test(s);
1092
- const hasImageSet = /\bimage-set\s*\(/i.test(s);
1093
-
1094
- const isImage = hasUrl || hasImageSet;
1095
- const isGradient = hasGradient;
1096
-
1097
- if (isImage && isGradient) return 'imageAndGradient';
1098
- if (isImage) return 'image';
1099
- if (isGradient) return 'gradient';
1100
-
1101
- // Other background-image functions exist; treat as unknown rather than guessing.
1102
- return 'unknown';
1103
- } catch {
1104
- return 'unknown';
1105
- }
1106
- }
1107
-
1108
- // -------- Computability blocker (memoized per element, per run) --------
1109
-
1110
- const __localComputabilityBlockerCache = new WeakMap();
1111
- const __computabilityBlockerCache =
1112
- __getSharedWeakMapCache('__computabilityBlockerCache') || __localComputabilityBlockerCache;
1113
-
1114
- function getComputabilityBlocker(el) {
1115
- try {
1116
- if (el && __computabilityBlockerCache.has(el)) return __computabilityBlockerCache.get(el);
1117
- } catch (_e) {}
1118
-
1119
- let cur = el;
1120
- let guard = 0;
1121
- // Once a closer ancestor's own background-color is confirmed fully
1122
- // opaque (and that ancestor is itself free of blend-mode/filter),
1123
- // it visually PAINTS OVER anything farther out -- a
1124
- // background-image/gradient beyond that point can no longer affect
1125
- // what's actually rendered behind el's text, so continuing to flag
1126
- // it as a computability blocker is a false "not computable".
1127
- // Confirmed via a minimal repro: solid black text on a
1128
- // fully-opaque white <div>, itself sitting on a <body> with a
1129
- // background-image, was reported cantTell even though the image is
1130
- // 100% visually irrelevant to that text's rendered background —
1131
- // and BACKGROUND_IMAGE_OR_GRADIENT was the dominant real-world
1132
- // computability blocker (100% of cantTell occurrences sampled on
1133
- // nasa.gov and en.wikipedia.org, 2026-08-01), so this single-layer
1134
- // "solid card/nav/modal over a page-level hero image" pattern is
1135
- // common enough to matter.
1136
- //
1137
- // This does NOT extend to mix-blend-mode/filter/backdrop-filter or
1138
- // an ancestor's `opacity` — those are compositing-GROUP operations
1139
- // applied to that ancestor's entire rendered subtree (including any
1140
- // "opaque" layer inside it) before blending against whatever is
1141
- // further out, so a closer opaque paint layer does not shield
1142
- // against them the way it shields against a plain background-image.
1143
- // Applying the same short-circuit there would risk a confidently
1144
- // wrong pass — deliberately NOT done, matching this engine's
1145
- // no-false-positives bar.
1146
- let paintOccluded = false;
1147
-
1148
- while (cur && guard++ < 200) {
1149
- if (cur.nodeType !== 1) {
1150
- cur = composedParent(cur);
1151
- continue;
1152
- }
1153
- const cs = __contrastComputedStyle(cur);
1154
-
1155
- if (__hasBlendModeEl(cur, cs)) {
1156
- const out = {
1157
- ok: false,
1158
- reasonCode: 'MIX_BLEND_MODE',
1159
- blockerSelector: __getSimpleSelectorCached(
1160
- cur,
1161
- (cur.tagName || '').toLowerCase() || 'html'
1162
- ),
1163
- blockerProperty: 'mix-blend-mode',
1164
- blockerValue: truncateCssValue(cs && cs.mixBlendMode, 80)
1165
- };
1166
- try {
1167
- if (el) __computabilityBlockerCache.set(el, out);
1168
- } catch (_e) {}
1169
- return out;
1170
- }
1171
-
1172
- if (__hasFilterEl(cur, cs)) {
1173
- const isFilter = cs && cs.filter && String(cs.filter).trim().toLowerCase() !== 'none';
1174
- const out = {
1175
- ok: false,
1176
- reasonCode: 'BACKGROUND_FILTER_OR_BACKDROP_FILTER',
1177
- blockerSelector: __getSimpleSelectorCached(
1178
- cur,
1179
- (cur.tagName || '').toLowerCase() || 'html'
1180
- ),
1181
- blockerProperty: isFilter ? 'filter' : 'backdrop-filter',
1182
- blockerValue: truncateCssValue((isFilter ? cs.filter : cs.backdropFilter) || '', 80)
1183
- };
1184
- try {
1185
- if (el) __computabilityBlockerCache.set(el, out);
1186
- } catch (_e) {}
1187
- return out;
1188
- }
1189
-
1190
- if (!paintOccluded && __hasBackgroundImageOrGradientEl(cur, cs)) {
1191
- const bgImg = (cs && cs.backgroundImage) || '';
1192
- const out = {
1193
- ok: false,
1194
- reasonCode: 'BACKGROUND_IMAGE_OR_GRADIENT',
1195
- blockerSelector: __getSimpleSelectorCached(
1196
- cur,
1197
- (cur.tagName || '').toLowerCase() || 'html'
1198
- ),
1199
- blockerProperty: 'background-image',
1200
- blockerValue: truncateCssValue(bgImg, 80),
1201
- backgroundFillType: classifyBackgroundImageValue(bgImg)
1202
- };
1203
- try {
1204
- if (el) __computabilityBlockerCache.set(el, out);
1205
- } catch (_e) {}
1206
- return out;
1207
- }
1208
-
1209
- // An ANCESTOR (not el itself) with fractional opacity is treated
1210
- // as a computability blocker rather than being folded into a
1211
- // confident ratio. Group opacity uniformly scales an ancestor's
1212
- // *entire* rendered subtree (its own background AND everything
1213
- // already accumulated from descendants, including el's text)
1214
- // when compositing against what's behind it — computing that
1215
- // precisely for the foreground would require re-deriving the
1216
- // text's rendered color the same way the background is folded
1217
- // (rather than compositing a separately opacity-scaled
1218
- // foreground against the fully-folded background, which
1219
- // double-counts the ancestor's opacity). Rather than risk a
1220
- // confidently wrong pass/fail from that mismatch, defer to
1221
- // manual review. (el's own opacity, if any, does not trigger
1222
- // this: it is already handled correctly by the existing
1223
- // per-element opacity product used for the foreground.)
1224
- if (cur !== el) {
1225
- const ancestorOpacity = clamp01(
1226
- Number.parseFloat(cs && cs.opacity != null ? cs.opacity : '1')
1227
- );
1228
- if (ancestorOpacity < 1) {
1229
- const out = {
1230
- ok: false,
1231
- reasonCode: 'ANCESTOR_OPACITY',
1232
- blockerSelector: __getSimpleSelectorCached(
1233
- cur,
1234
- (cur.tagName || '').toLowerCase() || 'html'
1235
- ),
1236
- blockerProperty: 'opacity',
1237
- blockerValue: truncateCssValue(String(cs && cs.opacity != null ? cs.opacity : '1'), 80)
1238
- };
1239
- try {
1240
- if (el) __computabilityBlockerCache.set(el, out);
1241
- } catch (_e) {}
1242
- return out;
1243
- }
1244
- }
1245
-
1246
- // `cur` cleared every check above (no blend-mode/filter/
1247
- // background-image/gradient of its own, and no reduced
1248
- // opacity) -- if its own background-color also happens to be
1249
- // fully opaque, it paints over everything farther out, so
1250
- // suppress BACKGROUND_IMAGE_OR_GRADIENT for any ancestor beyond
1251
- // this point (see the paintOccluded comment above the loop).
1252
- if (!paintOccluded) {
1253
- const ownBg = parseCssColorToRgba(cs && cs.backgroundColor);
1254
- if (ownBg && clamp01(ownBg.a) >= 1) paintOccluded = true;
1255
- }
1256
-
1257
- cur = composedParent(cur);
1258
- }
1259
-
1260
- const out = {
1261
- ok: true,
1262
- reasonCode: null,
1263
- blockerSelector: '',
1264
- blockerProperty: '',
1265
- blockerValue: ''
1266
- };
1267
- try {
1268
- if (el) __computabilityBlockerCache.set(el, out);
1269
- } catch (_e) {}
1270
- return out;
1271
- }
1272
-
1273
- return {
1274
- clamp01,
1275
- clamp255,
1276
- round2,
1277
- rgbaToString,
1278
- parsePx,
1279
- normalizeFontWeight,
1280
- isLargeText,
1281
- requiredRatio,
1282
- parseCssColorToRgba,
1283
- compositeRgba,
1284
- relativeLuminance,
1285
- contrastRatio,
1286
- toHex2,
1287
- rgbToHex,
1288
- pxToPt,
1289
- fontWeightLabel,
1290
- hasBackgroundImageOrGradient,
1291
- hasBlendMode,
1292
- hasFilter,
1293
- computeOpacityProduct,
1294
- computeEffectiveForeground,
1295
- computeEffectiveBackground,
1296
- getComputabilityBlocker,
1297
- getTextScan,
1298
- isInactiveUiComponent
1299
- };
1300
- }
1301
-
1302
- module.exports = { createContrastHelpers };