diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-10 00:16:11 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-10 00:16:11 -0500 |
| commit | ebe18d51ad99fe0a5916516c47d5dda3315e9add (patch) | |
| tree | 8ac92eab954f2cfe230938c15373002a6481ee24 /scripts | |
| parent | 4f4ceb3360e56ea2b1fb6f303c645a0b2205483c (diff) | |
| download | dotemacs-ebe18d51ad99fe0a5916516c47d5dda3315e9add.tar.gz dotemacs-ebe18d51ad99fe0a5916516c47d5dda3315e9add.zip | |
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.
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/theme-studio/app-core.js | 89 | ||||
| -rw-r--r-- | scripts/theme-studio/test-families.mjs | 113 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 87 |
3 files changed, 288 insertions, 1 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 83f8402d..70054255 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -121,4 +121,91 @@ 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. + +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;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; +} +// 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 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<NEUTRAL_C?neutrals:chromatic).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)); + 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}; +} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan }; diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs new file mode 100644 index 00000000..071c8307 --- /dev/null +++ b/scripts/theme-studio/test-families.mjs @@ -0,0 +1,113 @@ +// Unit tests for the color-families model (app-core.js): grouping a flat palette +// into hue families, regenerating a family's ramp, ranking members by lightness, +// and planning the assignment re-point across a regenerate. Phase 1 of the +// color-families spec. Pure, no DOM. Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan } from './app-core.js'; +import { oklch2hex } from './colormath.js'; + +// Build a palette entry at a controlled OKLCH hue so clustering is deterministic. +const at = (L, C, H, name) => [oklch2hex(L, C, H).hex, name || ('c' + H)]; + +// --- familiesFromPalette ---------------------------------------------------- + +test('familiesFromPalette: Normal — separated hues split into one family each', () => { + const pal = [at(0.6, 0.1, 30, 'red'), at(0.6, 0.1, 150, 'green'), at(0.6, 0.1, 270, 'blue')]; + const { ground, families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 3, 'three separated hues -> three families'); + assert.equal(ground.length, 2, 'ground strip carries bg and fg'); + 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° + 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° + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 2); +}); + +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' }); + const neutral = families.find(f => f.neutral); + assert.ok(neutral, 'a neutral family exists'); + assert.ok(neutral.members.some(m => m.name === 'gray')); + assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'blue'))); +}); + +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' }); + assert.equal(ground.length, 2); + assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a' && g.role === 'bg')); + assert.ok(ground.some(g => g.role === 'fg')); +}); + +test('familiesFromPalette: Boundary — a chip at a ground hex is not duplicated into a family', () => { + const pal = [['#0d0b0a', 'ground'], at(0.6, 0.1, 250, 'blue')]; + const { ground, families } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a')); + assert.ok(!families.some(f => f.members.some(m => m.hex.toLowerCase() === '#0d0b0a')), 'ground chip stays out of families'); +}); + +// --- regenFamily ------------------------------------------------------------ + +test('regenFamily: Normal — n steps each side plus the base, ordered by offset', () => { + const r = regenFamily('#67809c', 2); + assert.equal(r.members.length, 5); + assert.deepEqual(r.members.map(m => m.offset), [-2, -1, 0, 1, 2]); + assert.equal(r.members.find(m => m.offset === 0).hex, '#67809c'); +}); + +test('regenFamily: Boundary — n=0 is the base alone, no ramp() clamp to 1', () => { + const r = regenFamily('#67809c', 0); + assert.deepEqual(r.members, [{ hex: '#67809c', offset: 0, clamped: false }]); +}); + +test('regenFamily: Error — a malformed base returns a structured bad-hex', () => { + assert.deepEqual(regenFamily('nope', 2), { members: [], error: 'bad-hex' }); +}); + +// --- rankByLightness -------------------------------------------------------- + +test('rankByLightness: Normal — offsets are signed distance from the base by lightness', () => { + const members = regenFamily('#67809c', 2).members.map(m => m.hex); + const ranked = rankByLightness(members, '#67809c'); + const base = ranked.find(m => m.hex === '#67809c'); + assert.equal(base.offset, 0); + const sorted = [...ranked].sort((a, b) => a.offset - b.offset); + assert.deepEqual(sorted.map(m => m.offset), [-2, -1, 0, 1, 2]); +}); + +test('rankByLightness: Boundary — a base not among the members ranks by nearest lightness', () => { + const members = ['#222222', '#888888', '#dddddd']; + const ranked = rankByLightness(members, '#8a8a8a'); // near the mid member + const mid = ranked.find(m => m.hex === '#888888'); + assert.equal(mid.offset, 0, 'nearest-lightness member is the base rank'); +}); + +// --- stepRepointPlan -------------------------------------------------------- + +test('stepRepointPlan: Normal — surviving offsets map old->new, changed hex only', () => { + const oldR = [{ hex: '#111111', offset: -1 }, { hex: '#222222', offset: 0 }, { hex: '#333333', offset: 1 }]; + const neu = [{ hex: '#111111', offset: -1 }, { hex: '#aaaaaa', offset: 0 }, { hex: '#444444', offset: 1 }]; + const { map, removed } = stepRepointPlan(oldR, neu); + assert.deepEqual(removed, []); + assert.deepEqual(map, [['#222222', '#aaaaaa'], ['#333333', '#444444']]); // -1 unchanged, skipped +}); + +test('stepRepointPlan: Boundary — an offset with no new counterpart is removed, not repointed', () => { + const oldR = [{ hex: '#000033', offset: -3 }, { hex: '#222222', offset: 0 }]; + const neu = [{ hex: '#222222', offset: 0 }]; // count dropped, -3 gone + const { map, removed } = stepRepointPlan(oldR, neu); + assert.deepEqual(map, []); + assert.deepEqual(removed, ['#000033']); +}); 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;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; +} +// 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 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<NEUTRAL_C?neutrals:chromatic).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)); + 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}; +} // 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 |
