diff options
Diffstat (limited to 'scripts/theme-studio/app-core.js')
| -rw-r--r-- | scripts/theme-studio/app-core.js | 100 |
1 files changed, 60 insertions, 40 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index af90f13a4..23f73961b 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -33,46 +33,66 @@ function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(! // 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'}; -// 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';} +// 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. +function resolveSyntaxFg(cat,syntax,defaultFg){ + let k=(cat==='dec')?'ty':cat; + const seen={}; + while(k&&!seen[k]){ + seen[k]=1; + const fg=syntax[k]&&syntax[k].fg; + if(fg)return fg; + k=SYNTAX_INHERIT[k]; + } + return defaultFg; +} -// 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}); +// 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){ + let f=face; + const seen={}; + while(f&&!seen[f]){ + seen[f]=1; + const v=uimap[f]&&uimap[f][attr]; + if(v)return v; + f=UI_INHERIT[f]; } - return {steps,adjusted}; + return null; +} + +// 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. @@ -93,9 +113,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()]}; } @@ -294,7 +314,7 @@ function lightestFirstMembers(members){return [...members].sort((a,b)=>oklchOf(b 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) '+cur]); + 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})) @@ -322,4 +342,4 @@ function spanNeighborHex(cur,palette,ground,dir){ return null; } -export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, spanNeighborHex, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet }; +export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet }; |
