diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-10 01:38:40 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-10 01:38:40 -0500 |
| commit | c175e2bee24f4cba841b9bd57f53dd36c7bc25ef (patch) | |
| tree | 9918475c5d74f277188940e84f81626f71f99dff /scripts/theme-studio | |
| parent | 9daeff15182d98ab28e201a17fe8c1cfa7c4e6f8 (diff) | |
| download | dotemacs-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.
Diffstat (limited to 'scripts/theme-studio')
| -rw-r--r-- | scripts/theme-studio/README.md | 70 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 14 | ||||
| -rwxr-xr-x | scripts/theme-studio/run-tests.sh | 2 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 14 |
4 files changed, 72 insertions, 28 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 |
