From ebe18d51ad99fe0a5916516c47d5dda3315e9add Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 10 Jun 2026 00:16:11 -0500 Subject: feat(theme-studio): add the color-families model core Four pure functions in app-core.js, all derived from the hex so renaming never moves a color. familiesFromPalette groups a flat palette into the ground strip (the bg/fg assignment hexes, pinned, de-duped) plus hue families: near-neutrals split off by a chroma threshold, the rest cluster by hue proximity with a 25-degree gap and a 360 wrap, each family's base its most-saturated member. regenFamily returns a family's symmetric ramp around the base (n=0 is the base alone, handled without ramp()'s 1-4 clamp). rankByLightness gives each current member a signed offset from the base, and stepRepointPlan maps old positions to new ones across a regenerate, listing the positions that drop out so the caller can leave their references a visible "(gone)". Phase 1 of the color-families spec, pure logic, no UI. 13 node tests cover the gap split/merge, neutrals, absent and de-duped ground hexes, n=0, lightness ranking, and the survivor/removed repoint split. Suite 78 to 91 green. --- scripts/theme-studio/theme-studio.html | 87 ++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) (limited to 'scripts/theme-studio/theme-studio.html') diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 529f5d30..1716db9b 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -526,6 +526,93 @@ function lMax(hue,chroma,fgSet,target){ for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;} return {L:loL,status:at(loL).clamped?'clamp':'ok'}; } + +// --- 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. + +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 +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; +} +// 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)({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. +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=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({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}; +} // Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from // app-util.js. textOn uses rl from the colormath core above. // Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status -- cgit v1.2.3