$sloprun.dev

Color Vision Simulator

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 8 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

It opens on a release dashboard that looks perfectly legible — green ticks, a red failure row, an amber queue — and shows it a second time the way it lands for the roughly 1 in 16 men with deuteranopia: the passing green #2E9E4F and the failing red #D93A2B both arrive as the same mustard, #928755 beside #928223, a gap of ΔE 110 squeezed down to 22. The simulation is a published 3×3 matrix applied in linear light rather than a CSS filter guess, so the severity slider is a real dial and the active matrix is printed under the panel if you want to check the arithmetic. Point at any pixel to read its color both ways with the shift measured in ΔE, then hit market stall, where the red and green apples close from ΔE 97 to 17 and the green leaf merges outright with the brown of the stall behind it.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/colorblind-sim" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Color Vision Simulator — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/colorblind-sim

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 994 lines 45.4 KB index.html
demos/colorblind-sim/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Color Vision Simulator — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---- derived surfaces (theme-aware) ---- */
:root { --danger-soft: #FCEDEC; --sunk: #FAFAF7; --accent-ink: #A84B00; --shadow: 0 1px 2px rgba(16,18,22,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --danger-soft: #2D1A18; --sunk: #171A20; --accent-ink: #FFB454; --shadow: 0 1px 2px rgba(0,0,0,.4); }
}
:root[data-theme="dark"] { --danger-soft: #2D1A18; --sunk: #171A20; --accent-ink: #FFB454; --shadow: 0 1px 2px rgba(0,0,0,.4); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.wrap { max-width: 1120px; margin: 0 auto; padding: 22px 16px 44px; }
.mono { font-family: var(--font-mono); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* No border-radius here — it would override the focused element's own corners
   (the picture frames are 8px, the buttons 7px) and make them snap on focus. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---- header ---- */
header { margin-bottom: 14px; }
h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 66ch; font-size: .95rem; }
.spec { display: flex; flex-wrap: wrap; gap: 6px; }
.spec b {
  font-family: var(--font-mono); font-size: .7rem; font-weight: 500; letter-spacing: .02em;
  padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px;
  color: var(--muted); background: var(--surface); white-space: nowrap;
}

/* ---- cards ---- */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); }
.card + .card, .grid + .card, .card + .grid { margin-top: 14px; }
.card h2 {
  font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 10px;
}
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; align-items: stretch; }
@media (min-width: 860px) { .grid { grid-template-columns: minmax(0,1fr) minmax(0,1.25fr); } }

/* ---- controls ---- */
button, input, select { font: inherit; color: inherit; }
.ctlgrid { display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 780px) { .ctlgrid { grid-template-columns: minmax(0,1.5fr) minmax(0,1fr); } }
.ctl-label {
  font-family: var(--font-mono); font-size: .66rem; letter-spacing: .09em; text-transform: uppercase;
  color: var(--muted); margin: 0 0 7px; display: block;
}
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.types { position: relative; display: grid; gap: 7px; grid-template-columns: repeat(2, minmax(0,1fr)); }
.types input { position: absolute; top: 0; left: 0; width: 1px; height: 1px; opacity: 0; margin: 0; }
@media (min-width: 560px) { .types { grid-template-columns: repeat(4, minmax(0,1fr)); } }
.types label {
  display: block; cursor: pointer; border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 9px; background: var(--sunk); min-width: 0;
}
.types label .t1 { display: block; font-size: .84rem; font-weight: 600; }
.types label .t2 {
  display: block; font-family: var(--font-mono); font-size: .62rem; color: var(--muted);
  letter-spacing: .01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.types input:checked + label { background: var(--accent-soft); border-color: var(--accent); }
.types input:checked + label .t1 { color: var(--accent-ink); }
.types input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }
.types label:hover { border-color: var(--accent); }

.sevrow { display: flex; align-items: center; gap: 10px; }
input[type=range] { flex: 1 1 auto; min-width: 0; accent-color: var(--accent); height: 24px; }
.sevval { font-family: var(--font-mono); font-size: .92rem; font-weight: 600; min-width: 4.2ch; text-align: right; }

.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.btn {
  font-family: var(--font-mono); font-size: .72rem; padding: 6px 10px;
  background: var(--surface); color: var(--muted);
  border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
}
.btn:hover { border-color: var(--accent); color: var(--ink); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.row { position: relative; }
input[type=file] { position: absolute; bottom: 0; left: 0; width: 1px; height: 1px; opacity: 0; }
input[type=file]:focus-visible + label.btn { outline: 2px solid var(--accent); outline-offset: 2px; }
label.btn { display: inline-block; }

/* ---- stage ---- */
#stage.drag { border-color: var(--accent); background: var(--accent-soft); }
.frames { display: grid; gap: 12px; grid-template-columns: 1fr; }
@media (min-width: 700px) { .frames { grid-template-columns: 1fr 1fr; } }
figure.frame { margin: 0; min-width: 0; }
figcaption { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 8px; margin: 0 0 6px; }
figcaption .fl {
  font-family: var(--font-mono); font-size: .68rem; letter-spacing: .09em; text-transform: uppercase;
  font-weight: 600; color: var(--muted);
}
figcaption .fr { font-family: var(--font-mono); font-size: .66rem; color: var(--muted); }
.cwrap {
  position: relative; display: block; width: 100%; border: 1px solid var(--line);
  border-radius: 8px; overflow: hidden; background: var(--sunk); cursor: crosshair; line-height: 0;
}
.cwrap canvas { display: block; width: 100%; height: auto; }
.cross { position: absolute; width: 24px; height: 24px; margin: -12px 0 0 -12px; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 2px rgba(0,0,0,.8); pointer-events: none; }
.cross::after { content: ""; position: absolute; inset: 9px; background: #fff; border-radius: 50%; box-shadow: 0 0 0 1px rgba(0,0,0,.8); }
.hint { margin: 10px 0 0; font-size: .8rem; color: var(--muted); }
.hint kbd {
  font-family: var(--font-mono); font-size: .72rem; border: 1px solid var(--line);
  border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; background: var(--sunk);
}

/* ---- takeaway ---- */
.take {
  display: flex; gap: 10px; align-items: flex-start;
  border: 1px solid var(--line); border-left-width: 4px; border-radius: var(--radius);
  background: var(--surface); padding: 12px 14px; margin-top: 14px; box-shadow: var(--shadow);
}
.take .tag { font-family: var(--font-mono); font-size: .66rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; padding-top: 2px; white-space: nowrap; }
.take .msg { font-size: .95rem; }
.take .msg b { font-family: var(--font-mono); font-weight: 700; }
.take[data-state="warn"] { border-left-color: var(--danger); background: var(--danger-soft); }
.take[data-state="warn"] .tag { color: var(--danger); }
.take[data-state="ok"] { border-left-color: var(--run); background: var(--run-soft); }
.take[data-state="ok"] .tag { color: var(--run); }
.take[data-state="idle"] { border-left-color: var(--accent); }
.take[data-state="idle"] .tag { color: var(--accent-ink); }

/* ---- eyedropper ---- */
.card.eyedrop { display: flex; flex-direction: column; }
.chips { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 1 1 auto; }
.chip { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--sunk); min-width: 0; }
.chip .sw { flex: 1 1 auto; min-height: 72px; border-bottom: 1px solid var(--line); }
.chip .meta { padding: 7px 9px; }
.chip .k { font-family: var(--font-mono); font-size: .62rem; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.chip .hex { font-family: var(--font-mono); font-size: .95rem; font-weight: 700; letter-spacing: .01em; }
.chip .rgb { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); }
.dl { display: flex; align-items: baseline; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.dl .n { font-family: var(--font-mono); font-size: 1.6rem; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
.dl .u { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); }
.dl .verdict { font-size: .86rem; color: var(--muted); }
.bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-top: 8px; }
.bar i { display: block; height: 100%; background: var(--accent); border-radius: 3px; }

/* ---- palette ---- */
.pal { display: flex; gap: 6px; }
.pcol { flex: 1 1 0; min-width: 0; }
.pcol .pnum { font-family: var(--font-mono); font-size: .62rem; color: var(--muted); text-align: center; margin-bottom: 3px; }
.pcol .pa, .pcol .pb { height: 34px; border: 1px solid var(--line); }
.pcol .pa { border-radius: 6px 6px 0 0; border-bottom: 0; }
.pcol .pb { border-radius: 0 0 6px 6px; }
.pkey { display: flex; flex-wrap: wrap; gap: 2px 10px; justify-content: space-between; font-family: var(--font-mono); font-size: .62rem; color: var(--muted); margin-top: 5px; }
.pairs { margin: 10px 0 0; padding: 0; list-style: none; display: grid; gap: 5px; }
.pairs li {
  display: flex; align-items: center; flex-wrap: wrap; gap: 4px 7px;
  font-family: var(--font-mono); font-size: .72rem;
  border: 1px solid var(--line); border-radius: 6px; padding: 5px 7px; background: var(--sunk); min-width: 0;
}
.pairs li .sq { width: 15px; height: 15px; border-radius: 3px; border: 1px solid rgba(128,128,128,.45); flex: 0 0 auto; }
.pairs li .ar { color: var(--muted); }
.pairs li .de { margin-left: auto; font-weight: 700; color: var(--danger); white-space: nowrap; }
.pairs li.risky .de { color: var(--accent-ink); }
.pairs li.safe .de { color: var(--muted); }
.note { margin: 10px 0 0; font-size: .78rem; color: var(--muted); }
details.mx { margin-top: 12px; }
details.mx summary { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); cursor: pointer; letter-spacing: .04em; }
details.mx summary:hover { color: var(--ink); }
.mxgrid {
  display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 4px 10px; margin-top: 8px;
  font-family: var(--font-mono); font-size: .72rem; background: var(--sunk); border: 1px solid var(--line);
  border-radius: 8px; padding: 9px 11px;
}
.mxgrid span { text-align: right; font-variant-numeric: tabular-nums; }

footer { margin-top: 22px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">

<header>
  <h1>Color Vision Simulator</h1>
  <p class="lede">Roughly 1 in 12 men and 1 in 200 women see color differently. Here is any picture twice &mdash; the way it is, and the way it lands for them. Point at a pixel to read both colors.</p>
  <div class="spec"><b>4 vision types</b><b>severity 0&ndash;100%</b><b>eyedropper</b><b>palette collision check</b><b>drop your own image</b></div>
</header>

<div class="card">
  <div class="ctlgrid">
    <fieldset>
      <legend class="ctl-label">vision type</legend>
      <div class="types" id="types"></div>
    </fieldset>
    <div>
      <label class="ctl-label" for="sev">severity</label>
      <div class="sevrow">
        <input type="range" id="sev" min="0" max="100" step="1" value="100" aria-describedby="sevval">
        <span class="sevval" id="sevval">100%</span>
      </div>
      <div class="row" style="margin-top:12px">
        <span class="ctl-label" style="margin:0">image</span>
        <button class="btn" type="button" data-sample="board">status board</button>
        <button class="btn" type="button" data-sample="market">market stall</button>
        <button class="btn" type="button" data-sample="scales">color scales</button>
        <input type="file" id="file" accept="image/*">
        <label class="btn" for="file">your image&hellip;</label>
      </div>
    </div>
  </div>
</div>

<div class="card" id="stage">
  <div class="frames">
    <figure class="frame">
      <figcaption><span class="fl">original</span><span class="fr" id="dimA">880&times;520</span></figcaption>
      <div class="cwrap" id="wrapA" tabindex="0" role="img" aria-label="Original image. Use arrow keys to move the eyedropper.">
        <canvas id="cA" width="880" height="520"></canvas>
        <div class="cross" id="crossA"></div>
      </div>
    </figure>
    <figure class="frame">
      <figcaption><span class="fl" id="simLabel">simulated</span><span class="fr" id="simMeta">deuteranopia 100%</span></figcaption>
      <div class="cwrap" id="wrapB" tabindex="0" role="img" aria-label="Simulated image. Use arrow keys to move the eyedropper.">
        <canvas id="cB" width="880" height="520"></canvas>
        <div class="cross" id="crossB"></div>
      </div>
    </figure>
  </div>
  <p class="hint">Move the pointer over either picture to sample a pixel, or focus one and use <kbd>&larr;</kbd><kbd>&uarr;</kbd><kbd>&darr;</kbd><kbd>&rarr;</kbd> (<kbd>Shift</kbd> for bigger steps). Drop an image file anywhere on this panel to use your own.</p>
</div>

<div class="take" id="take" data-state="idle">
  <span class="tag" id="takeTag">takeaway</span>
  <span class="msg" id="takeMsg">Reading the image&hellip;</span>
</div>

<p class="sr" id="live" role="status" aria-live="polite"></p>

<div class="grid" style="margin-top:14px">
  <div class="card eyedrop">
    <h2>eyedropper</h2>
    <div class="chips">
      <div class="chip">
        <div class="sw" id="swA"></div>
        <div class="meta"><div class="k">original</div><div class="hex" id="hexA">#000000</div><div class="rgb" id="rgbA">rgb 0 0 0</div></div>
      </div>
      <div class="chip">
        <div class="sw" id="swB"></div>
        <div class="meta"><div class="k">seen as</div><div class="hex" id="hexB">#000000</div><div class="rgb" id="rgbB">rgb 0 0 0</div></div>
      </div>
    </div>
    <div class="dl">
      <span class="n" id="deVal">0.0</span><span class="u">&Delta;E shift</span>
      <span class="verdict" id="deVerdict">&mdash;</span>
    </div>
    <div class="bar"><i id="deBar" style="width:0%"></i></div>
    <p class="note" id="pxNote">pixel <span class="mono">x 0, y 0</span></p>
  </div>

  <div class="card">
    <h2>palette collision check</h2>
    <div class="pal" id="pal"></div>
    <div class="pkey"><span>top row: original</span><span>bottom row: simulated</span></div>
    <ul class="pairs" id="pairs"></ul>
    <p class="note">The image&rsquo;s most-used colorful shades, all clearly distinct to start. The four tightest pairs after simulation: under &Delta;E&nbsp;25 they are <b>risky</b> &mdash; still different, but not a difference you can count on. Under &Delta;E&nbsp;12 they have <b>merged</b> into one color.</p>
    <details class="mx">
      <summary>active transform matrix</summary>
      <div class="mxgrid" id="mxgrid" aria-label="3 by 3 color transform matrix"></div>
      <p class="note">Applied in linear-light RGB. Full-severity matrices from Machado, Oliveira &amp; Fernandes (2009); below 100% this blends linearly toward the identity matrix, which approximates their per-severity tables. Gray simulation uses CIE luminance weights. Distances are CIE76 &Delta;E in Lab.</p>
    </details>
  </div>
</div>

<footer>demo &middot; sloprun.dev</footer>
</div>

<script>
(function () {
  "use strict";

  /* ================= color math ================= */
  var DEC = new Float32Array(256);
  for (var i = 0; i < 256; i++) {
    var c = i / 255;
    DEC[i] = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  }
  /* Encode LUT for the per-pixel loop. 4096 steps keeps the worst case under
     half a code value even in the shadows, where the sRGB curve is steepest. */
  var ENC_N = 4096, ENC_MAX = ENC_N - 1;
  var ENC = new Uint8Array(ENC_N);
  for (var j = 0; j < ENC_N; j++) {
    var v = j / ENC_MAX;
    var s = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
    ENC[j] = Math.max(0, Math.min(255, Math.round(s * 255)));
  }
  function enc(lin) {
    if (lin <= 0) return ENC[0];
    if (lin >= 1) return ENC[ENC_MAX];
    return ENC[(lin * ENC_MAX + 0.5) | 0];
  }
  /* Exact encode for the handful of colors we print as hex (eyedropper +
     palette). No LUT quantisation, so an identity matrix round-trips to the
     same byte and severity 0 reads a true ΔE 0.0. */
  function encExact(lin) {
    if (lin <= 0) return 0;
    if (lin >= 1) return 255;
    var s = lin <= 0.0031308 ? lin * 12.92 : 1.055 * Math.pow(lin, 1 / 2.4) - 0.055;
    return Math.max(0, Math.min(255, Math.round(s * 255)));
  }
  function fLab(t) { return t > 0.008856451679 ? Math.cbrt(t) : (7.787037 * t + 0.137931034); }
  function lab(r, g, b) {
    var R = DEC[r], G = DEC[g], B = DEC[b];
    var x = fLab((0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047);
    var y = fLab(0.2126 * R + 0.7152 * G + 0.0722 * B);
    var z = fLab((0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883);
    return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
  }
  function dE(a, b) {
    var d0 = a[0] - b[0], d1 = a[1] - b[1], d2 = a[2] - b[2];
    return Math.sqrt(d0 * d0 + d1 * d1 + d2 * d2);
  }
  function hex(r, g, b) {
    return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
  }

  /* Full-severity simulation matrices (linear RGB), Machado/Oliveira/Fernandes 2009. */
  var TYPES = [
    { id: "deutan", name: "green-weak", tech: "deuteranopia", freq: "~1 in 16 men",
      m: [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.011820, 0.042940, 0.968881] },
    { id: "protan", name: "red-weak", tech: "protanopia", freq: "~1 in 50 men",
      m: [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998] },
    { id: "tritan", name: "blue-weak", tech: "tritanopia", freq: "~1 in 10,000",
      m: [1.255528, -0.076749, -0.178779, -0.078411, 0.930809, 0.147602, 0.004733, 0.691367, 0.303900] },
    { id: "mono", name: "no color", tech: "achromatopsia", freq: "~1 in 30,000",
      m: [0.2126, 0.7152, 0.0722, 0.2126, 0.7152, 0.0722, 0.2126, 0.7152, 0.0722] }
  ];
  var IDENT = [1, 0, 0, 0, 1, 0, 0, 0, 1];
  function blend(m, s) {
    var out = new Float64Array(9);
    for (var k = 0; k < 9; k++) out[k] = IDENT[k] * (1 - s) + m[k] * s;
    return out;
  }
  function simColor(r, g, b, M) {
    var R = DEC[r], G = DEC[g], B = DEC[b];
    return [
      encExact(M[0] * R + M[1] * G + M[2] * B),
      encExact(M[3] * R + M[4] * G + M[5] * B),
      encExact(M[6] * R + M[7] * G + M[8] * B)
    ];
  }

  /* ================= sample scenes ================= */
  function rr(x, y, w, h, r) {
    var p = new Path2D();
    p.moveTo(x + r, y);
    p.arcTo(x + w, y, x + w, y + h, r);
    p.arcTo(x + w, y + h, x, y + h, r);
    p.arcTo(x, y + h, x, y, r);
    p.arcTo(x, y, x + w, y, r);
    p.closePath();
    return p;
  }
  var SANS = "system-ui, -apple-system, 'Segoe UI', sans-serif";

  function drawBoard(g) {
    g.fillStyle = "#EDF1F6"; g.fillRect(0, 0, 880, 520);
    g.fillStyle = "#FFFFFF"; g.fill(rr(32, 26, 816, 468, 18));
    g.strokeStyle = "#DCE3EB"; g.lineWidth = 2; g.stroke(rr(32, 26, 816, 468, 18));

    g.fillStyle = "#1B2530"; g.font = "600 24px " + SANS;
    g.fillText("Release health", 60, 76);
    g.fillStyle = "#6B7684"; g.font = "14px " + SANS;
    g.fillText("build 4821 · last 24 hours", 60, 99);

    var rows = [
      ["unit tests", "1204 passing", "#2E9E4F"],
      ["integration", "3 failing", "#D93A2B"],
      ["deploy queue", "waiting", "#E8A317"]
    ];
    for (var i = 0; i < rows.length; i++) {
      var y = 140 + i * 52;
      g.fillStyle = rows[i][2];
      g.beginPath(); g.arc(72, y, 9, 0, 6.2832); g.fill();
      g.fillStyle = "#1B2530"; g.font = "15px " + SANS;
      g.fillText(rows[i][0], 94, y + 5);
      g.fillStyle = rows[i][2]; g.fill(rr(250, y - 14, 150, 28, 14));
      g.fillStyle = "#FFFFFF"; g.font = "600 12px " + SANS;
      g.textAlign = "center"; g.fillText(rows[i][1], 325, y + 5); g.textAlign = "left";
    }

    var cx = 460, cy = 128, cw = 360, ch = 168;
    g.strokeStyle = "#E7ECF2"; g.lineWidth = 1;
    for (var k = 0; k <= 4; k++) {
      var gy = cy + (ch / 4) * k;
      g.beginPath(); g.moveTo(cx, gy); g.lineTo(cx + cw, gy); g.stroke();
    }
    var pass = [0.30, 0.42, 0.38, 0.55, 0.62, 0.58, 0.74, 0.83, 0.79, 0.92];
    var fail = [0.62, 0.55, 0.60, 0.44, 0.40, 0.46, 0.31, 0.22, 0.28, 0.16];
    function series(data, stroke, fill) {
      g.beginPath();
      for (var n = 0; n < data.length; n++) {
        var px = cx + (cw / (data.length - 1)) * n, py = cy + ch - data[n] * ch;
        if (n === 0) g.moveTo(px, py); else g.lineTo(px, py);
      }
      g.strokeStyle = stroke; g.lineWidth = 3; g.lineJoin = "round"; g.stroke();
      g.lineTo(cx + cw, cy + ch); g.lineTo(cx, cy + ch); g.closePath();
      g.fillStyle = fill; g.fill();
    }
    series(pass, "#2E9E4F", "rgba(46,158,79,0.18)");
    series(fail, "#D93A2B", "rgba(217,58,43,0.16)");
    var leg = [["#2E9E4F", "passing"], ["#D93A2B", "failing"]];
    for (var L = 0; L < 2; L++) {
      var lx = 460 + L * 110;
      g.fillStyle = leg[L][0]; g.fill(rr(lx, 314, 13, 13, 3));
      g.fillStyle = "#6B7684"; g.font = "13px " + SANS;
      g.fillText(leg[L][1], lx + 20, 325);
    }

    var tiles = [["98.4%", "coverage", "#2D6CDF"], ["210 ms", "p95 latency", "#7A4FD1"],
                 ["12", "open issues", "#0E9C8E"], ["3", "flaky tests", "#E4761B"]];
    for (var t = 0; t < 4; t++) {
      var tx = 60 + t * 194;
      g.fillStyle = "#F7F9FB"; g.fill(rr(tx, 356, 178, 100, 12));
      g.strokeStyle = "#E3E9EF"; g.lineWidth = 1; g.stroke(rr(tx, 356, 178, 100, 12));
      g.fillStyle = tiles[t][2]; g.fill(rr(tx + 14, 372, 46, 6, 3));
      g.fillStyle = "#1B2530"; g.font = "600 22px " + SANS;
      g.fillText(tiles[t][0], tx + 14, 414);
      g.fillStyle = "#6B7684"; g.font = "13px " + SANS;
      g.fillText(tiles[t][1], tx + 14, 437);
    }
  }

  function ball(g, x, y, r, light, dark) {
    var gr = g.createRadialGradient(x - r * 0.34, y - r * 0.38, r * 0.08, x, y, r * 1.08);
    gr.addColorStop(0, light); gr.addColorStop(1, dark);
    g.fillStyle = gr;
    g.beginPath(); g.arc(x, y, r, 0, 6.2832); g.fill();
  }
  function stem(g, x, y, r) {
    g.strokeStyle = "#6B4A22"; g.lineWidth = Math.max(3, r * 0.11); g.lineCap = "round";
    g.beginPath(); g.moveTo(x, y - r * 0.92); g.lineTo(x + r * 0.06, y - r * 1.28); g.stroke();
    g.fillStyle = "#3E8E41";
    g.beginPath(); g.ellipse(x + r * 0.42, y - r * 1.12, r * 0.32, r * 0.15, -0.5, 0, 6.2832); g.fill();
  }
  function drawMarket(g) {
    var bgr = g.createLinearGradient(0, 0, 0, 520);
    bgr.addColorStop(0, "#F3E6C8"); bgr.addColorStop(1, "#D8BC8E");
    g.fillStyle = bgr; g.fillRect(0, 0, 880, 520);
    g.fillStyle = "#B08050"; g.fillRect(0, 392, 880, 128);
    g.strokeStyle = "rgba(90,58,28,0.35)"; g.lineWidth = 3;
    for (var s = 0; s < 6; s++) { g.beginPath(); g.moveTo(0, 400 + s * 22); g.lineTo(880, 400 + s * 22); g.stroke(); }
    g.fillStyle = "#8F5F2E"; g.fill(rr(52, 356, 776, 44, 8));

    /* grapes */
    var gp = [[0, 0], [34, 6], [66, -2], [18, 34], [50, 38], [84, 28], [34, 66], [66, 62], [0, 34], [100, 0], [100, 34]];
    for (var q = 0; q < gp.length; q++) ball(g, 140 + gp[q][0], 158 + gp[q][1], 22, "#BE93DA", "#431B6B");
    g.fillStyle = "#3E8E41";
    g.beginPath(); g.ellipse(196, 118, 46, 18, -0.35, 0, 6.2832); g.fill();

    /* bananas */
    g.strokeStyle = "#E7C63A"; g.lineWidth = 26; g.lineCap = "round";
    for (var bn = 0; bn < 3; bn++) {
      g.beginPath(); g.moveTo(360 + bn * 6, 150 + bn * 16);
      g.quadraticCurveTo(430 + bn * 6, 118 + bn * 16, 494 + bn * 6, 168 + bn * 16); g.stroke();
    }
    g.strokeStyle = "#C8A31E"; g.lineWidth = 4;
    g.beginPath(); g.moveTo(370, 158); g.quadraticCurveTo(432, 128, 492, 176); g.stroke();

    /* lemons */
    for (var lm = 0; lm < 3; lm++) {
      g.save(); g.translate(660 + lm * 62, 150 + (lm % 2) * 30); g.rotate(-0.3 + lm * 0.25);
      var lg = g.createRadialGradient(-14, -14, 4, 0, 0, 46);
      lg.addColorStop(0, "#FFF3A0"); lg.addColorStop(1, "#D9AE0B");
      g.fillStyle = lg; g.beginPath(); g.ellipse(0, 0, 42, 30, 0, 0, 6.2832); g.fill();
      g.restore();
    }

    /* apples: red beside green — the whole point */
    ball(g, 150, 320, 60, "#F4715B", "#A3120C"); stem(g, 150, 320, 60);
    ball(g, 268, 336, 52, "#F0684F", "#9A0F0A"); stem(g, 268, 336, 52);
    ball(g, 378, 320, 58, "#BCE470", "#3B7A16"); stem(g, 378, 320, 58);
    ball(g, 490, 338, 50, "#B2DD62", "#357211"); stem(g, 490, 338, 50);
    /* oranges */
    ball(g, 600, 322, 56, "#FFC262", "#D26800");
    ball(g, 712, 340, 48, "#FFBC55", "#C86100");
    ball(g, 812, 322, 44, "#FFCE7A", "#D97800");
    /* pepper accents */
    g.fillStyle = "#C41E1E";
    g.beginPath(); g.ellipse(60, 250, 16, 42, 0.5, 0, 6.2832); g.fill();
    g.fillStyle = "#2F8B2F";
    g.beginPath(); g.ellipse(50, 214, 12, 8, 0.7, 0, 6.2832); g.fill();
  }

  function drawScales(g) {
    g.fillStyle = "#FAFAF7"; g.fillRect(0, 0, 880, 520);
    g.fillStyle = "#22262B"; g.font = "600 20px " + SANS;
    g.fillText("Color scales", 44, 48);
    g.fillStyle = "#6B7078"; g.font = "13px " + SANS;
    g.fillText("hue wheel and three sequential ramps", 44, 70);

    var wx = 208, wy = 268, R = 138;
    for (var a = 0; a < 360; a++) {
      g.beginPath(); g.moveTo(wx, wy);
      g.arc(wx, wy, R, (a - 0.6) * Math.PI / 180, (a + 0.6) * Math.PI / 180);
      g.closePath();
      g.fillStyle = "hsl(" + a + ",92%,50%)"; g.fill();
    }
    var wg = g.createRadialGradient(wx, wy, 0, wx, wy, R);
    wg.addColorStop(0, "rgba(255,255,255,1)"); wg.addColorStop(0.55, "rgba(255,255,255,0.12)");
    wg.addColorStop(1, "rgba(255,255,255,0)");
    g.fillStyle = wg; g.beginPath(); g.arc(wx, wy, R, 0, 6.2832); g.fill();
    g.fillStyle = "#FAFAF7"; g.beginPath(); g.arc(wx, wy, 46, 0, 6.2832); g.fill();
    g.strokeStyle = "#DCDCD4"; g.lineWidth = 2;
    g.beginPath(); g.arc(wx, wy, 46, 0, 6.2832); g.stroke();
    g.beginPath(); g.arc(wx, wy, R, 0, 6.2832); g.stroke();
    g.fillStyle = "#6B7078"; g.font = "13px " + SANS; g.textAlign = "center";
    g.fillText("hue", wx, wy + 5); g.textAlign = "left";

    var bars = [
      ["rainbow", ["#D32F2F", "#F57C00", "#FBC02D", "#388E3C", "#0097A7", "#1976D2", "#7B1FA2"]],
      ["red ↔ green", ["#C62828", "#EFEFEF", "#2E7D32"]],
      ["blue ↔ orange", ["#1E5FA8", "#F2F2F2", "#E07B00"]]
    ];
    for (var b = 0; b < bars.length; b++) {
      var by = 108 + b * 108;
      g.fillStyle = "#40464D"; g.font = "600 14px " + SANS;
      g.fillText(bars[b][0], 420, by - 10);
      var lgr = g.createLinearGradient(420, 0, 836, 0);
      var st = bars[b][1];
      for (var t2 = 0; t2 < st.length; t2++) lgr.addColorStop(t2 / (st.length - 1), st[t2]);
      g.fillStyle = lgr; g.fill(rr(420, by, 416, 58, 8));
      g.strokeStyle = "#DCDCD4"; g.lineWidth = 1; g.stroke(rr(420, by, 416, 58, 8));
    }

    var sw = ["#E63946", "#F3A712", "#2A9D8F", "#264653", "#8338EC", "#3A86FF", "#57CC02", "#FF006E"];
    g.fillStyle = "#40464D"; g.font = "600 14px " + SANS;
    g.fillText("categorical set", 420, 422);
    for (var w = 0; w < sw.length; w++) {
      g.fillStyle = sw[w]; g.fill(rr(420 + w * 53, 434, 44, 44, 7));
    }
  }

  var SAMPLES = {
    board:  { draw: drawBoard,  pick: [0.307, 0.369], label: "status board" },
    market: { draw: drawMarket, pick: [0.170, 0.615], label: "market stall" },
    scales: { draw: drawScales, pick: [0.523, 0.471], label: "color scales" }
  };

  /* ================= state / DOM ================= */
  var $ = function (id) { return document.getElementById(id); };
  var cA = $("cA"), cB = $("cB");
  var gA = cA.getContext("2d", { willReadFrequently: true });
  var gB = cB.getContext("2d");
  var src = document.createElement("canvas");
  var gS = src.getContext("2d", { willReadFrequently: true });

  var state = { type: "deutan", sev: 1, px: 0, py: 0, sample: "board" };
  var srcData = null, outData = null, W = 880, H = 520, palette = [];

  /* type picker */
  var tw = $("types");
  TYPES.forEach(function (t) {
    var inp = document.createElement("input");
    inp.type = "radio"; inp.name = "cvdtype"; inp.id = "t_" + t.id; inp.value = t.id;
    inp.className = "sr";
    if (t.id === state.type) inp.checked = true;
    var lab2 = document.createElement("label");
    lab2.setAttribute("for", inp.id);
    lab2.innerHTML = '<span class="t1"></span><span class="t2"></span>';
    lab2.querySelector(".t1").textContent = t.name;
    lab2.querySelector(".t2").textContent = t.tech;
    inp.addEventListener("change", function () {
      if (inp.checked) { state.type = t.id; render(); }
    });
    tw.appendChild(inp); tw.appendChild(lab2);
  });

  function curType() {
    for (var i = 0; i < TYPES.length; i++) if (TYPES[i].id === state.type) return TYPES[i];
    return TYPES[0];
  }

  /* One debounced live region for the whole panel. The eyedropper updates on
     every pointer move and the takeaway on every slider tick, so announcing
     each one directly would flood a screen reader. */
  var liveTimer = 0, livePending = "";
  function announce(text) {
    livePending = text;
    clearTimeout(liveTimer);
    liveTimer = setTimeout(function () { $("live").textContent = livePending; }, 700);
  }

  /* ================= image loading ================= */
  /* Returns false if the pixels are unreadable (a tainted or undecodable
     source), leaving the previous image on screen for the caller to restore. */
  function setSource(canvasLike, w, h, pick) {
    var data;
    try {
      src.width = w; src.height = h;
      gS.clearRect(0, 0, w, h);
      gS.drawImage(canvasLike, 0, 0, w, h);
      data = gS.getImageData(0, 0, w, h);
    } catch (err) {
      return false;
    }
    W = w; H = h;
    srcData = data;
    cA.width = w; cA.height = h; cB.width = w; cB.height = h;
    outData = gA.createImageData(w, h);
    gA.putImageData(srcData, 0, 0);
    $("dimA").textContent = w + "×" + h;
    state.px = Math.max(0, Math.min(w - 1, Math.round(pick[0] * w)));
    state.py = Math.max(0, Math.min(h - 1, Math.round(pick[1] * h)));
    palette = extractPalette(srcData, w, h);
    render();
    return true;
  }

  function loadSample(id) {
    var sp = SAMPLES[id] || SAMPLES.board;
    state.sample = id;
    var tmp = document.createElement("canvas");
    tmp.width = 880; tmp.height = 520;
    var g = tmp.getContext("2d");
    g.textBaseline = "alphabetic";
    sp.draw(g);
    setSource(tmp, 880, 520, sp.pick);
    Array.prototype.forEach.call(document.querySelectorAll("[data-sample]"), function (b) {
      b.setAttribute("aria-pressed", b.getAttribute("data-sample") === id ? "true" : "false");
    });
  }

  Array.prototype.forEach.call(document.querySelectorAll("[data-sample]"), function (b) {
    b.addEventListener("click", function () { loadSample(b.getAttribute("data-sample")); });
  });

  function loadFile(f) {
    if (!f || !/^image\//.test(f.type)) { flashHint("That file is not an image — try a PNG or JPG."); return; }
    var fr = new FileReader();
    fr.onerror = function () { flashHint("Could not read that file."); };
    fr.onload = function () {
      var im = new Image();
      im.onerror = function () { flashHint("Could not decode that image."); };
      im.onload = function () {
        var w = im.naturalWidth || 880, h = im.naturalHeight || 520;
        var sc = Math.min(1, 960 / w, 700 / h);
        w = Math.max(1, Math.round(w * sc)); h = Math.max(1, Math.round(h * sc));
        if (!setSource(im, w, h, [0.5, 0.5])) {
          flashHint("This browser will not let the page read that image’s pixels — try a PNG or JPG.");
          return;
        }
        state.sample = "custom";
        Array.prototype.forEach.call(document.querySelectorAll("[data-sample]"), function (b) {
          b.setAttribute("aria-pressed", "false");
        });
      };
      im.src = fr.result;
    };
    fr.readAsDataURL(f);
  }
  $("file").addEventListener("change", function (e) {
    if (e.target.files && e.target.files[0]) loadFile(e.target.files[0]);
    e.target.value = "";
  });

  var hintEl = document.querySelector(".hint");
  var hintOrig = hintEl.innerHTML, hintTimer = 0;
  function flashHint(msg) {
    hintEl.textContent = msg;
    clearTimeout(hintTimer);
    hintTimer = setTimeout(function () { hintEl.innerHTML = hintOrig; }, 3200);
  }

  var stage = $("stage");
  ["dragenter", "dragover"].forEach(function (ev) {
    stage.addEventListener(ev, function (e) { e.preventDefault(); stage.classList.add("drag"); });
  });
  stage.addEventListener("dragleave", function (e) {
    if (!e.relatedTarget || !stage.contains(e.relatedTarget)) stage.classList.remove("drag");
  });
  stage.addEventListener("dragend", function () { stage.classList.remove("drag"); });
  /* Handle the drop at document level: a file dropped anywhere else on the page
     would otherwise make the browser navigate away from the demo. */
  document.addEventListener("dragover", function (e) { e.preventDefault(); });
  document.addEventListener("drop", function (e) {
    e.preventDefault(); stage.classList.remove("drag");
    if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]);
  });

  /* ================= palette ================= */
  function extractPalette(img, w, h) {
    var d = img.data, bins = new Map();
    var step = Math.max(1, Math.floor(Math.sqrt(w * h / 40000)));
    for (var y = 0; y < h; y += step) {
      for (var x = 0; x < w; x += step) {
        var i = (y * w + x) * 4;
        if (d[i + 3] < 128) continue;
        var key = ((d[i] >> 4) << 8) | ((d[i + 1] >> 4) << 4) | (d[i + 2] >> 4);
        var e = bins.get(key);
        if (e) { e.n++; e.r += d[i]; e.g += d[i + 1]; e.b += d[i + 2]; }
        else bins.set(key, { n: 1, r: d[i], g: d[i + 1], b: d[i + 2] });
      }
    }
    var list = [];
    bins.forEach(function (e) {
      list.push({ n: e.n, r: Math.round(e.r / e.n), g: Math.round(e.g / e.n), b: Math.round(e.b / e.n) });
    });
    /* Rank by area AND colorfulness: a small red dot matters more here than a
       big field of grey, because only the colorful ones can collide. */
    list.forEach(function (e) {
      e.lab = lab(e.r, e.g, e.b);
      e.c = Math.sqrt(e.lab[1] * e.lab[1] + e.lab[2] * e.lab[2]);
      e.score = Math.sqrt(e.n) * (10 + e.c);
    });
    list.sort(function (a, b) { return b.score - a.score; });
    list = list.slice(0, 260);

    /* Greedy pick of up to 6 mutually distinct colors, loosening the spacing
       threshold until enough survive. */
    function pick(cands) {
      var thresholds = [34, 26, 20, 14, 8];
      for (var t = 0; t < thresholds.length; t++) {
        var out = [];
        for (var i2 = 0; i2 < cands.length && out.length < 6; i2++) {
          var ok = true;
          for (var k = 0; k < out.length; k++) {
            if (dE(cands[i2].lab, out[k].lab) < thresholds[t]) { ok = false; break; }
          }
          if (ok) out.push(cands[i2]);
        }
        if (out.length >= 5 || t === thresholds.length - 1) return out;
      }
      return [];
    }

    /* Only colors carry meaning by hue, so paper, ink and wood are noise here.
       Drop the near-neutrals — unless that leaves nothing to compare. */
    var colorful = pick(list.filter(function (e) { return e.c >= 16; }));
    return colorful.length >= 3 ? colorful : pick(list);
  }

  /* ================= render ================= */
  var pending = false;
  function schedule() {
    if (pending) return;
    pending = true;
    requestAnimationFrame(function () { pending = false; render(); });
  }

  function render() {
    if (!srcData) return;
    var M = blend(curType().m, state.sev);
    var s = srcData.data, o = outData.data, n = s.length;
    if (state.sev === 0) {
      o.set(s);
    } else {
      var m0 = M[0], m1 = M[1], m2 = M[2], m3 = M[3], m4 = M[4], m5 = M[5], m6 = M[6], m7 = M[7], m8 = M[8];
      for (var i = 0; i < n; i += 4) {
        var R = DEC[s[i]], G = DEC[s[i + 1]], B = DEC[s[i + 2]];
        o[i] = enc(m0 * R + m1 * G + m2 * B);
        o[i + 1] = enc(m3 * R + m4 * G + m5 * B);
        o[i + 2] = enc(m6 * R + m7 * G + m8 * B);
        o[i + 3] = s[i + 3];
      }
    }
    gB.putImageData(outData, 0, 0);

    var pct = Math.round(state.sev * 100);
    $("simMeta").textContent = curType().tech + " " + pct + "%";
    $("simLabel").textContent = "seen as · " + curType().name;
    $("wrapB").setAttribute("aria-label",
      "Simulated image: " + curType().tech + " at " + pct + " percent. Use arrow keys to move the eyedropper.");

    renderMatrix(M);
    renderDropper(M);
    renderPalette(M);
  }

  function renderMatrix(M) {
    var g = $("mxgrid"), html = "";
    for (var i = 0; i < 9; i++) html += "<span>" + M[i].toFixed(4) + "</span>";
    g.innerHTML = html;
  }

  function renderDropper(M) {
    var i = (state.py * W + state.px) * 4, d = srcData.data;
    var r = d[i], gg = d[i + 1], b = d[i + 2];
    var sim = simColor(r, gg, b, M);
    $("swA").style.background = hex(r, gg, b);
    $("swB").style.background = hex(sim[0], sim[1], sim[2]);
    $("hexA").textContent = hex(r, gg, b);
    $("hexB").textContent = hex(sim[0], sim[1], sim[2]);
    $("rgbA").textContent = "rgb " + r + " " + gg + " " + b;
    $("rgbB").textContent = "rgb " + sim[0] + " " + sim[1] + " " + sim[2];
    var delta = dE(lab(r, gg, b), lab(sim[0], sim[1], sim[2]));
    $("deVal").textContent = delta.toFixed(1);
    $("deBar").style.width = Math.min(100, delta / 60 * 100).toFixed(1) + "%";
    var verdict = delta < 2 ? "identical — this pixel is unaffected"
      : delta < 6 ? "a nudge — barely a different color"
      : delta < 15 ? "a visible shift"
      : delta < 30 ? "a big shift — different color entirely"
      : "unrecognizable as the same color";
    $("deVerdict").textContent = verdict;
    $("pxNote").innerHTML = 'pixel <span class="mono">x ' + state.px + ", y " + state.py + "</span>";
    positionCross();
  }

  function positionCross() {
    var lx = ((state.px + 0.5) / W * 100) + "%", ly = ((state.py + 0.5) / H * 100) + "%";
    $("crossA").style.left = lx; $("crossA").style.top = ly;
    $("crossB").style.left = lx; $("crossB").style.top = ly;
  }

  function renderPalette(M) {
    var pal = $("pal"), pairsEl = $("pairs");
    if (palette.length < 2) {
      pal.innerHTML = ""; pairsEl.innerHTML = "";
      setTake("idle", "takeaway",
        "This image has fewer than two clearly distinct colors, so there is nothing for them to confuse. " +
        "The eyedropper still works — point at any pixel.");
      return;
    }
    var simmed = palette.map(function (p) {
      var s = simColor(p.r, p.g, p.b, M);
      return { r: s[0], g: s[1], b: s[2], lab: lab(s[0], s[1], s[2]) };
    });
    var html = "";
    for (var i = 0; i < palette.length; i++) {
      html += '<div class="pcol"><div class="pnum">' + (i + 1) + '</div>' +
        '<div class="pa" style="background:' + hex(palette[i].r, palette[i].g, palette[i].b) + '"></div>' +
        '<div class="pb" style="background:' + hex(simmed[i].r, simmed[i].g, simmed[i].b) + '"></div></div>';
    }
    pal.innerHTML = html;

    /* MERGED: reads as literally the same color. RISKY: still different, but no
       longer a difference you can trust someone to catch at a glance. */
    var MERGED = 12, RISKY = 25;
    var pairs = [], total = 0;
    for (var a = 0; a < palette.length; a++) {
      for (var b = a + 1; b < palette.length; b++) {
        total++;
        pairs.push({
          a: a, b: b,
          before: dE(palette[a].lab, palette[b].lab),
          after: dE(simmed[a].lab, simmed[b].lab)
        });
      }
    }
    pairs.sort(function (x, y) { return x.after - y.after; });
    var merged = pairs.filter(function (p) { return p.after < MERGED; });
    var risky = pairs.filter(function (p) { return p.after >= MERGED && p.after < RISKY; });
    var worst = pairs[0];

    var show = pairs.slice(0, 4), lis = "";
    for (var s2 = 0; s2 < show.length; s2++) {
      var p2 = show[s2];
      var cls = p2.after < MERGED ? "merged" : p2.after < RISKY ? "risky" : "safe";
      var word = p2.after < MERGED ? "merged" : p2.after < RISKY ? "risky" : "safe";
      lis += '<li class="' + cls + '">' +
        '<span class="sq" style="background:' + hex(palette[p2.a].r, palette[p2.a].g, palette[p2.a].b) + '"></span>' +
        "<span>" + (p2.a + 1) + " &harr; " + (p2.b + 1) + "</span>" +
        '<span class="sq" style="background:' + hex(palette[p2.b].r, palette[p2.b].g, palette[p2.b].b) + '"></span>' +
        '<span class="ar">&Delta;E ' + p2.before.toFixed(0) + " &rarr; " + p2.after.toFixed(0) + "</span>" +
        '<span class="de">' + word + "</span></li>";
    }
    pairsEl.innerHTML = lis;

    var pct = Math.round(state.sev * 100);
    var who = curType().tech + " <b>" + pct + "%</b>";
    if (state.sev === 0) {
      setTake("idle", "baseline",
        "Severity is at <b>0%</b> — both panels are identical. Push the slider up to see the difference.");
    } else if (merged.length) {
      setTake("warn", "problem",
        "<b>" + merged.length + " of " + total + "</b> color pairs here become the same color at " + who +
        " (" + curType().freq + "). Anything those colors alone are meant to tell apart needs a label, " +
        "shape, or pattern too.");
    } else if (risky.length) {
      var shrink = worst.before / Math.max(worst.after, 0.01);
      setTake("warn", "thin margin",
        "Nothing merges outright, but at " + who + " the closest pair drops to <b>&Delta;E " +
        worst.after.toFixed(0) + "</b> — <b>" + shrink.toFixed(1) + "&times;</b> closer than it started (" +
        worst.before.toFixed(0) + "). That is a difference you can no longer count on someone noticing.");
    } else {
      setTake("ok", "holds up",
        "Every one of the <b>" + total + "</b> color pairs stays comfortably apart at " + who +
        " — the closest is still <b>&Delta;E " + worst.after.toFixed(0) +
        "</b>. This palette carries its meaning without relying on color anyone might miss.");
    }
  }

  function setTake(stateName, tag, msgHTML) {
    $("take").setAttribute("data-state", stateName);
    $("takeTag").textContent = tag;
    $("takeMsg").innerHTML = msgHTML;
    announce(tag + ": " + $("takeMsg").textContent);
  }

  /* ================= interaction ================= */
  function pickFromEvent(canvas, e) {
    var r = canvas.getBoundingClientRect();
    if (!r.width || !r.height) return;
    var x = Math.floor((e.clientX - r.left) / r.width * W);
    var y = Math.floor((e.clientY - r.top) / r.height * H);
    state.px = Math.max(0, Math.min(W - 1, x));
    state.py = Math.max(0, Math.min(H - 1, y));
    renderDropper(blend(curType().m, state.sev));
  }
  [[$("wrapA"), cA], [$("wrapB"), cB]].forEach(function (pair) {
    var wrap = pair[0], canvas = pair[1];
    wrap.addEventListener("pointermove", function (e) { pickFromEvent(canvas, e); });
    wrap.addEventListener("pointerdown", function (e) { pickFromEvent(canvas, e); wrap.focus(); });
    wrap.addEventListener("keydown", function (e) {
      var stepBase = Math.max(1, Math.round(W / 120));
      var st = e.shiftKey ? stepBase * 5 : stepBase, moved = true;
      if (e.key === "ArrowLeft") state.px -= st;
      else if (e.key === "ArrowRight") state.px += st;
      else if (e.key === "ArrowUp") state.py -= st;
      else if (e.key === "ArrowDown") state.py += st;
      else if (e.key === "Home") state.px = 0;
      else if (e.key === "End") state.px = W - 1;
      else moved = false;
      if (!moved) return;
      e.preventDefault();
      state.px = Math.max(0, Math.min(W - 1, state.px));
      state.py = Math.max(0, Math.min(H - 1, state.py));
      renderDropper(blend(curType().m, state.sev));
      announce("x " + state.px + ", y " + state.py + ". " + $("hexA").textContent +
        " seen as " + $("hexB").textContent + ". ΔE " + $("deVal").textContent +
        " — " + $("deVerdict").textContent + ".");
    });
  });

  var sev = $("sev");
  sev.setAttribute("aria-valuetext", sev.value + " percent");
  sev.addEventListener("input", function () {
    state.sev = (+sev.value) / 100;
    $("sevval").textContent = sev.value + "%";
    sev.setAttribute("aria-valuetext", sev.value + " percent");
    schedule();
  });

  window.addEventListener("resize", positionCross);

  /* ---------- platform theme ---------- */
  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  /* ---------- boot ---------- */
  loadSample("board");
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong