diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-10 01:56:15 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-10 01:56:15 -0500 |
| commit | 04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e (patch) | |
| tree | c34f9d75c4d005f31df7e7b42fa2684c5a688531 /scripts/theme-studio | |
| parent | 980cbe1a3a7a3930690b7780ea2c6aa674e9f2a0 (diff) | |
| download | dotemacs-04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e.tar.gz dotemacs-04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e.zip | |
feat(theme-studio): group families by lightness-conditioned complete linkage
Replace the hue-anchor bucketing and the tent neutral threshold with the model two independent reviews of color-sorting.org converged on (Codex and Fable, with Fable's harness measuring pairwise F1 0.63 → 0.96 on the real palette).
Chromatic colors now cluster by complete-linkage agglomeration on a lightness-conditioned hue distance: hue must match tightly at equal lightness and may drift across a lightness gap, because a tonal ramp drifts in hue with lightness by design. A low-chroma noise term widens the tolerance where hue is ill-defined, and a chroma clause keeps a vivid accent out of a soft same-hue family. Complete linkage makes single-linkage chaining structurally impossible. The neutral threshold is floored at both ends instead of tapering to zero, which fixes two real defects: pale warm grays (gray+1, gray+2) that leaked into a color column, and pure white (C=0 at L=1) that evaded a zero threshold.
On the sterling/distinguished palette this separates the gold and olive ramps (the green/yellow complaint), keeps the red and blue ramps whole including drifted tints, isolates intense-red, and consolidates every gray and steel into the neutral column. The one residual — pale yellow+2 lands on the olive ramp — is geometrically irreducible from the hex (it sits on the olive trajectory by nearest-neighbor, ramp-line fit, and eye); only its name says gold. That needs the deferred per-hex family-hint override.
New node tests cover the gold/olive split, blue pale-tint cohesion, gray/white neutrality, intense-red isolation, and palette-order independence. The count gate now asserts the count action adds all ramp colors to the palette rather than that they all display in one family, since a chroma-eased extreme can sit at the neutral boundary.
Diffstat (limited to 'scripts/theme-studio')
| -rw-r--r-- | scripts/theme-studio/app-core.js | 57 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 5 | ||||
| -rw-r--r-- | scripts/theme-studio/test-families.mjs | 68 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 62 |
4 files changed, 146 insertions, 46 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index cf3e7ff8..60ee1410 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -127,20 +127,48 @@ 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. -// 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;} +function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);} -// 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 tint near either extreme keeps -// its hue. A tent peaking near mid lightness and tapering toward both ends. -function neutralThreshold(L){const PK=0.6,MAX=0.035,d=L<PK?(PK-L)/PK:(L-PK)/(1-PK);return MAX*(1-Math.min(1,d));} +// insight): the mid-tones need more chroma to read as a hue. Floored at both ends +// rather than tapering to zero, so pale warm grays stay neutral (and pure white, +// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay +// colored. Tuned on real palettes (Codex + Fable color-sorting reviews). +function neutralThreshold(L){ + if(L<=0.2)return 0.020; + if(L<0.6)return 0.020+0.015*(L-0.2)/0.4; + if(L<0.85)return 0.035-0.017*(L-0.6)/0.25; + return 0.018; +} +// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL): +// hue must match tightly at equal lightness and may drift across a lightness gap, +// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise +// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma +// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is +// compatible. Source: ~/color-sorting-fable.org. +function pairRatio(a,b){ + const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H); + const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI); + return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL)); +} +// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two +// clusters whose worst cross-pair is most compatible, stopping when no merge has +// every cross-pair compatible. Complete linkage makes single-linkage chaining +// structurally impossible — two ramps can't fuse through their converging pale +// ends because their mid-lightness members stay far apart. +function clusterChromatic(ms){ + let cl=ms.map(m=>[m]); + const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b)))); + for(;;){ + let best=null; + for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};} + if(!best||best.d>1)break; + cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1); + } + return cl; +} // 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,23 +180,22 @@ function makeFamily(ms,neutral){ // 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. +// cluster by lightness-conditioned complete linkage (clusterChromatic). 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=[],buckets=new Map(); + const neutrals=[],chromatic=[]; 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}; - 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);} + (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m); } const families=[]; if(neutrals.length)families.push(makeFamily(neutrals,true)); - for(const ms of buckets.values())families.push(makeFamily(ms,false)); + for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,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/app.js b/scripts/theme-studio/app.js index 92eec644..0b6663ee 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -1135,8 +1135,9 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(! const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex; A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg); setFamilyCount('#67809c',3); - const fam3=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>f.base.toLowerCase()==='#67809c'); - A(fam3&&fam3.members.length===7,'count up to 3 yields 7 members, got '+(fam3&&fam3.members.length)); + const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase()); + const have=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette'); PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); document.title='COUNTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs index 6d436394..c6602aeb 100644 --- a/scripts/theme-studio/test-families.mjs +++ b/scripts/theme-studio/test-families.mjs @@ -21,30 +21,24 @@ 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 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 +test('familiesFromPalette: Boundary — near hues at the same lightness stay one family', () => { + const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; 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 — different anchors split (blue vs teal)', () => { +test('familiesFromPalette: Boundary — well-separated hues split', () => { 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 +test('familiesFromPalette: Boundary — an intermediate chain does not merge gold into green', () => { + // complete linkage requires every cross-pair compatible, so the far endpoints (90° vs 150°) keep the chain from fusing 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'); + assert.equal(families.length, 2, 'not one chained family'); }); test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => { @@ -65,6 +59,56 @@ test('familiesFromPalette: Boundary — near-neutral colors form a separate fami assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'blue'))); }); +// --- real-palette grouping (the hard cases the color-sorting reviews measured) --- + +// The contested region of the distinguished/sterling palette: the gold ramp and +// the olive ramp whose hue ranges nearly touch but whose mid-tones are far apart. +const GOLD = [['#875f00', 'yellow-2'], ['#8e784c', 'yellow-1'], ['#d7af5f', 'yellow'], ['#ffd75f', 'yellow+1']]; +const OLIVE = [['#646d14', 'green-2'], ['#869038', 'green-1'], ['#a4ac64', 'green'], ['#ccc768', 'green+1']]; +const famOf = (families, name) => families.find(f => f.members.some(m => m.name === name)); + +test('familiesFromPalette: Normal — the gold and olive ramps separate', () => { + const { families } = familiesFromPalette([...GOLD, ...OLIVE], { bg: '#000000', fg: '#ffffff' }); + const gold = famOf(families, 'yellow'), olive = famOf(families, 'green'); + assert.notEqual(gold, olive, 'gold and olive are different families'); + assert.ok(!gold.members.some(m => m.name.startsWith('green')), 'gold family has no greens'); + assert.ok(!olive.members.some(m => m.name.startsWith('yellow')), 'olive family has no yellows'); +}); + +test('familiesFromPalette: Normal — the blue ramp stays whole despite pale-tint hue drift', () => { + // blue (H 252), blue+1 (H 231), blue+2 (H 272): low-chroma pale tints swing in hue but belong together + const pal = [['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#d9e2ff', 'blue+2']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + const blue = famOf(families, 'blue'); + assert.equal(blue.members.length, 3, 'all three blues in one family'); +}); + +test('familiesFromPalette: Boundary — pale warm grays and pure white read as neutral', () => { + const pal = [['#b4b1a2', 'gray+1'], ['#d0cbc0', 'gray+2'], ['#ffffff', 'white'], ['#67809c', 'blue']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#f0fef0' }); // fg distinct from the white swatch + const neutral = families.find(f => f.neutral); + for (const n of ['gray+1', 'gray+2', 'white']) assert.ok(neutral.members.some(m => m.name === n), n + ' is neutral'); + assert.ok(famOf(families, 'blue') && !famOf(families, 'blue').neutral, 'blue stays chromatic'); +}); + +test('familiesFromPalette: Boundary — a vivid accent stays out of a soft same-hue family', () => { + // intense-red (C 0.246) vs red (C 0.120) at similar lightness: the chroma clause keeps them apart + const pal = [['#ff2a00', 'intense-red'], ['#d47c59', 'red'], ['#a7502d', 'red-1']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.notEqual(famOf(families, 'intense-red'), famOf(families, 'red'), 'intense-red is its own family'); +}); + +test('familiesFromPalette: Boundary — grouping is independent of palette order', () => { + const base = [...GOLD, ...OLIVE, ['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#969385', 'gray']]; + const key = pal => familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }).families + .map(f => f.members.map(m => m.name).sort().join(',')).sort().join(' | '); + const ref = key(base); + for (const seed of [1, 2, 3]) { // a few deterministic shuffles + const shuffled = base.map((e, i) => [e, ((i + 1) * seed * 7) % base.length]).sort((a, b) => a[1] - b[1]).map(x => x[0]); + assert.equal(key(shuffled), ref, 'shuffle ' + seed + ' yields the same grouping'); + } +}); + test('familiesFromPalette: Boundary — ground hex absent from the palette still forms the strip', () => { const pal = [at(0.6, 0.1, 250, 'blue')]; const { ground } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' }); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 1a0b72c7..33358704 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -512,20 +512,48 @@ 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. -// 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;} +function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);} -// 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 tint near either extreme keeps -// its hue. A tent peaking near mid lightness and tapering toward both ends. -function neutralThreshold(L){const PK=0.6,MAX=0.035,d=L<PK?(PK-L)/PK:(L-PK)/(1-PK);return MAX*(1-Math.min(1,d));} +// insight): the mid-tones need more chroma to read as a hue. Floored at both ends +// rather than tapering to zero, so pale warm grays stay neutral (and pure white, +// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay +// colored. Tuned on real palettes (Codex + Fable color-sorting reviews). +function neutralThreshold(L){ + if(L<=0.2)return 0.020; + if(L<0.6)return 0.020+0.015*(L-0.2)/0.4; + if(L<0.85)return 0.035-0.017*(L-0.6)/0.25; + return 0.018; +} +// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL): +// hue must match tightly at equal lightness and may drift across a lightness gap, +// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise +// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma +// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is +// compatible. Source: ~/color-sorting-fable.org. +function pairRatio(a,b){ + const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H); + const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI); + return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL)); +} +// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two +// clusters whose worst cross-pair is most compatible, stopping when no merge has +// every cross-pair compatible. Complete linkage makes single-linkage chaining +// structurally impossible — two ramps can't fuse through their converging pale +// ends because their mid-lightness members stay far apart. +function clusterChromatic(ms){ + let cl=ms.map(m=>[m]); + const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b)))); + for(;;){ + let best=null; + for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};} + if(!best||best.d>1)break; + cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1); + } + return cl; +} // 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){ @@ -537,23 +565,22 @@ function makeFamily(ms,neutral){ // 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. +// cluster by lightness-conditioned complete linkage (clusterChromatic). 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=[],buckets=new Map(); + const neutrals=[],chromatic=[]; 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}; - 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);} + (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m); } const families=[]; if(neutrals.length)families.push(makeFamily(neutrals,true)); - for(const ms of buckets.values())families.push(makeFamily(ms,false)); + for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false)); return {ground:groundStrip,families}; } // Regenerate a family's members as a symmetric ramp around the base: n=0 is the @@ -1744,8 +1771,9 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(! const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex; A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg); setFamilyCount('#67809c',3); - const fam3=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>f.base.toLowerCase()==='#67809c'); - A(fam3&&fam3.members.length===7,'count up to 3 yields 7 members, got '+(fam3&&fam3.members.length)); + const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase()); + const have=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette'); PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); document.title='COUNTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} |
