diff options
Diffstat (limited to 'scripts/theme-studio')
| -rw-r--r-- | scripts/theme-studio/app-core.js | 41 | ||||
| -rw-r--r-- | scripts/theme-studio/test-families.mjs | 30 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 41 |
3 files changed, 66 insertions, 46 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 |
