diff options
Diffstat (limited to 'scripts/theme-studio/app-core.js')
| -rw-r--r-- | scripts/theme-studio/app-core.js | 723 |
1 files changed, 585 insertions, 138 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 60ee14108..94b5d7ae8 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -9,64 +9,157 @@ // 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, 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){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;} +// 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]={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). 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])];} +// 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]); +} // 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. @@ -87,9 +180,9 @@ function fgSetFor(face,state){ 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); + 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()]}; } @@ -121,97 +214,178 @@ 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 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. +// 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 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){ + opts=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); + // 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 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 +408,295 @@ 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; +// 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}); }); - return keyed.map(k=>sortFamilyMembers(k.f)); + 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; +} + +// --- preview-locate registry (preview-locate spec, Phase 0) ------------------ +// Pure helpers that turn the assignment state into a map from every data-face +// previewed element back to its owning app, effective rendered value, and the +// source of that value. All state is passed in; these return data, never HTML. +// The one stateful piece -- previewSpan, which reads the live globals and emits +// escaped HTML -- lives in previews.js, not here. + +const UI_SECTION_LABEL='UI faces'; + +// Owner-qualified registry key. owner is '@ui' for the UI surface or an app-key +// for a package; the owner already disambiguates the surface, so (owner, face) is +// the unique identity. The space separator is safe because Emacs face and app +// keys never contain spaces, so the same face name under two owners can never +// collapse to one key. +function locateKey(owner,face){return owner+' + +// Walk an inherit chain for ATTR from FACENAME, returning {value, from}: +// value -- the first truthy value up the chain, or null +// from -- the face name the value was actually set on when it was reached by +// inheritance, or null when FACENAME carries it directly +// getFace(name) returns the face object; nextName(name) gives the parent face name +// (the face's own :inherit for a package, the UI_INHERIT entry for a ui face). A +// seen-set guards against a cycle. Mirrors effResolve / resolveUiAttr's truthiness +// so the resolved value matches what the preview actually renders. +function resolveLocateAttr(faceName,getFace,nextName,attr){ + const seen={};let name=faceName,origin=true; + while(name&&!seen[name]){ + seen[name]=1; + const f=getFace(name); + if(f&&f[attr])return {value:f[attr],from:origin?null:name}; + name=nextName(name);origin=false; + } + return {value:null,from:null}; +} + +// The non-default structural attributes worth naming in a locate title. Weight +// 'normal'/slant 'normal'/height 1 are the defaults and stay out. +function locateAttrs(f){ + f=f||{};const out={}; + if(f.weight&&f.weight!=='normal')out.weight=f.weight; + if(f.slant&&f.slant!=='normal')out.slant=f.slant; + if(f.underline)out.underline=true; + if(f.strike)out.strike=true; + if(f.box)out.box=true; + if(f.inverse)out.inverse=true; + if(f.extend)out.extend=true; + if(f.height&&f.height!==1)out.height=f.height; + if(f.inherit)out.inherit=f.inherit; + return out; +} + +// Build one registry entry: effective fg/bg (matching the rendered pixels) plus a +// per-attribute source note. fg floors to the default foreground (floorFg) when +// nothing up the chain is set; bg has no floor (an unset bg draws no background), +// so an unset, non-cleared bg simply has no value and no note. A 'cleared' face +// notes the cleared state so the tooltip explains the rendered default. +function locateEntry(surface,owner,face,section,f,resolve,floorFg){ + f=f||{}; + const rf=resolve('fg'),rb=resolve('bg'); + let fgVal,fgSrc; + if(rf.value){fgVal=rf.value;fgSrc=rf.from?{kind:'inherited',from:rf.from}:{kind:'direct',from:null};} + else{fgVal=floorFg;fgSrc=(f.source==='cleared')?{kind:'cleared',from:null}:{kind:'default',from:null};} + let bgVal=null,bgSrc=null; + if(rb.value){bgVal=rb.value;bgSrc=rb.from?{kind:'inherited',from:rb.from}:{kind:'direct',from:null};} + else if(f.source==='cleared'){bgSrc={kind:'cleared',from:null};} + return {surface,owner,face,section,value:{fg:fgVal,bg:bgVal},attrs:locateAttrs(f),sources:{fg:fgSrc,bg:bgSrc}}; +} + +// The derived {surface, owner, face} -> value/attributes/source registry over the +// two data-face surfaces: package faces (PKGMAP, keyed by app-key, inherit via the +// face's own :inherit) and UI faces (UIMAP, keyed by '@ui', inherit via the +// built-in UI_INHERIT chain). map carries the ground floors (map.p default fg). +// Pure: every dependency is a parameter, no globals, no DOM. +function buildLocateRegistry(apps,pkgmap,uimap,map){ + const reg={},floorFg=(map&&map.p)||null; + for(const app in (pkgmap||{})){ + const section=(apps&&apps[app]&&apps[app].label)||app,faces=pkgmap[app]; + for(const face in faces){ + reg[locateKey(app,face)]=locateEntry('package',app,face,section,faces[face], + attr=>resolveLocateAttr(face,n=>faces[n],n=>(faces[n]&&faces[n].inherit)||null,attr),floorFg); + } + } + for(const face in (uimap||{})){ + reg[locateKey('@ui',face)]=locateEntry('ui','@ui',face,UI_SECTION_LABEL,uimap[face], + attr=>resolveLocateAttr(face,n=>uimap[n],n=>UI_INHERIT[n]||null,attr),floorFg); + } + return reg; +} + +// Look up one owner-qualified face's meta. A face not in the registry resolves to +// no owning app -- an {unassigned} marker the caller renders hover-only (never a +// dead click), not a thrown error. +function locateFaceMeta(owner,face,registry){ + const e=registry&®istry[locateKey(owner,face)]; + return e||{owner,face,unassigned:true}; +} + +// Clickable predicate: an element is on-pane only when its owner is the pane being +// viewed. Recomputed from the current view at render time (never stored in the +// registry), since switching panes changes clickability but not ownership. +function isLocateOnPane(owner,currentApp){return owner===currentApp;} + +// The human source note for one resolved attribute, or null when there's no note. +function locateSourceNote(src,attr){ + if(!src)return null; + if(src.kind==='direct')return 'direct'; + if(src.kind==='inherited')return 'inherited from '+src.from; + if(src.kind==='cleared')return 'cleared, rendering as default'; + if(src.kind==='default')return attr==='bg'?'default background':'default foreground'; + return null; +} + +// The non-default structural attributes as a flat label list for the title. +function locateAttrsList(attrs){ + attrs=attrs||{};const parts=[]; + if(attrs.weight)parts.push(attrs.weight); + if(attrs.slant)parts.push(attrs.slant); + if(attrs.underline)parts.push('underline'); + if(attrs.strike)parts.push('strike'); + if(attrs.box)parts.push('box'); + if(attrs.inverse)parts.push('inverse'); + if(attrs.extend)parts.push('extend'); + if(attrs.height)parts.push('height '+attrs.height); + if(attrs.inherit)parts.push('inherit '+attrs.inherit); + return parts; +} + +// The comma-separated title string from a meta: section, element, effective value +// (fg always; bg when set), per-attribute source note, then non-default attributes. +// An unassigned meta reads "<face>, unassigned" (no section -- it has no owner). +function formatLocateTitle(meta){ + if(!meta||meta.unassigned)return (meta&&meta.face?meta.face+', ':'')+'unassigned'; + const parts=[meta.section,meta.face],s=meta.sources||{}; + const fgNote=locateSourceNote(s.fg,'fg'); + parts.push('fg '+meta.value.fg+(fgNote?' ('+fgNote+')':'')); + if(meta.value.bg){ + const bgNote=locateSourceNote(s.bg,'bg'); + parts.push('bg '+meta.value.bg+(bgNote?' ('+bgNote+')':'')); + }else if(s.bg&&s.bg.kind==='cleared'){ + parts.push('bg cleared, rendering as default'); + } + return parts.concat(locateAttrsList(meta.attrs)).join(', '); } -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies, sortFamilyMembers }; +export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, 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, buildLocateRegistry, locateFaceMeta, formatLocateTitle, isLocateOnPane }; |
