aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-08 15:52:51 -0500
committerCraig Jennings <c@cjennings.net>2026-06-08 15:52:51 -0500
commitfba717f4f9be54e6164594aee077f0bda3063746 (patch)
tree695ceb6259e5efebe02312100da3602f632764df
parent453e13b31bd02d7f699b09532ecfc8d701ef116a (diff)
downloaddotemacs-fba717f4f9be54e6164594aee077f0bda3063746.tar.gz
dotemacs-fba717f4f9be54e6164594aee077f0bda3063746.zip
docs(theme-studio): add perceptual color metrics spec
The spec adds OKLCH editing, perceptual-lightness and APCA readouts, and a pairwise ΔE distinguishability check to the theme-studio, so it can build deliberately low-contrast themes by metric instead of by eye. The testing strategy extracts the color math into a Node-unit-tested colormath.js core, with the browser hash tests reduced to UI wiring and coverage measured on that core. todo.org carries the five implementation phases and the manual-validation checklist.
-rw-r--r--docs/design/theme-studio-perceptual-color-metrics-spec.org576
-rw-r--r--todo.org34
2 files changed, 609 insertions, 1 deletions
diff --git a/docs/design/theme-studio-perceptual-color-metrics-spec.org b/docs/design/theme-studio-perceptual-color-metrics-spec.org
new file mode 100644
index 00000000..7e7dedb2
--- /dev/null
+++ b/docs/design/theme-studio-perceptual-color-metrics-spec.org
@@ -0,0 +1,576 @@
+#+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 =<script>= the same way
+ it already inlines =samples.py='s data, so there is *one source of truth* — the
+ exact code the browser runs is the code the tests import. An inline-integrity
+ check (see Verification strategy) asserts the page contains the module verbatim
+ so the two can never drift.
+- The existing inline helpers it supersedes (=lin=, =rl=, =contrast=, =rating=,
+ =hsv2rgb=, =rgb2hsv=, =hex2rgb=, =rgb2hex=) move into =colormath.js= too, so the
+ whole color core lives and is tested in one place. =normHex= stays at the UI
+ boundary; module functions assume a normalized =#rrggbb= and the Node tests
+ cover their edges directly.
+
+The functions (standard published algorithms):
+
+- =srgb2oklab(hex)= / =oklab2srgb(L,a,b)= — Björn Ottosson's OKLab matrices
+ (2020). sRGB → linear (reuse =lin()=) → LMS → cube-root → OKLab, and the
+ inverse. ~20 lines.
+- =oklab2oklch(L,a,b)= / =oklch2oklab(L,C,H)= — Cartesian↔polar: =C=√(a²+b²)=,
+ =H=atan2(b,a)=. Trivial.
+- =oklch2hex(L,C,H)= with the *gamut clamp* (see below). Returns
+ ={hex, clamped}= — the in-gamut hex plus a boolean flag.
+- =apca(textHex, bgHex)= — the APCA-W3 algorithm. Returns a signed Lc (positive
+ for dark-text-on-light, negative for light-text-on-dark; magnitude ~0–107).
+- =deltaE(aHex, bHex)= — ΔE-OK: Euclidean distance in OKLab,
+ =√((ΔL)²+(Δa)²+(Δb)²)=. Five lines.
+
+** Gamut clamp policy (v1, fixed)
+
+OKLCH can express colors sRGB cannot show (high C at some L/H). The v1 policy is
+*binary-search chroma reduction*: hold L and H fixed, reduce C until the color
+is in sRGB gamut. This preserves the two perceptual axes the user is reasoning
+about and only sacrifices saturation. Component clipping (which can shift all
+three axes and make a slider feel broken) is explicitly *not* used.
+
+=oklch2hex= returns ={hex, clamped}= where =clamped= is true when chroma was
+reduced. The picker keeps its sliders and readouts on the *actual clamped color*
+after conversion, and shows a short status ("chroma clamped to sRGB") when
+=clamped= is true — so the user never sees an axis silently move.
+
+** APCA source (pinned)
+
+Implement against *APCA-W3 0.1.9* (Myndex), transcribing the constants verbatim:
+
+- Source: =https://github.com/Myndex/apca-w3= (the =apca-w3= package, version
+ 0.1.9). The implementation puts this URL + version in a code comment beside
+ =apca()=.
+- Screen luminance per color uses the *exact* APCA-W3 0.1.9 =colorSpace=
+ constants, not rounded values: =Ys = 0.2126729·R^2.4 + 0.7151522·G^2.4 +
+ 0.0721750·B^2.4= on the 0..1 sRGB channels (straight 2.4 power, not the WCAG
+ piecewise). All remaining APCA constants — the black soft-clamp
+ (=blkThrs=/=blkClmp=), the polarity-specific text/background exponents
+ (=normBG=/=normTXT=/=revTXT=/=revBG=), the low-contrast roll-off
+ (=loBoThresh=/=loBoFactor=/=loClip=), =deltaYmin=, and =scaleBoW= — are
+ likewise transcribed verbatim from the pinned source. The spec does not restate
+ those numbers, to avoid becoming a second, drift-prone source: the pinned
+ =apca-w3= 0.1.9 is the single authority.
+- Fixture values asserted by the Node unit tests: =apca('#000000','#ffffff')=
+ Lc ≈ *106.0* (dark on light, positive); =apca('#ffffff','#000000')= Lc ≈
+ *-107.9* (light on dark, negative); plus at least one *chromatic* APCA fixture
+ (e.g. =apca('#67809c','#ffffff')=) computed from the pinned reference —
+ black/white alone cannot reveal rounded-coefficient drift, since the rounding
+ error is near zero at the channel extremes.
+
+The tool ships as a single self-contained generated HTML file with no runtime
+build step or package manager, so the APCA algorithm is transcribed into
+=colormath.js= (inlined into the page) rather than vendored as an npm dependency.
+The Node test harness is dev-only — it imports =colormath.js= to assert against
+fixtures — and does not make the shipped artifact depend on Node or any package.
+
+** Verification — Node unit tests (=test-colormath.mjs=)
+
+The math is tested *directly*, not through the browser: =scripts/theme-studio/test-colormath.mjs=
+imports =colormath.js= and asserts against fixtures under =node --test=. No DOM,
+no Chrome, sub-second, and not capped by what the UI happens to exercise — this
+is where the bulk of the feature's test value lives, and it can be far more
+exhaustive than a hash test. It must include *chromatic* fixtures and properties,
+because many incorrect matrix/sign implementations still pass black, white, and
+round-trip:
+
+- =srgb2oklab('#ffffff')= L ≈ 1.0, a ≈ 0, b ≈ 0; =srgb2oklab('#000000')= L ≈ 0.
+- chromatic fixture 1 — saturated red =#ff0000=: OKLab/OKLCH within epsilon of
+ the reference (L ≈ 0.628, C ≈ 0.258, H ≈ 29.2°).
+- chromatic fixture 2 — the dupre blue =#67809c=: OKLCH ≈ (L 0.591, C 0.052,
+ H 252°), epsilon ~0.005 on L/C and ~1° on H. Computed from the Ottosson
+ reference; the implementation verifies against the same reference it
+ transcribes.
+- round-trip *property*: for a generated sample of hexes,
+ =oklch2hex(oklab2oklch(srgb2oklab(h))).hex= ≈ =h= within epsilon. A property
+ test over random inputs, not a fixed list — it explores corners a hand-written
+ list would miss.
+- =apca= both polarities against the pinned fixtures above (assert sign and
+ magnitude), plus the chromatic APCA fixture.
+- =deltaE(h,h)= = 0; =deltaE('#000000','#ffffff')= > 0; ordering: a near pair
+ scores below the 0.02 threshold, a well-separated pair above it.
+- gamut clamp: a known out-of-gamut OKLCH (very high C) returns a valid
+ =#rrggbb= with L and H preserved within epsilon, C reduced, and =clamped=
+ true; an in-gamut input returns =clamped= false unchanged.
+
+Pure-function TDD with no rendering dependency: write the failing Node test,
+confirm it FAILs (e.g. with a deliberately wrong constant), then make it pass.
+There is no =#mathtest= browser hash — the math is not a UI concern, so it is not
+tested through the UI.
+
+* Phase 2 — perceptual L and APCA readouts
+
+Smallest visible change; validates Phase 1 by eye.
+
+- Extend =pkReadout(hex)= (line 615) to populate new spans for OKLCH L / C / H
+ and APCA Lc, alongside the existing WCAG ratio in the =.pinfo= bar (line 451).
+ Add the spans to the picker DOM (lines 448-451) and minimal CSS.
+- The APCA span carries a compact polarity-aware label (e.g. =APCA Lc -58=); the
+ sign convention (positive = dark-on-light, negative = light-on-dark) is
+ documented in its tooltip and in the README.
+- WCAG remains exactly as-is in the picker and in all three table contrast cells.
+ Per "Agreed decisions" #3, no APCA in the tables for v1.
+
+Pure additions; no behavior changes. Headless guard: =#readouttest= loads a
+known hex and asserts the OKLCH L/C/H and APCA Lc spans carry the expected
+values and the WCAG readout is unchanged.
+
+* Phase 3 — pairwise ΔE across the palette
+
+Self-contained, high value for low-contrast work.
+
+- On =renderPalette()=, compute =deltaE= for every unordered pair of =PALETTE=
+ entries. Flag any pair below the threshold (0.02, the named constant).
+- Warning copy and ordering: sort failing pairs ascending by ΔE (closest first),
+ show the first *5*, and append "and N more" when capped — so a noisy palette
+ never silently hides the count. Format: "blue / steel — ΔE 0.014, hard to
+ distinguish".
+- Each palette chip's =title= gains its nearest-neighbor ΔE.
+- Reuses the chip rendering already in =renderPalette= / =buildPkChips= (line
+ 619). No new rendering surface.
+
+Headless guard: =#deltatest= seeds two near-identical palette colors and asserts
+the warning fires (and names the pair); seeds a well-spread palette and asserts
+it does not; if the cap triggers, asserts the "and N more" suffix and ascending
+order.
+
+* Phase 4 — the OKLCH editor
+
+The largest piece, and the one that delivers "hold lightness while changing
+chroma." Two shippable sub-phases, in order.
+
+** Phase 4a — OKLCH sliders + color-model control
+
+- Add a *separate color-model control* — a segmented =HSV= / =OKLCH= toggle with
+ its *own* state variable =pkModel= — distinct from the existing contrast-mask
+ control (=.pmode= / =pkMode=, values =any= / =aa= / =aaa=). The two are
+ orthogonal concepts: =pkModel= is "how I edit the color," =pkMode= is "what
+ constraint I mask." They must not share state.
+- In OKLCH mode, show L / C / H as numeric + range inputs that drive the color
+ through =oklch2hex=, updating =#newhexstr=, the swatch, and the readouts. On
+ clamp, the sliders snap to the clamped color and the status text appears.
+- No canvas work; delivers the independent-dials benefit immediately.
+
+Headless guard: =#oklchtest= asserts that switching =pkModel= to OKLCH preserves
+the selected color, that toggling the AA/AAA mask does *not* reset =pkModel=, and
+that switching =pkModel= does *not* reset =pkMode=.
+
+** Phase 4b — Chroma×Lightness plane
+
+- When =pkModel= is OKLCH, render the SV box (=#sv=, line 448) as a Chroma (x) by
+ Lightness (y) plane at the current fixed hue; the hue strip is unchanged. The
+ crosshair maps to (C, L) instead of (S, V).
+- *Gamut masking*: high chroma is unreachable at some L/H, so grey out the
+ out-of-gamut region of the plane — reuse the =drawMask()= pattern (line 613),
+ swapping the per-pixel test from "contrast < threshold" to "OKLCH(C,L,H) not
+ in sRGB gamut." The existing AA/AAA contrast mask can overlay on top.
+- *Render cost*: =drawMask()= already samples at =step=4= and runs =contrast()=
+ per cell; the gamut test adds an OKLCH→sRGB conversion per cell, and a naive
+ per-cell binary search on top would be expensive while dragging. Bound it: use
+ a coarse sampling step, cache the rendered plane on a key of
+ (hue + dimensions + mask mode + background hex) so it only recomputes when one
+ changes, and defer the redraw until pointer movement settles. The background
+ hex is in the key because when the AA/AAA contrast overlay is active the mask
+ depends on =MAP['bg']=, so a background edit must invalidate the cached plane.
+ The in-gamut test per cell
+ needs only a forward conversion + channel-range check, not the full binary
+ search (that is reserved for committing a chosen color).
+- This per-pixel gamut render is the only genuinely new rendering logic in the
+ spec, which is why it is sequenced last.
+
+Headless guard: open the picker in OKLCH mode on a known hex via a hash; assert
+the C×L crosshair lands at the expected plane coordinates and that a known
+out-of-gamut coordinate is masked.
+
+* Verification strategy (whole feature)
+
+The test surface is *layered* — a proper pyramid, broad and fast at the bottom,
+thin and DOM-bound at the top:
+
+1. *Unit (Node, the core)* — =test-colormath.mjs= imports =colormath.js= and
+ asserts the math directly under =node --test=. No browser. This is the bulk of
+ the coverage and the place exhaustive testing lives (every conversion, both
+ APCA polarities + chromatic, gamut clamp, ΔE ordering, round-trip property
+ over random hexes). *Coverage is measured here* with Node's built-in reporter
+ (no extra dependency): =node --test --experimental-test-coverage scripts/theme-studio/=.
+ Target for =colormath.js= is ≥90% line/branch (testing.md's utility-code bar);
+ in practice a pure, fully-fixtured module should land at or near 100%, and a
+ gap points at an untested branch worth a case. Coverage of the *core* is a
+ gate; coverage of the browser-executed UI code is out of scope for v1 (it
+ needs CDP/c8 instrumentation and the UI is verified by assertion, not line
+ count).
+2. *UI wiring (browser hash tests)* — only the things that genuinely need a DOM
+ or layout, now that the math is tested below them: =#cursortest= (crosshair
+ pixel position — needs real layout), =#readouttest= (Phase 2, spans populated),
+ =#deltatest= (Phase 3, warning list rendered), =#oklchtest= (Phase 4a,
+ =pkModel= / =pkMode= independence + color preserved across mode switch), the 4b
+ plane test (canvas render + gamut mask). Each appends a =PASS/FAIL= node;
+ command shape:
+ =google-chrome-stable --headless=new --dump-dom 'file://…/theme-studio.html#readouttest'=.
+3. *Integration smoke* — =#selftest= (data roundtrip), re-run every phase to
+ confirm no regression.
+4. *Inline-integrity* — a check (Node or grep) that the generated
+ =theme-studio.html= contains the =colormath.js= source verbatim, so the
+ tested module and the shipped inline copy cannot drift.
+
+Per-phase loop: edit the source (=colormath.js= for math, =generate.py= for the
+page — never hand-edit =theme-studio.html=); =python3 generate.py= to regenerate;
+=node --check= the emitted =<script>=; run the phase's tests (Node unit tests for
+Phase 1, the matching hash test for UI phases); re-run =#selftest= and the
+inline-integrity check; Chrome eyeball for the visible phases (2, 3, 4).
+
+On coverage and why this shape: =generate.py= (~1120 lines) and =samples.py=
+(~269) are the templating/assembly + data layer — string-emission and a sample
+corpus — so Python unit tests there are low value and stay out of scope. The
+logic worth hammering is the color *math*, which is JavaScript; extracting it to
+=colormath.js= makes it directly unit-testable in Node instead of only reachable
+through the rendered app. That is the correction this revision makes: the earlier
+draft tested the math through browser hash tests, which coupled math correctness
+to the DOM and capped coverage at what the UI exercises. With the core extracted,
+the math gets exhaustive direct unit tests and the browser tests shrink to UI
+wiring — the thin-UI-over-tested-core shape an API-first build would have
+produced. The separate =build-theme.el= converter keeps its 22 ERT tests.
+
+* Documentation
+
+Folded into the phases, landing with the code each describes:
+
+- README (=scripts/theme-studio/README.md=): document OKLCH, APCA, and ΔE; the
+ meaning of the signed APCA value; that WCAG remains the compatibility baseline
+ and APCA is an additional perceptual diagnostic, not a replacement.
+- Add the exact commands beside the existing run instructions: the Node unit run
+ with coverage (=node --test --experimental-test-coverage scripts/theme-studio/=)
+ and the headless hash tests (=#readouttest=, =#deltatest=, =#oklchtest=, the 4b
+ plane test).
+
+* Acceptance criteria
+
+- *Phase 1*: =colormath.js= extracted and inlined by =generate.py=;
+ =node --test= green — achromatic, chromatic, and round-trip conversions within
+ epsilon; APCA matches the pinned fixtures (magnitude and sign, both polarities,
+ plus a chromatic fixture); gamut clamp preserves L/H within epsilon, reduces C,
+ returns =clamped= true on out-of-gamut and false unchanged on in-gamut;
+ inline-integrity check confirms the page contains =colormath.js= verbatim;
+ =node --test --experimental-test-coverage= reports =colormath.js= at ≥90%
+ line/branch.
+- *Phase 2*: picker shows OKLCH L/C/H and APCA Lc (with polarity label) next to
+ the WCAG ratio; values match the Node-test references for hand-checked colors;
+ no behavior change to existing flows; tables unchanged; =#selftest= still PASS;
+ =#readouttest= PASS.
+- *Phase 3*: a palette with two near-identical colors raises a visible warning
+ naming the pair and ΔE, sorted closest-first, capped at 5 with "and N more"; a
+ well-spread palette raises none; chip titles carry nearest-neighbor ΔE;
+ =#deltatest= PASS.
+- *Phase 4a*: dragging L changes only lightness (C and H readouts hold); same for
+ C and H independently; =pkModel= and =pkMode= are independent (=#oklchtest=
+ PASS); clamp shows status text.
+- *Phase 4b*: the C×L plane crosshair opens on the current color's (C, L);
+ out-of-gamut regions are masked; the plane render stays responsive while
+ dragging (cached on hue/dims/mask key).
+
+* Implementation phases
+
+One shippable phase per increment, in dependency order, each gated on its own
+headless test plus a clean =#selftest=. These map to the drop-in =todo.org=
+tasks (filed in workflow Phase 6, after Craig confirms Ready):
+
+1. *Math foundation* — extract the color core into =colormath.js= (OKLab/OKLCH,
+ APCA-W3 0.1.9, ΔE-OK, gamut clamp, plus the migrated lin/rl/contrast/hsv
+ helpers); =generate.py= inlines it; =test-colormath.mjs= unit tests + the
+ inline-integrity check; gate =node --test= green.
+2. *Picker readouts* — OKLCH L/C/H + APCA Lc spans beside WCAG; gate
+ =#readouttest= + =#selftest=.
+3. *Palette ΔE warnings* — pairwise ΔE, sorted/capped warning, chip-title
+ nearest-neighbor; gate =#deltatest=.
+4a. *OKLCH sliders + color-model control* — =pkModel= separate from =pkMode=,
+ L/C/H inputs, clamp status; gate =#oklchtest=.
+4b. *Chroma×Lightness plane* — gamut-masked C×L render with caching; gate the 4b
+ plane test.
+
+A test-surface task keeps the Node unit tests, the UI hash tests, the
+inline-integrity check, =#selftest=, the script syntax check, and manual Chrome
+validation green across the feature.
+
+* Review dispositions
+
+Modified or rejected recommendations only; everything else in the Codex review
+(2026-06-08) was accepted as written and woven into the body above.
+
+- *Modified — APCA "transcribe vs vendor" question (high-priority finding 4).*
+ The review asked the spec to "state whether the project is comfortable
+ transcribing the algorithm rather than vendoring a package." Reframed as
+ settled rather than open: the tool is a single self-contained generated HTML
+ file with no build step or package manager, so transcription is the only path
+ consistent with the architecture. The source URL + version pin and the fixture
+ values were accepted in full.
+- *Modified — ΔE-OK default threshold (high-priority finding 1 / open question).*
+ The review accepted ΔE-OK on its native scale "with a named default threshold"
+ but left the number to the author. Concretized to 0.02 (the OKLab
+ just-noticeable-difference floor) as a named, calibratable constant, so the
+ warning fires only on genuinely indistinguishable pairs rather than deferring
+ the choice to implementation.
+
+* Review and iteration history
+
+** 2026-06-08 Mon @ 13:00:34 -0500 — Codex — reviewer
+- *What changed or was recommended:* Created the implementation-readiness review
+ and marked the spec =Not ready=. The review recommends resolving the listed
+ open questions, pinning the APCA source/fixtures, specifying OKLCH gamut clamp
+ semantics, separating color-model mode from contrast-mask mode, and converting
+ the increments into todo-ready implementation phases.
+- *Why:* The feature direction fits the current theme-studio architecture, but
+ coding from this draft would force the implementer to decide product behavior
+ around DeltaE thresholds, APCA placement, OKLCH UI state, and out-of-gamut
+ colors.
+- *Artifacts:* the review file (consumed and deleted on response).
+
+** 2026-06-08 Mon @ 13:08:00 -0500 — Claude Code — responder
+- *What changed:* Processed every Codex recommendation. Accepted all five
+ high-priority findings — resolved the four open questions into "Agreed
+ decisions (v1)", fixed the gamut clamp as binary-search chroma reduction
+ returning a =clamped= flag with picker status text, separated =pkModel=
+ (HSV/OKLCH) from =pkMode= (mask), pinned APCA-W3 0.1.9 with black/white
+ fixtures, and added an "Implementation phases" decomposition (1, 2, 3, 4a, 4b)
+ with per-phase test gates. Accepted the medium findings: chromatic =#mathtest=
+ fixtures, ΔE warning sort/cap copy, C×L render-cost bounds, refreshed
+ background to include the package-face table surface, the named hash tests
+ (=#readouttest=, =#oklchtest=), and the README/doc additions. Two modifications
+ recorded in "Review dispositions": the APCA transcribe-vs-vendor question
+ (settled by the no-build architecture) and the ΔE-OK default threshold (set to
+ 0.02).
+- *Why:* Converge to implementation-ready — remove every product decision from
+ the coding path. Rubric raised Draft → Ready pending Craig's confirm on the
+ four baked-in v1 decisions.
+- *Artifacts:* consumed and deleted =theme-studio-perceptual-color-metrics-spec-review.org=;
+ see "Review dispositions" and "Agreed decisions (v1)". Implementation tasks to
+ be filed in =todo.org= on confirm (workflow Phase 6).
+
+** 2026-06-08 Mon @ 13:15:26 -0500 — Codex — reviewer
+- *What changed or was recommended:* Re-reviewed the updated spec and marked it
+ =Not ready= on one remaining blocker: the APCA section pins =apca-w3= 0.1.9 and
+ says to transcribe constants verbatim, but still shows rounded luminance
+ coefficients instead of the exact published =colorSpace= constants. Also noted
+ two non-blocking edits: include background hex in the 4b mask cache key when
+ contrast masking is active, and either provide the =#67809c= OKLCH fixture
+ values or make that second chromatic fixture optional.
+- *Why:* The implementation is otherwise ready-shaped, but APCA math and numeric
+ fixtures need a single authoritative source before coding starts.
+- *Artifacts:* [[file:theme-studio-perceptual-color-metrics-spec-review.org][theme-studio-perceptual-color-metrics-spec-review.org]]
+
+** 2026-06-08 Mon @ 13:19:15 -0500 — Claude Code — responder
+- *What changed:* Processed Codex's second pass. Accepted all three findings, no
+ modify/reject. Blocking: replaced the rounded APCA luminance coefficients
+ (=0.2126=/=0.7152=/=0.0722=) with the exact APCA-W3 0.1.9 =colorSpace=
+ constants (=0.2126729=/=0.7151522=/=0.0721750=), and stated the remaining APCA
+ constants are transcribed verbatim from the pinned source rather than restated
+ in the spec, so there is one authority. Non-blocking: added =MAP['bg']= to the
+ Phase 4b mask cache key (the AA/AAA overlay depends on background); added
+ concrete OKLCH fixture values for =#67809c= (L 0.591, C 0.052, H 252°); added a
+ chromatic APCA fixture to =#mathtest= so black/white can't hide coefficient
+ drift.
+- *Why:* Remove the last numeric ambiguity so =#mathtest= fixtures and the
+ implementation agree on one exact APCA algorithm. Reviewer confirmed no design
+ gaps remain — only the author-confirm caveat.
+- *Artifacts:* consumed and deleted the second
+ =theme-studio-perceptual-color-metrics-spec-review.org=. Rubric stays =Ready,
+ pending author confirm= on the four v1 decisions; Phase 6 task-filing still
+ holds for that confirm.
+
+** 2026-06-08 Mon @ 13:21:11 -0500 — Codex — reviewer
+- *What changed or was recommended:* Re-read the spec-review workflow and
+ critically reviewed the updated spec against the current theme-studio code,
+ README, prior review dispositions, and task tracking. Assigned =Ready with
+ caveats=: no blocking implementation ambiguity remains, and the prior APCA
+ coefficient issue is resolved by the exact =apca-w3= =colorSpace= constants.
+ The only caveat is the spec's explicit author-confirm step before Phase 1.
+- *Why:* The spec now defines v1 scope, APCA source/fixtures, OKLCH gamut policy,
+ picker state ownership, DeltaE threshold/copy, performance guardrails, docs,
+ acceptance criteria, and shippable phases well enough for implementation.
+- *Artifacts:* No review file written; no blocking findings.
+
+** 2026-06-08 Mon @ 15:21:55 -0500 — Craig Jennings (review) + Claude Code (responder) — final review
+- *What changed:* Craig's final-review questions (six cj comments) answered and
+ the load-bearing ones folded into the body. Factual correction: WCAG 3 is still
+ a Working Draft (final Recommendation ~2028–2030) and APCA was moved *out* of
+ the WCAG 3 draft in 2023 — so the Background no longer frames APCA as a coming
+ standard, only as an independent diagnostic, with WCAG 2.x kept as baseline
+ because nothing has replaced it. Clarifications added to the Agreed decisions:
+ what CIEDE2000 is and why ΔE-OK suffices (#1); that no low-contrast preset
+ exists today and what one would add (#2); that table APCA is low-complexity and
+ the deferral is about density not difficulty (#3); the reason HSV stays default
+ until the 4b plane lands (#4). The Verification section now states Python
+ coverage is ~0% by design (the generator is output-tested via hash tests) and
+ the target is per-behavior hash-test gating, not a line-coverage number.
+- *Why:* Craig interrogated the decisions before confirming; the answers either
+ reinforce them or give him what he needs to override. No decision changed.
+- *Artifacts:* WCAG 3 status via W3C/industry sources (June 2026); repo check
+ confirmed no Python test files / pytest config for =scripts/theme-studio/=.
+
+** 2026-06-08 Mon @ 15:34:36 -0500 — Craig Jennings (direction) + Claude Code (responder) — testing-strategy override
+- *What changed:* Craig overrode the v1 testing approach. The prior draft tested
+ the color math through browser hash tests (=#mathtest=), which coupled math
+ correctness to the DOM and capped coverage at what the UI exercises. Corrected
+ to a layered pyramid: the pure math is extracted into =scripts/theme-studio/colormath.js=
+ (an importable ES module that =generate.py= inlines into the page), unit-tested
+ directly in Node (=test-colormath.mjs=, =node --test=) with exhaustive fixtures
+ + a round-trip property test; the browser hash tests shrink to UI wiring only
+ (=#cursortest=/=#readouttest=/=#deltatest=/=#oklchtest=/4b-plane); =#selftest=
+ stays as integration smoke; an inline-integrity check guards the module and the
+ inlined copy against drift. =#mathtest= is removed — the math is no longer a UI
+ concern. Updated Phase 1, Verification, Acceptance, Implementation phases, and
+ Documentation to match. Language correction: the math is JavaScript (emitted by
+ the Python), so the "Python unit tests" instinct lands as Node unit tests on the
+ extracted JS core; the Python stays templating/data and is out of test scope.
+- *Why:* Test the core directly, keep the UI thin — the API-first shape this
+ app grew past. Direct unit tests on the math are faster, more exhaustive, and
+ not limited by the UI surface.
+- *Decisions 1-4 confirmed* as written (4: OKLCH readouts always shown; only the
+ editing model is opt-in until 4b). Phase 6 task-filing + commit still pending
+ Craig's go.
diff --git a/todo.org b/todo.org
index 642a69e3..2bc725c4 100644
--- a/todo.org
+++ b/todo.org
@@ -118,6 +118,20 @@ Rewrote =README.md= for the full tool: three face tiers + palette, the in-page p
*** 2026-06-08 Mon @ 02:40:00 -0500 theme-studio tier 3 — test surface landed
Extended the guarded =#selftest= harness (headless Chrome) to assert the acceptance criteria against the real emitted code: old-JSON import (no =packages=), full round-trip (fg/bg/bold/italic/inherit/height/source), cleared-state export, unknown-package/face preservation, and inheritance-cycle termination — all PASS. The two DOM-coupled regressions are handled structurally: =updateColor= remaps =PKGMAP= on a palette-color edit, and =PKGMAP= stores hexes so a deleted palette color leaves package refs in the "(gone)" recoverable state. =generate.py= rebuilds =theme-studio.html= each run.
+** TODO [#B] theme-studio perceptual color metrics :feature:theme:theme-studio:
+Spec (Ready, opens confirmed 2026-06-08): [[file:docs/design/theme-studio-perceptual-color-metrics-spec.org][docs/design/theme-studio-perceptual-color-metrics-spec.org]]. OKLCH model + perceptual-L/APCA readouts + pairwise ΔE, for building low-contrast themes by metric rather than by eye. Phases run in dependency order; the math core extracts to a Node-unit-tested =colormath.js= and the browser hash tests verify UI wiring. vNext deferrals (low-contrast preset, CIEDE2000) are the two [#D] tasks below.
+*** TODO [#B] Color-math foundation + Node tests :solo:
+Extract the pure color core into =scripts/theme-studio/colormath.js= (OKLab/OKLCH, APCA-W3 0.1.9 exact =colorSpace= constants, ΔE-OK, binary-search gamut clamp returning ={hex,clamped}=, plus migrated lin/rl/contrast/hsv helpers). =generate.py= inlines it. =test-colormath.mjs= unit tests (chromatic fixtures, both APCA polarities, round-trip property over random hexes, gamut clamp, ΔE ordering) + an inline-integrity check that the page contains the module verbatim. Gate: =node --test --experimental-test-coverage scripts/theme-studio/= green, =colormath.js= ≥90% line/branch.
+*** TODO [#B] Picker OKLCH/APCA readouts :solo:
+Phase 2. OKLCH L/C/H + signed APCA Lc (polarity label + tooltip) beside the WCAG ratio in the picker =.pinfo=; always shown. Tables unchanged. Gate: =#readouttest= + =#selftest= PASS, no behavior change.
+*** TODO [#B] Palette ΔE warnings :solo:
+Phase 3. Pairwise ΔE-OK across =PALETTE=; warn on pairs below 0.02 (named constant), sorted closest-first, capped at 5 with "and N more"; nearest-neighbor ΔE in each chip title. Gate: =#deltatest= PASS.
+*** TODO [#B] OKLCH sliders + color-model control :solo:
+Phase 4a. Separate =pkModel= (HSV/OKLCH) from =pkMode= (AA/AAA mask); OKLCH L/C/H numeric + range inputs driving =oklch2hex=; clamp status text. HSV stays default. Gate: =#oklchtest= (model/mask independence + color preserved across switch) PASS.
+*** TODO [#B] Chroma×Lightness plane :solo:
+Phase 4b. Render the SV box as a C×L plane at fixed hue in OKLCH mode; gamut-mask the out-of-range region (reuse the =drawMask()= pattern); cache on (hue + dims + mask + bg hex); defer redraw until pointer settles. Gate: the 4b plane hash test (crosshair lands at the color's C/L; a known out-of-gamut coordinate is masked).
+*** TODO [#B] Test surface green :solo:tests:
+Keep the Node unit tests (+coverage), the UI hash tests (=#cursortest=/=#readouttest=/=#deltatest=/=#oklchtest=/4b), =#selftest=, the inline-integrity check, =node --check=, and the README/doc updates green across the feature.
** TODO Manual testing and validation :verify:theme-studio:
Exercised once the phases above land.
*** TODO Seeded package-face defaults look right
@@ -132,6 +146,25 @@ What we're verifying: org's ~88-face and magit's ~111-face tables stay navigable
- Type "agenda" in the filter
- Reassign one face and watch the preview
Expected: rows are grouped, the filter narrows them, and a reassignment updates the preview live.
+*** TODO Perceptual readouts read well in the picker
+What we're verifying: the OKLCH L/C/H and APCA Lc readouts are legible and correctly placed beside the WCAG number.
+- Open =scripts/theme-studio/theme-studio.html= in Chrome and open the picker on a few colors
+- Read the OKLCH and APCA values against the WCAG ratio
+Expected: the new readouts are clear, the APCA sign/polarity is understandable, and nothing crowds the readout bar.
+*** TODO ΔE warnings read clearly
+What we're verifying: the too-similar-pair warning is legible and the cap behaves.
+- Build a palette with two near-identical colors, then a well-spread one
+- Read the warning line each time
+Expected: the close pair is named with its ΔE, sorted closest-first, capped at 5 with "and N more"; the well-spread palette shows no warning.
+*** TODO OKLCH editor feels right
+What we're verifying: the OKLCH sliders / C×L plane edit cleanly and clamping is visible.
+- Switch the picker to OKLCH mode and drag L, then C, then H
+- Push chroma past the sRGB gamut, then toggle the AA/AAA mask
+Expected: each axis moves independently; the C×L plane (once 4b lands) opens on the current color; "chroma clamped to sRGB" shows on clamp; toggling the mask does not reset OKLCH mode.
+** TODO [#D] theme-studio low-contrast preset/mask mode :feature:theme-studio:
+Deferred from the perceptual color metrics spec (vNext). After raw OKLCH/APCA/DeltaE readouts exist, decide whether to add a named low-contrast workflow: APCA Lc bands, a contrast ceiling/floor mask, or a "soft" sibling to the existing any/AA+/AAA picker mask. Spec: [[file:docs/design/theme-studio-perceptual-color-metrics-spec.org][spec]] (vNext candidates; review folded in 2026-06-08).
+** TODO [#D] theme-studio CIEDE2000 DeltaE option :feature:theme-studio:
+Deferred from the perceptual color metrics spec (vNext). v1 uses DeltaE-OK on its native scale with a 0.02 threshold (decided); revisit CIEDE2000 only if the native OKLab scale proves too unfamiliar or poorly calibrated for palette distinguishability. Spec: [[file:docs/design/theme-studio-perceptual-color-metrics-spec.org][spec]] (vNext candidates; review folded in 2026-06-08).
** TODO [#B] Dashboard keybinding changes :quick:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-06
@@ -7170,4 +7203,3 @@ Expected: F12 excludes agent buffers and keeps saved geometry; the dashboard lau
What we're verifying: the aiv- tmux session survives an Emacs crash and reattaches.
- with a live agent, kill Emacs (not the tmux session); restart Emacs; F9 → project picker
Expected: the project shows "[detached]" and reattaches to the surviving tmux session.
-