Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/engines/visual/screenshot-contrast.mjs
1function sanitizeScreenshotClip(clip, viewport) {2if (!clip) return null;3const x = Math.max(0, Math.floor(clip.x || 0));4const y = Math.max(0, Math.floor(clip.y || 0));5const width = Math.min(6Math.max(1, Math.ceil(clip.width || 0)),7Math.max(1, viewport?.width || 1600),8);9const height = Math.min(10Math.max(1, Math.ceil(clip.height || 0)),11320,12);13if (width < 1 || height < 1) return null;14return { x, y, width, height };15}1617async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {18return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {19const loadImage = (base64) => new Promise((resolve, reject) => {20const img = new Image();21img.onload = () => resolve(img);22img.onerror = () => reject(new Error('Could not decode contrast screenshot'));23img.src = `data:image/png;base64,${base64}`;24});25const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);26const width = Math.min(before.width, after.width);27const height = Math.min(before.height, after.height);28if (width < 1 || height < 1) return null;2930const canvas = document.createElement('canvas');31canvas.width = width;32canvas.height = height;33const ctx = canvas.getContext('2d', { willReadFrequently: true });34if (!ctx) return null;3536ctx.drawImage(before, 0, 0, width, height);37const beforePixels = ctx.getImageData(0, 0, width, height).data;38ctx.clearRect(0, 0, width, height);39ctx.drawImage(after, 0, 0, width, height);40const afterPixels = ctx.getImageData(0, 0, width, height).data;4142const luminance = ({ r, g, b }) => {43const convert = c => {44const v = c / 255;45return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;46};47return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);48};49const ratio = (a, b) => {50const l1 = luminance(a);51const l2 = luminance(b);52return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);53};5455const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground56? {57r: candidate.textColor.r,58g: candidate.textColor.g,59b: candidate.textColor.b,60}61: null;62const ratios = [];63let glyphPixels = 0;64let strongestDelta = 0;65for (let i = 0; i < beforePixels.length; i += 4) {66const 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]);70strongestDelta = Math.max(strongestDelta, delta);71if (delta < 10) continue;72glyphPixels++;73const fg = cssTextColor || {74r: beforePixels[i],75g: beforePixels[i + 1],76b: beforePixels[i + 2],77};78const bg = {79r: afterPixels[i],80g: afterPixels[i + 1],81b: afterPixels[i + 2],82};83ratios.push(ratio(fg, bg));84}8586if (ratios.length < 8) {87return {88glyphPixels,89strongestDelta,90worstRatio: null,91p10Ratio: null,92medianRatio: null,93};94}9596ratios.sort((a, b) => a - b);97const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];98return {99glyphPixels,100strongestDelta,101worstRatio: ratios[0],102p10Ratio: pick(10),103medianRatio: pick(50),104};105}, { beforeBase64, afterBase64, candidate });106}107108async function captureVisualContrastCandidate(page, candidate, viewport) {109const clip = sanitizeScreenshotClip(candidate.clip, viewport);110if (!clip) return null;111112const beforeBase64 = await page.screenshot({113encoding: 'base64',114clip,115captureBeyondViewport: true,116});117const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;118const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {119let el;120try {121el = document.querySelector(selector);122} catch {123return false;124}125if (!el) return false;126let style = document.getElementById('impeccable-visual-contrast-hide-style');127if (!style) {128style = document.createElement('style');129style.id = 'impeccable-visual-contrast-hide-style';130style.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');140document.head.appendChild(style);141}142el.setAttribute('data-impeccable-visual-contrast-target', token);143if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');144return true;145}, {146selector: candidate.selector,147token,148backgroundClipText: candidate.backgroundClipText,149});150if (!applied) return null;151152let afterBase64;153try {154afterBase64 = await page.screenshot({155encoding: 'base64',156clip,157captureBeyondViewport: true,158});159} finally {160await page.evaluate(({ selector }) => {161try {162const el = document.querySelector(selector);163if (el) {164el.removeAttribute('data-impeccable-visual-contrast-target');165el.removeAttribute('data-impeccable-bgclip-text');166}167} catch {168// Ignore invalid or stale selectors during cleanup.169}170}, { selector: candidate.selector }).catch(() => {});171}172173const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);174if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;175const measuredRatio = metrics.p10Ratio;176if (measuredRatio >= candidate.threshold) return null;177const textLabel = candidate.text ? ` "${candidate.text}"` : '';178const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';179return {180id: 'low-contrast',181snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,182};183}184185export {186sanitizeScreenshotClip,187compareScreenshotContrast,188captureVisualContrastCandidate,189};190