1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
|
: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 =<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.
|