pour-engine 1.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/config/project.config.js +11 -0
  4. package/engine/COVERAGE.md +115 -0
  5. package/engine/README.md +115 -0
  6. package/engine/index.js +258 -0
  7. package/engine/lib/accessible-name.js +126 -0
  8. package/engine/lib/contrast.js +628 -0
  9. package/engine/lib/dom.js +116 -0
  10. package/engine/lib/roles.js +125 -0
  11. package/engine/rules/best-practice/accesskeys.js +29 -0
  12. package/engine/rules/best-practice/aria-label-misuse.js +22 -0
  13. package/engine/rules/best-practice/button-type.js +15 -0
  14. package/engine/rules/best-practice/empty-heading.js +19 -0
  15. package/engine/rules/best-practice/fieldset-legend.js +35 -0
  16. package/engine/rules/best-practice/heading-order.js +30 -0
  17. package/engine/rules/best-practice/landmark-one-main.js +22 -0
  18. package/engine/rules/best-practice/landmark-top-level.js +46 -0
  19. package/engine/rules/best-practice/landmark-unique.js +84 -0
  20. package/engine/rules/best-practice/multiple-labels.js +23 -0
  21. package/engine/rules/best-practice/new-window-link.js +21 -0
  22. package/engine/rules/best-practice/no-autofocus.js +16 -0
  23. package/engine/rules/best-practice/page-heading-one.js +17 -0
  24. package/engine/rules/best-practice/positive-tabindex.js +17 -0
  25. package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
  26. package/engine/rules/best-practice/redundant-aria-label.js +18 -0
  27. package/engine/rules/best-practice/redundant-aria.js +32 -0
  28. package/engine/rules/best-practice/redundant-image-alt.js +20 -0
  29. package/engine/rules/best-practice/redundant-role.js +62 -0
  30. package/engine/rules/best-practice/redundant-tabindex.js +15 -0
  31. package/engine/rules/best-practice/region.js +73 -0
  32. package/engine/rules/index.js +179 -0
  33. package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
  34. package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
  35. package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
  36. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  37. package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
  38. package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
  39. package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
  40. package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
  41. package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
  42. package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
  43. package/engine/rules/wcag/1.3.1-form-label.js +81 -0
  44. package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
  45. package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
  46. package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
  47. package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
  48. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
  49. package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
  50. package/engine/rules/wcag/1.4.10-reflow.js +25 -0
  51. package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
  52. package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
  53. package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
  54. package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
  55. package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
  56. package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
  57. package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
  58. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
  59. package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
  60. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
  61. package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
  62. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
  63. package/engine/rules/wcag/2.4.2-document-title.js +18 -0
  64. package/engine/rules/wcag/2.4.4-link-name.js +19 -0
  65. package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
  66. package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
  67. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
  68. package/engine/rules/wcag/2.5.8-target-size.js +159 -0
  69. package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
  70. package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
  71. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
  72. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
  73. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
  74. package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
  75. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
  76. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
  77. package/engine/rules/wcag/4.1.2-button-name.js +17 -0
  78. package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
  79. package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
  80. package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
  81. package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
  82. package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
  83. package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
  84. package/engine/wcag22.js +116 -0
  85. package/engine/wcag30-draft.js +149 -0
  86. package/package.json +34 -0
@@ -0,0 +1,628 @@
1
+ // WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
2
+
3
+ /** Parse a computed CSS colour (`rgb()`/`rgba()`) → {r,g,b,a} or null.
4
+ * Cached by string: pages use a handful of distinct colours across
5
+ * thousands of elements, and the regex was a top cost in contrast audits.
6
+ * Callers must not mutate the returned object. */
7
+ const colorCache = new Map();
8
+ export function parseColor(cssColor) {
9
+ if (cssColor == null) return null;
10
+ if (colorCache.has(cssColor)) return colorCache.get(cssColor);
11
+ const match = cssColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
12
+ const color = match
13
+ ? { r: +match[1], g: +match[2], b: +match[3], a: match[4] === undefined ? 1 : +match[4] }
14
+ : null;
15
+ if (colorCache.size < 10_000) colorCache.set(cssColor, color); // bounded: computed colors are few
16
+ return color;
17
+ }
18
+
19
+ function channelLuminance(value) {
20
+ const c = value / 255;
21
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
22
+ }
23
+
24
+ export function luminance({ r, g, b }) {
25
+ return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
26
+ }
27
+
28
+ /** WCAG contrast ratio between two opaque colours: 1 (none) → 21 (max). */
29
+ export function contrastRatio(foreground, background) {
30
+ const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
31
+ return (lighter + 0.05) / (darker + 0.05);
32
+ }
33
+
34
+ /** Alpha-composite `top` over `bottom` (both {r,g,b,a}) → opaque colour. */
35
+ export function composite(top, bottom) {
36
+ const a = top.a + bottom.a * (1 - top.a);
37
+ const blend = (t, b) => (t * top.a + b * bottom.a * (1 - top.a)) / (a || 1);
38
+ return { r: blend(top.r, bottom.r), g: blend(top.g, bottom.g), b: blend(top.b, bottom.b), a };
39
+ }
40
+
41
+ /**
42
+ * Walk up from an element to find the effective opaque background colour.
43
+ * Returns null when it can't be determined (background image/gradient, or
44
+ * nothing opaque found) — callers should report "incomplete", not guess.
45
+ */
46
+ // Per-audit memoization: thousands of text elements share the same ancestor
47
+ // chains, so resolved backgrounds and opacities are cached per element. The
48
+ // engine calls resetAuditCaches() at the start of every run — caches must
49
+ // never survive into a re-audit of a changed page.
50
+ let backgroundCache = new WeakMap();
51
+ let opacityCache = new WeakMap();
52
+ let opacityAnimatorsCache = null;
53
+ let mediaRectsCache = null;
54
+ let panelRectsCache = null;
55
+ const HAS_IMAGE = Symbol('background-image in chain');
56
+
57
+ export function resetAuditCaches() {
58
+ backgroundCache = new WeakMap();
59
+ opacityCache = new WeakMap();
60
+ opacityAnimatorsCache = null;
61
+ mediaRectsCache = null;
62
+ panelRectsCache = null;
63
+ }
64
+
65
+ function canvasColor(doc) {
66
+ // Pages using color-scheme: dark get a dark canvas from the browser even
67
+ // with no author background — assuming white there inverts every result.
68
+ const scheme = getComputedStyle(doc.documentElement).colorScheme ?? '';
69
+ const prefersDark = typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
70
+ const useDark = /dark/.test(scheme) && (!/light/.test(scheme) || prefersDark);
71
+ return useDark ? { r: 18, g: 18, b: 18, a: 1 } : { r: 255, g: 255, b: 255, a: 1 };
72
+ }
73
+
74
+ /** Resolved background color behind an element (compositing translucent
75
+ * layers down to the canvas), or null when a background image intervenes.
76
+ * Walks the FLAT tree (crosses shadow-host boundaries) and memoizes per
77
+ * element — repeated ancestors resolve once per audit. */
78
+ function resolveBackground(element, doc) {
79
+ if (!element || element.nodeType !== 1) return canvasColor(doc);
80
+ if (backgroundCache.has(element)) {
81
+ const cached = backgroundCache.get(element);
82
+ return cached === HAS_IMAGE ? null : cached;
83
+ }
84
+ let result;
85
+ const style = getComputedStyle(element);
86
+ if (style.backgroundImage !== 'none') {
87
+ result = null;
88
+ } else {
89
+ const color = parseColor(style.backgroundColor);
90
+ if (color && color.a >= 1) {
91
+ result = color;
92
+ } else {
93
+ const behind = resolveBackground(element.parentElement ?? element.getRootNode()?.host, doc);
94
+ result = behind === null ? null : (color && color.a > 0 ? composite(color, behind) : behind);
95
+ }
96
+ }
97
+ backgroundCache.set(element, result === null ? HAS_IMAGE : result);
98
+ return result;
99
+ }
100
+
101
+ export function effectiveBackground(element) {
102
+ return resolveBackground(element, element.ownerDocument);
103
+ }
104
+
105
+ /**
106
+ * Is the element's effective opacity mid-flight — a running CSS
107
+ * animation/transition (including scroll-driven timelines) touching opacity
108
+ * on it or any flat-tree ancestor? A contrast reading taken mid-fade is a
109
+ * snapshot of a transient frame, not the page's resting state — callers
110
+ * should report those for human review instead of failing them.
111
+ */
112
+ /** Per-audit set of elements with a running animation/transition that
113
+ * touches opacity. Built from ONE document.getAnimations() call: per-node
114
+ * getAnimations() filters every animation on the page each time, which is
115
+ * O(elements × animations) — on animation-heavy pages (scroll-driven
116
+ * fades everywhere) that alone took minutes; the document-wide call is
117
+ * single-digit milliseconds. */
118
+ function opacityAnimators(doc) {
119
+ if (!opacityAnimatorsCache) {
120
+ // target → the opacity it will REST at (null = unknowable, assume full).
121
+ opacityAnimatorsCache = new WeakMap();
122
+ for (const animation of (typeof doc.getAnimations === 'function' ? doc.getAnimations() : [])) {
123
+ // Only animations actually RUNNING are mid-flight. A finished fade
124
+ // (fill: forwards) resting at a dim opacity, or a paused one, IS the
125
+ // presented state — the computed style already reflects it.
126
+ if (animation.playState !== 'running') continue;
127
+ const target = animation.effect?.target;
128
+ if (!target) continue;
129
+ const keyframes = animation.effect.getKeyframes?.() ?? [];
130
+ if (!keyframes.some((frame) => frame.opacity !== undefined)) continue;
131
+ // Where does this animation LAND? Finite + fill forwards/both rests at
132
+ // its last opacity keyframe — an entrance fading to 30% is presented
133
+ // at 30% forever after, and must be judged there, not at full.
134
+ // Infinite loops and non-filling animations rest at the underlying
135
+ // style value, which the mid-flight computed style can't reveal: null.
136
+ let rest = null;
137
+ const timing = animation.effect.getComputedTiming?.() ?? {};
138
+ if (Number.isFinite(timing.endTime) && (timing.fill === 'forwards' || timing.fill === 'both')) {
139
+ const last = [...keyframes].reverse().find((frame) => frame.opacity !== undefined);
140
+ const value = parseFloat(last?.opacity);
141
+ if (Number.isFinite(value)) rest = value;
142
+ }
143
+ const previous = opacityAnimatorsCache.get(target);
144
+ // Two opacity animations on one element: keep the more conservative
145
+ // (dimmer) resting value; null (unknown) never overrides a known one.
146
+ if (previous === undefined || (rest !== null && (previous === null || rest < previous))) {
147
+ opacityAnimatorsCache.set(target, rest);
148
+ }
149
+ }
150
+ }
151
+ return opacityAnimatorsCache;
152
+ }
153
+
154
+ export function opacityAnimating(element) {
155
+ const animators = opacityAnimators(element.ownerDocument);
156
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
157
+ if (animators.has(node)) return true;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ /** The cumulative opacity this element RESTS at once running animations
163
+ * land: animating nodes contribute their fill-forwards end value (or full
164
+ * opacity when the landing point is unknowable), others their computed
165
+ * opacity. This is what mid-flight text should be judged against. */
166
+ export function restingOpacity(element) {
167
+ const animators = opacityAnimators(element.ownerDocument);
168
+ let product = 1;
169
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
170
+ if (animators.has(node)) {
171
+ const rest = animators.get(node);
172
+ product *= rest === null ? 1 : rest;
173
+ } else {
174
+ product *= parseFloat(getComputedStyle(node).opacity) || 0;
175
+ }
176
+ }
177
+ return product;
178
+ }
179
+
180
+ /** Product of the element's and its flat-tree ancestors' opacity, memoized
181
+ * per audit. Near-zero means the element isn't visually presented at all. */
182
+ export function cumulativeOpacity(element) {
183
+ if (!element || element.nodeType !== 1) return 1;
184
+ let cached = opacityCache.get(element);
185
+ if (cached === undefined) {
186
+ const parent = element.parentElement ?? element.getRootNode()?.host ?? null;
187
+ cached = (parseFloat(getComputedStyle(element).opacity) || 0) * cumulativeOpacity(parent);
188
+ opacityCache.set(element, cached);
189
+ }
190
+ return cached;
191
+ }
192
+
193
+ /**
194
+ * Is something painted between this element and the ancestor that provides
195
+ * its background colour — a positioned <img>, video, overlay…? If so, the
196
+ * ancestor-walk colour is not what the user actually sees (white caption
197
+ * over a dark photo on a white section, for example).
198
+ * Returns the obscuring layer element, or null. Samples the element's
199
+ * centre point: cheap, but misses partial overlaps and elements scrolled
200
+ * out of view — a known approximation.
201
+ */
202
+ export function backgroundObscured(element) {
203
+ const doc = element.ownerDocument;
204
+ if (typeof doc.elementsFromPoint !== 'function') return null;
205
+ // Text sitting on the element's OWN opaque paint can't be affected by
206
+ // anything painted beneath — only a background inherited from an
207
+ // ancestor's walk-up can be intercepted by an in-between layer.
208
+ const own = parseColor(getComputedStyle(element).backgroundColor);
209
+ if (own && own.a >= 1) return null;
210
+ const rect = element.getBoundingClientRect();
211
+ if (!rect.width || !rect.height) return null;
212
+ const win = doc.defaultView;
213
+ const x = Math.min(Math.max(rect.left + rect.width / 2, 0), win.innerWidth - 1);
214
+ const y = Math.min(Math.max(rect.top + rect.height / 2, 0), win.innerHeight - 1);
215
+ const stack = doc.elementsFromPoint(x, y);
216
+ const start = stack.indexOf(element);
217
+ // Outside the viewport the hit-test can't see the element at all, so
218
+ // "nothing found" would be a false all-clear, not a verdict — callers
219
+ // must fall back to a scroll-independent check before asserting a fail.
220
+ if (start === -1) return 'unverifiable';
221
+
222
+ for (const layer of stack.slice(start + 1)) {
223
+ if (layer.contains(element)) {
224
+ const style = getComputedStyle(layer);
225
+ if (style.backgroundImage !== 'none') return layer;
226
+ const bg = parseColor(style.backgroundColor);
227
+ if (bg && bg.a >= 1) return null; // opaque ancestor: anything deeper is hidden
228
+ continue;
229
+ }
230
+ return layer; // non-ancestor painted beneath the text (hero image, overlay…)
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /** Centre of the element's first rendered text box — the point worth
236
+ * hit-testing. A Range around the first non-blank text node beats the
237
+ * element's own rect centre: wide containers with left-aligned text would
238
+ * hit-test empty space. Falls back to the element rect. */
239
+ function textSamplePoint(element) {
240
+ for (const node of element.childNodes) {
241
+ if (node.nodeType !== 3 || !node.textContent.trim()) continue;
242
+ const range = element.ownerDocument.createRange();
243
+ range.selectNodeContents(node);
244
+ const rect = range.getClientRects()[0];
245
+ if (rect && rect.width && rect.height) {
246
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
247
+ }
248
+ }
249
+ const rect = element.getBoundingClientRect();
250
+ return rect.width && rect.height
251
+ ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
252
+ : null;
253
+ }
254
+
255
+ /**
256
+ * The backdrop actually painted beneath the element's text, resolved with
257
+ * the browser's own hit-testing — true paint order, not a reimplemented
258
+ * stacking model. Walks the elementsFromPoint stack downward from the
259
+ * element, compositing translucent background colours until an opaque one
260
+ * settles the answer. This is what sees backdrops the ancestor walk cannot:
261
+ * positioned siblings, hero panels, overlays painted under overlaid text.
262
+ *
263
+ * Returns:
264
+ * { color, scrim } — backdrop resolved with confidence
265
+ * { image: element, scrim } — an image/background-image paints beneath:
266
+ * sample it
267
+ * 'offscreen' — sample point outside the viewport; hit-testing is
268
+ * blind there, callers need a scroll-independent path
269
+ * 'unresolved' — in-viewport but unanswerable (shadow retargeting hid
270
+ * the element from the stack, blend/filter layers…)
271
+ *
272
+ * `scrim` (when present) is a translucent viewport-scale layer painted ON TOP
273
+ * of the text — a modal veil, loading overlay, consent dimmer. The text is
274
+ * presented through it, so the resting colours are not what the user sees.
275
+ *
276
+ * Known approximation: pointer-events:none layers paint but never appear in
277
+ * hit-test stacks, so a decorative tint above the resolved colour can be
278
+ * missed. The ancestor walk (which callers still run) covers the common
279
+ * cases of that shape.
280
+ */
281
+ /** A layer qualifies as a scrim over `element` when it is translucent paint
282
+ * (or a backdrop-filter) covering most of the viewport and the whole
283
+ * element — the shape of a modal/loading veil, not a badge or header. */
284
+ function scrimIn(layersAbove, element, win) {
285
+ const rect = element.getBoundingClientRect();
286
+ for (const layer of layersAbove) {
287
+ if (layer.contains(element) || element.contains(layer)) continue;
288
+ const style = getComputedStyle(layer);
289
+ const color = parseColor(style.backgroundColor);
290
+ const alpha = (color ? color.a : 0) * (parseFloat(style.opacity) || 1);
291
+ const hasBackdropFilter = style.backdropFilter && style.backdropFilter !== 'none';
292
+ // Fully opaque covers simply hide the text (nothing to judge through);
293
+ // near-invisible tints don't meaningfully change presented contrast.
294
+ if (!hasBackdropFilter && (alpha < 0.15 || alpha >= 1)) continue;
295
+ const r = layer.getBoundingClientRect();
296
+ const coversViewport = (Math.min(r.right, win.innerWidth) - Math.max(r.left, 0))
297
+ * (Math.min(r.bottom, win.innerHeight) - Math.max(r.top, 0))
298
+ >= 0.6 * win.innerWidth * win.innerHeight;
299
+ const coversElement = r.left <= rect.left + 1 && r.top <= rect.top + 1
300
+ && r.right >= rect.right - 1 && r.bottom >= rect.bottom - 1;
301
+ if (coversViewport && coversElement) return layer;
302
+ }
303
+ return null;
304
+ }
305
+
306
+ export function paintedBackdrop(element) {
307
+ const doc = element.ownerDocument;
308
+ const win = doc.defaultView;
309
+ if (typeof doc.elementsFromPoint !== 'function') return 'unresolved';
310
+ const point = textSamplePoint(element);
311
+ if (!point) return 'unresolved';
312
+ if (point.x < 0 || point.y < 0 || point.x >= win.innerWidth || point.y >= win.innerHeight) {
313
+ return 'offscreen';
314
+ }
315
+ const stack = doc.elementsFromPoint(point.x, point.y);
316
+ const start = stack.indexOf(element);
317
+ const scrim = start > 0 ? scrimIn(stack.slice(0, start), element, win) : null;
318
+ // The element's own background paints directly under its text — it heads
319
+ // the stack. Its background-image was already adjudicated upstream by the
320
+ // image-sampling path, so only its colour participates here.
321
+ let acc = null;
322
+ const own = parseColor(getComputedStyle(element).backgroundColor);
323
+ if (own && own.a > 0) {
324
+ if (own.a >= 1) return { color: own, scrim };
325
+ acc = own;
326
+ }
327
+ if (start === -1) return 'unresolved';
328
+ for (const layer of stack.slice(start + 1)) {
329
+ // Replaced elements paint their content, not a background — a photo or
330
+ // video in the stack is an image backdrop whatever its styles say.
331
+ if (/^(img|video|canvas|svg|picture|object|embed|iframe)$/i.test(layer.tagName)) {
332
+ return { image: layer, scrim };
333
+ }
334
+ const style = getComputedStyle(layer);
335
+ // Blend modes and filters change the paint in ways flat colour math
336
+ // can't follow — that's a human check, not a resolution.
337
+ if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
338
+ || (style.filter && style.filter !== 'none')
339
+ || (style.backdropFilter && style.backdropFilter !== 'none')) return 'unresolved';
340
+ if (style.backgroundImage !== 'none') return { image: layer, scrim };
341
+ let color = parseColor(style.backgroundColor);
342
+ if (!color) return 'unresolved';
343
+ // A layer's own opacity thins its paint. (Group opacity shared with the
344
+ // text via common ancestors is already folded into the foreground.)
345
+ const layerOpacity = parseFloat(style.opacity);
346
+ if (layerOpacity < 1) color = { ...color, a: color.a * layerOpacity };
347
+ if (color.a === 0) continue;
348
+ acc = acc ? composite(acc, color) : color;
349
+ if (acc.a >= 1) return { color: acc, scrim };
350
+ }
351
+ // Every layer was translucent: the canvas shows through underneath.
352
+ const canvas = canvasColor(doc);
353
+ return { color: acc ? composite(acc, canvas) : canvas, scrim };
354
+ }
355
+
356
+ /**
357
+ * Non-ancestor elements with an opaque background colour big enough to act
358
+ * as a text backdrop, with their viewport rects — collected once per audit.
359
+ * The fail path's scroll-independent bracket for text the hit-test can't
360
+ * reach: "could a coloured panel be painted where this text is, making the
361
+ * walked-up background the wrong one?" Only positioned or floated subtrees
362
+ * paint across sibling boundaries like that, which keeps the scan focused.
363
+ * Skipped on monster pages — the full-document style pass would dominate
364
+ * the audit; those pages keep the previous (walk-only) behaviour.
365
+ */
366
+ export function opaquePanelRects(doc) {
367
+ if (!panelRectsCache) {
368
+ panelRectsCache = { panels: [], veil: null };
369
+ const win = doc.defaultView;
370
+ const all = doc.querySelectorAll('body *');
371
+ if (all.length <= 30000) {
372
+ for (const el of all) {
373
+ const style = getComputedStyle(el);
374
+ if (style.position === 'static' && style.cssFloat === 'none') continue;
375
+ const color = parseColor(style.backgroundColor);
376
+ const rect = el.getBoundingClientRect();
377
+ if (color && color.a >= 1 && rect.width >= 24 && rect.height >= 12) {
378
+ // pointer-events: none panels paint but never appear in hit-test
379
+ // stacks — they can contradict even a "verified" backdrop.
380
+ panelRectsCache.panels.push({ element: el, rect, color, hitTestBlind: style.pointerEvents === 'none' });
381
+ }
382
+ // Same walk, second question: is a viewport-scale translucent FIXED
383
+ // veil up (modal scrim, loading dimmer)? Fixed overlays follow the
384
+ // viewport, so they dim offscreen content too — the one backdrop
385
+ // hazard that scrolling cannot escape.
386
+ if (!panelRectsCache.veil && (style.position === 'fixed' || style.position === 'sticky')
387
+ && style.visibility !== 'hidden') {
388
+ const alpha = (color ? color.a : 0) * (parseFloat(style.opacity) || 1);
389
+ const hasBackdropFilter = style.backdropFilter && style.backdropFilter !== 'none';
390
+ const coversViewport = rect.width >= 0.9 * win.innerWidth && rect.height >= 0.9 * win.innerHeight;
391
+ if (coversViewport && (hasBackdropFilter || (alpha >= 0.15 && alpha < 1))) {
392
+ panelRectsCache.veil = el;
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+ return panelRectsCache.panels;
399
+ }
400
+
401
+ /** The viewport-covering translucent fixed overlay currently up, if any —
402
+ * detected in the opaquePanelRects walk (call order doesn't matter). */
403
+ export function viewportVeil(doc) {
404
+ opaquePanelRects(doc);
405
+ return panelRectsCache.veil;
406
+ }
407
+
408
+ /** Parse a computed text-shadow list into layers. Computed style serializes
409
+ * the colour first ("rgb(0, 0, 0) 1px 0px 2px") with px lengths; author-ish
410
+ * orderings are tolerated. */
411
+ export function parseTextShadows(cssText) {
412
+ if (!cssText || cssText === 'none') return [];
413
+ const layers = [];
414
+ for (const part of cssText.split(/,(?![^(]*\))/)) {
415
+ const colorMatch = /rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b/.exec(part);
416
+ const lengths = [...part.replace(colorMatch?.[0] ?? '', '').matchAll(/(-?\d*\.?\d+)px/g)]
417
+ .map((m) => parseFloat(m[1]));
418
+ if (lengths.length < 2) continue;
419
+ layers.push({ x: lengths[0], y: lengths[1], blur: lengths[2] ?? 0, color: parseColor(colorMatch?.[0] ?? '') });
420
+ }
421
+ return layers;
422
+ }
423
+
424
+ /**
425
+ * The colour of a solid halo formed by the element's text-shadow, or null.
426
+ * WCAG technique G18 measures letter contrast against a halo when one hugs
427
+ * the glyphs. A halo counts when a single colour's layers surround the text
428
+ * — a radial ring (zero offset, non-zero blur) or offsets covering all four
429
+ * directions — crisply enough to read as an outline rather than a fog:
430
+ * offsets and blur within ~25% of the font size, effectively opaque paint.
431
+ * The thresholds are this engine's own judgement of "hugs the letterforms";
432
+ * they are deliberately conservative — ambiguous shadows stay unresolved.
433
+ */
434
+ export function textShadowHalo(cssText, fontSize) {
435
+ const cap = Math.max(2, fontSize * 0.25);
436
+ const byColor = new Map();
437
+ for (const layer of parseTextShadows(cssText)) {
438
+ if (!layer.color || layer.color.a < 0.95) continue;
439
+ if (layer.blur > cap || Math.abs(layer.x) > cap || Math.abs(layer.y) > cap) continue;
440
+ const key = `${Math.round(layer.color.r)},${Math.round(layer.color.g)},${Math.round(layer.color.b)}`;
441
+ const dirs = byColor.get(key) ?? { color: layer.color, up: false, down: false, left: false, right: false };
442
+ if (layer.blur >= Math.max(Math.abs(layer.x), Math.abs(layer.y), 0.5)) {
443
+ // Blur at least as wide as the offset bleeds around every glyph edge.
444
+ dirs.up = dirs.down = dirs.left = dirs.right = true;
445
+ } else {
446
+ if (layer.x > 0) dirs.right = true;
447
+ if (layer.x < 0) dirs.left = true;
448
+ if (layer.y > 0) dirs.down = true;
449
+ if (layer.y < 0) dirs.up = true;
450
+ }
451
+ byColor.set(key, dirs);
452
+ }
453
+ for (const dirs of byColor.values()) {
454
+ if (dirs.up && dirs.down && dirs.left && dirs.right) return dirs.color;
455
+ }
456
+ return null;
457
+ }
458
+
459
+ /** True when every shadow layer is too faint or too diffuse to affect
460
+ * legibility (soft decorative glows) — the text can be judged against its
461
+ * background alone. */
462
+ export function textShadowNegligible(cssText, fontSize) {
463
+ return parseTextShadows(cssText)
464
+ .every((l) => !l.color || l.color.a < 0.15 || l.blur > fontSize * 0.5);
465
+ }
466
+
467
+ /**
468
+ * Every rendered media element on the page (images, video, canvas, svg)
469
+ * with its viewport rect, collected once per audit. The contrast rule's
470
+ * fail path uses this as its scroll-independent question — "could a
471
+ * picture be painted where this text is?" — for elements the viewport
472
+ * hit-test can't reach. Open shadow roots aren't walked here; a known
473
+ * approximation that errs toward failing (never toward a false pass).
474
+ */
475
+ export function mediaRects(doc) {
476
+ if (!mediaRectsCache) {
477
+ mediaRectsCache = [];
478
+ for (const el of doc.querySelectorAll('img, video, canvas, svg')) {
479
+ const rect = el.getBoundingClientRect();
480
+ // Sub-icon-sized media can't plausibly serve as a text backdrop.
481
+ // Decorative media with pointer-events: none (animated gradient
482
+ // canvases, ambient video) paints under text yet never appears in
483
+ // elementsFromPoint stacks — flagged so callers can distrust even a
484
+ // hit-test-verified backdrop where such media overlaps.
485
+ if (rect.width >= 8 && rect.height >= 8) {
486
+ mediaRectsCache.push({ element: el, rect, hitTestBlind: getComputedStyle(el).pointerEvents === 'none' });
487
+ }
488
+ }
489
+ }
490
+ return mediaRectsCache;
491
+ }
492
+
493
+ /**
494
+ * The nearest background-image being painted behind this element (walking
495
+ * up until an opaque background colour would hide anything deeper).
496
+ * Returns { css, element } or null.
497
+ */
498
+ export function backgroundImageSource(element) {
499
+ for (let current = element; current; current = current.parentElement) {
500
+ const style = getComputedStyle(current);
501
+ if (style.backgroundImage !== 'none') return { css: style.backgroundImage, element: current };
502
+ const color = parseColor(style.backgroundColor);
503
+ if (color && color.a >= 1) return null;
504
+ }
505
+ return null;
506
+ }
507
+
508
+ /**
509
+ * Luminance range (min/max) of an image's pixels, downscaled for speed.
510
+ * null when the image can't be read: cross-origin without CORS headers
511
+ * (canvas tainting), load failure, or timeout. Cached per URL.
512
+ */
513
+ const imageRangeCache = new Map();
514
+ export function imageLuminanceRange(url) {
515
+ if (imageRangeCache.has(url)) return imageRangeCache.get(url);
516
+ const promise = new Promise((resolve) => {
517
+ const img = new Image();
518
+ img.crossOrigin = 'anonymous';
519
+ const timer = setTimeout(() => resolve(null), 1500); // never stall an audit on a slow image
520
+ img.onload = () => {
521
+ clearTimeout(timer);
522
+ try {
523
+ const size = 32;
524
+ const canvas = document.createElement('canvas');
525
+ canvas.width = size;
526
+ canvas.height = size;
527
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
528
+ ctx.drawImage(img, 0, 0, size, size);
529
+ const data = ctx.getImageData(0, 0, size, size).data;
530
+ let min = 1;
531
+ let max = 0;
532
+ for (let i = 0; i < data.length; i += 4) {
533
+ if (data[i + 3] < 128) continue; // mostly-transparent pixels reveal what's beneath — unknowable
534
+ const l = luminance({ r: data[i], g: data[i + 1], b: data[i + 2] });
535
+ if (l < min) min = l;
536
+ if (l > max) max = l;
537
+ }
538
+ resolve(min <= max ? { min, max, width: img.naturalWidth, height: img.naturalHeight } : null);
539
+ } catch {
540
+ resolve(null); // tainted canvas: cross-origin image without CORS headers
541
+ }
542
+ };
543
+ img.onerror = () => { clearTimeout(timer); resolve(null); };
544
+ img.src = url;
545
+ });
546
+ imageRangeCache.set(url, promise);
547
+ return promise;
548
+ }
549
+
550
+ /**
551
+ * Where a single no-repeat background image actually paints (viewport
552
+ * coordinates), or null when that can't be known statically (repeating
553
+ * layers, cover/contain, unknown intrinsic size). Lets the contrast rule
554
+ * clear text that never overlaps the image — e.g. external-link icons
555
+ * painted in a link's right padding (Wikipedia-style).
556
+ */
557
+ export function backgroundImagePaintRect(element, intrinsic) {
558
+ const style = getComputedStyle(element);
559
+ if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
560
+ if (style.backgroundRepeat !== 'no-repeat') return null; // may tile under the text
561
+ if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') return null;
562
+ const box = element.getBoundingClientRect();
563
+ const dimension = (value, total, auto) => {
564
+ if (value?.endsWith('px')) return parseFloat(value);
565
+ if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
566
+ return auto;
567
+ };
568
+ const size = style.backgroundSize.split(' ');
569
+ const width = dimension(size[0], box.width, intrinsic?.width);
570
+ const height = dimension(size[1] ?? size[0], box.height, intrinsic?.height);
571
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
572
+ // position N% aligns the image's N% point with the container's N% point.
573
+ // Anything else (calc() from three-value syntax, keywords) is not
574
+ // statically resolvable — returning a guessed 0 would invent a paint
575
+ // position over the text, so unknown positions make the rect unknowable.
576
+ const offset = (value, total, extent) => {
577
+ if (value?.endsWith('%')) return (parseFloat(value) / 100) * (total - extent);
578
+ if (value?.endsWith('px')) return parseFloat(value);
579
+ return null;
580
+ };
581
+ const pos = style.backgroundPosition.split(' ');
582
+ const offsetX = offset(pos[0], box.width, width);
583
+ const offsetY = offset(pos[1] ?? '50%', box.height, height);
584
+ if (offsetX === null || offsetY === null || Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
585
+ const left = box.left + offsetX;
586
+ const top = box.top + offsetY;
587
+ return { left, top, right: left + width, bottom: top + height };
588
+ }
589
+
590
+ /**
591
+ * Luminance range of a CSS gradient, from its colour stops. Interpolated
592
+ * colours stay close enough to their stops' luminances to bracket.
593
+ */
594
+ export function gradientLuminanceRange(backgroundImageCss) {
595
+ const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
596
+ if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
597
+ const lums = stops.map(luminance);
598
+ return { min: Math.min(...lums), max: Math.max(...lums) };
599
+ }
600
+
601
+ /** Contrast ratio from two luminances. */
602
+ function ratioFromLuminance(l1, l2) {
603
+ const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
604
+ return (lighter + 0.05) / (darker + 0.05);
605
+ }
606
+
607
+ /**
608
+ * Verdict for text of a known colour over a background whose luminance
609
+ * spans [range.min, range.max]. Definite in BOTH directions and
610
+ * positioning-independent: pass only if the worst-case pixel passes,
611
+ * fail only if the best-case pixel fails, otherwise 'mixed'.
612
+ */
613
+ export function rangeVerdict(foreground, range, required) {
614
+ const textLum = luminance(foreground);
615
+ const inside = textLum >= range.min && textLum <= range.max;
616
+ const worst = inside ? 1 : Math.min(ratioFromLuminance(textLum, range.min), ratioFromLuminance(textLum, range.max));
617
+ const best = Math.max(ratioFromLuminance(textLum, range.min), ratioFromLuminance(textLum, range.max));
618
+ if (worst >= required) return { verdict: 'pass', worst, best };
619
+ if (best < required) return { verdict: 'fail', worst, best };
620
+ return { verdict: 'mixed', worst, best };
621
+ }
622
+
623
+ /** WCAG "large text": ≥24px, or ≥18.66px (14pt) bold. */
624
+ export function isLargeText(style) {
625
+ const size = parseFloat(style.fontSize);
626
+ const weight = parseInt(style.fontWeight, 10) || 400;
627
+ return size >= 24 || (size >= 18.66 && weight >= 700);
628
+ }