aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-10 01:18:20 -0500
committerCraig Jennings <c@cjennings.net>2026-06-10 01:18:20 -0500
commit77783126c8e35d5880a3e16a0014fc727f59b00a (patch)
tree2cf11ad85a2ac99c3bf30d2ebd7c7b1d5482fadd
parente7ae18c4731d5576747679814befd56eadc2d461 (diff)
downloaddotemacs-77783126c8e35d5880a3e16a0014fc727f59b00a.tar.gz
dotemacs-77783126c8e35d5880a3e16a0014fc727f59b00a.zip
feat(theme-studio): group families by hue anchor with a lightness-scaled neutral cut
Replace gap-based hue clustering and the flat neutral threshold. Chromatic colors now bucket by nearest perceptual hue anchor (red, orange, yellow, green, teal, blue, purple, pink), so adjacent categories stay separate by construction and there's no single-linkage chaining merging them through intermediate tones. The neutral cut is lightness-scaled rather than flat: a color reads as neutral below a chroma that's highest in the mid-tones and tapers toward the light end, so a faint mid gray goes neutral while an equally-faint pale tint keeps its hue. This fixes the two concrete problems: the grays and steels consolidate into one neutral column, and pale tints (light blues) stay with their hue instead of falling into the grays. What it doesn't fix is hue-adjacent warm colors: this palette's olive-greens sit on top of the golds in OKLCH hue, so they still group together, and a ramp that drifts in hue can split across an anchor boundary. That's a real property of the colors, not a bug, and it's filed for research (a writeup of the problem and the four approaches tried lives outside the repo; the task points to it). 20 family node tests including the yellow/green split and the no-chaining case; suite green.
-rw-r--r--scripts/theme-studio/app-core.js41
-rw-r--r--scripts/theme-studio/test-families.mjs30
-rw-r--r--scripts/theme-studio/theme-studio.html41
-rw-r--r--todo.org15
4 files changed, 75 insertions, 52 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index e6d90039..0d1ce999 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -127,24 +127,20 @@ function lMax(hue,chroma,fgSet,target){
// truth; these pure functions group it, regenerate a family's ramp, and plan the
// assignment re-point across a regenerate.
-const NEUTRAL_C=0.02; // OKLCH chroma below this has no meaningful hue (neutral)
-const HUE_GAP=25; // a hue gap wider than this (degrees) splits two families
+// Perceptual hue-category centers (OKLCH degrees). A chromatic color joins the
+// family of its nearest anchor, so adjacent categories (yellow vs green) stay
+// separate by construction and there's no single-linkage chaining across them.
+const HUE_ANCHORS=[30,65,100,145,200,255,310,350]; // red,orange,yellow,green,teal,blue,purple,pink
function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;}
-// Split hue-bearing items (each {H,...}) into clusters by hue proximity: sort
-// around the circle and cut wherever the gap to the next item exceeds HUE_GAP,
-// handling the 360 wrap so a family straddling 0 stays together.
-function clusterByHue(items,gap){
- if(items.length<=1)return items.length?[items]:[];
- const s=[...items].sort((a,b)=>a.H-b.H),cuts=[];
- for(let i=0;i<s.length;i++){const d=i<s.length-1?s[i+1].H-s[i].H:(s[0].H+360)-s[i].H;if(d>gap)cuts.push(i);}
- if(!cuts.length)return [s];
- const start=(cuts[cuts.length-1]+1)%s.length,rot=[...s.slice(start),...s.slice(0,start)],out=[];
- let cur=[rot[0]];
- for(let i=1;i<rot.length;i++){let d=rot[i].H-rot[i-1].H;if(d<0)d+=360;if(d>gap){out.push(cur);cur=[rot[i]];}else cur.push(rot[i]);}
- out.push(cur);return out;
-}
+// Nearest hue anchor to H, by circular distance.
+function nearestAnchor(H){let best=HUE_ANCHORS[0],bd=999;for(const a of HUE_ANCHORS){let d=Math.abs(H-a);d=Math.min(d,360-d);if(d<bd){bd=d;best=a;}}return best;}
+// A color reads as neutral below this chroma. Lightness-scaled (the Munsell
+// insight): the mid-tones need more chroma to read as a hue, so a faint warm gray
+// at mid lightness is neutral while an equally-faint pale tint at high lightness
+// keeps its hue. Highest near mid lightness, tapering toward the light end.
+function neutralThreshold(L){const HI=0.6,LO=0.85,CMAX=0.04,CMIN=0.015;if(L<=HI)return CMAX;if(L>=LO)return CMIN;return CMAX-(L-HI)/(LO-HI)*(CMAX-CMIN);}
// A family from its members: base is the most-saturated member (tie toward
// mid-lightness), the anchor for a generated ramp.
function makeFamily(ms,neutral){
@@ -152,24 +148,27 @@ function makeFamily(ms,neutral){
for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m;
return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))};
}
-// Group a flat palette into the ground strip plus hue families. ground is
-// {bg,fg}: those two hexes form the pinned ground strip even when absent from the
-// palette, and a palette chip at a ground hex is not duplicated into a family.
+// Group a flat palette into the ground strip plus families. ground is {bg,fg}:
+// those two hexes form the pinned ground strip even when absent from the palette,
+// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals
+// (chroma below the lightness-scaled threshold) form one neutral family; the rest
+// bucket by nearest hue anchor.
function familiesFromPalette(palette,ground){
const bg=ground&&ground.bg,fg=ground&&ground.fg;
const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase()));
const groundStrip=[];
if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)});
if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)});
- const neutrals=[],chromatic=[];
+ const neutrals=[],buckets=new Map();
for(const [hex,name] of palette){
if(gset.has(hex.toLowerCase()))continue;
const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H};
- (c.C<NEUTRAL_C?neutrals:chromatic).push(m);
+ if(c.C<neutralThreshold(c.L))neutrals.push(m);
+ else{const a=nearestAnchor(c.H);if(!buckets.has(a))buckets.set(a,[]);buckets.get(a).push(m);}
}
const families=[];
if(neutrals.length)families.push(makeFamily(neutrals,true));
- for(const cl of clusterByHue(chromatic,HUE_GAP))families.push(makeFamily(cl,false));
+ for(const ms of buckets.values())families.push(makeFamily(ms,false));
return {ground:groundStrip,families};
}
// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs
index 14e80c9b..6d436394 100644
--- a/scripts/theme-studio/test-families.mjs
+++ b/scripts/theme-studio/test-families.mjs
@@ -21,19 +21,41 @@ test('familiesFromPalette: Normal — separated hues split into one family each'
for (const f of families) assert.equal(f.members.length, 1);
});
-test('familiesFromPalette: Boundary — hues within the gap stay one family', () => {
- const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; // 6° apart < 25°
+test('familiesFromPalette: Boundary — hues sharing an anchor stay one family', () => {
+ const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; // both nearest the blue anchor
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
assert.equal(families.length, 1, 'a near hue-pair is one family');
assert.equal(families[0].members.length, 2);
});
-test('familiesFromPalette: Boundary — hues past the gap split', () => {
- const pal = [at(0.6, 0.1, 250, 'b'), at(0.6, 0.1, 200, 'c')]; // 50° apart > 25°
+test('familiesFromPalette: Boundary — different anchors split (blue vs teal)', () => {
+ const pal = [at(0.6, 0.1, 255, 'b'), at(0.6, 0.1, 200, 'c')];
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
assert.equal(families.length, 2);
});
+test('familiesFromPalette: Normal — yellow and green land in separate families', () => {
+ const pal = [at(0.7, 0.12, 100, 'yellow'), at(0.6, 0.12, 145, 'green')];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(families.length, 2, 'yellow and green are separate anchors');
+});
+
+test('familiesFromPalette: Boundary — an intermediate chain does not merge yellow into green', () => {
+ // gold, olive, yellow-green, green: anchor assignment buckets by nearest, no single-linkage chaining
+ const pal = [at(0.7, 0.1, 90, 'gold'), at(0.65, 0.1, 110, 'olive'), at(0.6, 0.1, 130, 'yg'), at(0.55, 0.1, 150, 'green')];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(families.length, 2, 'two anchors (yellow, green), not one chained family');
+});
+
+test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => {
+ const paleBlue = oklch2hex(0.9, 0.03, 255).hex; // light, faint -> still blue
+ const midGray = oklch2hex(0.6, 0.025, 100).hex; // mid, faint -> reads neutral
+ const { families } = familiesFromPalette([[paleBlue, 'paleblue'], [midGray, 'graytone']], { bg: '#000000', fg: '#ffffff' });
+ const neutral = families.find(f => f.neutral);
+ assert.ok(neutral && neutral.members.some(m => m.name === 'graytone'), 'mid faint color is neutral');
+ assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'paleblue')), 'pale tint stays chromatic');
+});
+
test('familiesFromPalette: Boundary — near-neutral colors form a separate family', () => {
const pal = [at(0.6, 0.1, 250, 'blue'), at(0.5, 0.004, 250, 'gray')]; // gray below the chroma threshold
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index d0f4d2b0..40cfda8d 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -534,24 +534,20 @@ function lMax(hue,chroma,fgSet,target){
// truth; these pure functions group it, regenerate a family's ramp, and plan the
// assignment re-point across a regenerate.
-const NEUTRAL_C=0.02; // OKLCH chroma below this has no meaningful hue (neutral)
-const HUE_GAP=25; // a hue gap wider than this (degrees) splits two families
+// Perceptual hue-category centers (OKLCH degrees). A chromatic color joins the
+// family of its nearest anchor, so adjacent categories (yellow vs green) stay
+// separate by construction and there's no single-linkage chaining across them.
+const HUE_ANCHORS=[30,65,100,145,200,255,310,350]; // red,orange,yellow,green,teal,blue,purple,pink
function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;}
-// Split hue-bearing items (each {H,...}) into clusters by hue proximity: sort
-// around the circle and cut wherever the gap to the next item exceeds HUE_GAP,
-// handling the 360 wrap so a family straddling 0 stays together.
-function clusterByHue(items,gap){
- if(items.length<=1)return items.length?[items]:[];
- const s=[...items].sort((a,b)=>a.H-b.H),cuts=[];
- for(let i=0;i<s.length;i++){const d=i<s.length-1?s[i+1].H-s[i].H:(s[0].H+360)-s[i].H;if(d>gap)cuts.push(i);}
- if(!cuts.length)return [s];
- const start=(cuts[cuts.length-1]+1)%s.length,rot=[...s.slice(start),...s.slice(0,start)],out=[];
- let cur=[rot[0]];
- for(let i=1;i<rot.length;i++){let d=rot[i].H-rot[i-1].H;if(d<0)d+=360;if(d>gap){out.push(cur);cur=[rot[i]];}else cur.push(rot[i]);}
- out.push(cur);return out;
-}
+// Nearest hue anchor to H, by circular distance.
+function nearestAnchor(H){let best=HUE_ANCHORS[0],bd=999;for(const a of HUE_ANCHORS){let d=Math.abs(H-a);d=Math.min(d,360-d);if(d<bd){bd=d;best=a;}}return best;}
+// A color reads as neutral below this chroma. Lightness-scaled (the Munsell
+// insight): the mid-tones need more chroma to read as a hue, so a faint warm gray
+// at mid lightness is neutral while an equally-faint pale tint at high lightness
+// keeps its hue. Highest near mid lightness, tapering toward the light end.
+function neutralThreshold(L){const HI=0.6,LO=0.85,CMAX=0.04,CMIN=0.015;if(L<=HI)return CMAX;if(L>=LO)return CMIN;return CMAX-(L-HI)/(LO-HI)*(CMAX-CMIN);}
// A family from its members: base is the most-saturated member (tie toward
// mid-lightness), the anchor for a generated ramp.
function makeFamily(ms,neutral){
@@ -559,24 +555,27 @@ function makeFamily(ms,neutral){
for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m;
return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))};
}
-// Group a flat palette into the ground strip plus hue families. ground is
-// {bg,fg}: those two hexes form the pinned ground strip even when absent from the
-// palette, and a palette chip at a ground hex is not duplicated into a family.
+// Group a flat palette into the ground strip plus families. ground is {bg,fg}:
+// those two hexes form the pinned ground strip even when absent from the palette,
+// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals
+// (chroma below the lightness-scaled threshold) form one neutral family; the rest
+// bucket by nearest hue anchor.
function familiesFromPalette(palette,ground){
const bg=ground&&ground.bg,fg=ground&&ground.fg;
const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase()));
const groundStrip=[];
if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)});
if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)});
- const neutrals=[],chromatic=[];
+ const neutrals=[],buckets=new Map();
for(const [hex,name] of palette){
if(gset.has(hex.toLowerCase()))continue;
const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H};
- (c.C<NEUTRAL_C?neutrals:chromatic).push(m);
+ if(c.C<neutralThreshold(c.L))neutrals.push(m);
+ else{const a=nearestAnchor(c.H);if(!buckets.has(a))buckets.set(a,[]);buckets.get(a).push(m);}
}
const families=[];
if(neutrals.length)families.push(makeFamily(neutrals,true));
- for(const cl of clusterByHue(chromatic,HUE_GAP))families.push(makeFamily(cl,false));
+ for(const ms of buckets.values())families.push(makeFamily(ms,false));
return {ground:groundStrip,families};
}
// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
diff --git a/todo.org b/todo.org
index ae6fa1a6..282ab967 100644
--- a/todo.org
+++ b/todo.org
@@ -94,12 +94,12 @@ Commit =23926837=. README documents the ramp controls and defaults, the worst-ca
** 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.
-*** TODO [#B] Family model core :solo:
-Phase 1. In app-core.js, pure: =familiesFromPalette(palette, groundHexes)= → ground strip (from the two ground hexes, de-duped) + hue-clustered families (gap-split at 25°, neutrals at C<0.02 separated), each with a base (most-saturated, tie toward mid-lightness); =regenFamily(baseHex, n, opts)= → members (n=0 → base only, handled without =ramp()='s 1-4 clamp; n≥1 → ramp base±n); =stepRepointPlan(oldMembers, newMembers)= → {map: [[oldHex,newHex]], removed: [hex]} by signed lightness rank. Node tests: spectrum splits, near-pair stays, neutrals/ground-absent, n=0, repoint survivors + removed. Verify: =make theme-studio-test= green.
-*** TODO [#B] Family sort core :solo:
-Phase 2. =sortFamilies= orders ground-first, neutrals (C<0.02) next, chromatic by base hue (ties by base lightness then hex); within-strip by OKLCH lightness. Node tests: spectrum order, all-neutral, ties, the 25° gap boundary. Sorting is display-only; the stored palette order is untouched.
-*** TODO [#B] Family-strip rendering :solo:
-Phase 3. Render the palette panel as the pinned ground strip + hue-sorted family strips (base marked, dark→light), reusing chip styling; the existing per-chip rename/remove/edit keep working over the still-flat palette. No count control yet. Verify: headless screenshot + the panel still drives the existing flows.
+*** 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=).
+*** 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:
@@ -107,6 +107,9 @@ Phase 5. Synthesize the ground strip from =MAP.bg=/=MAP.p= (editable, pinned, de
*** 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.
+** 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.
+
** TODO [#C] Internet radio now-playing song :feature:music:emms:
Show the currently-playing song while streaming an internet radio station. Lives in =modules/music-config.el= (EMMS + MPV backend, M3U radio stations). The track title comes from the stream's ICY metadata — EMMS exposes it via =emms-track-description= / =emms-playing-time= and updates it on the metadata-change hook; MPV reports the ICY title too. Add an option to show the song in the minibuffer (e.g. echo on track change, or an on-demand command). Consider also a mode-line indicator as a second surface.