Contrast Checker
WCAG contrast for any two colours -- or drop in a screenshot and it flags the low-contrast text, all in your browser
Running live from https://fun.whitneys.co,
cross-origin and sandboxed.
Put it on your site
<iframe
src="https://fun.whitneys.co/g/contrast-checker"
width="360"
height="660"
title="Contrast Checker"
sandbox="allow-scripts allow-popups-to-escape-sandbox"
allow="clipboard-write"
loading="lazy"
style="border:0"></iframe>
An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.
Optional: let it resize itself to fit
<script>
addEventListener('message', function (e) {
var d = e.data;
if (!d || d.type !== 'fwf:height') return;
if (typeof d.height !== 'number' || !isFinite(d.height)) return;
document.querySelectorAll('iframe').forEach(function (f) {
// Identity by source window, not by origin -- sandboxed frames report
// their origin as "null". Do not "fix" this into an origin check.
if (f.contentWindow !== e.source) return;
f.style.height = Math.max(40, Math.min(2000, Math.ceil(d.height))) + 'px';
});
});
</script>
The whole thing
Raw fileEvery gadget is one file, and this is it -- all 26.6 kB. Take it, change it, ship it. Just keep the credit.
Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
"name": "Contrast Checker",
"tagline": "WCAG contrast for any two colours -- or drop in a screenshot and it flags the low-contrast text, all in your browser",
"category": "data",
"tags": ["contrast", "wcag", "accessibility", "a11y", "color"],
"order": 10,
"author": "rwhitney",
"background": "dark",
"size": { "w": 360, "h": 660, "resizable": true },
"config": {
"fg": { "type": "color", "label": "Text colour", "default": "#F8F8F2" },
"bg": { "type": "color", "label": "Background", "default": "#272822" }
},
"permissions": ["clipboard"]
}
</script>
<style>
:root {
--ink:#F8F8F2; --dim:#A59F85; --line:rgb(248 248 242 / .14);
--panel:rgb(248 248 242 / .05); --ok:#A6E22E; --bad:#FF6188; --accent:#66D9EF;
}
html, body { margin:0; background:transparent; color:var(--ink);
font:14px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
#app { padding:14px; box-sizing:border-box; }
h3 { font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--dim);
margin:18px 0 8px; font-weight:700; }
/* colour controls */
.crow { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
.crow .lab { width:80px; font-size:11px; color:var(--dim); font-weight:600; }
.sw { width:34px; height:34px; border-radius:8px; border:1px solid var(--line);
padding:0; overflow:hidden; position:relative; flex:none; cursor:pointer; }
.sw input[type=color] { position:absolute; inset:-4px; width:calc(100% + 8px);
height:calc(100% + 8px); border:0; padding:0; background:none; cursor:pointer; }
.hex { flex:1; min-width:0; background:var(--panel); border:1px solid var(--line);
border-radius:7px; color:var(--ink); font:600 13px ui-monospace, Menlo, monospace;
padding:8px 9px; text-transform:uppercase; }
.hex:focus { outline:none; border-color:var(--accent); }
.eye { flex:none; width:34px; height:34px; border-radius:8px; border:1px solid var(--line);
background:var(--panel); color:var(--dim); cursor:pointer; font-size:16px; line-height:1; }
.eye.on { border-color:var(--accent); color:var(--accent); background:rgb(102 217 239 / .12); }
#swap { width:100%; margin:2px 0 6px; background:var(--panel); border:1px solid var(--line);
border-radius:7px; color:var(--dim); padding:6px; cursor:pointer; font:inherit; font-size:12px; }
#swap:hover { color:var(--ink); }
/* ratio + verdicts */
#ratio { display:flex; align-items:baseline; gap:7px; margin:14px 0 2px; cursor:pointer; }
#ratioNum { font-size:36px; font-weight:800; letter-spacing:-.02em; }
#ratioNum.pass { color:var(--ok); } #ratioNum.fail { color:var(--bad); }
#ratio .x { font-size:16px; color:var(--dim); }
#ratioHint { font-size:11px; color:var(--dim); margin-bottom:12px; }
#verdicts { display:flex; flex-direction:column; gap:5px; }
.vrow { display:grid; grid-template-columns:1fr auto auto; gap:6px; align-items:center; font-size:12px; }
.vname { color:var(--dim); }
.badge { font-size:11px; font-weight:700; padding:3px 9px; border-radius:20px;
border:1px solid var(--line); white-space:nowrap; text-align:center; }
.badge.pass { color:#14150F; background:var(--ok); border-color:var(--ok); }
.badge.fail { color:var(--bad); background:rgb(255 97 136 / .10); }
/* live preview */
#preview { margin-top:8px; border-radius:11px; border:1px solid var(--line);
padding:16px; overflow:hidden; }
#pvBig { font-size:22px; font-weight:800; margin:0 0 6px; line-height:1.2; }
#pvNorm { font-size:14px; margin:0 0 12px; }
#pvBtn { display:inline-block; font-size:13px; font-weight:700; padding:7px 14px; border-radius:8px; }
/* screenshot eyedrop */
#drop { border:1.5px dashed var(--line); border-radius:12px; padding:18px 14px;
text-align:center; cursor:pointer; transition:border-color .12s, background .12s; }
#drop:hover, #drop.over { border-color:var(--accent); background:rgb(102 217 239 / .06); }
#drop .big { font-weight:700; margin-bottom:3px; }
#drop .sub { font-size:11px; color:var(--dim); }
#drop .priv { font-size:10.5px; color:var(--accent); margin-top:7px; letter-spacing:.02em; }
#file { display:none; }
#imgwrap { margin-top:8px; position:relative; }
#cv { display:block; max-width:100%; border-radius:9px; border:1px solid var(--line); cursor:crosshair; }
#loupe { position:absolute; width:96px; height:96px; border-radius:50%; border:2px solid var(--ink);
box-shadow:0 6px 20px rgb(0 0 0 / .5); overflow:hidden; pointer-events:none; display:none;
transform:translate(-50%, -118%); }
#loupe canvas { width:100%; height:100%; image-rendering:pixelated; }
#loupe::after { content:""; position:absolute; left:50%; top:50%; width:9px; height:9px;
transform:translate(-50%, -50%); border:1px solid rgb(255 255 255 / .9);
box-shadow:0 0 0 1px rgb(0 0 0 / .6); border-radius:2px; }
#loupeHex { position:absolute; left:50%; bottom:3px; transform:translateX(-50%);
font:700 9px ui-monospace, monospace; background:rgb(0 0 0 / .6); color:#fff;
padding:1px 4px; border-radius:3px; }
.pickhint { font-size:11px; color:var(--dim); margin-top:7px; text-align:center; min-height:14px; }
#scanbar { display:flex; align-items:center; gap:8px; margin-top:8px; font-size:12px; flex-wrap:wrap; }
#scanSummary { font-weight:600; color:var(--dim); }
#scanSummary.bad { color:var(--bad); }
#scanSummary.ok { color:var(--ok); }
.thr { color:var(--dim); margin-left:auto; }
#thr, #rescan { background:var(--panel); border:1px solid var(--line); border-radius:6px;
color:var(--ink); font:inherit; font-size:12px; padding:3px 6px; cursor:pointer; }
#results { display:flex; flex-direction:column; gap:5px; margin-top:8px; }
.fcard { display:flex; align-items:center; gap:9px; padding:7px 9px; border:1px solid var(--line);
border-radius:8px; cursor:pointer; background:var(--panel); }
.fcard:hover { border-color:var(--accent); }
.fchip { width:40px; height:27px; border-radius:5px; border:1px solid var(--line); flex:none;
display:flex; align-items:center; justify-content:center; font:700 13px ui-serif, Georgia, serif; }
.fmeta { flex:1; min-width:0; font-size:11px; color:var(--dim); overflow:hidden;
text-overflow:ellipsis; white-space:nowrap; }
.fratio { font:800 14px ui-sans-serif; flex:none; }
.fratio.fail { color:var(--bad); }
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }
</style>
</head>
<body>
<div id="app">
<div class="crow" data-t="fg">
<span class="lab">Text</span>
<span class="sw"><input type="color" id="fgc" aria-label="Text colour"></span>
<input class="hex" id="fgh" spellcheck="false" maxlength="7" aria-label="Text colour hex">
<button class="eye" id="fge" type="button" title="Eyedrop the text colour from a screenshot">◎</button>
</div>
<div class="crow" data-t="bg">
<span class="lab">Background</span>
<span class="sw"><input type="color" id="bgc" aria-label="Background colour"></span>
<input class="hex" id="bgh" spellcheck="false" maxlength="7" aria-label="Background colour hex">
<button class="eye" id="bge" type="button" title="Eyedrop the background colour from a screenshot">◎</button>
</div>
<button id="swap" type="button">⇅ swap text & background</button>
<div id="ratio" role="button" tabindex="0" title="Click to copy the ratio">
<span id="ratioNum">--</span><span class="x">: 1</span>
</div>
<div id="ratioHint"></div>
<div id="verdicts"></div>
<div id="preview">
<div id="pvBig">Large heading</div>
<p id="pvNorm">The quick brown fox jumps over the lazy dog.</p>
<span id="pvBtn">Button</span>
</div>
<h3>Pick from a screenshot</h3>
<div id="drop" tabindex="0" role="button" aria-label="Load a screenshot">
<div class="big">Drop or paste a screenshot, or click</div>
<div class="sub">PNG, JPG, GIF, WEBP · or Ctrl/⌘+V</div>
<div class="priv">🔒 Stays in your browser -- never uploaded</div>
</div>
<input id="file" type="file" accept="image/*">
<div id="imgwrap" hidden>
<canvas id="cv"></canvas>
<div id="loupe"><canvas id="loupecv" width="13" height="13"></canvas><span id="loupeHex"></span></div>
<div class="pickhint" id="pickhint"></div>
<div id="scanbar" hidden>
<span id="scanSummary"></span>
<label class="thr">flag below
<select id="thr">
<option value="4.5">4.5 (AA text)</option>
<option value="3">3 (large / UI)</option>
<option value="7">7 (AAA)</option>
</select>
</label>
<button id="rescan" type="button">rescan</button>
</div>
<div id="results"></div>
</div>
<div class="sr" role="status" aria-live="polite" id="say"></div>
</div>
<script>
(function () {
var cfg = window.gadget ? window.gadget.config : {};
/* ---- colour helpers -------------------------------------------------- */
function normHex(s) {
if (s == null) return null;
s = String(s).trim().replace(/^#/, '');
if (/^[0-9a-fA-F]{3}$/.test(s)) s = s.split('').map(function (c) { return c + c; }).join('');
if (!/^[0-9a-fA-F]{6}$/.test(s)) return null;
return '#' + s.toUpperCase();
}
function hexToRgb(hex) {
var n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(function (v) {
return Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0');
}).join('').toUpperCase();
}
// WCAG relative luminance + contrast ratio (sRGB).
function lin(v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }
function lum(rgb) { return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); }
function contrast(a, b) {
var la = lum(a), lb = lum(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
var $ = function (id) { return document.getElementById(id); };
var state = { fg: normHex(cfg.fg) || '#F8F8F2', bg: normHex(cfg.bg) || '#272822' };
var pick = null; // 'fg' | 'bg' | null -- which colour the next image click sets
var els = {
fgc: $('fgc'), fgh: $('fgh'), fge: $('fge'),
bgc: $('bgc'), bgh: $('bgh'), bge: $('bge'),
ratioNum: $('ratioNum'), ratioHint: $('ratioHint'), verdicts: $('verdicts'),
preview: $('preview'), pvBig: $('pvBig'), pvNorm: $('pvNorm'), pvBtn: $('pvBtn'),
say: $('say'),
};
/* ---- verdict rows ---------------------------------------------------- */
// [name, [[level, threshold], ...]]
var ROWS = [
['Normal text', [['AA', 4.5], ['AAA', 7]]],
['Large text', [['AA', 3], ['AAA', 4.5]]],
['UI & graphics', [['AA', 3]]],
];
function badge(level, thr, r) {
var pass = r >= thr;
var b = document.createElement('span');
b.className = 'badge ' + (pass ? 'pass' : 'fail');
b.textContent = level + ' ' + (pass ? '✓' : '✗');
b.title = level + ' needs ≥ ' + thr + ':1';
return b;
}
function fmt(r) { return (Math.round(r * 100) / 100).toString(); }
function update() {
var fg = hexToRgb(state.fg), bg = hexToRgb(state.bg);
var r = contrast(fg, bg);
els.ratioNum.textContent = fmt(r);
els.ratioNum.className = r >= 4.5 ? 'pass' : 'fail';
els.ratioHint.textContent =
r >= 7 ? 'Passes AAA -- good for any text.'
: r >= 4.5 ? 'Passes AA for normal text (AAA for large).'
: r >= 3 ? 'Large text / UI only -- fails AA for body text.'
: 'Fails AA -- too low for text.';
els.verdicts.innerHTML = '';
ROWS.forEach(function (row) {
var el = document.createElement('div');
el.className = 'vrow';
var name = document.createElement('span');
name.className = 'vname';
name.textContent = row[0];
el.appendChild(name);
row[1].forEach(function (lv) { el.appendChild(badge(lv[0], lv[1], r)); });
// pad the UI row (one badge) so columns line up
if (row[1].length === 1) { var sp = document.createElement('span'); el.appendChild(sp); }
els.verdicts.appendChild(el);
});
// live preview
els.preview.style.background = state.bg;
els.preview.style.borderColor = 'rgba(248,248,242,.14)';
els.pvBig.style.color = state.fg;
els.pvNorm.style.color = state.fg;
els.pvBtn.style.background = state.fg;
els.pvBtn.style.color = state.bg;
// swatches + inputs
els.fgc.value = state.fg; els.bgc.value = state.bg;
if (document.activeElement !== els.fgh) els.fgh.value = state.fg;
if (document.activeElement !== els.bgh) els.bgh.value = state.bg;
}
function setColor(target, hex) {
var h = normHex(hex);
if (!h) return;
state[target] = h;
update();
}
/* ---- colour input wiring -------------------------------------------- */
els.fgc.addEventListener('input', function () { setColor('fg', els.fgc.value); });
els.bgc.addEventListener('input', function () { setColor('bg', els.bgc.value); });
els.fgh.addEventListener('input', function () { var h = normHex(els.fgh.value); if (h) setColor('fg', h); });
els.bgh.addEventListener('input', function () { var h = normHex(els.bgh.value); if (h) setColor('bg', h); });
$('swap').addEventListener('click', function () {
var t = state.fg; state.fg = state.bg; state.bg = t; update();
});
// copy the ratio
function copyRatio() {
var txt = fmt(contrast(hexToRgb(state.fg), hexToRgb(state.bg))) + ':1';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(function () {
els.say.textContent = 'Copied ' + txt;
}, function () {});
}
}
$('ratio').addEventListener('click', copyRatio);
$('ratio').addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyRatio(); }
});
/* ---- eyedrop from a screenshot -------------------------------------- */
var drop = $('drop'), fileIn = $('file'), imgwrap = $('imgwrap');
var cv = $('cv'), ctx = cv.getContext('2d', { willReadFrequently: true });
var full = document.createElement('canvas'); // natural-res, for accurate reads
var fctx = full.getContext('2d', { willReadFrequently: true });
var loupe = $('loupe'), lctx = $('loupecv').getContext('2d');
var loupeHex = $('loupeHex'), pickhint = $('pickhint');
var scale = 1;
function setPick(target) {
pick = target;
els.fge.classList.toggle('on', target === 'fg');
els.bge.classList.toggle('on', target === 'bg');
pickhint.textContent = target
? 'Click the image to set the ' + (target === 'fg' ? 'text' : 'background') + ' colour.'
: '';
}
els.fge.addEventListener('click', function () {
if (imgwrap.hidden) { fileIn.click(); return; }
setPick(pick === 'fg' ? null : 'fg');
});
els.bge.addEventListener('click', function () {
if (imgwrap.hidden) { fileIn.click(); return; }
setPick(pick === 'bg' ? null : 'bg');
});
function drawImage(img) {
var maxW = document.getElementById('app').clientWidth - 28; // padding
var s = Math.min(1, maxW / img.width);
scale = s;
full.width = img.width; full.height = img.height;
fctx.drawImage(img, 0, 0);
cv.width = Math.max(1, Math.round(img.width * s));
cv.height = Math.max(1, Math.round(img.height * s));
ctx.drawImage(img, 0, 0, cv.width, cv.height);
imgwrap.hidden = false;
if (!pick) setPick('fg'); // ready to pick the text colour immediately
els.say.textContent = 'Screenshot loaded -- scanning for low-contrast text.';
analyze();
}
/* ---- auto-scan the screenshot for low-contrast text ------------------ *
* Heuristic, NOT a DOM audit: over a grid of small tiles, Otsu-split each
* into two tones and keep only ones that look like TEXT ON A SOLID BG -- a
* minority "ink" cluster, both clusters tight (which rejects photos and
* gradients). Flag those whose ink/bg contrast is below the chosen
* threshold; merge adjacent flags into boxes; list the distinct pairs. */
function luma(r, g, b) { return r * 0.299 + g * 0.587 + b * 0.114; }
var lastRegions = [];
function analyze() {
var W = full.width, H = full.height;
if (!W || !H) return;
var img = fctx.getImageData(0, 0, W, H).data;
var thr = parseFloat($('thr').value) || 4.5;
var T = Math.max(12, Math.min(30, Math.round(Math.min(W, H) / 45)));
var cols = Math.ceil(W / T), rows = Math.ceil(H / T);
var grid = new Array(rows * cols).fill(null);
for (var ry = 0; ry < rows; ry++) {
for (var rx = 0; rx < cols; rx++) {
var x0 = rx * T, y0 = ry * T, x1 = Math.min(W, x0 + T), y1 = Math.min(H, y0 + T);
var hist = new Uint32Array(256), n = 0, lmin = 255, lmax = 0, yy, xx, i, L;
for (yy = y0; yy < y1; yy++) { var base = yy * W;
for (xx = x0; xx < x1; xx++) { i = (base + xx) * 4;
L = luma(img[i], img[i + 1], img[i + 2]) | 0; if (L < 0) L = 0; if (L > 255) L = 255;
hist[L]++; n++; if (L < lmin) lmin = L; if (L > lmax) lmax = L;
}
}
if (n < 16 || (lmax - lmin) < 40) continue; // flat / tiny -> no text
var sum = 0, t; for (t = 0; t < 256; t++) sum += t * hist[t];
var sumB = 0, wB = 0, maxVar = -1, cut = lmin; // Otsu on the tile
for (t = 0; t < 256; t++) {
wB += hist[t]; if (wB === 0) continue; var wF = n - wB; if (wF === 0) break;
sumB += t * hist[t]; var mB = sumB / wB, mF = (sum - sumB) / wF;
var v = wB * wF * (mB - mF) * (mB - mF); if (v > maxVar) { maxVar = v; cut = t; }
}
// Full split drives the gating (minority fraction + cluster tightness).
// A separate CORE pair excludes an anti-aliasing band around the cut, so
// blended edge pixels don't drag the ink colour toward the background
// (which would understate the real ratio).
var band = Math.max(12, (lmax - lmin) * 0.16);
var dR = 0, dG = 0, dB = 0, dN = 0, dLs = 0, dL2 = 0, lR = 0, lG = 0, lB = 0, lN = 0, lLs = 0, lL2 = 0;
var kdR = 0, kdG = 0, kdB = 0, kdN = 0, klR = 0, klG = 0, klB = 0, klN = 0;
for (yy = y0; yy < y1; yy++) { var b2 = yy * W;
for (xx = x0; xx < x1; xx++) { i = (b2 + xx) * 4;
var r = img[i], g = img[i + 1], b = img[i + 2]; L = luma(r, g, b);
if (L <= cut) { dR += r; dG += g; dB += b; dN++; dLs += L; dL2 += L * L;
if (L <= cut - band) { kdR += r; kdG += g; kdB += b; kdN++; } }
else { lR += r; lG += g; lB += b; lN++; lLs += L; lL2 += L * L;
if (L >= cut + band) { klR += r; klG += g; klB += b; klN++; } }
}
}
if (!dN || !lN) continue;
var minor = Math.min(dN, lN) / n;
if (minor < 0.03 || minor > 0.45) continue; // text is a minority stroke
var dSd = Math.sqrt(Math.max(0, dL2 / dN - (dLs / dN) * (dLs / dN)));
var lSd = Math.sqrt(Math.max(0, lL2 / lN - (lLs / lN) * (lLs / lN)));
if (dSd > 26 || lSd > 26) continue; // spread clusters -> photo/gradient
// core colour when enough non-edge pixels survive the band, else the full mean
var dark = kdN >= 3 ? [kdR / kdN | 0, kdG / kdN | 0, kdB / kdN | 0] : [dR / dN | 0, dG / dN | 0, dB / dN | 0];
var light = klN >= 3 ? [klR / klN | 0, klG / klN | 0, klB / klN | 0] : [lR / lN | 0, lG / lN | 0, lB / lN | 0];
var ratio = contrast(dark, light);
if (ratio >= thr) continue; // passes -> not flagged
var fg = dN <= lN ? dark : light, bg = dN <= lN ? light : dark;
grid[ry * cols + rx] = { fg: fg, bg: bg, ratio: ratio, x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
}
// merge adjacent flagged tiles (4-connected) into rectangles
var seen = new Uint8Array(rows * cols), regions = [];
for (var sy = 0; sy < rows; sy++) for (var sx = 0; sx < cols; sx++) {
var k = sy * cols + sx; if (!grid[k] || seen[k]) continue;
var stack = [[sx, sy]]; seen[k] = 1;
var minx = W, miny = H, maxx = 0, maxy = 0, worst = grid[k];
while (stack.length) {
var p = stack.pop(), cx = p[0], cy = p[1], g = grid[cy * cols + cx];
if (g.x < minx) minx = g.x; if (g.y < miny) miny = g.y;
if (g.x + g.w > maxx) maxx = g.x + g.w; if (g.y + g.h > maxy) maxy = g.y + g.h;
if (g.ratio < worst.ratio) worst = g;
var nb = [[cx - 1, cy], [cx + 1, cy], [cx, cy - 1], [cx, cy + 1]];
for (var mm = 0; mm < 4; mm++) { var ax = nb[mm][0], ay = nb[mm][1];
if (ax < 0 || ay < 0 || ax >= cols || ay >= rows) continue; var kk = ay * cols + ax;
if (grid[kk] && !seen[kk]) { seen[kk] = 1; stack.push([ax, ay]); }
}
}
regions.push({ x: minx, y: miny, w: maxx - minx, h: maxy - miny, fg: worst.fg, bg: worst.bg, ratio: worst.ratio });
}
// group into distinct (quantised) colour pairs for the list
function qh(c) { return rgbToHex(Math.round(c[0] / 10) * 10, Math.round(c[1] / 10) * 10, Math.round(c[2] / 10) * 10); }
var pmap = {};
regions.forEach(function (rg) {
var key = qh(rg.fg) + '|' + qh(rg.bg);
var e = pmap[key] || (pmap[key] = { fg: rg.fg, bg: rg.bg, ratio: rg.ratio, count: 0 });
e.count++; if (rg.ratio < e.ratio) { e.ratio = rg.ratio; e.fg = rg.fg; e.bg = rg.bg; }
});
var pairs = Object.keys(pmap).map(function (k) { return pmap[k]; })
.sort(function (a, b) { return b.count - a.count || a.ratio - b.ratio; });
lastRegions = regions;
paintOverlay(regions);
renderResults(regions.length, pairs);
}
function paintOverlay(regions) {
ctx.drawImage(full, 0, 0, cv.width, cv.height);
regions.forEach(function (rg) {
var x = rg.x * scale, y = rg.y * scale, w = rg.w * scale, h = rg.h * scale;
ctx.fillStyle = 'rgba(255,97,136,.18)'; ctx.fillRect(x, y, w, h);
ctx.strokeStyle = 'rgba(255,97,136,.95)'; ctx.lineWidth = 1.5;
ctx.strokeRect(x + 0.75, y + 0.75, Math.max(1, w - 1.5), Math.max(1, h - 1.5));
});
}
function renderResults(count, pairs) {
var sb = $('scanbar'), sum = $('scanSummary'), res = $('results');
sb.hidden = false; res.innerHTML = '';
if (count === 0) { sum.textContent = 'No low-contrast text found ✓'; sum.className = 'ok'; return; }
sum.textContent = count + ' low-contrast area' + (count === 1 ? '' : 's') + ' flagged';
sum.className = 'bad';
pairs.slice(0, 6).forEach(function (pr) {
var fgh = rgbToHex(pr.fg[0], pr.fg[1], pr.fg[2]), bgh = rgbToHex(pr.bg[0], pr.bg[1], pr.bg[2]);
var card = document.createElement('div'); card.className = 'fcard';
card.title = 'Load this pair into the checker above';
var chip = document.createElement('span'); chip.className = 'fchip';
chip.style.background = bgh; chip.style.color = fgh; chip.textContent = 'Aa';
var meta = document.createElement('span'); meta.className = 'fmeta';
meta.textContent = fgh + ' on ' + bgh + ' · ' + (pr.count > 1 ? pr.count + ' areas' : '1 area');
var rt = document.createElement('span'); rt.className = 'fratio fail'; rt.textContent = fmt(pr.ratio) + ':1';
card.appendChild(chip); card.appendChild(meta); card.appendChild(rt);
card.addEventListener('click', function () {
state.fg = fgh; state.bg = bgh; update();
els.say.textContent = 'Loaded ' + fgh + ' on ' + bgh;
});
res.appendChild(card);
});
}
function loadFile(file) {
if (!file || !/^image\//.test(file.type)) { els.say.textContent = 'Please choose an image file.'; return; }
var reader = new FileReader();
reader.onerror = function () { els.say.textContent = 'Could not read that file.'; };
reader.onload = function () {
var img = new Image();
img.onload = function () { drawImage(img); };
img.onerror = function () { els.say.textContent = 'That file would not decode as an image.'; };
img.src = reader.result; // data: URL -- same-origin, loads in the sandbox, canvas stays readable
};
reader.readAsDataURL(file);
}
// natural-pixel colour under a display-canvas event
function pixelAt(e) {
var rect = cv.getBoundingClientRect();
var dx = (e.clientX - rect.left) * (cv.width / rect.width);
var dy = (e.clientY - rect.top) * (cv.height / rect.height);
var nx = Math.max(0, Math.min(full.width - 1, Math.round(dx / scale)));
var ny = Math.max(0, Math.min(full.height - 1, Math.round(dy / scale)));
var d = fctx.getImageData(nx, ny, 1, 1).data;
return { hex: rgbToHex(d[0], d[1], d[2]), nx: nx, ny: ny, x: e.clientX - rect.left, y: e.clientY - rect.top };
}
cv.addEventListener('mousemove', function (e) {
var p = pixelAt(e);
// loupe: a zoomed crop from the natural-res canvas, centred on the pixel
var Z = 13, half = (Z - 1) / 2;
lctx.imageSmoothingEnabled = false;
lctx.clearRect(0, 0, Z, Z);
lctx.drawImage(full, p.nx - half, p.ny - half, Z, Z, 0, 0, Z, Z);
loupe.style.left = p.x + 'px';
loupe.style.top = p.y + 'px';
loupe.style.display = 'block';
loupeHex.textContent = p.hex;
});
cv.addEventListener('mouseleave', function () { loupe.style.display = 'none'; });
cv.addEventListener('click', function (e) {
if (!pick) return;
var p = pixelAt(e);
setColor(pick, p.hex);
els.say.textContent = 'Set ' + (pick === 'fg' ? 'text' : 'background') + ' to ' + p.hex;
});
drop.addEventListener('click', function () { fileIn.click(); });
drop.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileIn.click(); }
});
fileIn.addEventListener('change', function () { if (fileIn.files[0]) loadFile(fileIn.files[0]); });
['dragenter', 'dragover'].forEach(function (ev) {
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('over'); });
});
['dragleave', 'drop'].forEach(function (ev) {
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (e) {
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) loadFile(f);
});
// Paste a screenshot straight from the clipboard (Ctrl/Cmd+V) -- the natural
// way in for "grab a screenshot, check its contrast". The paste event's own
// clipboardData is a user gesture, so it needs no clipboard-read permission.
document.addEventListener('paste', function (e) {
var dt = e.clipboardData || window.clipboardData;
if (!dt || !dt.items) return;
for (var i = 0; i < dt.items.length; i++) {
if (dt.items[i].type && dt.items[i].type.indexOf('image') === 0) {
var f = dt.items[i].getAsFile();
if (f) { e.preventDefault(); loadFile(f); return; }
}
}
});
$('rescan').addEventListener('click', function () { if (!imgwrap.hidden) analyze(); });
$('thr').addEventListener('change', function () { if (!imgwrap.hidden) analyze(); });
update();
})();
</script>
</body>
</html>