1function sanitizeScreenshotClip(clip, viewport) {
2 if (!clip) return null;
3 const x = Math.max(0, Math.floor(clip.x || 0));
4 const y = Math.max(0, Math.floor(clip.y || 0));
5 const width = Math.min(
6 Math.max(1, Math.ceil(clip.width || 0)),
7 Math.max(1, viewport?.width || 1600),
8 );
9 const height = Math.min(
10 Math.max(1, Math.ceil(clip.height || 0)),
11 320,
12 );
13 if (width < 1 || height < 1) return null;
14 return { x, y, width, height };
15}
16
17async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
18 return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
19 const loadImage = (base64) => new Promise((resolve, reject) => {
20 const img = new Image();
21 img.onload = () => resolve(img);
22 img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
23 img.src = `data:image/png;base64,${base64}`;
24 });
25 const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
26 const width = Math.min(before.width, after.width);
27 const height = Math.min(before.height, after.height);
28 if (width < 1 || height < 1) return null;
29
30 const canvas = document.createElement('canvas');
31 canvas.width = width;
32 canvas.height = height;
33 const ctx = canvas.getContext('2d', { willReadFrequently: true });
34 if (!ctx) return null;
35
36 ctx.drawImage(before, 0, 0, width, height);
37 const beforePixels = ctx.getImageData(0, 0, width, height).data;
38 ctx.clearRect(0, 0, width, height);
39 ctx.drawImage(after, 0, 0, width, height);
40 const afterPixels = ctx.getImageData(0, 0, width, height).data;
41
42 const luminance = ({ r, g, b }) => {
43 const convert = c => {
44 const v = c / 255;
45 return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
46 };
47 return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
48 };
49 const ratio = (a, b) => {
50 const l1 = luminance(a);
51 const l2 = luminance(b);
52 return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
53 };
54
55 const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
56 ? {
57 r: candidate.textColor.r,
58 g: candidate.textColor.g,
59 b: candidate.textColor.b,
60 }
61 : null;
62 const ratios = [];
63 let glyphPixels = 0;
64 let strongestDelta = 0;
65 for (let i = 0; i < beforePixels.length; i += 4) {
66 const delta = Math.abs(beforePixels[i] - afterPixels[i])
67 + Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
68 + Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
69 + Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
70 strongestDelta = Math.max(strongestDelta, delta);
71 if (delta < 10) continue;
72 glyphPixels++;
73 const fg = cssTextColor || {
74 r: beforePixels[i],
75 g: beforePixels[i + 1],
76 b: beforePixels[i + 2],
77 };
78 const bg = {
79 r: afterPixels[i],
80 g: afterPixels[i + 1],
81 b: afterPixels[i + 2],
82 };
83 ratios.push(ratio(fg, bg));
84 }
85
86 if (ratios.length < 8) {
87 return {
88 glyphPixels,
89 strongestDelta,
90 worstRatio: null,
91 p10Ratio: null,
92 medianRatio: null,
93 };
94 }
95
96 ratios.sort((a, b) => a - b);
97 const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
98 return {
99 glyphPixels,
100 strongestDelta,
101 worstRatio: ratios[0],
102 p10Ratio: pick(10),
103 medianRatio: pick(50),
104 };
105 }, { beforeBase64, afterBase64, candidate });
106}
107
108async function captureVisualContrastCandidate(page, candidate, viewport) {
109 const clip = sanitizeScreenshotClip(candidate.clip, viewport);
110 if (!clip) return null;
111
112 const beforeBase64 = await page.screenshot({
113 encoding: 'base64',
114 clip,
115 captureBeyondViewport: true,
116 });
117 const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
118 const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
119 let el;
120 try {
121 el = document.querySelector(selector);
122 } catch {
123 return false;
124 }
125 if (!el) return false;
126 let style = document.getElementById('impeccable-visual-contrast-hide-style');
127 if (!style) {
128 style = document.createElement('style');
129 style.id = 'impeccable-visual-contrast-hide-style';
130 style.textContent = [
131 '[data-impeccable-visual-contrast-target] {',
132 ' color: transparent !important;',
133 ' -webkit-text-fill-color: transparent !important;',
134 ' text-shadow: none !important;',
135 '}',
136 '[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
137 ' background-image: none !important;',
138 '}',
139 ].join('\n');
140 document.head.appendChild(style);
141 }
142 el.setAttribute('data-impeccable-visual-contrast-target', token);
143 if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
144 return true;
145 }, {
146 selector: candidate.selector,
147 token,
148 backgroundClipText: candidate.backgroundClipText,
149 });
150 if (!applied) return null;
151
152 let afterBase64;
153 try {
154 afterBase64 = await page.screenshot({
155 encoding: 'base64',
156 clip,
157 captureBeyondViewport: true,
158 });
159 } finally {
160 await page.evaluate(({ selector }) => {
161 try {
162 const el = document.querySelector(selector);
163 if (el) {
164 el.removeAttribute('data-impeccable-visual-contrast-target');
165 el.removeAttribute('data-impeccable-bgclip-text');
166 }
167 } catch {
168 // Ignore invalid or stale selectors during cleanup.
169 }
170 }, { selector: candidate.selector }).catch(() => {});
171 }
172
173 const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
174 if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
175 const measuredRatio = metrics.p10Ratio;
176 if (measuredRatio >= candidate.threshold) return null;
177 const textLabel = candidate.text ? ` "${candidate.text}"` : '';
178 const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
179 return {
180 id: 'low-contrast',
181 snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
182 };
183}
184
185export {
186 sanitizeScreenshotClip,
187 compareScreenshotContrast,
188 captureVisualContrastCandidate,
189};