From e110f7afac89322a2af4f3c4ebafe303be044cc2 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 18 Jun 2026 22:06:53 -0500 Subject: refactor(theme-studio): cut the face model over to weight/slant/objects I replaced the legacy bold/italic/underline/strike booleans with the final model shape across both sides of the tool. weight (light/normal/medium/semibold/bold/heavy) and slant (normal/italic/oblique) replace the bold/italic flags, underline becomes {style: line|wave, color}, strike becomes {color}, and null means unset. A single migration converts a legacy face on the way in, mirrored as migrateLegacyFace in app-core.js and migrate_legacy in face_specs.py so the JS and Python models can't drift. It runs on import (applyImported, mergePackagesInto) and on every seed that face_spec touches. The captured-snapshot seed (default_faces.seed) narrows the same way it did before. Only bold and italic survive, as weight "bold" and slant "italic", so the generated themes stay byte-identical. The B/I/U/S toggle buttons keep working through a transitional bridge (legacyStyleOn / toggleLegacyStyle). The weight/slant dropdowns and underline/strike controls that replace them land next. The live previews read the new shape, with a weight name mapped to a numeric CSS font-weight. The cutover is proven emit-neutral two ways. An ERT test asserts the migrated shapes emit the same attributes as the legacy booleans, and deep-migrating every face in dupre, distinguished, sterling, now, theme, and WIP then running build-theme yields byte-identical output. Full suite green: Python 59, Node 200, ERT 41, plus the browser hash gates. --- scripts/theme-studio/app-core.js | 41 ++++++++-- scripts/theme-studio/app.js | 36 +++++---- scripts/theme-studio/browser-gates.js | 64 +++++++-------- scripts/theme-studio/default_faces.py | 13 ++- scripts/theme-studio/face_specs.py | 53 +++++++++--- scripts/theme-studio/generate.py | 10 +-- scripts/theme-studio/test-app-core.mjs | 90 ++++++++++++++++++--- scripts/theme-studio/test_generate.py | 58 +++++++------ scripts/theme-studio/theme-studio.html | 143 ++++++++++++++++++++------------- tests/test-build-theme.el | 13 +++ 10 files changed, 358 insertions(+), 163 deletions(-) diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 10fa0c570..f9c752bd0 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -14,17 +14,48 @@ import { oklch2hex, srgb2oklab, oklab2oklch, oklab2lrgb, lrgb2hex, inGamut, cont // 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; +} + function normalizePkgFace(d,source,palette){ - d=d||{}; + d=migrateLegacyFace(d||{}); const resolve=(v)=>palette?nameToHex(v,palette):v; - return {fg:resolve(d.fg)??null,bg:resolve(d.bg)??null,'distant-fg':resolve(d['distant-fg'])??null,family:d.family??null,bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,overline:d.overline??null,inherit:d.inherit??null,height:d.height||1,box:d.box??null,inverse:!!d.inverse,extend:!!d.extend,source:source||d.source||'user'}; + return {fg:resolve(d.fg)??null,bg:resolve(d.bg)??null,'distant-fg':resolve(d['distant-fg'])??null,family:d.family??null,weight:d.weight??null,slant:d.slant??null,underline:d.underline??null,strike:d.strike??null,overline:d.overline??null,inherit:d.inherit??null,height:d.height||1,box:d.box??null,inverse:!!d.inverse,extend:!!d.extend,source:source||d.source||'user'}; +} + +// Transitional bridge for the legacy B/I/U/S toggle buttons (mkStyleButtons), +// which the weight/slant dropdowns and underline/strike controls replace next. +// The button reads on/off and flips a single attribute on the new-shape face. +function legacyStyleOn(f,attr){ + if(attr==='bold')return f.weight==='bold'; + if(attr==='italic')return f.slant==='italic'; + if(attr==='underline')return !!f.underline; + if(attr==='strike')return !!f.strike; + return false; +} +function toggleLegacyStyle(f,attr){ + if(attr==='bold')f.weight=f.weight==='bold'?null:'bold'; + else if(attr==='italic')f.slant=f.slant==='italic'?null:'italic'; + else if(attr==='underline')f.underline=f.underline?null:{style:'line',color:null}; + else if(attr==='strike')f.strike=f.strike?null:{color:null}; } // Seed the package-face map from the app inventory's per-face defaults. function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){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['distant-fg'])o['distant-fg']=f['distant-fg'];if(f.family)o.family=f.family;if(f.overline)o.overline=f.overline;if(f.inverse)o.inverse=true;if(f.extend)o.extend=true;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;} +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,inherit:f.inherit,source:f.source};if(f.weight)o.weight=f.weight;if(f.slant)o.slant=f.slant;if(f.underline)o.underline=f.underline;if(f.strike)o.strike=f.strike;if(f['distant-fg'])o['distant-fg']=f['distant-fg'];if(f.family)o.family=f.family;if(f.overline)o.overline=f.overline;if(f.inverse)o.inverse=true;if(f.extend)o.extend=true;if(f.height&&f.height!==1)o.height=f.height;if(f.box)o.box=f.box;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;} // Merge an imported package block into a face map, filling missing fields. function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]=normalizePkgFace(f,f.source||'user');}}} @@ -436,11 +467,11 @@ function faceBoxNonDefaults(cur,def){ return { fg: !eq(cur.fg,def.fg), bg: !eq(cur.bg,def.bg), - style: ['bold','italic','underline','strike'].some(a=>!!cur[a]!==!!def[a]), + style: ['weight','slant','underline','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), }; } -export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet }; +export { nameToHex, migrateLegacyFace, legacyStyleOn, toggleLegacyStyle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 82dccc111..bd07f1de1 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -4,7 +4,7 @@ let MAP=MAP_J, PALETTE=PALETTE_J, SYNTAX=SYNTAX_J, UIMAP=UIMAP_J; let LOCKED=new Set(LOCKS_J); // rows whose choice is decided (controls disabled, skipped by erase/reset batch actions) const DELTAE_MIN=0.02; // OKLab ΔE below this = colors too close to tell apart (perceptual-metrics spec) const DEFAULT_UIMAP=JSON.parse(JSON.stringify(UIMAP)); -function syntaxBlank(k){return {fg:MAP[k]||null,bg:null,'distant-fg':null,family:null,bold:false,italic:false,underline:false,strike:false,overline:null,box:null,inverse:false,extend:false,inherit:null,height:null};} +function syntaxBlank(k){return {fg:MAP[k]||null,bg:null,'distant-fg':null,family:null,weight:null,slant:null,underline:null,strike:null,overline:null,box:null,inverse:false,extend:false,inherit:null,height:null};} function syncSyntaxCache(k){const s=SYNTAX[k]||syntaxBlank(k);MAP[k]=s.fg||'';} function syncAllSyntaxCache(){CATS.forEach(c=>syncSyntaxCache(c[0]));} function syncSyntaxFromCache(){CATS.forEach(c=>{const k=c[0];syntaxFace(k).fg=MAP[k]||null;});} @@ -209,13 +209,13 @@ function buildTable(){ const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt'; function rowFg(){return kind==='bg'?MAP['p']:effFg(syntaxFace(kind).fg);} function rowBg(){return syntaxFace(kind).bg||MAP['bg'];} - function styleEx(){const s=syntaxFace(kind);exTd.style.color=rowFg();exTd.style.background=rowBg();exTd.style.fontWeight=s.bold?'bold':'normal';exTd.style.fontStyle=s.italic?'italic':'normal';exTd.style.textDecoration=(s.underline?'underline ':'')+(s.strike?'line-through':'')||'none';exTd.style.boxShadow=boxCss(s.box,rowBg());} + function styleEx(){const s=syntaxFace(kind);exTd.style.color=rowFg();exTd.style.background=rowBg();exTd.style.fontWeight=cssWeight(s.weight);exTd.style.fontStyle=s.slant||'normal';exTd.style.textDecoration=(s.underline?'underline ':'')+(s.strike?'line-through':'')||'none';exTd.style.boxShadow=boxCss(s.box,rowBg());} function styleCr(){const r=contrast(rowFg(),rowBg());crTd.innerHTML=crHtml(r);} const dd=mkColorDropdown(list,cur,(hex)=>{const s=syntaxFace(kind);s.fg=hex||null;syncSyntaxCache(kind);styleEx();styleCr();renderCode();if(kind==='bg'||kind==='p'){applyGround();buildTable();buildPkgTable();buildPkgPreview();}repaintCovered();},{compact:true,defaultHex:rowFg()}); const bgd=mkColorDropdown(ddList(sf.bg||''),sf.bg||'',hex=>{const s=syntaxFace(kind);s.bg=hex||null;styleEx();styleCr();renderCode();repaintCovered();},{compact:true,defaultHex:rowBg()}); styleEx();styleCr(); const stTd=document.createElement('td'); - const stBtns=mkStyleButtons(at=>syntaxFace(kind)[at],at=>{const s=syntaxFace(kind);s[at]=!s[at];styleEx();renderCode();}); + const stBtns=mkStyleButtons(at=>legacyStyleOn(syntaxFace(kind),at),at=>{toggleLegacyStyle(syntaxFace(kind),at);styleEx();renderCode();}); const stCluster=document.createElement('div');stCluster.className='stylecluster';stBtns.forEach(b=>stCluster.appendChild(b));stTd.appendChild(stCluster); const c0=document.createElement('td');c0.appendChild(dd); const cB=document.createElement('td');cB.appendChild(bgd); @@ -361,9 +361,9 @@ function updateTitle(){const n=document.getElementById('themename').value.trim() function exportTheme(){const blob=new Blob([JSON.stringify(exportObj(),null,1)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fileSlug()+'.json';a.click();} function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette.map(normalizePaletteEntry); if(!d.syntax)throw new Error('theme JSON is missing syntax; convert older files first'); - SYNTAX={};CATS.forEach(c=>{const k=c[0];SYNTAX[k]=Object.assign(syntaxBlank(k),d.syntax[k]||{});});syncAllSyntaxCache(); + SYNTAX={};CATS.forEach(c=>{const k=c[0];SYNTAX[k]=Object.assign(syntaxBlank(k),migrateLegacyFace(d.syntax[k]||{}));});syncAllSyntaxCache(); LOCKED=new Set(d.locks||[]); - if(d.ui)Object.assign(UIMAP,d.ui); + if(d.ui)for(const k in d.ui)UIMAP[k]=Object.assign(uiFaceBlank(),migrateLegacyFace(d.ui[k])); PKGMAP=seedPkgmap();if(d.packages)mergePackagesInto(PKGMAP,d.packages); refreshPaletteState({pkgPreview:true});updateTitle();} function importFile(ev){const f=ev.target.files[0];if(!f)return;const r=new FileReader(); @@ -381,7 +381,11 @@ async function importTheme(){ // against the new ground for faces without their own bg). function applyGround(){document.querySelectorAll('pre').forEach(p=>p.style.background=MAP['bg']);UI_FACES.forEach(([f])=>{if(document.getElementById('uiprev-'+f))paintUI(f);});} function uf(f){return UIMAP[f]||{};} -function udeco(o){return `font-weight:${o.bold?'bold':'normal'};font-style:${o.italic?'italic':'normal'};text-decoration:${(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none'}`;} +// Map a weight name to a CSS font-weight for the live previews. The named +// weights light/medium/semibold/heavy aren't CSS keywords, so resolve to the +// numeric scale; an unset weight renders normal. +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 udeco(o){return `font-weight:${cssWeight(o.weight)};font-style:${o.slant||'normal'};text-decoration:${(o.underline?'underline ':'')+(o.strike?'line-through':'')||'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 @@ -398,7 +402,7 @@ function boxCss(b,bg){if(!b||!b.style)return '';const w=b.width||1; return `inset 0 0 0 ${w}px ${b.color||'currentColor'}`;} function syntaxStyle(k){const s=syntaxFace(k),fg=(k==='bg'?MAP['p']:resolveSyntaxFg(k,SYNTAX,MAP['p'])),bg=s.bg||null,dec=(s.underline?'underline ':'')+(s.strike?'line-through':''), bx=boxCss(s.box,bg||MAP['bg']); - return `color:${fg};${bg?'background:'+bg+';':''}font-weight:${s.bold?'bold':'normal'};font-style:${s.italic?'italic':'normal'};text-decoration:${dec.trim()||'none'}${bx?';box-shadow:'+bx:''}`;} + return `color:${fg};${bg?'background:'+bg+';':''}font-weight:${cssWeight(s.weight)};font-style:${s.slant||'normal'};text-decoration:${dec.trim()||'none'}${bx?';box-shadow:'+bx:''}`;} // The per-row box control: none / line / raised / pressed plus optional line // color. get()/set() read and write the face's box object (null = no box). // Box control: a 2x2 cluster of radio buttons for the four box styles (no box / @@ -434,7 +438,7 @@ function flashPkgPreview(f){const sp=document.querySelectorAll(`#pkgpreview [dat function mockSpan(k,t){return `${esc(t)}`;} function uiCss(o,fgv,bgv,opts={}){const fg=fgv===undefined?effFg(o.fg):fgv,bg=bgv===undefined?o.bg:bgv,dec=(o.underline?'underline ':'')+(o.strike?'line-through':''), bx=boxCss(o.box,bg||MAP['bg']); - return `color:${fg};${bg&&!opts.noBg?'background:'+bg+';':''}font-weight:${o.bold?'bold':'normal'};font-style:${o.italic?'italic':'normal'};text-decoration:${dec.trim()||'none'}${bx?';box-shadow:'+bx:''}`;} + return `color:${fg};${bg&&!opts.noBg?'background:'+bg+';':''}font-weight:${cssWeight(o.weight)};font-style:${o.slant||'normal'};text-decoration:${dec.trim()||'none'}${bx?';box-shadow:'+bx:''}`;} function syncMockHeight(){const t=document.getElementById('uitable'),m=document.getElementById('mockframe');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} function buildMockFrame(){ const fr=document.getElementById('mockframe');if(!fr)return; @@ -514,8 +518,8 @@ function buildMockFrame(){ function uiSelect(face,attr){const cur=UIMAP[face][attr]||''; return mkColorDropdown(ddList(cur),cur,h=>{UIMAP[face][attr]=h||null;paintUI(face);buildMockFrame();},{compact:true,defaultHex:attr==='fg'?effFg(null):effBg(null)});} const BASE_INHERITS=['fixed-pitch','variable-pitch','default','link','bold','italic','shadow']; -function uiFaceBlank(){return {fg:null,bg:null,'distant-fg':null,family:null,bold:false,italic:false,underline:false,strike:false,overline:null,box:null,inverse:false,extend:false,inherit:null,height:null};} -function seedFace(d){return normalizePkgFace({fg:pname(d.fg),bg:pname(d.bg),'distant-fg':pname(d['distant-fg']),family:d.family,bold:d.bold,italic:d.italic,underline:d.underline,strike:d.strike,overline:d.overline,inherit:d.inherit,height:d.height,box:d.box,inverse:d.inverse,extend:d.extend},'default');} +function uiFaceBlank(){return {fg:null,bg:null,'distant-fg':null,family:null,weight:null,slant:null,underline:null,strike:null,overline:null,box:null,inverse:false,extend:false,inherit:null,height:null};} +function seedFace(d){return normalizePkgFace({fg:pname(d.fg),bg:pname(d.bg),'distant-fg':pname(d['distant-fg']),family:d.family,weight:d.weight,slant:d.slant,bold:d.bold,italic:d.italic,underline:d.underline,strike:d.strike,overline:d.overline,inherit:d.inherit,height:d.height,box:d.box,inverse:d.inverse,extend:d.extend},'default');} function curApp(){const s=document.getElementById('viewsel');const v=s&&s.value;return (v&&v[0]!=='@')?v:Object.keys(APPS)[0];} function pkgEffFg(app,face,seen){return effResolve(PKGMAP,app,face,'fg',seen);} function pkgEffBg(app,face,seen){return effResolve(PKGMAP,app,face,'bg',seen);} @@ -553,15 +557,15 @@ function buildPkgTable(){ const f=PKGMAP[app][face],tr=document.createElement('tr');tr.dataset.face=face; const def=normalizePkgFace(row[2]||{},'default',PALETTE); const nd=faceBoxNonDefaults( - {fg:nameToHex(f.fg,PALETTE),bg:nameToHex(f.bg,PALETTE),bold:f.bold,italic:f.italic,underline:f.underline,strike:f.strike,inherit:f.inherit,height:f.height,box:f.box}, - {fg:nameToHex(def.fg,PALETTE),bg:nameToHex(def.bg,PALETTE),bold:def.bold,italic:def.italic,underline:def.underline,strike:def.strike,inherit:def.inherit,height:def.height,box:def.box}); + {fg:nameToHex(f.fg,PALETTE),bg:nameToHex(f.bg,PALETTE),weight:f.weight,slant:f.slant,underline:f.underline,strike:f.strike,inherit:f.inherit,height:f.height,box:f.box}, + {fg:nameToHex(def.fg,PALETTE),bg:nameToHex(def.bg,PALETTE),weight:def.weight,slant:def.slant,underline:def.underline,strike:def.strike,inherit:def.inherit,height:def.height,box:def.box}); const c0=document.createElement('td');c0.className='cat';c0.textContent=label;c0.title=face;c0.style.cursor='pointer';c0.onclick=()=>flashPkgPreview(face); const fgd=mkColorDropdown(ddList(f.fg||''),f.fg||'',h=>{f.fg=h||null;f.source='user';pkgChanged();},{compact:true,defaultHex:effFg(pkgEffFg(app,face))}), bgd=mkColorDropdown(ddList(f.bg||''),f.bg||'',h=>{f.bg=h||null;f.source='user';pkgChanged();},{compact:true,defaultHex:effBg(pkgEffBg(app,face))}); const cf=document.createElement('td');cf.appendChild(fgd); const cb=document.createElement('td');cb.appendChild(bgd); const cw=document.createElement('td'); - const pkBtns=mkStyleButtons(at=>f[at],at=>{f[at]=!f[at];f.source='user';pkgChanged();}); + const pkBtns=mkStyleButtons(at=>legacyStyleOn(f,at),at=>{toggleLegacyStyle(f,at);f.source='user';pkgChanged();}); const pkCluster=document.createElement('div');pkCluster.className='stylecluster';pkBtns.forEach(b=>pkCluster.appendChild(b));cw.appendChild(pkCluster); const ci=document.createElement('td');const isel=document.createElement('select');isel.className='chip';isel.style.cssText='width:150px;font:10pt monospace';inh.forEach(o=>{const op=document.createElement('option');op.value=o;op.textContent=o||'— none —';isel.appendChild(op);});isel.value=f.inherit||'';isel.onchange=()=>{f.inherit=isel.value||null;f.source='user';pkgChanged();};ci.appendChild(isel); const ch=document.createElement('td');const hin=document.createElement('input');hin.type='number';hin.min='0.8';hin.max='2.5';hin.step='0.05';hin.value=f.height||1;hin.className='hstep';hin.onchange=()=>{f.height=parseFloat(hin.value)||1;f.source='user';pkgChanged();};ch.appendChild(hin); @@ -575,7 +579,7 @@ function buildPkgTable(){ applyTableSort('pkgbody'); updateLockToggle('pkg'); } -function ofs(app,face){const f=PKGMAP[app][face]||{},fg=effFg(pkgEffFg(app,face)),bg=pkgEffBg(app,face);const dec=(f.underline?'underline ':'')+(f.strike?'line-through':'');const bx=boxCss(f.box,bg||MAP['bg']);return `color:${fg};${bg?'background:'+bg+';':''}font-weight:${f.bold?'bold':'normal'};font-style:${f.italic?'italic':'normal'};text-decoration:${dec.trim()||'none'};font-size:${(f.height||1)}em${bx?';box-shadow:'+bx:''}`;} +function ofs(app,face){const f=PKGMAP[app][face]||{},fg=effFg(pkgEffFg(app,face)),bg=pkgEffBg(app,face);const dec=(f.underline?'underline ':'')+(f.strike?'line-through':'');const bx=boxCss(f.box,bg||MAP['bg']);return `color:${fg};${bg?'background:'+bg+';':''}font-weight:${cssWeight(f.weight)};font-style:${f.slant||'normal'};text-decoration:${dec.trim()||'none'};font-size:${(f.height||1)}em${bx?';box-shadow:'+bx:''}`;} function os(app,face,txt){return `${txt}`;} // Shared wrapper for the line-based package previews: a monospace pre block. // Each renderer builds its own L array of os(...) lines and returns previewLines(L). @@ -1091,7 +1095,7 @@ function worstCellHtml(face){ // Repaint every covered overlay face (their floors depend on the syntax palette, // so a syntax-color edit has to refresh them even though it doesn't rebuild the table). function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getElementById('uicr-'+f))paintUI(f);});} -function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=o.bold?'bold':'normal';pv.style.fontStyle=o.italic?'italic':'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box,effBg(o.bg)); +function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=cssWeight(o.weight);pv.style.fontStyle=o.slant||'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box,effBg(o.bg)); const report=coveredContrastReport(face); pv.querySelectorAll('.crerr').forEach(e=>e.remove()); pv.title=''; @@ -1108,7 +1112,7 @@ function buildUITable(){ const cF=document.createElement('td');cF.appendChild(fgSel); const cB=document.createElement('td');cB.appendChild(bgSel); const cS=document.createElement('td'); - const stBtns=mkStyleButtons(at=>UIMAP[face][at],at=>{UIMAP[face][at]=!UIMAP[face][at];paintUI(face);buildMockFrame();}); + const stBtns=mkStyleButtons(at=>legacyStyleOn(UIMAP[face],at),at=>{toggleLegacyStyle(UIMAP[face],at);paintUI(face);buildMockFrame();}); const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stBtns.forEach(b=>uiCluster.appendChild(b));cS.appendChild(uiCluster); const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt'; const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px'; diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js index de11bc3ee..9f7034f8d 100644 --- a/scripts/theme-studio/browser-gates.js +++ b/scripts/theme-studio/browser-gates.js @@ -1,7 +1,7 @@ // Phase-1 self-test (open with #selftest): seed -> export -> import -> compare. function pkgSelftest(){ const seeded=seedPkgmap(); - seeded['org-mode']['org-level-2']={fg:'#e8bd30',bg:null,bold:false,italic:false,inherit:'org-level-1',height:1.2,source:'user'}; + seeded['org-mode']['org-level-2']={fg:'#e8bd30',bg:null,weight:null,slant:null,inherit:'org-level-1',height:1.2,source:'user'}; const exp=packagesForExport(seeded); const round=seedPkgmap();mergePackagesInto(round,exp); const roundtrip=JSON.stringify(exp)===JSON.stringify(packagesForExport(round)); @@ -9,11 +9,11 @@ function pkgSelftest(){ const l2=exp['org-mode']['org-level-2']; const inherited=l2.inherit==='org-level-1'&&l2.source==='user'; const height=l2.height===1.2 && !('height' in (exp['org-mode']['org-todo'])); - const sc=seedPkgmap();sc['org-mode']['org-todo']={fg:null,bg:null,bold:false,italic:false,inherit:null,height:1,source:'cleared'}; + const sc=seedPkgmap();sc['org-mode']['org-todo']={fg:null,bg:null,weight:null,slant:null,inherit:null,height:1,source:'cleared'}; const cleared='org-todo' in packagesForExport(sc)['org-mode']; const su=seedPkgmap();mergePackagesInto(su,{'zzz-pkg':{'zzz-face':{fg:'#112233',source:'user'}}}); const unknown=!!(su['zzz-pkg']&&su['zzz-pkg']['zzz-face'].fg==='#112233'); - PKGMAP['__cyc']={a:{fg:null,bg:null,bold:false,italic:false,inherit:'b',height:1,source:'user'},b:{fg:null,bg:null,bold:false,italic:false,inherit:'a',height:1,source:'user'}}; + PKGMAP['__cyc']={a:{fg:null,bg:null,weight:null,slant:null,inherit:'b',height:1,source:'user'},b:{fg:null,bg:null,weight:null,slant:null,inherit:'a',height:1,source:'user'}}; let cyc=true;try{pkgEffFg('__cyc','a');}catch(e){cyc=false;}delete PKGMAP['__cyc']; const verdict=(roundtrip&&oldjson&&inherited&&height&&cleared&&unknown&&cyc)?'PASS':'FAIL'; document.title='SELFTEST '+verdict; @@ -123,13 +123,13 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c A(Q('[data-face="region"] [data-k]'),'region-keeps-token-colors'); const curCell=Q('[data-face="cursor"]'); A(curCell&&curCell.textContent.trim().length===1,'cursor-on-glyph'); - UIMAP['cursor']={fg:'#112233',bg:'#aabbcc',bold:false,italic:false,underline:false,strike:false,box:null};buildMockFrame(); + UIMAP['cursor']={fg:'#112233',bg:'#aabbcc',weight:null,slant:null,underline:null,strike:null,box:null};buildMockFrame(); const curStyled=Q('[data-face="cursor"]'),curSt=curStyled&&curStyled.getAttribute('style')||''; A(curSt.includes('#112233')&&curSt.includes('#aabbcc'),'cursor preview honors fg and bg: '+curSt); - UIMAP['hl-line']={fg:'#112233',bg:'#aabbcc',bold:false,italic:false,underline:false,strike:false,box:null};buildMockFrame(); + UIMAP['hl-line']={fg:'#112233',bg:'#aabbcc',weight:null,slant:null,underline:null,strike:null,box:null};buildMockFrame(); const hlStyled=Q('[data-face="hl-line"]'),hlSt=hlStyled&&hlStyled.getAttribute('style')||''; A(hlSt.includes('#112233')&&hlSt.includes('#aabbcc'),'hl-line preview honors fg and bg: '+hlSt); - UIMAP['link']={fg:'#112233',bg:'#aabbcc',bold:false,italic:false,underline:true,strike:false,box:null};buildMockFrame(); + UIMAP['link']={fg:'#112233',bg:'#aabbcc',weight:null,slant:null,underline:{style:'line',color:null},strike:null,box:null};buildMockFrame(); const linkStyled=Q('[data-face="link"]'),linkSt=linkStyled&&linkStyled.getAttribute('style')||''; A(linkSt.includes('#112233')&&linkSt.includes('#aabbcc'),'inline UI face preview honors fg and bg: '+linkSt); const missing=UI_FACES.map(f=>f[0]).filter(f=>!Q('[data-face="'+f+'"]')); @@ -150,21 +150,21 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c const ch=parseFloat(getComputedStyle(textBox).fontSize)*0.65; A(br.left-tr.right<=ch*4.8,'vertical-border-near-text'); }else A(false,'vertical-border-layout-elements-present'); - UIMAP['line-number-current-line'].bold=true;buildMockFrame(); + UIMAP['line-number-current-line'].weight='bold';buildMockFrame(); const curNum=Q('[data-face="line-number-current-line"]'); - A(curNum&&/font-weight:\s*bold/.test(curNum.getAttribute('style')||''),'line-number-honors-weight'); - UIMAP['region'].bold=false;buildUITable(); + A(curNum&&/font-weight:\s*700/.test(curNum.getAttribute('style')||''),'line-number-honors-weight'); + UIMAP['region'].weight=null;buildUITable(); const uiBold=[...document.querySelectorAll('#uibody tr')].find(r=>r.dataset.face==='region').querySelector('.sbtn[title="bold"]'); - A(uiBold&&!uiBold.classList.contains('on'),'ui style button starts off when model is false'); + A(uiBold&&!uiBold.classList.contains('on'),'ui style button starts off when model is unset'); uiBold.click(); - A(uiBold.classList.contains('on')&&UIMAP['region'].bold===true,'ui style button visual state turns on with model'); + A(uiBold.classList.contains('on')&&UIMAP['region'].weight==='bold','ui style button visual state turns on with model'); uiBold.click(); - A(!uiBold.classList.contains('on')&&UIMAP['region'].bold===false,'ui style button visual state turns off with model'); - const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].bold=false;buildPkgTable(); + A(!uiBold.classList.contains('on')&&UIMAP['region'].weight===null,'ui style button visual state turns off with model'); + const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].weight=null;buildPkgTable(); const pkgBtn=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"] .sbtn[title="bold"]'); - A(pkgBtn()&&!pkgBtn().classList.contains('on'),'pkg style button starts off when model is false'); + A(pkgBtn()&&!pkgBtn().classList.contains('on'),'pkg style button starts off when model is unset'); pkgBtn().click(); - A(pkgBtn()&&pkgBtn().classList.contains('on')&&PKGMAP[app][face].bold===true,'pkg style button visual state turns on after rebuild'); + A(pkgBtn()&&pkgBtn().classList.contains('on')&&PKGMAP[app][face].weight==='bold','pkg style button visual state turns on after rebuild'); document.title='MOCKTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='mocktest';d.textContent='MOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} // Palette-generator gate (open with #generatortest): previewing is non-mutating, @@ -296,7 +296,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP)); CATS.forEach(c=>{if(c[0]!=='bg'&&c[0]!=='p')setSyntaxFg(c[0],'');}); setSyntaxFg('p','#f0fef0');setSyntaxFg('kw','#67809c');setSyntaxFg('str','#a3b18a');setSyntaxFg('bg','#000000'); - UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false}; + UIMAP['region']={fg:null,bg:'#202830',weight:null,slant:null,underline:null,strike:null}; buildUITable(); const cell=document.getElementById('uicr-region'); A(cell&&/^\d+\.\d (PASS|FAIL)$/.test(cell.textContent.trim()),'region shows compact worst-case readout: '+(cell&&cell.textContent)); @@ -310,7 +310,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i const fl=floor('#202830',fgSetForFace('region').set); A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex); A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg'); - UIMAP['region']={fg:'#f0fef0',bg:'#202830',bold:false,italic:false,underline:false,strike:false}; + UIMAP['region']={fg:'#f0fef0',bg:'#202830',weight:null,slant:null,underline:null,strike:null}; buildUITable(); const pairCell=document.getElementById('uicr-region'),pairWant=contrast('#f0fef0','#202830'); A(pairCell&&Math.abs(parseFloat(pairCell.textContent)-pairWant)<0.06,'region with explicit fg rates its own fg/bg pair: got '+(pairCell&&pairCell.textContent.trim())+' want '+pairWant.toFixed(1)); @@ -319,12 +319,12 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i const ml=document.getElementById('uicr-mode-line'); A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)'); A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent)); - UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false}; + UIMAP['region']={fg:null,bg:'#202830',weight:null,slant:null,underline:null,strike:null}; setSyntaxFg('p','');CATS.forEach(c=>{if(c[0]!=='bg')setSyntaxFg(c[0],'');});buildUITable(); const empty=document.getElementById('uicr-region'); A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent)); // A two-color face (own fg AND own bg) rates its own pair, never the ground bg. - UIMAP['mode-line']={fg:'#112233',bg:'#aabbcc',bold:false,italic:false,underline:false,strike:false}; + UIMAP['mode-line']={fg:'#112233',bg:'#aabbcc',weight:null,slant:null,underline:null,strike:null}; buildUITable(); const two=document.getElementById('uicr-mode-line'),twoWant=contrast('#112233','#aabbcc'); A(two&&Math.abs(parseFloat(two.textContent)-twoWant)<0.06,'ui two-color face rates own fg-on-bg: got '+(two&&two.textContent.trim())+' want '+twoWant.toFixed(1)); @@ -335,7 +335,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i PKGMAP[tApp][tFace]=savePF;buildPkgTable(); // A ground-bg change must not clobber a face's own preview bg, must leave a // two-color ratio alone, and must re-rate a ground-dependent face's cell. - UIMAP['fringe']={fg:'#ddeeff',bg:null,bold:false,italic:false,underline:false,strike:false}; + UIMAP['fringe']={fg:'#ddeeff',bg:null,weight:null,slant:null,underline:null,strike:null}; buildUITable(); setSyntaxFg('bg','#440000');applyGround(); const pv=document.getElementById('uiprev-mode-line'); @@ -346,7 +346,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i A(frc&&Math.abs(parseFloat(frc.textContent)-frWant)<0.06,'ground change re-rates a ground-dependent face: got '+(frc&&frc.textContent.trim())+' want '+frWant.toFixed(1)); // A default-fg (p) change through the real syntax dropdown re-rates a face // whose fg falls back to it. Drives the DOM so the handler wiring is pinned. - UIMAP['fringe']={fg:null,bg:'#aabbcc',bold:false,italic:false,underline:false,strike:false}; + UIMAP['fringe']={fg:null,bg:'#aabbcc',weight:null,slant:null,underline:null,strike:null}; buildUITable(); const pLocked=LOCKED.has('p');if(pLocked){LOCKED.delete('p');buildTable();} const pdd=document.querySelector('#legbody tr[data-kind="p"] .cdd'); @@ -366,7 +366,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i // algorithm, and pressed draws the shadow edge first. if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; const saveUI=JSON.parse(JSON.stringify(UIMAP)),saveP=PALETTE.slice(),savePK=JSON.parse(JSON.stringify(PKGMAP)); - UIMAP['mode-line']={fg:'#d8dee9',bg:'#30343c',bold:false,italic:false,underline:false,strike:false,box:{style:'released',width:1,color:null}}; + UIMAP['mode-line']={fg:'#d8dee9',bg:'#30343c',weight:null,slant:null,underline:null,strike:null,box:{style:'released',width:1,color:null}}; buildUITable(); const pv=document.getElementById('uiprev-mode-line'); const bs=pv&&pv.style.boxShadow; @@ -594,8 +594,8 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(! regenColumn('#67809c',2,{ground:groundPair()}).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); const innerOld=regenColumn('#67809c',2,{ground:groundPair()}).members.find(m=>m.offset===1).hex; // survives a count change const outerOld=regenColumn('#67809c',2,{ground:groundPair()}).members.find(m=>m.offset===2).hex; // dropped on count-down - UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false}; - UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false}; + UIMAP['region']={fg:null,bg:innerOld,weight:null,slant:null,underline:null,strike:null}; + UIMAP['highlight']={fg:null,bg:outerOld,weight:null,slant:null,underline:null,strike:null}; selectedIdx=null;renderPalette(); const blueSpanInput=document.querySelector('#pals .fstrip[data-column="blue"] .fcount input'); A(blueSpanInput&&blueSpanInput.max==='8','normal column span control allows up to 8 per side'); @@ -623,7 +623,7 @@ if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{i setSyntaxFg('bg','#0d0b0a');setSyntaxFg('p','#f0fef0'); PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; regenColumn('#67809c',2,{ground:groundPair()}).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); - UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + UIMAP['region']={fg:null,bg:'#67809c',weight:null,slant:null,underline:null,strike:null}; renderPalette();buildUITable(); selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c'); document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal'; @@ -713,9 +713,9 @@ if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ const tr1=document.querySelector('#pkgbody tr[data-face="'+face+'"]'); A(tr1.cells[7].classList.contains('nd'),'nondefault-height-marks-size-box'); A(!tr1.cells[4].classList.contains('nd'),'unchanged-style-box-stays-unmarked'); - PKGMAP[app][face].height=(row[2]&&row[2].height)||1;PKGMAP[app][face].bold=!((row[2]&&row[2].bold));buildPkgTable(); + PKGMAP[app][face].height=(row[2]&&row[2].height)||1;PKGMAP[app][face].weight=seedFace(row[2]||{}).weight==='bold'?null:'bold';buildPkgTable(); const tr2=document.querySelector('#pkgbody tr[data-face="'+face+'"]'); - A(tr2.cells[4].classList.contains('nd'),'toggled-bold-marks-style-box'); + A(tr2.cells[4].classList.contains('nd'),'toggled-weight-marks-style-box'); A(!tr2.cells[7].classList.contains('nd'),'restored-height-unmarks-size-box'); PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable(); document.title='NDTEST '+(ok?'PASS':'FAIL'); @@ -877,7 +877,7 @@ if(location.hash==='#unusedtest'){let ok=true;const notes=[];const A=(c,n)=>{if( const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSyn=JSON.parse(JSON.stringify(SYNTAX)),saveU=JSON.parse(JSON.stringify(UIMAP)); setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0'); PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue'],['#123456','teal','teal']]; - for(const f in UIMAP)UIMAP[f]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false}; + for(const f in UIMAP)UIMAP[f]={fg:null,bg:null,weight:null,slant:null,underline:null,strike:null}; setSyntaxFg('kw','#67809c'); renderPalette(); const tealStrip=document.querySelector('#pals .fstrip[data-column="teal"]'); @@ -898,8 +898,8 @@ if(location.hash==='#gonetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)); setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0'); PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']]; - UIMAP['region']={fg:null,bg:'#deadbe',bold:false,italic:false,underline:false,strike:false}; - UIMAP['highlight']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + UIMAP['region']={fg:null,bg:'#deadbe',weight:null,slant:null,underline:null,strike:null}; + UIMAP['highlight']={fg:null,bg:'#67809c',weight:null,slant:null,underline:null,strike:null}; buildUITable(); const goneDd=document.querySelector('#uibody tr[data-face="region"]').cells[3].querySelector('.cdd'); const okDd=document.querySelector('#uibody tr[data-face="highlight"]').cells[3].querySelector('.cdd'); @@ -915,8 +915,8 @@ if(location.hash==='#usagetest'){let ok=true;const notes=[];const A=(c,n)=>{if(! setSyntaxFg('bg','#101010');setSyntaxFg('p','#f0f0f0'); PALETTE=[['#101010','bg','ground'],['#f0f0f0','fg','ground'],['#67809c','blue','blue']]; const f0=UI_FACES[0][0],f0label=UI_FACES[0][1]||f0; - for(const f in UIMAP)UIMAP[f]={fg:null,bg:null,bold:false,italic:false,underline:false,strike:false}; - UIMAP[f0]={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + for(const f in UIMAP)UIMAP[f]={fg:null,bg:null,weight:null,slant:null,underline:null,strike:null}; + UIMAP[f0]={fg:null,bg:'#67809c',weight:null,slant:null,underline:null,strike:null}; renderPalette(); const blueChip=document.querySelector('#pals .fstrip[data-column="blue"] .pchip'); A(blueChip&&blueChip.title.includes('ui faces > '+f0label),'hover-title-lists-ui-face-usage'); diff --git a/scripts/theme-studio/default_faces.py b/scripts/theme-studio/default_faces.py index ce2bf3196..c8f7167d1 100644 --- a/scripts/theme-studio/default_faces.py +++ b/scripts/theme-studio/default_faces.py @@ -44,14 +44,19 @@ class DefaultFaces: out["fg"] = fg if bg: out["bg"] = bg + # Representation-only cutover: the snapshot's bold/italic become the new + # weight/slant shape, and underline/strike become objects. The same + # narrowing as before (only "bold"/"italic" survive; richer weights and + # underline colors wait for the snapshot refresh), so the emitted theme + # is byte-identical. if data.get("weight") == "bold": - out["bold"] = True + out["weight"] = "bold" if data.get("slant") == "italic": - out["italic"] = True + out["slant"] = "italic" if data.get("underline"): - out["underline"] = True + out["underline"] = {"style": "line", "color": None} if data.get("strike"): - out["strike"] = True + out["strike"] = {"color": None} if data.get("inherit"): out["inherit"] = data.get("inherit") if data.get("height") and data.get("height") != 1: diff --git a/scripts/theme-studio/face_specs.py b/scripts/theme-studio/face_specs.py index 697eec50f..5fa038068 100644 --- a/scripts/theme-studio/face_specs.py +++ b/scripts/theme-studio/face_specs.py @@ -5,20 +5,20 @@ from __future__ import annotations from typing import Any -# The full per-face attribute model. inherit and height live here (every tier -# can set them now, not just packages). bold/italic/underline/strike stay as the -# legacy booleans for this phase; the weight/slant/underline-object cutover lands -# with the editor widgets that force it. distant-fg, family, overline, inverse, -# and extend are added in their final shape (no legacy form to migrate). +# The full per-face attribute model, in its final shape. weight and slant replace +# the legacy bold/italic booleans (weight is one of light/normal/medium/semibold/ +# bold/heavy; slant is normal/italic/oblique). underline and strike are objects: +# underline is {style: line|wave, color} and strike is {color}; null means unset. +# inherit and height are no longer package-only — every tier can set them. STYLE_DEFAULTS: dict[str, Any] = { "fg": None, "bg": None, "distant-fg": None, "family": None, - "bold": False, - "italic": False, - "underline": False, - "strike": False, + "weight": None, + "slant": None, + "underline": None, + "strike": None, "overline": None, "box": None, "inverse": False, @@ -32,10 +32,43 @@ STYLE_DEFAULTS: dict[str, Any] = { PACKAGE_DEFAULTS: dict[str, Any] = dict(STYLE_DEFAULTS) +def migrate_legacy(spec: dict[str, Any]) -> dict[str, Any]: + """Convert a face spec's legacy boolean style fields to the new shape. + + bold -> weight "bold", italic -> slant "italic", underline true -> + {style: line, color: null}, strike true -> {color: null}. An explicit + weight/slant already present wins over the legacy flag. Specs already in the + new shape pass through unchanged, so this is safe to apply to any input. The + JS side mirrors this in app-core.js migrateLegacyFace; keep them in step. + """ + out = dict(spec) + if "bold" in out: + bold = out.pop("bold") + if bold and not out.get("weight"): + out["weight"] = "bold" + if "italic" in out: + italic = out.pop("italic") + if italic and not out.get("slant"): + out["slant"] = "italic" + if "underline" in out: + underline = out["underline"] + if underline is True: + out["underline"] = {"style": "line", "color": None} + elif underline is False: + out["underline"] = None + if "strike" in out: + strike = out["strike"] + if strike is True: + out["strike"] = {"color": None} + elif strike is False: + out["strike"] = None + return out + + def face_spec(spec: dict[str, Any] | None = None, *, package: bool = False) -> dict[str, Any]: out = dict(PACKAGE_DEFAULTS if package else STYLE_DEFAULTS) if spec: - out.update(spec) + out.update(migrate_legacy(spec)) return out diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index ae31afae2..347a21976 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -2,7 +2,7 @@ import json, os, re from app_inventory import add_inventory_apps, apply_default_face_seeds, apply_package_overrides, face_rows from default_faces import DefaultFaces from face_data import * -from face_specs import face_spec, ui_face_spec +from face_specs import face_spec, ui_face_spec, migrate_legacy HERE=os.path.dirname(os.path.abspath(__file__)) def read_text(name): @@ -100,11 +100,11 @@ def initial_maps(cols,defaults): def apply_builtin_fallback_styles(uimap): """Fill the small set of style defaults used when no Emacs snapshot exists.""" - uimap["link"]["underline"]=True + uimap["link"]["underline"]={"style":"line","color":None} for face in ("lazy-highlight","show-paren-match"): - uimap[face]["underline"]=True + uimap[face]["underline"]={"style":"line","color":None} for face in ("error","warning","success"): - uimap[face]["bold"]=True + uimap[face]["weight"]="bold" for face in ("mode-line","mode-line-inactive"): uimap[face]["box"]={"style":"released","width":1,"color":None} @@ -148,7 +148,7 @@ def apply_seed_basics(data,palette,uimap,locks): palette=data['palette'] if data.get('ui'): for key,value in data['ui'].items(): - uimap[key]=value + uimap[key]=migrate_legacy(value) if 'locks' in data: locks=data['locks'] return palette,uimap,locks diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index f45a72be5..b39eb44d3 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { - nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, + nameToHex, migrateLegacyFace, legacyStyleOn, toggleLegacyStyle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, clearPalettePlan, deletePaletteColumnPlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet, galleryModel, appViewKeysSorted, faceBoxNonDefaults, stepViewIndex, } from './app-core.js'; @@ -621,7 +621,7 @@ test('buildPkgmap: Normal — seeds faces, resolving names and applying defaults ] } }; const m = buildPkgmap(apps, PAL); assert.equal(m['org-mode']['org-todo'].fg, '#67809c'); - assert.equal(m['org-mode']['org-todo'].bold, true); + assert.equal(m['org-mode']['org-todo'].weight, 'bold'); // legacy bold migrated on seed assert.equal(m['org-mode']['org-todo'].source, 'default'); assert.equal(m['org-mode']['org-todo'].height, 1); assert.equal(m['org-mode']['org-done'].inherit, 'org-todo'); @@ -630,13 +630,53 @@ test('buildPkgmap: Normal — seeds faces, resolving names and applying defaults test('normalizePkgFace: Normal — fills every package face field', () => { assert.deepEqual(normalizePkgFace({ fg: 'blue', bold: true, inherit: 'base' }, 'default', PAL), { - fg: '#67809c', bg: null, 'distant-fg': null, family: null, bold: true, - italic: false, underline: false, strike: false, overline: null, + fg: '#67809c', bg: null, 'distant-fg': null, family: null, weight: 'bold', + slant: null, underline: null, strike: null, overline: null, inherit: 'base', height: 1, box: null, inverse: false, extend: false, source: 'default', }); }); +test('migrateLegacyFace: Normal — legacy booleans become the new shape', () => { + assert.deepEqual( + migrateLegacyFace({ bold: true, italic: true, underline: true, strike: true }), + { weight: 'bold', slant: 'italic', underline: { style: 'line', color: null }, strike: { color: null } }, + ); +}); + +test('migrateLegacyFace: Boundary — false booleans clear, explicit weight/slant win', () => { + const m = migrateLegacyFace({ bold: false, italic: false, underline: false, strike: false }); + assert.ok(!('weight' in m), 'bold:false sets no weight'); + assert.ok(!('slant' in m), 'italic:false sets no slant'); + assert.equal(m.underline, null); + assert.equal(m.strike, null); + assert.ok(!('bold' in m) && !('italic' in m), 'legacy booleans are removed'); + // an explicit weight/slant already set is not overwritten by the legacy flag + assert.equal(migrateLegacyFace({ bold: true, weight: 'light' }).weight, 'light'); + assert.equal(migrateLegacyFace({ italic: true, slant: 'oblique' }).slant, 'oblique'); +}); + +test('migrateLegacyFace: Boundary — a new-shape face passes through unchanged (idempotent)', () => { + const f = { weight: 'semibold', slant: 'oblique', underline: { style: 'wave', color: '#abcdef' }, strike: { color: null } }; + assert.deepEqual(migrateLegacyFace(f), f); + assert.deepEqual(migrateLegacyFace(migrateLegacyFace(f)), f); +}); + +test('legacyStyleOn / toggleLegacyStyle: Normal — bridge the B/I/U/S buttons to the model', () => { + const f = { weight: null, slant: null, underline: null, strike: null }; + assert.equal(legacyStyleOn(f, 'bold'), false); + toggleLegacyStyle(f, 'bold'); assert.equal(f.weight, 'bold'); assert.equal(legacyStyleOn(f, 'bold'), true); + toggleLegacyStyle(f, 'bold'); assert.equal(f.weight, null); + toggleLegacyStyle(f, 'italic'); assert.equal(f.slant, 'italic'); + toggleLegacyStyle(f, 'underline'); assert.deepEqual(f.underline, { style: 'line', color: null }); + toggleLegacyStyle(f, 'underline'); assert.equal(f.underline, null); + toggleLegacyStyle(f, 'strike'); assert.deepEqual(f.strike, { color: null }); +}); + +test('legacyStyleOn: Boundary — a non-bold weight reads the bold button as off', () => { + assert.equal(legacyStyleOn({ weight: 'semibold' }, 'bold'), false); +}); + test('normalizePkgFace: Normal — carries the additive attribute model', () => { const f = normalizePkgFace({ fg: 'blue', 'distant-fg': '#222222', family: 'Iosevka', @@ -658,8 +698,8 @@ test('normalizePkgFace: Boundary — distant-fg resolves through the palette', ( test('buildPkgmap: Boundary — a face with no default dict still seeds blank', () => { const m = buildPkgmap({ a: { faces: [['f', 'f']] } }, PAL); assert.deepEqual(m.a.f, { - fg: null, bg: null, 'distant-fg': null, family: null, bold: false, - italic: false, underline: false, strike: false, overline: null, + fg: null, bg: null, 'distant-fg': null, family: null, weight: null, + slant: null, underline: null, strike: null, overline: null, inherit: null, height: 1, box: null, inverse: false, extend: false, source: 'default', }); @@ -692,15 +732,29 @@ test('effResolve: Error — an inherit cycle terminates at null, no overflow', ( test('packagesForExport: Normal — exports sourced faces, omits height 1', () => { const m = { a: { f: { - fg: '#67809c', bg: null, bold: true, italic: false, underline: false, - strike: false, inherit: null, height: 1, source: 'user', + fg: '#67809c', bg: null, weight: 'bold', slant: null, underline: null, + strike: null, inherit: null, height: 1, source: 'user', } } }; const out = packagesForExport(m); assert.equal(out.a.f.fg, '#67809c'); + assert.equal(out.a.f.weight, 'bold'); assert.equal(out.a.f.source, 'user'); + assert.ok(!('slant' in out.a.f), 'unset slant is omitted'); assert.ok(!('height' in out.a.f), 'height 1 is omitted'); }); +test('packagesForExport: Normal — emits weight/slant/underline/strike only when set', () => { + const m = { a: { f: normalizePkgFace({ + fg: '#67809c', weight: 'semibold', slant: 'oblique', + underline: { style: 'wave', color: '#abcdef' }, strike: { color: null }, + }, 'user') } }; + const o = packagesForExport(m).a.f; + assert.equal(o.weight, 'semibold'); + assert.equal(o.slant, 'oblique'); + assert.deepEqual(o.underline, { style: 'wave', color: '#abcdef' }); + assert.deepEqual(o.strike, { color: null }); +}); + test('packagesForExport: Boundary — keeps a non-default height', () => { const m = { a: { f: { fg: null, bg: null, source: 'user', height: 1.2 } } }; assert.equal(packagesForExport(m).a.f.height, 1.2); @@ -736,13 +790,22 @@ test('mergePackagesInto: Normal — fills missing fields with defaults', () => { const m = {}; mergePackagesInto(m, { a: { f: { fg: '#112233' } } }); assert.deepEqual(m.a.f, { - fg: '#112233', bg: null, 'distant-fg': null, family: null, bold: false, - italic: false, underline: false, strike: false, overline: null, + fg: '#112233', bg: null, 'distant-fg': null, family: null, weight: null, + slant: null, underline: null, strike: null, overline: null, inherit: null, height: 1, box: null, inverse: false, extend: false, source: 'user', }); }); +test('mergePackagesInto: Normal — migrates a legacy preset face on import', () => { + const m = {}; + mergePackagesInto(m, { a: { f: { fg: '#112233', bold: true, italic: true, underline: true } } }); + assert.equal(m.a.f.weight, 'bold'); + assert.equal(m.a.f.slant, 'italic'); + assert.deepEqual(m.a.f.underline, { style: 'line', color: null }); + assert.ok(!('bold' in m.a.f) && !('italic' in m.a.f), 'legacy booleans dropped'); +}); + test('mergePackagesInto: Boundary — undefined pkgs is a no-op', () => { const m = { a: { f: { fg: '#000000' } } }; mergePackagesInto(m, undefined); @@ -910,9 +973,10 @@ test('faceBoxNonDefaults: a set fg over an empty default flags fg', () => { assert.equal(faceBoxNonDefaults({}, {}).fg, false); }); test('faceBoxNonDefaults: any style attr differing flags the style box once', () => { - assert.equal(faceBoxNonDefaults({ bold: true }, { bold: false }).style, true); - assert.equal(faceBoxNonDefaults({ strike: true }, {}).style, true); - assert.equal(faceBoxNonDefaults({ bold: true }, { bold: true }).style, false); + assert.equal(faceBoxNonDefaults({ weight: 'bold' }, { weight: null }).style, true); + assert.equal(faceBoxNonDefaults({ slant: 'italic' }, {}).style, true); + assert.equal(faceBoxNonDefaults({ underline: { style: 'line', color: null } }, {}).style, true); + assert.equal(faceBoxNonDefaults({ weight: 'bold' }, { weight: 'bold' }).style, false); }); test('faceBoxNonDefaults: inherit and box differences are flagged', () => { assert.equal(faceBoxNonDefaults({ inherit: 'bold' }, { inherit: null }).inherit, true); diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py index 91f8b4bd0..e20e90b3e 100644 --- a/scripts/theme-studio/test_generate.py +++ b/scripts/theme-studio/test_generate.py @@ -192,15 +192,16 @@ class FacesHelper(unittest.TestCase): class FaceSpecDefaults(unittest.TestCase): def test_ui_face_spec_fills_style_fields(self): + # The legacy "bold" migrates to weight "bold" through face_spec. self.assertEqual(ui_face_spec({"bg": "#ffffff", "bold": True}), { "fg": None, "bg": "#ffffff", "distant-fg": None, "family": None, - "bold": True, - "italic": False, - "underline": False, - "strike": False, + "weight": "bold", + "slant": None, + "underline": None, + "strike": None, "overline": None, "box": None, "inverse": False, @@ -215,16 +216,24 @@ class FaceSpecDefaults(unittest.TestCase): self.assertEqual(spec["inherit"], "shadow") self.assertEqual(spec["height"], 1.3) + def test_face_spec_migrates_legacy_style_booleans(self): + spec = ui_face_spec({"italic": True, "underline": True, "strike": True}) + self.assertEqual(spec["slant"], "italic") + self.assertEqual(spec["underline"], {"style": "line", "color": None}) + self.assertEqual(spec["strike"], {"color": None}) + self.assertNotIn("bold", spec) + self.assertNotIn("italic", spec) + def test_package_face_spec_fills_structure_fields(self): self.assertEqual(package_face_spec({"inherit": "base", "height": 1.2}), { "fg": None, "bg": None, "distant-fg": None, "family": None, - "bold": False, - "italic": False, - "underline": False, - "strike": False, + "weight": None, + "slant": None, + "underline": None, + "strike": None, "overline": None, "box": None, "inverse": False, @@ -280,8 +289,8 @@ class GeneratorStateHelpers(unittest.TestCase): DefaultFaces(None), ) self.assertEqual(syntax["kw"]["fg"], "#d3d3d3") - self.assertTrue(syntax["kw"]["bold"]) - self.assertFalse(syntax["kw"]["italic"]) + self.assertEqual(syntax["kw"]["weight"], "bold") + self.assertIsNone(syntax["kw"]["slant"]) def test_builtin_fallback_styles_fill_known_emacs_styles(self): uimap = { @@ -293,12 +302,13 @@ class GeneratorStateHelpers(unittest.TestCase): ) } generate.apply_builtin_fallback_styles(uimap) - self.assertTrue(uimap["link"]["underline"]) - self.assertTrue(uimap["lazy-highlight"]["underline"]) - self.assertTrue(uimap["show-paren-match"]["underline"]) - self.assertTrue(uimap["error"]["bold"]) - self.assertTrue(uimap["warning"]["bold"]) - self.assertTrue(uimap["success"]["bold"]) + line_underline = {"style": "line", "color": None} + self.assertEqual(uimap["link"]["underline"], line_underline) + self.assertEqual(uimap["lazy-highlight"]["underline"], line_underline) + self.assertEqual(uimap["show-paren-match"]["underline"], line_underline) + self.assertEqual(uimap["error"]["weight"], "bold") + self.assertEqual(uimap["warning"]["weight"], "bold") + self.assertEqual(uimap["success"]["weight"], "bold") self.assertEqual(uimap["mode-line"]["box"], {"style": "released", "width": 1, "color": None}) self.assertEqual(uimap["mode-line-inactive"]["box"], {"style": "released", "width": 1, "color": None}) @@ -334,7 +344,7 @@ class GeneratorStateHelpers(unittest.TestCase): } }, syntax, color_map) self.assertEqual(syntax["kw"]["fg"], "#222222") - self.assertTrue(syntax["kw"]["bold"]) + self.assertEqual(syntax["kw"]["weight"], "bold") self.assertEqual(color_map["kw"], "#222222") self.assertNotIn("unknown", syntax) @@ -418,9 +428,9 @@ class DefaultFaceAdapter(unittest.TestCase): self.assertEqual(self.defaults.seed("sample", effective=False), { "fg": "#333333", "bg": "#ffffff", - "bold": True, - "italic": True, - "underline": True, + "weight": "bold", + "slant": "italic", + "underline": {"style": "line", "color": None}, "inherit": "parent", "box": {"style": "released", "width": 2, "color": None}, }) @@ -544,11 +554,11 @@ class GeneratedDefaults(unittest.TestCase): def test_syntax_defaults_capture_font_lock_styles(self): self.assertEqual(generate.MAP["kw"], "#d3d3d3") - self.assertTrue(generate.SYNTAX["kw"]["bold"]) - self.assertFalse(generate.SYNTAX["kw"]["italic"]) + self.assertEqual(generate.SYNTAX["kw"]["weight"], "bold") + self.assertIsNone(generate.SYNTAX["kw"]["slant"]) self.assertEqual(generate.MAP["str"], "#696969") - self.assertFalse(generate.SYNTAX["str"]["bold"]) - self.assertTrue(generate.SYNTAX["str"]["italic"]) + self.assertIsNone(generate.SYNTAX["str"]["weight"]) + self.assertEqual(generate.SYNTAX["str"]["slant"], "italic") if __name__ == "__main__": diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index d58859eac..b6aad069f 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -270,13 +270,13 @@