aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/app-core.js
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio/app-core.js')
-rw-r--r--scripts/theme-studio/app-core.js100
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 };