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
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
|
// 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, oklab2lrgb, lrgb2hex, inGamut, contrast, oklchOf, isPureEndpointHex, reliefColors } 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;}
// Convert a face dict's legacy boolean style fields to the new shape: bold ->
// weight "bold", italic -> slant "italic", underline true -> {style:line,color},
// strike true -> {color}. An explicit weight/slant already set wins over the
// legacy flag. Faces already in the new shape pass through, so this is safe on
// any input. Mirrors migrate_legacy in face_specs.py; keep the two in step.
function migrateLegacyFace(d){
const out=Object.assign({},d||{});
if('bold' in out){const b=out.bold;delete out.bold;if(b&&out.weight==null)out.weight='bold';}
if('italic' in out){const i=out.italic;delete out.italic;if(i&&out.slant==null)out.slant='italic';}
if('underline' in out){if(out.underline===true)out.underline={style:'line',color:null};else if(out.underline===false)out.underline=null;}
if('strike' in out){if(out.strike===true)out.strike={color:null};else if(out.strike===false)out.strike=null;}
return out;
}
// --- face CSS rendering ------------------------------------------------------
// Pure builders for the face preview/inline CSS strings. app.js's syntaxStyle /
// uiCss / ofs / udeco wrappers differ only in how they resolve fg/bg and whether
// they add a font-size; they all delegate here. cssWeight maps the curated weight
// names to numeric CSS weights; faceDecoration is the underline/strike value.
function cssWeight(w){const M={light:300,normal:400,medium:500,semibold:600,bold:700,heavy:900};return w&&M[w]!=null?M[w]:'normal';}
function faceDecoration(face){return ((face.underline?'underline ':'')+(face.strike?'line-through':'')).trim()||'none';}
// A face's :box, rendered as an inset box-shadow (no layout shift). Returns the
// box-shadow VALUE (or '' for no box). 'line' is a flat border in the box color
// (or the face's own color when unset); 'released'/'pressed' are the 3D button
// styles Emacs draws, derived from explicit box color when set, otherwise BG so
// they read on any color (reliefColors is ported from xterm.c).
function boxCss(b,bg){if(!b||!b.style)return '';const w=b.width||1;
if(b.style==='released'||b.style==='pressed'){
const r=(b.color||bg)?reliefColors(b.color||bg):{hl:null,sh:null};
const hl=r.hl||'#ffffff33',sh=r.sh||'#00000066';
const [a,z]=b.style==='released'?[hl,sh]:[sh,hl];
return `inset ${w}px ${w}px 0 ${a},inset -${w}px -${w}px 0 ${z}`;}
return `inset 0 0 0 ${w}px ${b.color||'currentColor'}`;}
// CSS declaration string for FACE with already-resolved FG/BG. opts: noBg
// (never emit background), fontSize (em number for height), boxBg (background
// handed to the relief shading). Declaration order matches the strings the four
// callers previously assembled by hand, so the rendered output is unchanged.
function faceCss(face,fg,bg,opts){
opts=opts||{};
const parts=['color:'+fg];
if(bg&&!opts.noBg)parts.push('background:'+bg);
parts.push('font-weight:'+cssWeight(face.weight),
'font-style:'+(face.slant||'normal'),
'text-decoration:'+faceDecoration(face));
if(opts.fontSize!=null)parts.push('font-size:'+opts.fontSize+'em');
const bx=boxCss(face.box,opts.boxBg);
if(bx)parts.push('box-shadow:'+bx);
return parts.join(';');
}
// Single source of truth for the per-face attribute model. One row per
// attribute drives both normalizePkgFace (defaulting + palette resolution) and
// packagesForExport (which attrs serialize and when). Adding a face attribute
// is one row here, not an edit in four hand-kept lists.
// def : value when unset
// resolve : fg/bg/distant-fg run through the palette name->hex resolver
// coerce : 'bool' -> !!v ; 'height' -> v||1 ; default -> v ?? def
// emit : export rule -- 'always' | 'truthy' | 'non-one' | 'bool'
// A hoisted function rather than a const: the inlined page calls normalizePkgFace
// at top level (seedPkgmap) before this point in source order, and a const would
// be in its temporal dead zone there; a function declaration is hoisted.
function faceAttrs(){return [
{k:'fg', def:null, resolve:true, emit:'always'},
{k:'bg', def:null, resolve:true, emit:'always'},
{k:'distant-fg', def:null, resolve:true, emit:'truthy'},
{k:'family', def:null, emit:'truthy'},
{k:'weight', def:null, emit:'truthy'},
{k:'slant', def:null, emit:'truthy'},
{k:'underline', def:null, emit:'truthy'},
{k:'strike', def:null, emit:'truthy'},
{k:'overline', def:null, emit:'truthy'},
{k:'inherit', def:null, emit:'always'},
{k:'height', def:1, coerce:'height', emit:'non-one'},
{k:'box', def:null, emit:'truthy'},
{k:'inverse', def:false, coerce:'bool', emit:'bool'},
{k:'extend', def:false, coerce:'bool', emit:'bool'},
];}
function normalizePkgFace(d,source,palette){
d=migrateLegacyFace(d||{});
const resolve=(v)=>palette?nameToHex(v,palette):v;
const out={};
for(const a of faceAttrs()){
let v=a.resolve?resolve(d[a.k]):d[a.k];
out[a.k]=a.coerce==='bool'?!!v:a.coerce==='height'?(v||1):(v??a.def);
}
out.source=source||d.source||'user';
return out;
}
// 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){m[app][row[0]]=normalizePkgFace(row[2],'default',palette);}}return m;}
// The package faces worth exporting (anything seeded or user-touched), trimmed.
// Driven by FACE_ATTRS: each attribute's `emit` rule decides whether it lands.
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={};for(const a of faceAttrs()){const v=f[a.k];if(a.emit==='always')o[a.k]=v;else if(a.emit==='truthy'){if(v)o[a.k]=v;}else if(a.emit==='non-one'){if(v&&v!==1)o[a.k]=v;}else if(a.emit==='bool'){if(v)o[a.k]=true;}}o.source=f.source;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]=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).
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;}
// Emacs built-in inherit chains for the syntax categories theme studio exposes.
// An unset category foreground resolves the way the generated theme renders in
// Emacs: build-theme.el writes no override for an unset face, so Emacs falls back
// to the face's own :inherit -- comment-delimiter->comment, doc->string,
// property-name->variable-name, function-call->function-name -- not to the
// default foreground.
const SYNTAX_INHERIT={cmd:'cm',doc:'str',prop:'var',fnc:'fnd'};
// Effective foreground for a syntax category, following the Emacs inherit chain.
// SYNTAX maps category -> face object with an optional `fg`; DEFAULTFG is the
// theme's default foreground (the chain's floor). `dec` (decorator) is pinned to
// `ty`: Emacs has no decorator face and renders decorators with
// font-lock-type-face, so a dec color set in the studio would never reach Emacs.
// Walk an inherit chain from START, returning the first truthy valueFn(key) or
// null. nextFn(key) gives the parent key; a seen-set guards against a cycle.
function walkInheritChain(start,nextFn,valueFn){
let k=start;const seen={};
while(k&&!seen[k]){seen[k]=1;const v=valueFn(k);if(v)return v;k=nextFn(k);}
return null;
}
function resolveSyntaxFg(cat,syntax,defaultFg){
const start=(cat==='dec')?'ty':cat;
return walkInheritChain(start,k=>SYNTAX_INHERIT[k],k=>syntax[k]&&syntax[k].fg)||defaultFg;
}
// Emacs built-in inherit chains for the ui faces whose parent is also a studio ui
// face, so an unset attribute previews the way Emacs renders it: mode-line-inactive
// inherits mode-line, line-number-current-line inherits line-number.
const UI_INHERIT={'mode-line-inactive':'mode-line','line-number-current-line':'line-number'};
// First set value of ATTR ('fg'/'bg') for ui FACE, walking UI_INHERIT; null when
// nothing up the chain is set. The caller applies its own floor (default fg,
// ground, or transparent), since that floor differs per attribute and face.
function resolveUiAttr(face,attr,uimap){
return walkInheritChain(face,f=>UI_INHERIT[f],f=>uimap[f]&&uimap[f][attr]);
}
// Text color for a swatch-dropdown popup row. A row showing a real palette color
// sits on the popup's own fixed background, so its name/hex text must inherit the
// popup foreground (return '' to use the CSS color). Coloring it for contrast
// against the swatch instead picks near-black text for a mid/dark swatch, which
// is unreadable on the dark popup. Only the "default" row, filled solid with
// SHOWN, uses a contrast color computed against that fill.
function dropdownRowTextColor(hex,shown,textOnFn){
if(hex)return '';
return shown?textOnFn(shown):'';
}
// 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';}
// --- 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,name,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label,name:name||label});else if(isRole&&cur.label==='default'){cur.label=label;cur.name=name||label;}};
if(state&&state.defaultFg)add(state.defaultFg,'default','default',false);
for(const a of syn)add(a.hex,a.role||a.hex,a.name||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 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 isReservedGroundLikeName(name){return /^(bg|fg)(?:[-_+].+|\d.*)$/i.test(name||'');}
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 {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.
// Reverse lookup: every palette hex referenced by an assignment (syntax, ui, or
// package fg / bg / box-color), plus the ground endpoints, which are always in
// use. Values may be palette names or hexes; nameToHex resolves both, so a tile
// whose hex is absent from this set is genuinely unreferenced. Biased safe: an
// unresolvable value simply marks nothing, so a used color is never flagged.
function usedPaletteHexes(palette,syntax,uimap,pkgmap,ground){
const used=new Set();
const add=v=>{const h=nameToHex(v,palette);if(h)used.add(h.toLowerCase());};
const addFace=f=>{if(!f)return;add(f.fg);add(f.bg);if(f.box&&f.box.color)add(f.box.color);};
if(ground){if(ground.bg)add(ground.bg);if(ground.fg)add(ground.fg);}
for(const k in (syntax||{}))addFace(syntax[k]);
for(const face in (uimap||{}))addFace(uimap[face]);
for(const app in (pkgmap||{}))for(const face in pkgmap[app])addFace(pkgmap[app][face]);
return used;
}
// Enumerate where a palette color is used, as "area > element" strings. scopes
// is [{area, faces:{element: faceObj}}] -- one scope per view area (color/code,
// ui faces, each package app), element keyed by its display label. A face counts
// if any of fg / bg / box-color resolves (by hex or palette name) to the target.
function paletteUsages(hex,scopes,palette){
const target=(hex||'').toLowerCase();
if(!target)return [];
const out=[];
for(const {area,faces} of (scopes||[])){
for(const element in (faces||{})){
const f=faces[element];if(!f)continue;
const vals=[f.fg,f.bg,f.box&&f.box.color];
if(vals.some(v=>{const h=nameToHex(v,palette);return h&&h.toLowerCase()===target;}))out.push(area+' > '+element);
}
}
return out;
}
function columnsFromPalette(palette,ground){
const bg=ground&&ground.bg,fg=ground&&ground.fg;
const groundStrip=[];
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}))});
}
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){
opts=opts||{};
const hex=typeof baseHex==='string'?normHex(baseHex):null;
if(!hex)return {members:[],error:'bad-hex'};
const k=Math.min(8,Math.max(0,Math.round(n||0)));
if(k===0)return {members:[{hex,offset:0,clamped:false}]};
// Bound the span to the ground endpoints when given: the dark side ramps toward
// the darker ground (bg), the light side toward the lighter ground (fg), so no
// generated step is darker than bg or lighter than fg. Falls back to pure
// black/white when no ground is supplied. isPureEndpointHex still dedupes the
// black/white case when bg/fg are themselves pure.
const g=opts.ground||{};
const gb=(g.bg&&normHex(g.bg))?srgb2oklab(normHex(g.bg)):srgb2oklab('#000000');
const gf=(g.fg&&normHex(g.fg))?srgb2oklab(normHex(g.fg)):srgb2oklab('#ffffff');
const darkEnd=gb.L<=gf.L?gb:gf, lightEnd=gb.L<=gf.L?gf:gb;
const base=srgb2oklab(hex),steps=[];
for(let i=1;i<=k;i++){
const dark=interpOklabHex(darkEnd,base,i/(k+1),i-k-1);
const light=interpOklabHex(base,lightEnd,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 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){
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};
}
// 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;
}
// Grid model for the gallery color picker. Mirrors the palette panel layout: a
// ground row (bg/fg + ground steps) then one row per color family, members run
// dark->light to match the panel. cur marks the one selected cell. The leading
// "default" entry (clears the assignment) and, when cur points at a color no
// longer in the palette, a "(gone)" entry live outside the family grid so every
// dropdown choice stays reachable. Pure — shares columnsFromPalette / sortColumns
// with the panel and the option list.
function galleryModel(cur,palette,ground){
const want=(cur||'').toLowerCase(),sel=h=>(h||'').toLowerCase()===want;
const byLightAsc=(a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L;
const cell=m=>({hex:m.hex,name:m.name||m.hex,selected:sel(m.hex)});
const rows=[];
const groundCells=groundColumnMembersFromPalette(palette,ground||{})
.filter(m=>m&&m.hex).sort(byLightAsc).map(cell);
if(groundCells.length)rows.push({kind:'ground',cells:groundCells});
sortColumns(columnsFromPalette(palette,ground||{}).columns).forEach(f=>{
const cells=[...f.members].filter(m=>m&&m.hex).sort(byLightAsc).map(cell);
if(cells.length)rows.push({kind:'column',column:f.column,cells});
});
const have=cur===''||cur==null||rows.some(r=>r.cells.some(c=>sel(c.hex)));
const gone=(cur&&!have)?{hex:cur,name:'(gone)',selected:true}:null;
return {default:{hex:'',selected:cur===''||cur==null},gone,rows};
}
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;
}
// The package apps for the assignment-view dropdown, keyed and sorted by display
// label (case-insensitive). generate.py builds APPS as bespoke apps first then
// inventory apps, so the raw key order isn't alphabetical; this orders the list
// the reader scans. An app missing a label falls back to its key.
function appViewKeysSorted(apps){
return Object.keys(apps||{}).sort((a,b)=>
String((apps[a]&&apps[a].label)||a).localeCompare(
String((apps[b]&&apps[b].label)||b), undefined, {sensitivity:'base'}));
}
// The prev/next arrows step the view-dropdown selection by DIR (-1/+1), clamped
// to [0, LEN-1] with no wrap. An empty list (LEN<=0) keeps CUR.
function stepViewIndex(cur,len,dir){
if(!(len>0)) return cur;
return Math.max(0, Math.min(len-1, cur+dir));
}
// Which of the six per-face setting boxes (fg, bg, style, inherit, height, box)
// differ from the face's seed default, so the table can mark a non-default box.
// A non-default height looks identical to the default in the number input, so the
// mark is the only at-a-glance signal. cur and def are face objects; the caller
// resolves fg/bg to hex first so a palette-name-vs-hex difference doesn't read as a
// change. The four style attributes collapse to one "style" flag.
function faceBoxNonDefaults(cur,def){
cur=cur||{}; def=def||{};
const eq=(a,b)=>(a??null)===(b??null);
return {
fg: !eq(cur.fg,def.fg),
bg: !eq(cur.bg,def.bg),
style: ['weight','slant','strike'].some(a=>JSON.stringify(cur[a]??null)!==JSON.stringify(def[a]??null)),
inherit: !eq(cur.inherit,def.inherit),
height: (cur.height||1)!==(def.height||1),
box: JSON.stringify(cur.box??null)!==JSON.stringify(def.box??null),
};
}
// True when the per-row expander hides at least one attribute that differs from
// the face's default, so the collapsed toggle can flag it. Covers exactly the
// attributes the expander holds: distant-fg, family, underline, overline,
// inverse, extend, and (for ui/syntax) inherit + height. The in-row controls
// (fg/bg/weight/slant/strike/box) have their own cell markers and are excluded.
function overflowNonDefault(cur,def,showInheritHeight){
cur=cur||{}; def=def||{};
const eq=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
if(['distant-fg','family','underline','overline'].some(a=>!eq(cur[a],def[a])))return true;
if((!!cur.inverse)!==(!!def.inverse))return true;
if((!!cur.extend)!==(!!def.extend))return true;
if(showInheritHeight){
if(!eq(cur.inherit,def.inherit))return true;
if((cur.height||1)!==(def.height||1))return true;
}
return false;
}
// Height bounds for a face :height scaling factor. 0.1 is Emacs's own floor (a
// smaller value errors out) and doubles as the modeline-shrink-to-nothing value;
// 2.0 is the studio's chosen ceiling. The number input's min/max attributes only
// guard its stepper arrows — typed or pasted values bypass them — so every height
// edit is coerced through clampHeight instead.
const HEIGHT_MIN=0.1, HEIGHT_MAX=2.0;
// Coerce a height-field value to either null (unset → inherit the default height)
// or a number clamped into [min,max]. Blank/whitespace/non-numeric → null; any
// number, including 0, a negative, or an over-max value, snaps into range.
function clampHeight(raw,min=HEIGHT_MIN,max=HEIGHT_MAX){
if(raw===null||raw===undefined)return null;
const s=(''+raw).trim();
if(s==='')return null;
const n=parseFloat(s);
if(!isFinite(n))return null;
return n<min?min:n>max?max:n;
}
// Compose an element-hover tooltip: the face's docstring on top, the existing
// hover text (e.g. the bare face name) below it, separated by a blank line. A
// missing doc or base collapses to whichever is present; missing both yields ''.
// Keyed lookups (FACE_DOCS[face], SYNTAX_DOCS[kind]) supply DOC; BASE is
// whatever title the element carried before.
function composeHoverTitle(doc,base){
doc=doc||''; base=base||'';
if(doc&&base)return doc+'\n\n'+base;
return doc||base;
}
export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, clampHeight, HEIGHT_MIN, HEIGHT_MAX, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
|