aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/theme-studio.html
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 /scripts/theme-studio/theme-studio.html
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.
Diffstat (limited to 'scripts/theme-studio/theme-studio.html')
-rw-r--r--scripts/theme-studio/theme-studio.html41
1 files changed, 20 insertions, 21 deletions
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