diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-15 10:24:40 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-15 10:24:40 -0500 |
| commit | 3805128015688f057eefb7cc82a2a0f0ac67bba2 (patch) | |
| tree | 734eefab040d1e6ad3553778dde861f088f9de27 /docs/design/theme-studio-perceptual-color-metrics-spec.org | |
| parent | 032909a444a228bde2ec80004512a9d6bddb44d1 (diff) | |
| download | dotemacs-3805128015688f057eefb7cc82a2a0f0ac67bba2.tar.gz dotemacs-3805128015688f057eefb7cc82a2a0f0ac67bba2.zip | |
docs: move specs to docs/specs/ with lifecycle-status filenames
Separate the 27 formal specs from working notes. Specs move to docs/specs/, notes stay in docs/design/. Each spec carries its lifecycle in the filename (-spec, -spec-doing, -spec-implemented, -spec-superseded) plus an authoritative ID and STATUS property drawer. The status came from checking each spec against the code, not the doc's own field: 6 implemented, 8 in progress, 12 not started, 1 superseded.
Inbound links become org-id links so future status renames don't break them; code-comment paths repoint to docs/specs/. Working notes, inventories, reviews, and brainstorms stay in docs/design/.
Diffstat (limited to 'docs/design/theme-studio-perceptual-color-metrics-spec.org')
| -rw-r--r-- | docs/design/theme-studio-perceptual-color-metrics-spec.org | 576 |
1 files changed, 0 insertions, 576 deletions
diff --git a/docs/design/theme-studio-perceptual-color-metrics-spec.org b/docs/design/theme-studio-perceptual-color-metrics-spec.org deleted file mode 100644 index 7e7dedb2..00000000 --- a/docs/design/theme-studio-perceptual-color-metrics-spec.org +++ /dev/null @@ -1,576 +0,0 @@ -#+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. |
