Imported from edwinm/bitstorm.org (
static/color-tokens/AGENTS.md). Install upstream withnpx skills add edwinm/bitstorm.org --skill color-tokens. Copyright stays with the author.
Color tokens — notes for whoever works on this next
Served at /color-tokens. Generates color scales — solved in OKLCH, exported
as hex, rgb, hsl or oklch — in which any two tokens far enough apart are
guaranteed to meet a WCAG 2 contrast level, for every hue, in light mode and
dark mode alike — and proves it on the final 8-bit hex values rather than on
floats. Which level, and how far apart, are both shown in the page.
The one idea everything rests on
WCAG 2 contrast is (Y_light + 0.05) / (Y_dark + 0.05), where Y is relative
luminance. The formula has no hue or chroma term. So if step i fixes
Y_i for all color families, then blue-3 vs red-9 has exactly the same
contrast as blue-3 vs blue-9. Per hue only the OKLCH lightness (and chroma,
within gamut) has to be solved to land on that luminance.
Everything else in this app is a consequence of that sentence. If a change breaks the shared luminance ladder, the promise is gone, whatever the UI says.
Hard constraints
The owner set these explicitly. Do not relax them without asking.
- Only HTML, JS and CSS, and nothing outside this directory. No build step,
no TypeScript, no bundler, no test runner, no npm dependencies. Do not touch
package.json,eleventy.config.js,.gitignoreornginx/nginx.conf. static/is passthrough-copied to the site root, so these files are the deployed artifact. Eleventy serves them unprocessed; the brotli plugin precompresses them.- No CDN imports. The site CSP is
script-src 'self' 'unsafe-inline', and there is no bundler to resolve a bare specifier. That is why the color maths incolor.jsis hand-written instead of using culori. - No theme toggle. The owner wrote a blog post arguing against them. Use
color-scheme: light darkandlight-dark(). The preview switch in the Scale panel is scoped to that one container and is not a site theme toggle. - This app is independent of
weblogdata/assets/style.css. That stylesheet inspired it and is not a specification. Do not justify choices here by what the site palette happens to do.
Files
| File | Owns |
|---|---|
color.js |
sRGB ↔ linear ↔ Oklab ↔ OKLCH, gamut clamp, quantization, WCAG luminance, CSS color parsing |
core.js |
feasibility, the ladder, chroma envelopes, per-hue fitting, exhaustive verification |
export.js |
CSS and W3C-tokens serializers, color formats, guarantee gating |
app.js |
UI, state (URL hash + localStorage), and the in-page self-test |
index.html, style.css |
markup and the app's own palette (generated by this tool) |
core.js and color.js are pure and DOM-free. Keep them that way — the
self-test imports them the same way the UI does.
The model, as it currently stands
- The light and dark tones are the only thing that defines the range:
R = (Y_light + 0.05) / (Y_dark + 0.05). - The tones sit one step outside the scale, so the range spans
n + 1gaps:r = R^(1/(n+1)). That is why the lightest token is a soft tint rather than the page background itself. - The guaranteed distance follows:
d = ceil(ln(T) / ln(r)), whereT = threshold × QUANT_HEADROOM. It is not a user setting. buildLadderis handedtarget = r^dso it reproduces exactly thatr.β = 0.5, hard-coded. It is the only exactly symmetric value — the room above step 1 then equals the room below step n, so mirroring a token to its partner costs nothing in either scheme (verified to 2.2e-16).
Consequence, and it is the point: more steps across the same range means a
larger distance is needed for the same contrast. At AA between #f6f6f6 and
#161319: 4 steps → 3 apart, 6 → 4, 9 → 6, 12 → 7, 15 → 9.
Within one scale the ladder is geometric, so equal index distance is equal contrast wherever you take it. That is the property the step numbers rely on.
Quantization
PHI_MAX = 0.00794208 is the worst relative shift of Y + 0.05 from rounding
to 8 bits, found by exhaustive search over all 16 777 216 sRGB colors (it
peaks at #4a4a4a, a neutral gray). QUANT_HEADROOM = 1.01 clears both the
directed floor (threshold) and the hybrid floor (threshold / (1 − Φ)).
There is no repair loop, and there must not be one. Instead roundingPlan
gives each step one safe rounding direction: steps 1..n−d round up, steps
d+1..n round down. Those sets are disjoint exactly when n ≤ 2d. When
n > 2d the middle band is both at once, no nudge can satisfy both sides, and
a repair loop provably oscillates — that band takes nearest rounding and the
target floor rises instead. With the current model d ≥ (n+1)/2 in practice,
so rounding is always directed; the hybrid path exists and is tested but
is not normally reached.
Invariants that silently destroy the guarantee if broken
- Luminance is recomputed from the 8-bit code, never from the float RGB.
This is the easiest way to produce a report that passes while the shipped
hex fails.
verifyre-parses the hex string from scratch on purpose. - Every family shares one ladder. The light/dark mirror is an isometry of
index distance only if the step luminances are the same for all families.
verifyrejects scales whoseladderIddiffers. - hex and rgb carry the proof; oklch and hsl do not. The browser
re-resolves those through its own pipeline, and outside sRGB on a wide-gamut
display.
GUARANTEEinexport.jsencodes this, and the UI warns for the two weaker formats. - One
GAMUT_EPS. The solver and the exporter must clamp identically; a mismatch shifts a channel by a whole code value. - No opacity on anything whose contrast is claimed. See the traps below.
Decisions already taken — please do not re-litigate
- Tokens are mirrored
light-dark()pairs:--blue-1: light-dark(a, b)where index 1 is closest to the background in both schemes. Mirroring preserves|i−j|, which is why dark mode is covered for free. - The tones export as
--backgroundand--text, mirrored the same way. - Distance, internal margin and chroma mode are derived, not exposed.
Chroma is always "preserve": the envelope
cMax · 4l(1−l)is derived from the input color, so a muted color stays muted. An achromatic input yields a neutral scale. - Colors reorder by dragging the grip handle or with the arrow keys while
it has focus. The keyboard path is not a courtesy: dragging is unusable
without a pointer and the rest of the panel is keyboard-operable. If you
rework reordering, keep both, and clear
dragFromon drop — the re-render replaces the row, sodragendmay never fire. - The saved state carries
STATE_VERSION. Bump it whenever the shape changes; older hashes and localStorage entries are then discarded rather than half-restored, and superseded keys are cleaned up on load. - The "what the promise does not cover" panel was removed at the owner's request. The important caveats still travel in the exported CSS header. Do not reinstate the panel unasked, but do keep that header honest.
- A downloadable "checker script" export was removed as useless: it embedded its own copy of the hex values and never read the user's CSS, so hand-editing a token left it passing. A worthwhile version would parse the stylesheet and check that; until someone writes it, do not bring the snapshot version back.
The self-test replaces a test suite
There is no test runner, so the acceptance checks live in the page and run on every render. A green panel is the equivalent of a green test run, and it proves the promise for the user's own configuration, not just a fixture. Twelve checks: ladder algebra, monotonic luminance, the handoff reference ladder within 1e-4, feasibility depending on its inputs, the rounding invariant, even seating between the tones, the exhaustive pair check, the predicted floors, equal-distance-equal-contrast, mirror isometry, export round-trip, and tamper detection.
If you change the maths, expect some of these to fail and read them before "fixing" them — twice now a failure was the test encoding a stale assumption, not a broken ladder.
The "Run full hue sweep" button is the heavy proof: 120 hues × 5 chroma envelopes. At 15 steps that is 9000 swatches and 37.8M pairs, and it completes in well under a second.
Traps that have already bitten
opacityon a label broke the app's own contrast. A 0.9 on the swatch value text dropped 4.65:1 to ~3.9:1 — the exact failure the tool warns about. Lighthouse caught it; it audits the light scheme only, so check dark by hand.- A fixed white on a mirrored surface fails in dark mode.
color: whiteon--navy-5measured 1.95:1 once navy-5 flipped light. Use a mirrored token. [hidden]loses todisplay: flex. There is a[hidden] { display: none !important }rule for this; keep it.- When the ladder cannot be built, blank every downstream panel. A stale "Proven" report sitting next to an error is worse than no report.
n ≤ 2d−1leaves middle tokens with no partner at distanced. The grid label lookup used to run off the array and throw mid-render. It now falls back to black or white and says so in the tooltip.- Script-filled elements need reserved height or they wreck CLS.
- When patching CSS by matching a comment banner, assert the match — the dash counts differ between banners and a silent no-op costs a debugging pass.
Running it
npm run serve # then open http://localhost:8099/color-tokens/
No build step; edit and reload. To verify a change: self-test panel green, switch AA ↔ AAA, drag Steps across its whole range, flip the Scale preview to dark, switch the export format to oklch and confirm the warning appears, and run Lighthouse (it has been at 100 on all four categories).
Origin
Built from a detailed handoff spec. Three places where the implementation deliberately departs from it, each for a reason worth keeping:
- The spec's quantization repair loop is replaced by directed rounding (see above — the loop cannot terminate in the one case it is needed).
- The spec's
n_maxclaim was ambiguous between two readings, sofeasibilitynow takestarget,yMaxandyMinas required, defaultless arguments; non_maxcan be stated without saying what it was computed at. - The spec's §2.4 reference table is the β=0.5 ladder scaled by a uniform
1.00024 in c-space.
DEFAULT_BETA = 0.4995reproduces it within the required 1e-4 and exists only for that fixture; the product uses 0.5.