diff options
Diffstat (limited to 'scripts/theme-studio/app-core.js')
| -rw-r--r-- | scripts/theme-studio/app-core.js | 133 |
1 files changed, 132 insertions, 1 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 83f8402d..60ee1410 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -121,4 +121,135 @@ function lMax(hue,chroma,fgSet,target){ return {L:loL,status:at(loL).clamped?'clamp':'ok'}; } -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES }; +// --- color families (color-families spec, Phase 1) --------------------------- +// Families are a display grouping derived from the hex every render — never from +// names — so renaming a color can't move it. The flat palette stays the editable +// truth; these pure functions group it, regenerate a family's ramp, and plan the +// assignment re-point across a regenerate. + +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);} + +// A color reads as neutral below this chroma. Lightness-scaled (the Munsell +// 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){ + let base=ms[0]; + 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 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 +// 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=[],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}; + (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m); + } + const families=[]; + if(neutrals.length)families.push(makeFamily(neutrals,true)); + 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 +// base alone (without ramp()'s 1-4 clamp), n>=1 is base plus ramp() steps, sorted +// by offset. {members:[{hex,offset,clamped}]} or {members:[],error:'bad-hex'}. +function regenFamily(baseHex,n,opts){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + if(!hex)return {members:[],error:'bad-hex'}; + const k=Math.min(4,Math.max(0,Math.round(n||0))); + if(k===0)return {members:[{hex,offset:0,clamped:false}]}; + const r=ramp(hex,Object.assign({},opts,{n:k})); + if(r.error)return {members:[],error:r.error}; + const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset); + return {members}; +} +// Rank a family's current member hexes by lightness and give each a signed offset +// from the base (the matching hex, or the nearest by lightness if the base isn't +// present). Lets a regenerate match old positions to new ramp offsets. +function rankByLightness(memberHexes,baseHex){ + const items=memberHexes.map(h=>({hex:h,L:oklchOf(h).L})).sort((a,b)=>a.L-b.L); + let bi=items.findIndex(m=>m.hex.toLowerCase()===(baseHex||'').toLowerCase()); + if(bi<0){const bl=oklchOf(baseHex).L;let best=Infinity;items.forEach((m,i)=>{const d=Math.abs(m.L-bl);if(d<best){best=d;bi=i;}});} + return items.map((m,i)=>({hex:m.hex,offset:i-bi})); +} +// Plan the assignment re-point for a regenerate: for each old ranked member, the +// new member at the same offset is the same position. {map:[[old,new]]} for +// positions whose hex changed; {removed:[hex]} for positions with no new +// counterpart (the caller leaves their references a visible "(gone)"). +function stepRepointPlan(oldRanked,newMembers){ + const byOff=new Map(newMembers.map(m=>[m.offset,m.hex])),map=[],removed=[]; + for(const o of oldRanked){ + const nh=byOff.get(o.offset); + if(nh===undefined)removed.push(o.hex); + else if(nh.toLowerCase()!==o.hex.toLowerCase())map.push([o.hex,nh]); + } + return {map,removed}; +} + +// Order a family's members dark to light by OKLCH lightness. +function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});} +// Order families for display: neutrals first (by base lightness), then chromatic +// by base hue, ties broken by base lightness then base hex. Each family's members +// are lightness-sorted. Display-only — the stored palette order is untouched. +function sortFamilies(families){ + const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};}); + keyed.sort((a,b)=>{ + if(a.neutral!==b.neutral)return a.neutral?-1:1; + if(a.neutral&&b.neutral)return a.L-b.L; + const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness + if(ah!==bh)return ah-bh; + if(a.L!==b.L)return a.L-b.L; + return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0; + }); + return keyed.map(k=>sortFamilyMembers(k.f)); +} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies, sortFamilyMembers }; |
