:PROPERTIES: :ID: 15db8ae3-fc14-49f3-9ed5-d5ff59790904 :STATUS: implemented :END: #+TITLE: theme-studio — perceptual color metrics (OKLCH, APCA, ΔE) #+AUTHOR: Craig Jennings #+DATE: 2026-06-08 * Status Spec / review incorporated (Codex, 2026-06-08, two passes). Adds perceptual color metrics to the theme-studio (=scripts/theme-studio/=) so it can build deliberately low-contrast themes (Solarized / Zenburn class) with the same rigor it already brings to high-contrast WCAG checking. Four additions: an OKLCH color model, a per-color perceptual-lightness readout, an APCA contrast score alongside the existing WCAG ratio, and a pairwise ΔE distinguishability check across the palette. Came out of a design conversation comparing the low-contrast school (Solarized, Zenburn) against Prot's high-contrast Modus themes. The conclusion: a theme has three independent dials — contrast ratio, overall luminance, and chroma — and the low-contrast camp turns down the first while Modus leaves it high and turns down the other two. The current tool only measures the first (WCAG contrast) and edits color in HSV, whose "lightness" is not perceptually uniform. To build low-contrast themes by metric rather than by eye, the tool needs perceptually-uniform lightness and chroma controls plus distinguishability and polarity-aware contrast measures. Rubric: *Ready.* The four v1 product questions are decided in "Agreed decisions (v1)" below and confirmed by Craig (2026-06-08); the testing strategy was revised on his direction to a layered pyramid (Node-unit-tested color core + thin UI hash tests + measured coverage). No remaining blocking ambiguity — the implementer no longer has to invent product behavior while coding. Implementation is sequenced into five phases, each independently shippable and tested. Tasks filed in =todo.org=. * Background — the current color model The tool today works entirely in sRGB hex, HSV, and WCAG luminance. The relevant cluster in =generate.py=: - =rl(hex)= (line 517) — WCAG relative luminance, via the existing =lin()= sRGB-linearization helper. - =contrast(a,b)= (line 519) — the WCAG 2.x ratio =(L1+0.05)/(L2+0.05)=. - =rating(r)= / =ratingColor(r)= (lines 520-521) — AA (≥4.5) / AAA (≥7) verdict and its display color. - =hsv2rgb=, =rgb2hsv=, =hex2rgb=, =rgb2hex=, =normHex= (lines 604-609). - The picker holds state as =pkH, pkS, pkV= (HSV) and renders an SV box (=#sv=), a hue strip (=#hue=), crosshairs (=#svcur=, =#huecur=), a hex+contrast readout (=#pkhex= / =#pkcon=, line 451), the contrast-mask buttons (=.pmode=, state =pkMode=, values =any= / =aa= / =aaa=), and palette chips (=#pkchips=). - =drawMask()= (line 613) greys SV-box regions whose contrast against the background falls below the selected mask threshold (=pkThresh()=). - Per-face contrast readouts appear across *three* tables — syntax (line 548), UI (line 1064), and package faces (line 752) — each via =contrast()= + =rating()=. The package-face tier has grown large since the tool's early versions (51 packages in the current inventory), so any "add a column to the table readouts" change now touches that whole surface, not just the two original tables. Two limitations this spec addresses: 1. *HSV lightness lies.* Two colors at the same HSV =V= can differ markedly in perceived brightness, so the SV box cannot hold perceived lightness constant while hue changes — exactly the operation a calm, even palette needs. 2. *WCAG 2.x is a known-flawed contrast model in the low-contrast / dark band.* Its ratio misjudges contrast most where this work operates, and it is not polarity-aware: it scores light-on-dark and dark-on-light identically, which perception does not. WCAG 3 is reworking contrast but is years out — still a Working Draft in 2026, with the final Recommendation not expected until roughly 2028–2030 — and its contrast algorithm is undetermined: APCA was moved *out* of the WCAG 3 draft in 2023 for further evaluation. So APCA enters here as a well-regarded independent perceptual model used as an additional diagnostic, not as a coming standard. WCAG 2.x stays the baseline precisely because nothing has replaced it yet. * Goal Add four metrics, each a discrete increment: 1. *OKLCH color model* — perceptually-uniform Lightness / Chroma / Hue, so the editor can move one axis without disturbing the others, plus a gamut clamp for OKLCH values outside sRGB. 2. *Perceptual-lightness readout* — show each color's OKLCH L (and C, H) in the picker, so "low, even lightness steps" becomes a number rather than a guess. 3. *APCA score* — the Accessible Perceptual Contrast Algorithm Lc value displayed next to the WCAG ratio, as the more trustworthy contrast metric in the low-contrast band. 4. *Pairwise ΔE check* — perceptual color-difference between every pair of palette entries, flagging pairs too similar to tell apart, which is the constraint that keeps a low-chroma / low-lightness-spread palette from collapsing into mush. Non-goals: replacing WCAG (it stays as the compatibility baseline, shown alongside APCA, which is an additional perceptual diagnostic, not a replacement); replacing the HSV picker outright (OKLCH is added as a parallel color model, HSV remains the default); CIEDE2000 in v1 (ΔE-OK is the v1 difference metric — see vNext). * Agreed decisions (v1) Settled on author + reviewer alignment and confirmed by Craig (2026-06-08). 1. *ΔE metric and threshold.* Use ΔE-OK (Euclidean distance in OKLab) on its native scale (OKLab L is 0..1). Default "too similar" warning threshold is *0.02* — the just-noticeable-difference floor, so the warning fires only when two palette colors are genuinely hard to tell apart. The threshold is a named constant, calibratable in one place. CIEDE2000 (the CIE's 2000 perceptual color-difference standard — more accurate than plain Euclidean distance, but ~40 lines of trigonometric lightness/chroma/hue corrections plus a blue-region rotation term) is deferred to vNext: ΔE-OK is accurate enough to flag indistinguishable pairs, which is all this check needs, and it is five lines. 2. *Low-contrast preset.* v1 ships *readouts only* (OKLCH, APCA, ΔE). No named low-contrast preset / mask mode yet. No such preset exists anywhere today — it would be a new feature: a saved low-contrast target (e.g. an APCA Lc band, or a contrast ceiling as well as a floor) that masks the palette to a comfortable range in one click, the way the current any/AA+/AAA buttons mask by a contrast floor. It is deferred until the raw readouts are in use, because only then is it clear which band is worth presetting. v1 gives the numbers; the preset would automate a judgment the numbers first have to inform. 3. *APCA placement.* v1 shows APCA *only in the picker* readout, not in the syntax/UI/package table contrast cells. Adding it to the tables is low-complexity once =apca()= exists — the same pattern as the existing =contrast()= + =rating()= cells, repeated across the three tables — so the deferral is about table *density*, not difficulty: the package table alone is 51 packages wide, and a second contrast number per row risks clutter before it is clear anyone reads it there. Table-wide APCA is a vNext candidate if picker-only proves too hidden. 4. *Picker default model.* HSV stays the *default* picker model; OKLCH is opt-in via a color-model control. The reason: HSV is the familiar 2D SV-box the picker already has, and OKLCH is slider-only until the C×L plane (Phase 4b) lands — so defaulting to OKLCH before 4b would hand users a worse default editing surface than they have now. Once 4b ships the C×L plane, making OKLCH the default becomes a real option worth revisiting; until then, HSV default keeps the current editing experience intact and makes OKLCH an additive choice, not a regression. * Color-math foundation (Phase 1, prerequisite) The pure color math is *extracted into its own importable module* rather than inlined as loose functions in the page. This is the core architectural change this spec makes to the test surface: the math is logic, so it gets tested as logic — directly, in Node, with exhaustive fixtures — and the picker becomes a thin UI layer over a tested core, not the only way to exercise the math. - New file: =scripts/theme-studio/colormath.js= — the pure, side-effect-free conversion + metric functions, written as an ES module (each =export=-ed), with a small guard so the same source loads both ways: =import=-ed by the Node tests and spliced into the page by the generator. - =generate.py= inlines =colormath.js= into the page's =