aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/app-core.js
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio/app-core.js')
-rw-r--r--scripts/theme-studio/app-core.js274
1 files changed, 172 insertions, 102 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 60ee1410..5da52177 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -9,19 +9,25 @@
// where normHex (app-util.js) and the colormath helpers are already present from
// the bodies inlined above this one.
import { normHex } from './app-util.js';
-import { oklch2hex, srgb2oklab, oklab2oklch, contrast } from './colormath.js';
+import { oklch2hex, srgb2oklab, oklab2oklch, oklab2lrgb, lrgb2hex, inGamut, contrast } from './colormath.js';
// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
+function normalizePkgFace(d,source,palette){
+ d=d||{};
+ const resolve=(v)=>palette?nameToHex(v,palette):v;
+ return {fg:resolve(d.fg)??null,bg:resolve(d.bg)??null,bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit??null,height:d.height||1,box:d.box??null,source:source||d.source||'user'};
+}
+
// Seed the package-face map from the app inventory's per-face defaults.
-function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){const face=row[0],d=row[2]||{};m[app][face]={fg:nameToHex(d.fg,palette),bg:nameToHex(d.bg,palette),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,box:d.box||null,source:'default'};}}return m;}
+function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){m[app][row[0]]=normalizePkgFace(row[2],'default',palette);}}return m;}
// The package faces worth exporting (anything seeded or user-touched), trimmed.
function packagesForExport(map){const out={};for(const app in map){const faces={};for(const face in map[app]){const f=map[app][face];if(f.source==='default'||f.source==='user'||f.source==='cleared'){const o={fg:f.fg,bg:f.bg,bold:f.bold,italic:f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit,source:f.source};if(f.height&&f.height!==1)o.height=f.height;if(f.box)o.box=f.box;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;}
// Merge an imported package block into a face map, filling missing fields.
-function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,box:f.box??null,source:f.source||'user'};}}}
+function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]=normalizePkgFace(f,f.source||'user');}}}
// Effective fg/bg for a package face, following its inherit chain. seen guards
// against an inherit cycle (returns null rather than recursing forever).
@@ -29,7 +35,7 @@ function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[
// Standard swatch-dropdown option list: a default entry, then the palette. When
// cur is set but no longer in the palette, surface it as a "(gone)" entry first.
-function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone) '+cur],...palette])];}
+function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone)'],...palette])];}
// Turn a theme name into a safe filename slug: collapse runs of disallowed
// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
@@ -121,97 +127,138 @@ function lMax(hue,chroma,fgSet,target){
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
+// --- color columns -----------------------------------------------------------
+// Columns are structural, not inferred by color. Generated ramp entries are named
+// base-1/base/base+1 and remain in that base column regardless of their hex. A
+// manually-added color starts as its own singleton column. The flat palette stays
+// the editable truth; these pure functions group it, regenerate a ramp, and plan
// 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);
+function isReservedGroundLikeName(name){return /^(bg|fg)(?:[-_+].+|\d.*)$/i.test(name||'');}
+function isPureEndpointHex(hex){const h=(hex||'').toLowerCase();return h==='#ffffff'||h==='#000000';}
+function interpOklabHex(a,b,t,offset){
+ const lab={L:a.L+(b.L-a.L)*t,a:a.a+(b.a-a.a)*t,b:a.b+(b.b-a.b)*t};
+ const lrgb=oklab2lrgb(lab.L,lab.a,lab.b);
+ return {hex:lrgb2hex(lrgb),offset,clamped:!inGamut(lrgb)};
+}
+function columnStem(name){name=name||'color';if(/^color-\d+$/.test(name))return name;name=name.replace(/[+-]\d+$/,'');return name.replace(/\d+$/,'')||'color';}
+function columnOffset(name){const m=(name||'').match(/([+-]\d+)$/);return m?parseInt(m[1],10):0;}
+function legacyColumnStem(name){return isReservedGroundLikeName(name)?name:columnStem(name);}
+function legacyColumnOffset(name){return isReservedGroundLikeName(name)?0:columnOffset(name);}
+function columnIdOf(entry){return (entry&&entry[2])||legacyColumnStem(entry&&entry[1]);}
+function groundRoleOfEntry(entry,ground){
+ if(!entry)return null;
+ const [hex,name]=entry,col=entry[2],n=(name||'').toLowerCase(),h=(hex||'').toLowerCase();
+ const bg=(ground&&ground.bg||'').toLowerCase(),fg=(ground&&ground.fg||'').toLowerCase();
+ if(/^ground[+-]\d+$/i.test(name||''))return 'step';
+ if(col==='ground'){
+ if(bg&&h===bg)return 'bg';
+ if(fg&&h===fg)return 'fg';
+ return 'step';
+ }
+ if(bg&&h===bg&&(n==='bg'||n==='ground'))return 'bg';
+ if(fg&&h===fg&&n==='fg')return 'fg';
+ return null;
+}
+function nameOfGroundRole(palette,ground,role){
+ const found=palette.find(entry=>groundRoleOfEntry(entry,ground)===role);
+ return found?found[1]:null;
+}
+
+function normalizePaletteEntryCore(entry){
+ const hex=entry&&entry[0],name=(entry&&entry[1])||'color';
+ return [hex,name,(entry&&entry[2])||columnIdOf(entry)];
+}
+
+function groundColumnMembersFromPalette(palette,ground){
+ const byRole={bg:null,fg:null,steps:[]};
+ for(const entry of palette){
+ const role=groundRoleOfEntry(entry,ground);
+ if(role==='bg'||role==='fg')byRole[role]={hex:entry[0],name:entry[1]};
+ else if(role==='step')byRole.steps.push({hex:entry[0],name:entry[1]});
+ }
+ const stepIndex=m=>{const x=(m.name||'').match(/^ground[+-](\d+)$/i);return x?parseInt(x[1],10):Infinity;};
+ byRole.steps.sort((a,b)=>stepIndex(a)-stepIndex(b));
+ return [byRole.bg||{hex:ground&&ground.bg,name:'bg'},...byRole.steps,byRole.fg||{hex:ground&&ground.fg,name:'fg'}].filter(m=>m.hex);
+}
+
+function clearPalettePlan(palette,ground){
+ const normalized=palette.map(normalizePaletteEntryCore),removed=[],keep=[];
+ normalized.filter(entry=>!groundRoleOfEntry(entry,ground)).forEach(([hex,name])=>{if(name)removed.push({hex,name});});
+ const addEndpoint=(role,hex,name)=>{
+ const found=normalized.find(entry=>groundRoleOfEntry(entry,ground)===role);
+ if(found)keep.push(found);else if(hex)keep.push([hex,name,'ground']);
+ };
+ addEndpoint('bg',ground&&ground.bg,'bg');
+ addEndpoint('fg',ground&&ground.fg,'fg');
+ return {palette:keep,removed};
+}
+
+function deletePaletteColumnPlan(palette,ground,columnId){
+ const normalized=palette.map(normalizePaletteEntryCore),removed=[],keep=[];
+ for(const entry of normalized){
+ if(groundRoleOfEntry(entry,ground)||columnIdOf(entry)!==columnId)keep.push(entry);
+ else removed.push({hex:entry[0],name:entry[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){
+ return {palette:keep,removed};
+}
+
+function areAllLocked(keys,locked){
+ const has=k=>locked instanceof Set?locked.has(k):Array.isArray(locked)&&locked.includes(k);
+ return !!(keys&&keys.length)&&keys.every(has);
+}
+function lockToggleLabel(keys,locked){return areAllLocked(keys,locked)?'unlock all':'lock all';}
+function toggleLockSet(keys,locked){
+ const next=new Set(locked||[]),all=areAllLocked(keys,next);
+ (keys||[]).forEach(k=>all?next.delete(k):next.add(k));
+ return next;
+}
+
+// Group a flat palette into the ground strip plus structural columns. ground is
+// {bg,fg}; those endpoint hexes form the pinned ground column even when absent
+// from the palette, and ground+N entries are reserved for that column. Everything
+// else groups by its stable column id, not by OKLCH hue/chroma or display name.
+// Legacy two-field entries fall back to their generated-name stem until edited.
+function columnsFromPalette(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);
+ if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfGroundRole(palette,ground,'bg')});
+ if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfGroundRole(palette,ground,'fg')});
+ const byColumn=new Map(),columns=[];
+ for(const entry of palette){
+ const [hex,name]=entry;
+ if(groundRoleOfEntry(entry,ground))continue;
+ const column=columnIdOf(entry),offset=entry[2]?columnOffset(name):legacyColumnOffset(name);
+ if(!byColumn.has(column))byColumn.set(column,{column,members:[]});
+ byColumn.get(column).members.push({hex,name,offset,column});
+ }
+ for(const f of byColumn.values()){
+ const base=(f.members.find(m=>m.offset===0)||f.members[0]).hex;
+ columns.push({base,column:f.column,stem:f.column,members:f.members.map(m=>({hex:m.hex,name:m.name,column:m.column}))});
}
- 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){
+ return {ground:groundStrip,columns};
+}
+// Regenerate a column's members as a symmetric span around the base: n=0 is the
+// base alone, n>=1 divides the OKLab intervals black..base and base..white into
+// n interior steps per side. Pure black/white endpoint duplicates and rounded
+// base duplicates are skipped. {members:[{hex,offset,clamped}]} or
+// {members:[],error:'bad-hex'}.
+function regenColumn(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)));
+ const k=Math.min(8,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);
+ const base=srgb2oklab(hex),black=srgb2oklab('#000000'),white=srgb2oklab('#ffffff'),steps=[];
+ for(let i=1;i<=k;i++){
+ const dark=interpOklabHex(black,base,i/(k+1),i-k-1);
+ const light=interpOklabHex(base,white,i/(k+1),i);
+ steps.push(dark,light);
+ }
+ const members=[...steps.filter(s=>!isPureEndpointHex(s.hex)&&s.hex.toLowerCase()!==hex),{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
+// Rank a column'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){
@@ -234,22 +281,45 @@ function stepRepointPlan(oldRanked,newMembers){
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 };
+// Preserve structural order. Generated ramps are inserted in offset order, and
+// columns are emitted in first-seen palette order. No color sorting happens here.
+function sortColumnMembers(column){return Object.assign({},column,{members:[...column.members]});}
+function sortColumns(columns){return columns.map(sortColumnMembers);}
+function lightestFirstMembers(members){return [...members].sort((a,b)=>oklchOf(b.hex).L-oklchOf(a.hex).L);}
+
+// Dropdown order for color selection mirrors the visual palette organization:
+// bg/fg first, then structural columns in display order. Within each group,
+// choices run lightest-to-darkest. Stored palette order stays untouched; this is
+// selection-only organization.
+function paletteOptionList(cur,palette,ground){
+ const have=cur===''||palette.some(p=>p[0]===cur)||[ground&&ground.bg,ground&&ground.fg].filter(Boolean).includes(cur);
+ const out=[['','— default —']],seen=new Set();
+ if(!have)out.push([cur,'(gone)']);
+ const add=(hex,name)=>{if(!hex)return;const key=hex.toLowerCase()+'|'+(name||'');if(seen.has(key))return;seen.add(key);out.push([hex,name||hex]);};
+ const grouped=columnsFromPalette(palette,ground||{});
+ const groundMembers=grouped.ground.map(g=>({hex:g.hex,name:g.name||g.role}))
+ .concat(palette.filter(entry=>groundRoleOfEntry(entry,ground)==='step').map(([hex,name])=>({hex,name})));
+ groundMembers.forEach(m=>add(m.hex,m.name));
+ sortColumns(grouped.columns).forEach(f=>lightestFirstMembers(f.members).forEach(m=>add(m.hex,m.name)));
+ return out;
+}
+function spanNeighborHex(cur,palette,ground,dir){
+ if(!cur)return null;
+ const wanted=(cur||'').toLowerCase(),groups=[],byLight=(a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L;
+ const addGroup=members=>{
+ const seen=new Set(),g=[];
+ members.filter(m=>m&&m.hex).sort(byLight).forEach(m=>{const h=m.hex.toLowerCase();if(!seen.has(h)){seen.add(h);g.push(m);}});
+ if(g.length)groups.push(g);
+ };
+ addGroup(groundColumnMembersFromPalette(palette,ground||{}));
+ sortColumns(columnsFromPalette(palette,ground||{}).columns).forEach(f=>addGroup(f.members));
+ for(const g of groups){
+ const i=g.findIndex(m=>(m.hex||'').toLowerCase()===wanted);
+ if(i<0)continue;
+ const next=g[i+(dir>0?1:-1)];
+ return next?next.hex:null;
+ }
+ return null;
+}
+
+export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, spanNeighborHex, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };