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 +++++++++++++++++----------------- 3 files changed, 66 insertions(+), 46 deletions(-) (limited to 'scripts/theme-studio') 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