← All gadgets

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 file

Every 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">&#9678;</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">&#9678;</button>
  </div>
  <button id="swap" type="button">&#8645; swap text &amp; 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 &middot; or Ctrl/&#8984;+V</div>
    <div class="priv">&#128274; 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>