screenshot-contrast.mjs

  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};