From 38200c6683e55860b044568cd70004dcbc7c4031 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 4 Jul 2026 15:38:00 -0500 Subject: docs(specs): adopt status-heading lifecycle convention across specs Migrate 29 legacy specs off the old shape (a status suffix in the filename plus a :STATUS: property drawer) onto the docs-lifecycle status heading: a top-level heading carrying the org lifecycle keyword and a dated history line, with the two #+TODO sequences in the header. Dropping the -doing/-implemented/-superseded suffixes means a status change no longer forces a rename and link surgery. Each keyword comes from the spec's own recorded status. The four specs already on the heading form are untouched, and every inbound reference now points at the new names. The status board is one grep: rg '^\* (DRAFT|READY|DOING|IMPLEMENTED|SUPERSEDED|CANCELLED) ' docs/specs/ --- .../theme-studio-perceptual-color-metrics-spec.org | 584 +++++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 docs/specs/theme-studio-perceptual-color-metrics-spec.org (limited to 'docs/specs/theme-studio-perceptual-color-metrics-spec.org') diff --git a/docs/specs/theme-studio-perceptual-color-metrics-spec.org b/docs/specs/theme-studio-perceptual-color-metrics-spec.org new file mode 100644 index 00000000..233632a5 --- /dev/null +++ b/docs/specs/theme-studio-perceptual-color-metrics-spec.org @@ -0,0 +1,584 @@ +#+TITLE: theme-studio — perceptual color metrics (OKLCH, APCA, ΔE) +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-08 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* IMPLEMENTED theme-studio — perceptual color metrics (OKLCH, APCA, ΔE) +:PROPERTIES: +:ID: 15db8ae3-fc14-49f3-9ed5-d5ff59790904 +:END: +- 2026-07-04 Sat @ 15:30:41 -0500 — retrofitted to status-heading convention; keyword IMPLEMENTED from existing :STATUS: implemented + -implemented filename (Craig's prior determination) + +* 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 =