aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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.