From 04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 10 Jun 2026 01:56:15 -0500 Subject: feat(theme-studio): group families by lightness-conditioned complete linkage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/theme-studio/app-core.js | 57 +++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 15 deletions(-) (limited to 'scripts/theme-studio/app-core.js') 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[m]); + const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b)))); + for(;;){ + let best=null; + for(let i=0;i1)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