From bb2aed2f4ea57bfd0468e683bc33e795a2bf4711 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 10 Jun 2026 15:23:50 -0500 Subject: fix(theme-studio): derive box bevel colors from the face background The released/pressed bevel was a flat translucent white/black overlay, which reads weaker than the box Emacs draws. reliefColors in colormath.js now ports Emacs 30's x_alloc_lighter_color: highlight = bg x1.2, shadow = bg x0.6, an additive boost for dark backgrounds, and the same-color fallback for pure black and white. boxCss takes the face's effective bg and derives both edges from it. Pressed swaps the pair, and the translucent pair remains only when no bg is known. Width stays 1px because dupre's :line-width -1 draws 1px lines in Emacs too. The gap was color strength, not width. Five node tests pin hand-computed fixtures from the C source, and a new #beveltest gate pins the wiring. --- scripts/theme-studio/theme-studio.html | 65 ++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) (limited to 'scripts/theme-studio/theme-studio.html') diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 436e2b03..b85eead8 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -383,6 +383,33 @@ function paletteWarnings(palette, threshold = 0.02, cap = 5) { pairs.sort((a, b) => a.dE - b.dE); return { warnings: pairs.slice(0, cap), overflow: Math.max(0, pairs.length - cap), nearest }; } + +// --- 3D-box relief colors, matching Emacs's renderer --------------------- +// Port of x_alloc_lighter_color (Emacs 30 xterm.c): highlight = bg x 1.2 +// (delta 0x8000), shadow = bg x 0.6 (delta 0x4000), both in 16-bit channel +// space. Backgrounds dimmer than 48000/65535 (by Emacs's 2R+3G+B/6 weighting) +// get an additive boost of delta*dimness*factor/2, because scaling alone +// barely moves a dark color. When the result still equals the background +// (pure black shadow, pure white highlight), Emacs retries with bg+delta. +function reliefColors(bgHex) { + const rgb = hex2rgb(bgHex); + if (rgb.some((c) => Number.isNaN(c))) return { hl: null, sh: null }; + const ch16 = rgb.map((c) => c * 257); + const one = (factor, delta) => { + let nw = ch16.map((c) => Math.min(0xffff, factor * c)); + const bright = (2 * ch16[0] + 3 * ch16[1] + ch16[2]) / 6; + if (bright < 48000) { + const md = delta * (1 - bright / 48000) * factor / 2; + nw = factor < 1 + ? nw.map((v) => Math.max(0, v - md)) + : nw.map((v) => Math.min(0xffff, v + md)); + } + if (nw.every((v, i) => Math.round(v) === ch16[i])) + nw = ch16.map((c) => Math.min(0xffff, c + delta)); + return '#' + nw.map((v) => Math.round(v / 257).toString(16).padStart(2, '0')).join(''); + }; + return { hl: one(1.2, 0x8000), sh: one(0.6, 0x4000) }; +} // Pure package-model + dropdown logic, inlined verbatim from app-core.js. The // wrappers above (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) delegate here. // Pure app logic — the package-face model and the dropdown option list — with no @@ -1044,9 +1071,14 @@ function udeco(o){return `font-weight:${o.bold?'bold':'normal'};font-style:${o.i // 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 the background so they read on any color. -function boxCss(b){if(!b||!b.style)return '';const w=b.width||1; - if(b.style==='released')return `inset ${w}px ${w}px 0 #ffffff33,inset -${w}px -${w}px 0 #00000066`; - if(b.style==='pressed')return `inset ${w}px ${w}px 0 #00000066,inset -${w}px -${w}px 0 #ffffff33`; +function boxCss(b,bg){if(!b||!b.style)return '';const w=b.width||1; + if(b.style==='released'||b.style==='pressed'){ + // Emacs derives the 3D edges from the face's background (reliefColors, + // ported from xterm.c); the translucent pair is only the no-bg fallback. + const r=bg?reliefColors(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'}`;} // The per-row box control: none / line / raised / pressed. get()/set() read and // write the face's box object (null = no box). @@ -1130,7 +1162,7 @@ function buildMockFrame(){ buf+=`
${L.cont?'↪':''}${i+1}${cd||' '}
`; }); let html=`
${buf}
`; - const mlbx=boxCss(ml.box),mlibx=boxCss(mli.box); + const mlbx=boxCss(ml.box,ml.bg||bg),mlibx=boxCss(mli.box,mli.bg||bg); html+=`
init.el (Emacs Lisp) L5 git:main
`; html+=`
*Messages* (Fundamental)
`; html+=`
I-search: count zzz [no match]
`; @@ -1175,7 +1207,7 @@ function buildPkgTable(){ } applyTableSort('pkgbody'); } -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);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:${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 os(app,face,txt){return `${txt}`;} function renderOrgPreview(){const a='org-mode',L=[]; L.push(os(a,'org-document-info-keyword','#+TITLE:')+' '+os(a,'org-document-title','Project Notes')); @@ -1499,7 +1531,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); +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)); const cr=document.getElementById('uicr-'+face);if(cr){const w=worstCellHtml(face);if(w!==null){cr.innerHTML=w;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}} function buildUITable(){ const tb=document.getElementById('uibody');tb.innerHTML=''; @@ -1712,7 +1744,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i // 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}; buildUITable(); - const gb=MAP['bg'];MAP['bg']='#440000';applyGround(); + MAP['bg']='#440000';applyGround(); const pv=document.getElementById('uiprev-mode-line'); A(pv&&pv.style.background==='rgb(170, 187, 204)','ground change keeps a face own preview bg: got '+(pv&&pv.style.background)); const twoAfter=document.getElementById('uicr-mode-line'); @@ -1736,6 +1768,25 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();applyGround(); document.title='CONTRASTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Bevel gate (open with #beveltest): released/pressed boxes derive their +// highlight and shadow from the face's effective bg per Emacs's relief +// 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)); + UIMAP['mode-line']={fg:'#d8dee9',bg:'#30343c',bold:false,italic:false,underline:false,strike:false,box:{style:'released',width:1,color:null}}; + buildUITable(); + const pv=document.getElementById('uiprev-mode-line'); + const bs=pv&&pv.style.boxShadow; + A(bs&&bs.includes('rgb(113, 118, 127)'),'released highlight derives from the face bg (#71767f): '+bs); + A(bs&&bs.includes('rgb(15, 17, 22)'),'released shadow derives from the face bg (#0f1116): '+bs); + UIMAP['mode-line'].box={style:'pressed',width:1,color:null};paintUI('mode-line'); + const bs2=pv&&pv.style.boxShadow; + A(bs2&&bs2.includes('rgb(15, 17, 22)')&&bs2.includes('rgb(113, 118, 127)')&&bs2.indexOf('rgb(15, 17, 22)'){if(!c){ok=false;notes.push(n);}}; -- cgit v1.2.3