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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/config/project.config.js +11 -0
- package/engine/COVERAGE.md +115 -0
- package/engine/README.md +115 -0
- package/engine/index.js +258 -0
- package/engine/lib/accessible-name.js +126 -0
- package/engine/lib/contrast.js +628 -0
- package/engine/lib/dom.js +116 -0
- package/engine/lib/roles.js +125 -0
- package/engine/rules/best-practice/accesskeys.js +29 -0
- package/engine/rules/best-practice/aria-label-misuse.js +22 -0
- package/engine/rules/best-practice/button-type.js +15 -0
- package/engine/rules/best-practice/empty-heading.js +19 -0
- package/engine/rules/best-practice/fieldset-legend.js +35 -0
- package/engine/rules/best-practice/heading-order.js +30 -0
- package/engine/rules/best-practice/landmark-one-main.js +22 -0
- package/engine/rules/best-practice/landmark-top-level.js +46 -0
- package/engine/rules/best-practice/landmark-unique.js +84 -0
- package/engine/rules/best-practice/multiple-labels.js +23 -0
- package/engine/rules/best-practice/new-window-link.js +21 -0
- package/engine/rules/best-practice/no-autofocus.js +16 -0
- package/engine/rules/best-practice/page-heading-one.js +17 -0
- package/engine/rules/best-practice/positive-tabindex.js +17 -0
- package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
- package/engine/rules/best-practice/redundant-aria-label.js +18 -0
- package/engine/rules/best-practice/redundant-aria.js +32 -0
- package/engine/rules/best-practice/redundant-image-alt.js +20 -0
- package/engine/rules/best-practice/redundant-role.js +62 -0
- package/engine/rules/best-practice/redundant-tabindex.js +15 -0
- package/engine/rules/best-practice/region.js +73 -0
- package/engine/rules/index.js +179 -0
- package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
- package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
- package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
- package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
- package/engine/rules/wcag/1.3.1-form-label.js +81 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
- package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
- package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
- package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
- package/engine/rules/wcag/1.4.10-reflow.js +25 -0
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
- package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
- package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
- package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
- package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
- package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
- package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
- package/engine/rules/wcag/2.4.2-document-title.js +18 -0
- package/engine/rules/wcag/2.4.4-link-name.js +19 -0
- package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
- package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
- package/engine/rules/wcag/2.5.8-target-size.js +159 -0
- package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
- package/engine/rules/wcag/4.1.2-button-name.js +17 -0
- package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
- package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
- package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
- package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
- package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
- package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
- package/engine/wcag22.js +116 -0
- package/engine/wcag30-draft.js +149 -0
- 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
|
+
}
|