aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-10 01:38:40 -0500
committerCraig Jennings <c@cjennings.net>2026-06-10 01:38:40 -0500
commitc175e2bee24f4cba841b9bd57f53dd36c7bc25ef (patch)
tree9918475c5d74f277188940e84f81626f71f99dff
parent9daeff15182d98ab28e201a17fe8c1cfa7c4e6f8 (diff)
downloaddotemacs-c175e2bee24f4cba841b9bd57f53dd36c7bc25ef.tar.gz
dotemacs-c175e2bee24f4cba841b9bd57f53dd36c7bc25ef.zip
feat(theme-studio): color-families export round-trip and README close-out
Export stays a flat palette and import needs no reconstruction, because families are derived from the hex every render rather than stored. A #roundtriptest gate confirms export to import to export is byte-identical, and that the exported palette is still a flat [hex, name] list. Package seeding is unaffected since it reads the same flat palette. The spec's planned ramp-step warning exemption is dropped after analysis: a generated ramp's steps are a stepL apart, well above the too-similar ΔE threshold, so they never trigger the warning, and exempting same-family pairs would hide genuine near-duplicates that should be flagged (the case #deltatest checks). So the warning stays on the full palette. README documents color families: the hue grouping and its limitation, the ground strip, the per-column count control and regenerate, removed-step references reading "(gone)", and the removal of the standalone ramp panel. Phase 6, the last phase; the color-families v1 build is code-complete.
-rw-r--r--scripts/theme-studio/README.md70
-rw-r--r--scripts/theme-studio/app.js14
-rwxr-xr-xscripts/theme-studio/run-tests.sh2
-rw-r--r--scripts/theme-studio/theme-studio.html14
-rw-r--r--todo.org18
5 files changed, 82 insertions, 36 deletions
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md
index a2eb59b2..caee7b24 100644
--- a/scripts/theme-studio/README.md
+++ b/scripts/theme-studio/README.md
@@ -42,8 +42,9 @@ The runner regenerates the page, runs the Python templating tests
(`test-colormath.mjs`, including the inline-integrity check), a syntax check of
the spliced page script, and the browser hash gates in headless Chrome
(`#selftest`, `#cursortest`, `#readouttest`, `#deltatest`, `#oklchtest`,
-`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#ramptest`,
-`#contrasttest`, `#safetest`). It exits non-zero on any failure. The browser gates need a
+`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#contrasttest`,
+`#safetest`, `#healtest`, `#familytest`, `#counttest`, `#baseedittest`,
+`#roundtriptest`). It exits non-zero on any failure. The browser gates need a
Chromium-family browser; without one they report SKIPPED rather than passing
silently. The pure color math and the extracted picker logic (`planeCell`,
`paletteWarnings`) live in `colormath.js` so they are unit-tested directly in
@@ -66,10 +67,11 @@ Node; the DOM glue is covered by the browser hash gates.
Three tiers of faces, plus the palette:
-- **Palette** — named colors. Add by hex or with the in-page color picker
+- **Palette** — named colors, shown grouped into hue *families* (see Color
+ families below). Add by hex or with the in-page color picker
(saturation/value square, hue slider, palette reuse chips, live contrast
- readout, and an any / AA+ / AAA legibility mask). Remove, rename, reorder with
- arrows or drag. The colors serving as background and foreground are locked.
+ readout, and an any / AA+ / AAA legibility mask). Remove and rename per chip;
+ the colors serving as background and foreground are locked.
The picker also shows perceptual readouts beside the WCAG ratio: the OKLCH
coordinates (lightness, chroma, hue°) and the APCA Lc contrast against the
@@ -93,28 +95,42 @@ Three tiers of faces, plus the palette:
per face, shown in a live mock Emacs buffer.
- **Package faces** — per-package face tables with a live preview (below).
-## Ramps and background-contrast safety
-
-Two coupled features help build a harmonized palette and keep background tints
-readable. Both work in OKLCH, where lightness, chroma, and hue move
-independently. The pure math is in `app-core.js` (`ramp`, `fgSetFor`, `floor`,
-`lMax`); the DOM is in `app.js`.
-
-**Ramps.** The "ramp" button on the palette controls generates a tonal ramp from
-the current color: lighter and darker steps on a held hue, with the chroma easing
-out toward the extremes. Three controls set the shape, with defaults that produce
-a sensible first ramp:
-
-- `steps` — how many steps each direction (default 2, range 1-4).
-- `stepL` — the OKLCH lightness delta per step (default 0.08, range 0.04-0.12).
-- `chroma ease` — how much chroma drops at the farthest step (default 0.5, range
- 0-1; 0 holds chroma flat, 1 fully desaturates the last step).
-
-Each previewed step is named after the source swatch (`blue` gives `blue+1`,
-`blue-1`) and shows a clamp badge when it left the sRGB gamut. Click a step to
-add it, or "add all"; steps insert next to the source in order. A name that
-already exists is skipped (never overwritten); a generated hex that matches
-another entry is added but flagged as a duplicate.
+## Color families
+
+The palette is displayed as **families**: colors grouped into vertical columns by
+their actual color, dark at the top and light at the bottom, columns arranged left
+to right. Grouping is derived from the hex on every render — never from the name —
+so renaming a color to anything never moves it between columns. The flat palette
+underneath is unchanged (export stays a flat `[hex, name]` list); families are a
+view over it, and the per-chip rename/remove still work.
+
+- **Grouping.** Chromatic colors bucket by their nearest perceptual hue (red,
+ orange, yellow, green, teal, blue, purple, pink). Near-neutrals — grays, the
+ background and foreground ramps — collapse into one neutral column ordered by
+ lightness, using a lightness-scaled chroma threshold so a faint pale tint keeps
+ its hue while a faint mid gray reads as neutral. Columns sort by hue; the ground
+ strip (the `bg` and `fg` assignments) pins first, neutrals next. (Hue-adjacent
+ warm colors like olive-greens and golds can still share a column — a known
+ limitation, since by hue they really are adjacent.)
+- **The count control** under each chromatic column sets how many steps sit on
+ each side of the family's base (its most-saturated color). Setting N regenerates
+ the family as a symmetric base ±N tonal ramp via `ramp()` — lighter and darker
+ steps on the base's hue with chroma easing toward the extremes — *replacing* the
+ column's current colors. N=0 collapses to the base alone.
+- **Editing a base** recolors the whole family: change a base color and the family
+ regenerates from it at the same count.
+- **References follow.** When a regenerate changes a step's hex, any face assigned
+ to that step is re-pointed to the new hex. A step *removed* by lowering the count
+ leaves its references showing "(gone)" — visible and recoverable, never a silent
+ jump to a different color.
+
+The standalone ramp generator is gone; fanning a color into a ramp is now "add the
+color, then raise its column's count."
+
+## Background-contrast safety
+
+Keep background tints readable. Works in OKLCH; the pure math is in `app-core.js`
+(`fgSetFor`, `floor`, `lMax`), the DOM in `app.js`.
**Worst-case contrast.** A background overlay sits behind many foregrounds at
once, so one fg/bg contrast pair is the wrong number. For the covered overlay
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index 2680c084..92eec644 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -198,6 +198,9 @@ function renderPalette(){
else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex;
sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);}
});
+ // The too-similar warning stays on the full flat palette: a generated ramp's
+ // steps are a stepL apart (well above the warning's ΔE threshold), so they never
+ // trigger it, and any pair that does is a genuine near-duplicate worth flagging.
sortFamilies(families).forEach(f=>{
const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base;
f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));});
@@ -1163,3 +1166,14 @@ if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{i
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
document.title='BASEEDITTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Round-trip gate (open with #roundtriptest): export stays a flat palette and
+// import needs no family reconstruction, so export → import → export is identical.
+if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const before=JSON.stringify(exportObj());
+ applyImported(before);
+ const after=JSON.stringify(exportObj());
+ A(before===after,'export → import → export is byte-identical');
+ const obj=JSON.parse(after);
+ A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list');
+ document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh
index 44990588..2f46602c 100755
--- a/scripts/theme-studio/run-tests.sh
+++ b/scripts/theme-studio/run-tests.sh
@@ -53,7 +53,7 @@ CHROME=""
for c in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi
done
-HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest contrasttest safetest healtest familytest counttest baseedittest"
+HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest contrasttest safetest healtest familytest counttest baseedittest roundtriptest"
if [ "$NO_BROWSER" = 1 ]; then
skip_msg "browser hash gates (--no-browser)"
elif [ -z "$CHROME" ]; then
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 7d2f7b05..1a0b72c7 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -807,6 +807,9 @@ function renderPalette(){
else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex;
sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);}
});
+ // The too-similar warning stays on the full flat palette: a generated ramp's
+ // steps are a stepL apart (well above the warning's ΔE threshold), so they never
+ // trigger it, and any pair that does is a genuine near-duplicate worth flagging.
sortFamilies(families).forEach(f=>{
const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base;
f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));});
@@ -1772,4 +1775,15 @@ if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{i
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
document.title='BASEEDITTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Round-trip gate (open with #roundtriptest): export stays a flat palette and
+// import needs no family reconstruction, so export → import → export is identical.
+if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const before=JSON.stringify(exportObj());
+ applyImported(before);
+ const after=JSON.stringify(exportObj());
+ A(before===after,'export → import → export is byte-identical');
+ const obj=JSON.parse(after);
+ A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list');
+ document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
</script> \ No newline at end of file
diff --git a/todo.org b/todo.org
index 282ab967..b389790a 100644
--- a/todo.org
+++ b/todo.org
@@ -93,19 +93,21 @@ Phase 5 (commit =843bbf08=). The OKLCH picker gets a "safe for" selector over th
Commit =23926837=. README documents the ramp controls and defaults, the worst-case floor / limiting foreground, the five covered faces, the safe-lightness guidance, and WCAG-drives-PASS-FAIL with APCA as a diagnostic; the browser-gate list is updated. =make theme-studio-test= carries all new node tests and the #ramptest/#contrasttest/#safetest gates. All acceptance criteria met.
** TODO [#B] theme-studio color families :feature:theme-studio:
-Show the palette as hue-grouped strips (dark→light) over the existing flat, individually-editable palette. Grouping is by OKLCH hue from the hex, so renaming a color never moves it. A per-strip count control generates a symmetric ramp (N → base ±N) from the strip's most-saturated color; regenerate is authoritative, repointing surviving-step references by lightness rank and leaving removed-step references a visible "(gone)". The ground strip is synthesized from the bg/fg assignments and pinned first; the standalone ramp panel is removed. Designed in [[file:docs/theme-studio-color-families-spec.org][docs/theme-studio-color-families-spec.org]]. Codex-reviewed Ready 2026-06-10 after response folded: pivoted from name-derived families to hex-derived families over a flat palette, which designs out the name-grammar/import-inference and chip-ownership blockers. All review findings dispositioned; both open decisions resolved. Builds on and supersedes the palette-ramps v1 ramp UI. Six phases below; manual aesthetic checks under the Manual testing parent.
+Show the palette as hue-grouped strips (dark→light) over the existing flat, individually-editable palette. Grouping is by OKLCH hue from the hex, so renaming a color never moves it. A per-strip count control generates a symmetric ramp (N → base ±N) from the strip's most-saturated color; regenerate is authoritative, repointing surviving-step references by lightness rank and leaving removed-step references a visible "(gone)". The ground strip is synthesized from the bg/fg assignments and pinned first; the standalone ramp panel is removed. Designed in [[file:docs/theme-studio-color-families-spec.org][docs/theme-studio-color-families-spec.org]]. Codex-reviewed Ready 2026-06-10 after response folded: pivoted from name-derived families to hex-derived families over a flat palette, which designs out the name-grammar/import-inference and chip-ownership blockers. All review findings dispositioned; both open decisions resolved. Builds on and supersedes the palette-ramps v1 ramp UI.
+
+All six phases landed 2026-06-10 (commits ebe18d51, 74db9a52, 111687b0, e7ae18c4, 77783126, f6ab0001, 9daeff15, and the Phase 6 commit); =make theme-studio-test= green (98 node tests, 16 browser gates). Code-complete and self-verified. The hue-adjacent warm-color grouping limitation is filed as a separate research task (=~/color-sorting.org=). Remaining: the manual aesthetic/fidelity sign-off under the Manual testing parent (hue grouping reads right, regenerate-replace reads as deliberate, removed-step "(gone)" is clear). Mark this DONE once that passes.
*** 2026-06-10 Wed @ 01:17:45 -0500 Family model core landed
-Phase 1 (commit =ebe18d51=, grouping reworked in =<this commit>=). =familiesFromPalette=, =regenFamily=, =rankByLightness=, =stepRepointPlan= in app-core.js, pure and hex-derived. Grouping started as gap-clustering + flat neutral threshold; after the design discussion it became nearest-hue-anchor bucketing (no single-linkage chaining) + a lightness-scaled neutral threshold (pale tints keep their hue, mid grays go neutral). regenFamily handles n=0 without ramp()'s clamp; stepRepointPlan maps survivors / lists removed by signed lightness rank. 20 node tests including the green/yellow split and the no-chaining case. Open: hue-adjacent warm colors still merge — research task above (=~/color-sorting.org=).
+Phase 1 (commit =ebe18d51=, grouping reworked in =77783126=). =familiesFromPalette=, =regenFamily=, =rankByLightness=, =stepRepointPlan= in app-core.js, pure and hex-derived. Grouping started as gap-clustering + flat neutral threshold; after the design discussion it became nearest-hue-anchor bucketing (no single-linkage chaining) + a lightness-scaled neutral threshold (pale tints keep their hue, mid grays go neutral). regenFamily handles n=0 without ramp()'s clamp; stepRepointPlan maps survivors / lists removed by signed lightness rank. 20 node tests including the green/yellow split and the no-chaining case. Open: hue-adjacent warm colors still merge — research task above (=~/color-sorting.org=).
*** 2026-06-10 Wed @ 01:17:45 -0500 Family sort core landed
Phase 2 (commit =74db9a52=). =sortFamilies=/=sortFamilyMembers=: neutrals first, then chromatic by base hue (rounded so a hue hair doesn't outrank lightness), ties by base lightness then hex; members dark→light. Display-only; stored palette order untouched. 4 node tests.
*** 2026-06-10 Wed @ 01:17:45 -0500 Family-strip rendering landed
Phase 3 (commit =111687b0=, columns =e7ae18c4=). renderPalette restructured into the pinned ground strip + hue-sorted family columns (top→bottom dark→light), chips keep per-chip rename/remove/select, move-arrows/drag dropped. #familytest gate locks the structure + rename-stays-in-strip. Existing palette flows stay green.
-*** TODO [#B] Count control + regenerate :solo:
-Phase 4. Per-strip count input (0-4). On change: =regenFamily=, apply =stepRepointPlan= (repoint survivors via =repointHex=, leave removed refs "(gone)"), update PALETTE, re-render. New browser gate: count up adds symmetric steps; count down drops extremes and a ref to a dropped step reads "(gone)" while a ref to a surviving step follows the new hex. Depends on Phase 1.
-*** TODO [#B] Ground strip + base edit + retire ramp panel :solo:
-Phase 5. Synthesize the ground strip from =MAP.bg=/=MAP.p= (editable, pinned, de-duped by hex, works when the ground hex isn't a palette entry). Base edit regenerates + repoints. Remove the standalone ramp panel and its #ramptest gate; adding a color yields a singleton strip that fans via its count. New gate: ground-strip derivation (incl. assignment-only ground hex) + base-edit repoint.
-*** TODO [#B] Warnings, seeding, export, README close-out :solo:
-Phase 6. Keep =paletteWarnings= on the flattened palette but exempt adjacent same-family ramp steps from the too-similar warning. Confirm =seedPkgmap= still reads the flat palette unchanged. Confirm export emits the flat palette unchanged and import needs no reconstruction; gate an import→render→export round-trip leaving the JSON identical. Update README (families, ground strip, regenerate, removed-step refs, ramp-panel removal). Closes the README + round-trip acceptance criteria.
+*** 2026-06-10 Wed @ 01:17:45 -0500 Count control + regenerate landed
+Phase 4 (commit =f6ab0001=). Per-chromatic-strip count input (0-4); setting N regenerates the family as base ±N, repointing survivor references by lightness rank and leaving removed-step references on their now-gone hex. Also fixed the neutral-threshold curve to taper at both lightness ends (symmetric Munsell) so chroma-eased dark/light extremes keep their hue. #counttest gate covers count up/down + the survivor/removed reference behavior.
+*** 2026-06-10 Wed @ 01:17:45 -0500 Base edit + retire ramp panel landed
+Phase 5 (commit =9daeff15=). Editing a family base recolors the whole family (shared =regenFamilyInPlace= with the count control); editing a ground swatch writes the bg/fg assignment. The standalone ramp panel (button, panel, JS, CSS, #ramptest) is removed — fan a color via its column's count instead. #baseedittest gate covers base-edit recolor + reference follow + the bg-swatch edit.
+*** 2026-06-10 Wed @ 01:17:45 -0500 Warnings, seeding, export, README close-out landed
+Phase 6 (commit =<this>=). Export stays a flat palette and import needs no reconstruction (#roundtriptest: export→import→export byte-identical). =seedPkgmap= reads the flat palette unchanged. The too-similar warning stays on the full palette — the planned ramp-step exemption was dropped after analysis: ramp steps are a stepL apart (well above the ΔE threshold) so they never warn, and exempting same-family pairs would hide genuine near-duplicates (caught by #deltatest). README documents families, the ground strip, the count control/regenerate, removed-step references, and the ramp-panel removal.
** TODO [#B] Color-family grouping for hue-adjacent warm colors :feature:theme-studio:research:
The hue-anchor + lightness-scaled-threshold grouping (shipped in color families) fixed the neutrals and pale tints but can't cleanly separate hue-adjacent warm colors: this palette's olive-greens (~110-120° OKLCH) sit right on the golds (~85-95°), so by hue they merge, and a ramp that drifts in hue can split across an anchor boundary. The problem, the four approaches tried, why each failed, and directions to research (2D chromaticity clustering, lightness-aware hue grouping, ramp detection, perceptual color-naming models, an optional hex-derived family hint) are written up in =~/color-sorting.org=. Craig is finding someone to comment. Pick the work back up from that doc.