$sloprun.dev

Contrast Checker

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

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

▶ 9 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 pair that looks perfectly fine and isn't: slate on warm paper, 4.38:1, which is 0.12 short of the 4.5:1 that body text needs to clear WCAG AA. Every threshold is scored separately — body, large, AAA, non-text — against a live preview of real headings, paragraphs and captions in your two colors, so you see the failure instead of reading about it. Click fix text color and it converts to OKLCH, holds hue and chroma still, and binary searches lightness for the nearest passing value: one percent darker, 55% to 54%, turns five red marks green across the page. Then load red on green, set the goal to AAA, and try again — no lightness of that red can reach 7:1 against that green, and the demo says so instead of quietly guessing.

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/contrast-checker" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Contrast Checker — 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/contrast-checker

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 936 lines 41.6 KB index.html
demos/contrast-checker/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contrast Checker — 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); }

/* ---- extra surfaces (theme-aware, derived from the token set) ---- */
:root { --danger-soft: #FCEDEC; --sunk: #FAFAF7; --accent-ink: #A84B00;
         --run-ink: #136B2C; --danger-ink: #B32B26; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --danger-soft: #2D1A18; --sunk: #171A20; --accent-ink: #FFB454;
         --run-ink: #3FB950; --danger-ink: #F47067; }
}
:root[data-theme="dark"] { --danger-soft: #2D1A18; --sunk: #171A20; --accent-ink: #FFB454;
         --run-ink: #3FB950; --danger-ink: #F47067; }

* { 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 48px; }
.mono { font-family: var(--font-mono); }

/* ---- header ---- */
header { margin-bottom: 16px; }
h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 64ch; 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 / layout ---- */
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 14px;
}
.card + .card { 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: start; margin-top: 14px; }
@media (min-width: 900px) { .grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); } }

/* ---- controls ---- */
button, input, select { font: inherit; color: inherit; }
.btn {
  font-family: var(--font-mono); font-size: .74rem; padding: 6px 10px;
  background: var(--surface); color: var(--muted);
  border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--ink); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary { color: var(--accent-ink); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
/* no border-radius here — it would reshape the control it lands on (54px swatch
   from 9px to 4px corners the moment it takes focus). Outlines already follow the
   element's own radius. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* ---- hero readout ---- */
.hero { display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 760px) { .hero { grid-template-columns: minmax(190px, auto) minmax(0, 1fr); align-items: center; } }
.ratio {
  font-family: var(--font-mono); font-weight: 600; letter-spacing: -0.035em;
  font-size: clamp(2.9rem, 13vw, 4.4rem); line-height: 1; display: flex; align-items: baseline; gap: .1em;
}
.ratio .suffix { font-size: .3em; font-weight: 500; color: var(--muted); letter-spacing: 0; }
.verdictline { font-size: .95rem; margin-top: 8px; max-width: 40ch; }
.verdictline b { font-weight: 600; }
.verdictline.pass b { color: var(--run-ink); }
.verdictline.fail b { color: var(--danger-ink); }
.verdicts { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); }
.v {
  border: 1px solid var(--line); border-radius: 8px; padding: 8px 9px; background: var(--sunk);
  display: flex; flex-direction: column; gap: 2px; min-width: 0;
}
.v .vn { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .02em; }
.v .vq { font-family: var(--font-mono); font-size: .64rem; color: var(--muted); }
.v .vm { font-family: var(--font-mono); font-size: .74rem; font-weight: 700; letter-spacing: .06em; margin-top: 2px; }
.v[data-state="pass"] { background: var(--run-soft); border-color: var(--run); }
.v[data-state="pass"] .vm { color: var(--run-ink); }
.v[data-state="fail"] { background: var(--danger-soft); border-color: var(--danger); }
.v[data-state="fail"] .vm { color: var(--danger-ink); }

/* ---- color rows ---- */
.crow { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: center; }
.crow + .swapwrap { margin: 8px 0; }
input[type="color"].swatch {
  -webkit-appearance: none; appearance: none; width: 54px; height: 54px; padding: 0;
  border: 1px solid var(--line); border-radius: 9px; background: none; cursor: pointer;
}
input[type="color"].swatch::-webkit-color-swatch-wrapper { padding: 3px; }
input[type="color"].swatch::-webkit-color-swatch { border: none; border-radius: 6px; }
input[type="color"].swatch::-moz-color-swatch { border: none; border-radius: 6px; }
.clab { display: block; font-family: var(--font-mono); font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 4px; }
input.hex {
  width: 100%; min-width: 0; font-family: var(--font-mono); font-size: .92rem; font-weight: 600;
  letter-spacing: .04em; padding: 6px 8px; border: 1px solid var(--line); border-radius: 7px;
  background: var(--bg); color: var(--ink);
}
input.hex:focus { border-color: var(--accent); }
input.hex[aria-invalid="true"] { border-color: var(--danger); color: var(--danger-ink); }
.cfacts { font-family: var(--font-mono); font-size: .64rem; color: var(--muted); margin-top: 4px; }
.cfacts.bad { color: var(--danger-ink); }
.swapwrap { display: flex; align-items: center; gap: 10px; }
.swapwrap .hr { flex: 1; height: 1px; background: var(--line); }

/* ---- ramp ---- */
.ramp { position: relative; height: 52px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-top: 4px; }
/* the range fills .ramp exactly, so its own outline lands in the clipped region —
   put the focus ring on the container instead */
.ramp:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.ramp input[type="range"]:focus-visible { outline: none; }
.ramp .grad { position: absolute; inset: 0; }
.ramp .pass { position: absolute; left: 0; right: 0; bottom: 0; height: 9px; }
.ramp .tick { position: absolute; top: 0; bottom: 9px; width: 2px; background: rgba(0,0,0,.6); box-shadow: 1px 0 0 rgba(255,255,255,.7), -1px 0 0 rgba(255,255,255,.7); }
.ramp input[type="range"] {
  -webkit-appearance: none; appearance: none; position: absolute; inset: 0;
  width: 100%; height: 100%; margin: 0; padding: 0; background: transparent; cursor: ew-resize;
}
.ramp input[type="range"]::-webkit-slider-runnable-track { background: transparent; height: 100%; }
.ramp input[type="range"]::-moz-range-track { background: transparent; height: 100%; }
.ramp input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 12px; height: 42px; border-radius: 3px;
  background: #FFFFFF; border: 2px solid #111111; box-shadow: 0 0 0 1px rgba(255,255,255,.85); margin-top: 3px;
}
.ramp input[type="range"]::-moz-range-thumb {
  width: 10px; height: 40px; border-radius: 3px; border: 2px solid #111111;
  background: #FFFFFF; box-shadow: 0 0 0 1px rgba(255,255,255,.85);
}
/* 6px, not 3px: the ramp's focus ring sits at outline-offset 2px + 2px wide, so a
   3px gap put the ring through the top row of tick labels */
.ticklabels { position: relative; height: 30px; margin-top: 6px; }
.ticklabels span {
  position: absolute; transform: translateX(-50%); white-space: nowrap;
  font-family: var(--font-mono); font-size: .6rem; color: var(--muted);
}
.rampfoot { display: flex; flex-wrap: wrap; gap: 4px 12px; justify-content: space-between; margin-top: 8px; }
.legend { font-family: var(--font-mono); font-size: .64rem; color: var(--muted); display: flex; align-items: center; gap: 5px; }
.legend i { width: 16px; height: 8px; border-radius: 2px; background: var(--run); display: inline-block; }

/* ---- suggest ---- */
.status { font-family: var(--font-mono); font-size: .72rem; color: var(--muted); margin-top: 10px; line-height: 1.6; }
.status.ok { color: var(--run-ink); }
.status.bad { color: var(--danger-ink); }

/* ---- preview ---- */
.preview { border: 1px solid var(--line); border-radius: 8px; padding: 18px 16px 20px; overflow-wrap: break-word; }
/* 700 to match the weight the sample table reports for this row */
.preview .ph { margin: 0 0 10px; font-size: 28px; font-weight: 700; line-height: 1.25; letter-spacing: -0.01em; }
.preview .pb { margin: 0 0 12px; }
.preview .pc { margin: 0 0 14px; font-size: 13px; }
.preview .pbtn { display: inline-block; font-family: var(--font-mono); font-size: 13px; padding: 6px 12px; border: 1.5px solid currentColor; border-radius: 7px; }
.ptable { width: 100%; border-collapse: collapse; margin-top: 12px; font-family: var(--font-mono); font-size: .68rem; }
.ptable th { text-align: left; font-weight: 600; color: var(--muted); font-size: .62rem; letter-spacing: .06em; text-transform: uppercase; padding: 0 8px 5px 0; border-bottom: 1px solid var(--line); }
.ptable td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
.ptable tr:last-child td { border-bottom: none; }
.ptable tr[data-live="true"] td:first-child { color: var(--accent-ink); font-weight: 600; }
.ptable .mark { font-weight: 700; letter-spacing: .04em; }
.ptable .mark.p { color: var(--run-ink); }
.ptable .mark.f { color: var(--danger-ink); }
.tablescroll { overflow-x: auto; }
.sizerow { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 12px; }
.sizerow input[type="range"] { flex: 1 1 130px; min-width: 110px; height: 24px; accent-color: var(--accent); }
.sizerow .val { font-family: var(--font-mono); font-size: .72rem; color: var(--ink); min-width: 4.4em; }
.sizelab { font-family: var(--font-mono); font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
select.btn { min-width: 0; max-width: 100%; }
/* the popup inherits neither, and defaults to system colors in dark mode */
select.btn option { background: var(--surface); color: var(--ink); }

/* ---- explainer / footer ---- */
details { margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 10px 14px; }
summary { cursor: pointer; font-family: var(--font-mono); font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
details p { font-size: .86rem; color: var(--muted); max-width: 74ch; }
details code { font-family: var(--font-mono); font-size: .92em; color: var(--ink); }
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }

@media (prefers-reduced-motion: no-preference) {
  .btn, .v, input.hex, .preview { transition: background-color .14s ease, border-color .14s ease, color .14s ease; }
}
</style>
</head>
<body>
<div class="wrap">

<header>
  <h1>Contrast Checker</h1>
  <p class="lede">Pick two colors and see whether real text on them is actually readable — the WCAG ratio, what passes at each text size, and the smallest change that fixes it.</p>
  <div class="spec">
    <b>WCAG 2.1</b><b>sRGB</b><b>runs offline</b><b>no setup</b>
  </div>
</header>

<section class="card hero" aria-labelledby="heroLabel">
  <div>
    <h2 id="heroLabel">Contrast ratio</h2>
    <div class="ratio" id="ratioBig" aria-hidden="true"><span id="ratioNum">4.38</span><span class="suffix">:1</span></div>
    <p class="verdictline fail" id="verdictLine" aria-live="polite" aria-atomic="true">Body text <b>fails AA</b> — short by 0.12.</p>
  </div>
  <div class="verdicts" id="verdicts" role="list"></div>
</section>

<div class="grid">

  <div>
    <section class="card" aria-labelledby="colorsLabel">
      <h2 id="colorsLabel">Colors</h2>

      <div class="crow">
        <input type="color" class="swatch" id="fgColor" value="#657184" aria-label="Text color picker">
        <div>
          <label class="clab" for="fgHex">Text color</label>
          <input class="hex" id="fgHex" type="text" spellcheck="false" autocomplete="off" inputmode="text" value="#657184" aria-describedby="fgFacts">
          <div class="cfacts" id="fgFacts"></div>
        </div>
      </div>

      <div class="swapwrap">
        <span class="hr"></span>
        <button class="btn" id="swapBtn" type="button" title="Swap text and background colors">&#8645; swap</button>
        <span class="hr"></span>
      </div>

      <div class="crow">
        <input type="color" class="swatch" id="bgColor" value="#f4f1ea" aria-label="Background color picker">
        <div>
          <label class="clab" for="bgHex">Background color</label>
          <input class="hex" id="bgHex" type="text" spellcheck="false" autocomplete="off" inputmode="text" value="#F4F1EA" aria-describedby="bgFacts">
          <div class="cfacts" id="bgFacts"></div>
        </div>
      </div>

      <div class="row" style="margin-top:14px">
        <span class="sizelab" style="width:100%">Try a pair</span>
        <button class="btn" type="button" data-preset="slate">slate on paper</button>
        <button class="btn" type="button" data-preset="gray">gray on white</button>
        <button class="btn" type="button" data-preset="amber">amber on charcoal</button>
        <button class="btn" type="button" data-preset="clash">red on green</button>
      </div>
    </section>

    <section class="card" aria-labelledby="rampLabel">
      <h2 id="rampLabel">Lightness ramp</h2>
      <div class="row" style="margin-bottom:8px">
        <button class="btn" type="button" id="rampFg" aria-pressed="true">text color</button>
        <button class="btn" type="button" id="rampBg" aria-pressed="false">background</button>
      </div>
      <div class="ramp">
        <div class="grad" id="rampGrad"></div>
        <div class="pass" id="rampPass"></div>
        <div id="rampTicks"></div>
        <input type="range" id="rampRange" min="0" max="200" step="1" value="100"
               aria-label="Lightness of the text color">
      </div>
      <div class="ticklabels" id="rampLabels"></div>
      <div class="rampfoot">
        <span class="legend"><i></i> passes AA for body text (4.5:1)</span>
        <span class="legend" id="rampReadout"></span>
      </div>
    </section>

  </div>

  <div>
    <section class="card" aria-labelledby="previewLabel">
      <h2 id="previewLabel">Live preview</h2>
      <div class="preview" id="preview">
        <p class="ph" id="pvHead">Readable at a glance?</p>
        <p class="pb" id="pvBody">This paragraph is the one most people actually read. Drag the size slider under the preview and watch the rules change the moment the text counts as large.</p>
        <p class="pc" id="pvCap">Caption &middot; 13px &middot; the size everyone forgets to check</p>
        <span class="pbtn" id="pvBtn" aria-hidden="true">Read more</span>
      </div>

      <div class="sizerow">
        <label class="sizelab" for="sizeRange">Body size</label>
        <input type="range" id="sizeRange" min="12" max="32" step="1" value="16">
        <span class="val mono" id="sizeVal">16px</span>
        <button class="btn" type="button" id="boldBtn" aria-pressed="false">bold</button>
      </div>

      <div class="tablescroll">
        <table class="ptable">
          <thead>
            <tr><th>Sample</th><th>Size</th><th>Counts as</th><th>AA</th><th>AAA</th></tr>
          </thead>
          <tbody id="ptbody"></tbody>
        </table>
      </div>
    </section>

    <section class="card" aria-labelledby="fixLabel">
      <h2 id="fixLabel">Nearest passing color</h2>
      <div class="row">
        <label class="sizelab" for="targetSel" style="margin-right:2px">Goal</label>
        <select class="btn" id="targetSel" style="padding:6px 8px">
          <option value="4.5" selected>AA &middot; body text &mdash; 4.5:1</option>
          <option value="3">AA &middot; large text &mdash; 3:1</option>
          <option value="7">AAA &middot; body text &mdash; 7:1</option>
          <option value="4.5-aaa">AAA &middot; large text &mdash; 4.5:1</option>
        </select>
      </div>
      <div class="row" style="margin-top:10px">
        <button class="btn primary" type="button" id="fixFg">fix text color</button>
        <button class="btn primary" type="button" id="fixBg">fix background</button>
        <button class="btn" type="button" id="undoBtn" title="Go back to the colors from before this change" disabled>undo</button>
      </div>
      <p class="status" id="fixStatus" aria-live="polite">Keeps the hue and saturation, moves only lightness — the smallest visible change that clears the bar.</p>
    </section>
  </div>
</div>

<details>
  <summary>How the number is worked out</summary>
  <p>Each color is converted from sRGB to relative luminance: every channel is
  normalized to 0&ndash;1, linearized with the sRGB transfer curve, then weighted
  <code>0.2126R + 0.7152G + 0.0722B</code> &mdash; green counts for most because eyes
  are most sensitive to it. The ratio is
  <code>(L<sub>light</sub> + 0.05) / (L<sub>dark</sub> + 0.05)</code>, which lands
  between 1:1 (identical) and 21:1 (black on white).</p>
  <p>WCAG 2.1 asks for 4.5:1 on body text and 3:1 on large text at level AA, and 7:1
  / 4.5:1 at AAA. Large means 24px or bigger, or 18.66px and bold. Icons, borders and
  other non-text things need 3:1.</p>
  <p>The suggestion converts the color to OKLCH, holds hue and chroma still, and binary
  searches lightness in both directions for the closest value that clears the target,
  pulling chroma in if the result would fall outside sRGB. It nudges past the rounding
  to 8-bit hex so the applied color really passes, not just the math before rounding.
  The displayed ratio is rounded down for the same reason &mdash; a 4.4996 never shows
  as 4.50 next to a FAIL.</p>
  <p>Worth knowing: this formula is a blunt instrument. It over-rates dark-on-light
  pairs at the light end and under-rates some mid-tones, which is why newer perceptual
  models exist. It is still the number everyone checks against, so it is the number here.</p>
</details>

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

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

  /* ---------- color math ---------- */
  function srgbToLin(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
  function linToSrgb(c) { return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; }

  function hexToRgb(h) {
    var n = parseInt(h.slice(1), 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function rgbToHex(rgb) {
    var out = "#";
    for (var i = 0; i < 3; i++) {
      var v = Math.round(rgb[i]);
      if (v < 0) v = 0; if (v > 255) v = 255;
      out += (v < 16 ? "0" : "") + v.toString(16);
    }
    return out.toUpperCase();
  }
  function luminance(hex) {
    var c = hexToRgb(hex);
    return 0.2126 * srgbToLin(c[0]) + 0.7152 * srgbToLin(c[1]) + 0.0722 * srgbToLin(c[2]);
  }
  function contrast(a, b) {
    var x = luminance(a), y = luminance(b);
    return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
  }

  function rgbToOklab(rgb) {
    var r = srgbToLin(rgb[0]), g = srgbToLin(rgb[1]), b = srgbToLin(rgb[2]);
    var l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
    var m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
    var s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
    return [
      0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
      1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
      0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
    ];
  }
  function oklabToRgb(L, a, b) {
    var l_ = L + 0.3963377774 * a + 0.2158037573 * b;
    var m_ = L - 0.1055613458 * a - 0.0638541728 * b;
    var s_ = L - 0.0894841775 * a - 1.2914855480 * b;
    var l = l_ * l_ * l_, m = m_ * m_ * m_, s = s_ * s_ * s_;
    return [
      linToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
      linToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
      linToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)
    ];
  }
  function inGamut(c) {
    for (var i = 0; i < 3; i++) { if (!(c[i] >= -0.0015 && c[i] <= 1.0015)) return false; }
    return true;
  }
  function toLCH(hex) {
    var lab = rgbToOklab(hexToRgb(hex));
    return { L: lab[0], C: Math.hypot(lab[1], lab[2]), H: Math.atan2(lab[2], lab[1]) };
  }
  /* OKLCH -> hex, pulling chroma in until the color fits inside sRGB */
  function lchToHex(L, C, H) {
    if (L < 0) L = 0; if (L > 1) L = 1;
    var cosH = Math.cos(H), sinH = Math.sin(H);
    var make = function (c) { return oklabToRgb(L, c * cosH, c * sinH); };
    var use = C;
    if (!inGamut(make(C))) {
      var lo = 0, hi = C;
      for (var i = 0; i < 22; i++) {
        var mid = (lo + hi) / 2;
        if (inGamut(make(mid))) lo = mid; else hi = mid;
      }
      use = lo;
    }
    var rgb = make(use);
    return rgbToHex([rgb[0] * 255, rgb[1] * 255, rgb[2] * 255]);
  }

  /* Closest lightness (in OKLCH) to `hex` that reaches `target` against `other`.
     Searches darker and lighter separately, returns the smaller move. */
  function nearestPassing(hex, other, target) {
    var base = toLCH(hex);
    var at = function (L) { return contrast(lchToHex(L, base.C, base.H), other); };

    function search(up) {
      var edge = up ? 1 : 0;
      if (at(edge) < target) return null;
      var lo = up ? base.L : edge, hi = up ? edge : base.L;
      for (var i = 0; i < 40; i++) {
        var mid = (lo + hi) / 2;
        var ok = at(mid) >= target;
        if (up) { if (ok) hi = mid; else lo = mid; }
        else { if (ok) lo = mid; else hi = mid; }
      }
      var L = up ? hi : lo;
      /* walk past 8-bit rounding so the applied hex genuinely passes */
      for (var k = 0; k < 80 && at(L) < target; k++) L += (up ? 1 : -1) * 0.0015;
      if (L < 0 || L > 1 || at(L) < target) return null;
      return { L: L, hex: lchToHex(L, base.C, base.H), move: Math.abs(L - base.L) };
    }

    var up = search(true), down = search(false);
    var pick = null;
    if (up && down) pick = up.move <= down.move ? up : down;
    else pick = up || down;
    if (!pick) return null;
    pick.dir = pick.L > base.L ? "lighter" : "darker";
    pick.from = base.L;
    return pick;
  }

  /* ---------- helpers ---------- */
  function $(id) { return document.getElementById(id); }
  /* Floor to 2dp, and stay idempotent while doing it. 4.31 * 100 is
     430.99999999999994 in IEEE754, so a bare Math.floor knocks an
     already-floored value down another cent — which made the hero read 4.30
     while the ramp readout right beside it read 4.31. */
  function floor2(x) { return Math.floor(x * 100 + 1e-9) / 100; }
  function fmt(x) { return floor2(x).toFixed(2); }
  function normHex(raw) {
    var s = String(raw).trim().replace(/^#/, "");
    if (/^[0-9a-fA-F]{3}$/.test(s)) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
    if (!/^[0-9a-fA-F]{6}$/.test(s)) return null;
    return ("#" + s).toUpperCase();
  }

  var PRESETS = {
    slate: ["#657184", "#F4F1EA"],
    gray:  ["#9AA0A6", "#FFFFFF"],
    amber: ["#FFB454", "#14161A"],
    clash: ["#C93C37", "#1A7F37"]
  };

  var state = {
    fg: "#657184",
    bg: "#F4F1EA",
    ramp: "fg",
    size: 16,
    bold: false,
    target: 4.5,
    targetName: "AA · body text"
  };
  var undoStack = null;
  var rampDragging = false;
  var rampAnchor = null;
  /* true while the edited color is one the ramp itself produced. The ramp then keeps
     the hue/chroma it started the session with instead of re-reading it from the
     color each render: near L=0 and L=1 sRGB has no room for chroma, so re-reading
     would bleach the ramp a little on every pass and strand it on a gray gradient at
     the ends, with no way back to the color you dragged in with. Any change from
     outside the ramp (picker, hex, swap, preset, fix, undo) clears the flag and the
     ramp re-derives from the new color. */
  var rampOwned = false;

  /* ---------- verdict model ---------- */
  var LEVELS = [
    { n: "AA · body",   q: "needs 4.5:1", need: 4.5 },
    { n: "AA · large",  q: "needs 3:1",   need: 3 },
    { n: "AAA · body",  q: "needs 7:1",   need: 7 },
    { n: "AAA · large", q: "needs 4.5:1", need: 4.5 },
    { n: "UI · non-text", q: "needs 3:1", need: 3 }
  ];

  function isLarge(px, bold) { return px >= 24 || (bold && px >= 18.66); }

  /* ---------- render ---------- */
  /* renderHero's only input is the ratio, and #verdictLine is an aria-live region.
     Without this guard every size-slider frame and every bold toggle rewrote that
     region with identical text, so a screen reader re-read "Contrast ratio 4.38 to
     1. Body text fails AA…" on each tick of a control that cannot change the ratio. */
  var lastHeroRatio = null;
  function renderHero(r) {
    if (r === lastHeroRatio) return;
    lastHeroRatio = r;
    $("ratioNum").textContent = fmt(r);
    var box = $("verdicts");
    box.textContent = "";
    LEVELS.forEach(function (lv) {
      var pass = r >= lv.need;
      var el = document.createElement("div");
      el.className = "v";
      el.setAttribute("role", "listitem");
      el.setAttribute("data-state", pass ? "pass" : "fail");
      var n = document.createElement("span"); n.className = "vn"; n.textContent = lv.n;
      var q = document.createElement("span"); q.className = "vq"; q.textContent = lv.q;
      var m = document.createElement("span"); m.className = "vm";
      m.textContent = pass ? "✓ PASS" : "✗ FAIL";
      el.appendChild(n); el.appendChild(q); el.appendChild(m);
      box.appendChild(el);
    });

    var line = $("verdictLine");
    line.textContent = "";
    /* screen readers get the number too — the big readout is visual only */
    var said = document.createElement("span");
    said.className = "sr";
    said.textContent = "Contrast ratio " + fmt(r) + " to 1. ";
    line.appendChild(said);
    var strong = document.createElement("b");
    var tail = "";
    if (r >= 7) {
      line.className = "verdictline pass";
      strong.textContent = "Passes everything";
      tail = " — body text clears AAA with " + (r - 7).toFixed(2) + " to spare.";
    } else if (r >= 4.5) {
      line.className = "verdictline pass";
      strong.textContent = "Body text passes AA";
      tail = " — " + (7 - r).toFixed(2) + " short of AAA.";
    } else if (r >= 3) {
      line.className = "verdictline fail";
      strong.textContent = "Body text fails AA";
      tail = " — short by " + (4.5 - r).toFixed(2) + ". Large text still passes.";
    } else {
      line.className = "verdictline fail";
      strong.textContent = "Fails at every text size";
      tail = " — " + (3 - r).toFixed(2) + " short of even the large-text bar.";
    }
    line.appendChild(strong);
    line.appendChild(document.createTextNode(tail));
  }

  /* Which hex fields are mid-edit holding something that is not a color. Their
     message has to survive renders triggered by *other* controls, otherwise
     nudging the size slider wipes the explanation and leaves the field sitting
     there with a red border and no reason given. */
  var badHex = { fg: false, bg: false };

  /* `force` means the colors were replaced by something other than the hex
     fields (picker, ramp, swap, preset, fix, undo), so the fields must resync
     even if one still holds focus. Focus alone is not a safe guard: Safari and
     Firefox on macOS do not focus a button when it is clicked, so a caret left
     in a hex box would strand that box on a stale color for the rest of the
     session while every other readout moved on. */
  function renderColors(force) {
    if ($("fgColor").value.toUpperCase() !== state.fg) $("fgColor").value = state.fg.toLowerCase();
    if ($("bgColor").value.toUpperCase() !== state.bg) $("bgColor").value = state.bg.toLowerCase();
    ["fg", "bg"].forEach(function (key) {
      var el = $(key + "Hex"), facts = $(key + "Facts");
      if (force || document.activeElement !== el) { el.value = state[key]; badHex[key] = false; }
      if (badHex[key]) return;
      el.setAttribute("aria-invalid", "false");
      facts.className = "cfacts";
      facts.textContent = "L " + Math.round(toLCH(state[key]).L * 100) +
        "%  ·  lum " + luminance(state[key]).toFixed(3);
    });
  }

  function renderPreview(r) {
    var pv = $("preview");
    pv.style.background = state.bg;
    pv.style.color = state.fg;
    var body = $("pvBody");
    body.style.fontSize = state.size + "px";
    body.style.fontWeight = state.bold ? "700" : "400";
    $("sizeVal").textContent = state.size + "px";

    var rows = [
      { name: "Heading", px: 28, bold: true, live: false },
      { name: "Body", px: state.size, bold: state.bold, live: true },
      { name: "Caption", px: 13, bold: false, live: false }
    ];
    var tb = $("ptbody");
    tb.textContent = "";
    rows.forEach(function (row) {
      var large = isLarge(row.px, row.bold);
      var aa = large ? 3 : 4.5, aaa = large ? 4.5 : 7;
      var tr = document.createElement("tr");
      if (row.live) tr.setAttribute("data-live", "true");
      var cells = [
        row.name,
        row.px + "px " + (row.bold ? "700" : "400"),
        large ? "large" : "normal"
      ];
      cells.forEach(function (t) {
        var td = document.createElement("td");
        td.textContent = t;
        tr.appendChild(td);
      });
      [[aa, "AA"], [aaa, "AAA"]].forEach(function (pair) {
        var td = document.createElement("td");
        var sp = document.createElement("span");
        var ok = r >= pair[0];
        sp.className = "mark " + (ok ? "p" : "f");
        sp.textContent = (ok ? "✓ " : "✗ ") + pair[0] + ":1";
        td.appendChild(sp);
        tr.appendChild(td);
      });
      tb.appendChild(tr);
    });
  }

  function renderRamp() {
    var editing = state.ramp === "fg" ? state.fg : state.bg;
    var other = state.ramp === "fg" ? state.bg : state.fg;
    var lch = toLCH(editing);
    var keepAnchor = rampAnchor && rampAnchor.key === state.ramp &&
      (rampDragging || rampOwned);
    if (!keepAnchor) rampAnchor = { key: state.ramp, C: lch.C, H: lch.H };
    var base = { L: lch.L, C: rampAnchor.C, H: rampAnchor.H };

    var stops = [];
    for (var i = 0; i <= 24; i++) {
      var L = i / 24;
      stops.push(lchToHex(L, base.C, base.H) + " " + ((i / 24) * 100).toFixed(2) + "%");
    }
    $("rampGrad").style.background = "linear-gradient(90deg," + stops.join(",") + ")";

    var N = 160, pass = [], ratios = [];
    for (var j = 0; j <= N; j++) {
      var Lj = j / N;
      var rr = contrast(lchToHex(Lj, base.C, base.H), other);
      ratios.push(rr);
      pass.push(floor2(rr) >= 4.5);
    }
    var seg = [], cur = pass[0], start = 0;
    for (var k = 1; k <= N; k++) {
      if (pass[k] !== cur) { seg.push([start / N, k / N, cur]); cur = pass[k]; start = k; }
    }
    seg.push([start / N, 1, cur]);
    var g = seg.map(function (s) {
      var c = s[2] ? "var(--run)" : "transparent";
      return c + " " + (s[0] * 100).toFixed(2) + "% " + (s[1] * 100).toFixed(2) + "%";
    });
    $("rampPass").style.background = "linear-gradient(90deg," + g.join(",") + ")";

    /* threshold crossings */
    function crossings(t) {
      var out = [];
      for (var i2 = 1; i2 <= N; i2++) {
        if ((ratios[i2 - 1] >= t) !== (ratios[i2] >= t)) out.push((i2 - 0.5) / N);
      }
      return out;
    }
    var ticks = $("rampTicks"), labels = $("rampLabels");
    ticks.textContent = ""; labels.textContent = "";
    [[4.5, "AA 4.5", 0], [7, "AAA 7", 15]].forEach(function (t) {
      crossings(t[0]).forEach(function (p) {
        var d = document.createElement("div");
        d.className = "tick";
        d.style.left = (p * 100).toFixed(2) + "%";
        ticks.appendChild(d);
        var s = document.createElement("span");
        s.textContent = t[1];
        s.style.left = Math.min(93, Math.max(7, p * 100)).toFixed(2) + "%";
        s.style.top = t[2] + "px";
        labels.appendChild(s);
      });
    });

    if (!rampDragging) $("rampRange").value = String(Math.round(base.L * 200));
    $("rampRange").setAttribute("aria-label",
      "Lightness of the " + (state.ramp === "fg" ? "text color" : "background color"));
    $("rampRange").setAttribute("aria-valuetext",
      "lightness " + Math.round(base.L * 100) + " percent, contrast " + fmt(contrast(editing, other)) + " to 1");
    $("rampReadout").textContent =
      (state.ramp === "fg" ? "text" : "background") + " L " + Math.round(base.L * 100) +
      "%  ·  " + editing + "  ·  " + fmt(contrast(editing, other)) + ":1";
  }

  function render(force) {
    var r = floor2(contrast(state.fg, state.bg));
    renderColors(force);
    renderHero(r);
    renderPreview(r);
    renderRamp();
  }

  var HINT = "Keeps the hue and saturation, moves only lightness — " +
             "the smallest visible change that clears the bar.";

  function setStatus(text, cls) {
    var el = $("fixStatus");
    el.className = "status" + (cls ? " " + cls : "");
    el.textContent = text;
  }
  /* Any hand-made color change invalidates whatever the last suggestion said.
     Without this the panel keeps a stale green line — "already there — 21.00:1
     … nothing to change" — sitting under a pair that now reads 1.00:1.
     Guarded so it does not re-fire the live region on every drag frame. */
  function resetStatus() {
    if ($("fixStatus").textContent !== HINT) setStatus(HINT, "");
  }

  function pushUndo() {
    undoStack = { fg: state.fg, bg: state.bg };
    var b = $("undoBtn");
    b.disabled = false;
    b.textContent = "undo";
    b.title = "Go back to the colors from before this change";
  }

  /* ---------- events: colors ---------- */
  $("fgColor").addEventListener("input", function () {
    var h = normHex(this.value); if (h) { state.fg = h; rampOwned = false; resetStatus(); render(true); }
  });
  $("bgColor").addEventListener("input", function () {
    var h = normHex(this.value); if (h) { state.bg = h; rampOwned = false; resetStatus(); render(true); }
  });

  function wireHex(inputId, key) {
    var el = $(inputId);
    var facts = $(key + "Facts");
    el.addEventListener("input", function () {
      var raw = el.value.trim();
      var h = normHex(raw);
      if (h) {
        badHex[key] = false;
        state[key] = h; rampOwned = false;
        resetStatus(); render();
        return;
      }
      badHex[key] = true;
      /* An empty field, or one still short of six digits, is unfinished rather
         than wrong \u2014 going red the instant someone clears the box to retype is
         just nagging. Only flag it once the text cannot become a color. */
      var partial = /^#?[0-9a-fA-F]{0,6}$/.test(raw);
      el.setAttribute("aria-invalid", partial ? "false" : "true");
      facts.className = partial ? "cfacts" : "cfacts bad";
      facts.textContent = partial
        ? "keep typing \u2014 3 or 6 hex digits, like #4A5568 or #abc"
        : "not a hex color \u2014 try #4A5568 or #abc";
    });
    el.addEventListener("blur", function () {
      badHex[key] = false;
      el.setAttribute("aria-invalid", "false");
      el.value = state[key];
      renderColors();
    });
  }
  wireHex("fgHex", "fg");
  wireHex("bgHex", "bg");

  $("swapBtn").addEventListener("click", function () {
    pushUndo();
    var t = state.fg; state.fg = state.bg; state.bg = t;
    rampOwned = false;
    render(true);
    setStatus("swapped — " + state.fg + " on " + state.bg + " · " +
      fmt(contrast(state.fg, state.bg)) + ":1", "");
  });

  Array.prototype.forEach.call(document.querySelectorAll("[data-preset]"), function (b) {
    b.addEventListener("click", function () {
      var p = PRESETS[b.getAttribute("data-preset")];
      if (!p) return;
      pushUndo();
      state.fg = p[0]; state.bg = p[1];
      rampOwned = false;
      render(true);
      setStatus("loaded " + b.textContent + " · " + fmt(contrast(state.fg, state.bg)) + ":1", "");
    });
  });

  /* ---------- events: ramp ---------- */
  function setRampTarget(which) {
    state.ramp = which;
    $("rampFg").setAttribute("aria-pressed", which === "fg" ? "true" : "false");
    $("rampBg").setAttribute("aria-pressed", which === "bg" ? "true" : "false");
    renderRamp();
  }
  $("rampFg").addEventListener("click", function () { setRampTarget("fg"); });
  $("rampBg").addEventListener("click", function () { setRampTarget("bg"); });

  var range = $("rampRange");
  range.addEventListener("pointerdown", function () { rampDragging = true; });
  window.addEventListener("pointerup", function () { if (rampDragging) { rampDragging = false; renderRamp(); } });
  window.addEventListener("pointercancel", function () { rampDragging = false; });
  range.addEventListener("input", function () {
    var L = Number(range.value) / 200;
    var key = state.ramp;
    var a = (rampAnchor && rampAnchor.key === key) ? rampAnchor : toLCH(state[key]);
    state[key] = lchToHex(L, a.C, a.H);
    rampOwned = true;
    resetStatus();
    render(true);
  });
  range.addEventListener("keydown", function () { rampDragging = true; });
  /* keyup must be global: tabbing away moves focus before keyup, so a handler bound
     to the range alone would never fire and rampDragging would stay stuck true */
  window.addEventListener("keyup", function () { if (rampDragging) { rampDragging = false; renderRamp(); } });
  range.addEventListener("blur", function () { rampDragging = false; renderRamp(); });

  /* ---------- events: preview ---------- */
  $("sizeRange").addEventListener("input", function () {
    state.size = Number(this.value) || 16;
    render();
  });
  $("boldBtn").addEventListener("click", function () {
    state.bold = !state.bold;
    this.setAttribute("aria-pressed", state.bold ? "true" : "false");
    render();
  });

  /* ---------- events: suggest ---------- */
  $("targetSel").addEventListener("change", function () {
    var v = this.value;
    state.target = v === "4.5-aaa" ? 4.5 : Number(v);
    var names = { "4.5": "AA · body text", "3": "AA · large text",
                  "7": "AAA · body text", "4.5-aaa": "AAA · large text" };
    state.targetName = names[v];
    setStatus("goal set to " + state.targetName + " — " + state.target + ":1", "");
  });

  function doFix(which) {
    var fixing = which === "fg" ? state.fg : state.bg;
    var other = which === "fg" ? state.bg : state.fg;
    var label = which === "fg" ? "text color" : "background";
    var cur = floor2(contrast(state.fg, state.bg));

    if (cur >= state.target) {
      setStatus("already there — " + fmt(cur) + ":1 clears " + state.targetName +
        " (" + state.target + ":1). Nothing to change.", "ok");
      return;
    }
    var res = nearestPassing(fixing, other, state.target);
    if (!res) {
      setStatus("no " + label + " with this hue can reach " + state.target +
        ":1 against " + other + " — try fixing the other side, or lower the goal.", "bad");
      return;
    }
    pushUndo();
    if (which === "fg") state.fg = res.hex; else state.bg = res.hex;
    rampOwned = false;
    render(true);
    var now = floor2(contrast(state.fg, state.bg));
    setStatus("✓ " + label + " " + fixing + " → " + res.hex + "  ·  " +
      Math.round(res.from * 100) + "% → " + Math.round(res.L * 100) + "% lightness (" +
      res.dir + ")  ·  " + fmt(cur) + ":1 → " + fmt(now) + ":1, clears " +
      state.targetName + ".", "ok");
  }
  $("fixFg").addEventListener("click", function () { doFix("fg"); });
  $("fixBg").addEventListener("click", function () { doFix("bg"); });

  $("undoBtn").addEventListener("click", function () {
    if (!undoStack) return;
    var undoing = this.textContent === "undo";
    var other = { fg: state.fg, bg: state.bg };
    state.fg = undoStack.fg; state.bg = undoStack.bg;
    undoStack = other;
    this.textContent = undoing ? "redo" : "undo";
    this.title = undoing ? "Put the change back" : "Go back to the colors from before this change";
    rampOwned = false;
    render(true);
    setStatus((undoing ? "reverted to " : "redid \u2014 ") + state.fg + " on " + state.bg + " \u00b7 " +
      fmt(contrast(state.fg, state.bg)) + ":1", "");
  });

  /* ---------- 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 ---------- */
  render();
})();
</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