From 77783126c8e35d5880a3e16a0014fc727f59b00a Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 10 Jun 2026 01:18:20 -0500 Subject: 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. --- scripts/theme-studio/app-core.js | 41 +++++++++++++++++----------------- scripts/theme-studio/test-families.mjs | 30 +++++++++++++++++++++---- scripts/theme-studio/theme-studio.html | 41 +++++++++++++++++----------------- todo.org | 15 ++++++++----- 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;igap)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;igap){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=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)({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 { - 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;igap)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;igap){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=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)({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=). =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. -- cgit v1.2.3