aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/app-core.js
blob: e6d9003900d65bc795639396965e4164be1be3d8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
// Pure app logic — the package-face model and the dropdown option list — with no
// DOM and no module globals (every dependency is a parameter). It is unit-tested
// directly (test-app-core.mjs) and inlined into the page like colormath.js, so
// the browser runs the same code the tests import. The app.js wrappers (pname,
// seedPkgmap, ddList, pkgEffFg, pkgEffBg) are thin delegators that pass the
// live PALETTE / APPS / PKGMAP into these.
//
// The imports below are for the Node tests; generate.py strips them on inline,
// 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';

// 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;}

// 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;}

// 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'};}}}

// Effective fg/bg for a package face, following its inherit chain. seen guards
// against an inherit cycle (returns null rather than recursing forever).
function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[app][face];if(!f||seen[face])return null;seen[face]=1;if(f[attr])return f[attr];if(f.inherit&&map[app][f.inherit])return effResolve(map,app,f.inherit,attr,seen);return null;}

// 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])];}

// 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'.
function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}

// Generate a tonal ramp from one base color: 2n steps at offsets -n..-1 and
// +1..+n (the base itself is excluded — it already lives in the palette),
// ordered darkest -> lightest. Holds the OKLCH hue, steps lightness by stepL per
// stop, and eases chroma toward the extremes (quadratic in |offset|/n, so only
// the farthest step loses most of its color). Every step is gamut-clamped and
// carries its own clamped flag. Returns {steps:[{hex,clamped,offset}], adjusted}
// where adjusted names any knob clamped/rounded into range, or {steps:[],
// error:'bad-hex'} for an unparseable base. Pure — opts are clamped, never thrown.
function ramp(baseHex,opts){
  const hex=typeof baseHex==='string'?normHex(baseHex):null;
  if(!hex)return {steps:[],error:'bad-hex'};
  const o=opts||{},adjusted=[];
  const knob=(name,def,lo,hi,isInt)=>{
    const v=o[name];
    if(typeof v!=='number'||!isFinite(v))return def;
    const r=isInt?Math.round(v):v,c=Math.min(hi,Math.max(lo,r));
    if(c!==v)adjusted.push(name);
    return c;
  };
  const n=knob('n',2,1,4,true),stepL=knob('stepL',0.08,0.04,0.12,false),chromaEase=knob('chromaEase',0.5,0,1,false);
  const {L:L0,C:C0,H:H0}=oklab2oklch(srgb2oklab(hex));
  const steps=[];
  for(let off=-n;off<=n;off++){
    if(off===0)continue;
    const L=Math.min(1,Math.max(0,L0+off*stepL));
    const t=Math.abs(off)/n,C=C0*(1-chromaEase*t*t);
    const {hex:h,clamped}=oklch2hex(L,C,H0);
    steps.push({hex:h,clamped,offset:off});
  }
  return {steps,adjusted};
}

// --- background-contrast safety (palette-ramps spec, Phase 3) ----------------
// An overlay background sits behind many foregrounds at once, so its real
// constraint is the worst-case contrast over the whole set, not one fg/bg pair.

// The closed v1 set of code-overlay faces whose worst-case floor we compute.
// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added
// explicitly rather than by a heuristic. Shared by app.js and the tests.
const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch'];

// A covered face's foreground set: the distinct syntax-token colors plus the
// default foreground, each labeled (syntax role preferred, else 'default').
// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns
// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the
// face isn't in the covered set) or 'empty' (no syntax assignments constrain it).
function fgSetFor(face,state){
  const covered=(state&&state.covered)||COVERED_FACES;
  if(!covered.includes(face))return {set:[],reason:'out-of-scope'};
  const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex);
  if(!syn.length)return {set:[],reason:'empty'};
  const byHex=new Map();
  const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;};
  if(state&&state.defaultFg)add(state.defaultFg,'default',false);
  for(const a of syn)add(a.hex,a.role||a.hex,true);
  return {set:[...byHex.values()]};
}

// Worst-case (minimum) WCAG contrast of a background against a foreground set,
// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty
// set returns nulls so the caller can show the no-set readout instead of a floor.
function floor(bgHex,fgSet){
  if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null};
  let best=Infinity,lh=null,ll=null;
  for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}}
  return {ratio:best,limitingHex:lh,limitingLabel:ll};
}

// The lightest background at (hue, chroma) whose worst-case floor over fgSet still
// clears target (a WCAG ratio). Scans L up from black to bracket the first
// dark-side crossing, then binary-searches it to tol 0.001. status:
//   'ok'    - a ceiling L was found
//   'none'  - even pure black fails (a foreground is too dark for the target)
//   'all'   - no foreground set to constrain (vacuously safe everywhere)
//   'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there)
function lMax(hue,chroma,fgSet,target){
  if(!fgSet||!fgSet.length)return {L:1,status:'all'};
  const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};};
  if(at(0).r<target)return {L:null,status:'none'};
  let loL=0,hiL=null;
  for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;}
  if(hiL===null)return {L:1,status:'all'};
  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};
}

// 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 };