aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/app.js38
-rw-r--r--scripts/theme-studio/colormath.js29
-rwxr-xr-xscripts/theme-studio/run-tests.sh2
-rw-r--r--scripts/theme-studio/test-colormath.mjs32
-rw-r--r--scripts/theme-studio/theme-studio.html65
5 files changed, 150 insertions, 16 deletions
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index 57057328..aadfd5b7 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -408,9 +408,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).
@@ -494,7 +499,7 @@ function buildMockFrame(){
buf+=`<div class="ln" style="background:${rowBg}"><span class="fr" data-face="fringe" style="background:${frng.bg||bg};color:${frng.fg||fg};text-align:center;font-size:10px;overflow:hidden" title="fringe">${L.cont?'&#8618;':''}</span><span class="num" data-face="${nFace}" style="color:${nFg};background:${nBg};${udeco(isc?lnc:ln)}">${i+1}</span><span class="cd">${cd||'&nbsp;'}</span></div>`;
});
let html=`<div class="mbuf" style="display:flex;background:${bg}"><div style="flex:1;min-width:0">${buf}</div><div data-face="vertical-border" title="vertical-border" style="width:3px;flex:0 0 auto;background:${vb.fg||vb.bg||'#2f343a'}"></div></div>`;
- 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+=`<div class="bar" data-face="mode-line" style="background:${ml.bg||fg};color:${ml.fg||bg};${udeco(ml)}${mlbx?';box-shadow:'+mlbx:''}"> init.el (Emacs Lisp) L5 git:main </div>`;
html+=`<div class="bar" data-face="mode-line-inactive" style="background:${mli.bg||bg};color:${mli.fg||fg};${udeco(mli)}${mlibx?';box-shadow:'+mlibx:''}"> *Messages* (Fundamental) </div>`;
html+=`<div class="echo" style="color:${fg}"><span data-face="minibuffer-prompt" style="color:${mb.fg||fg};${udeco(mb)}">I-search:</span> count <span data-face="isearch-fail" style="color:${isf.fg||fg};background:${isf.bg||'transparent'};${udeco(isf)}">zzz [no match]</span></div>`;
@@ -539,7 +544,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 `<span data-face="${face}" style="${ofs(app,face)}">${txt}</span>`;}
function renderOrgPreview(){const a='org-mode',L=[];
L.push(os(a,'org-document-info-keyword','#+TITLE:')+' '+os(a,'org-document-title','Project Notes'));
@@ -863,7 +868,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='';
@@ -1076,7 +1081,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');
@@ -1100,6 +1105,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)')<bs2.indexOf('rgb(113, 118, 127)'),'pressed swaps the pair (shadow edge first): '+bs2);
+ UIMAP['mode-line'].box={style:'line',width:1,color:'#ff0000'};paintUI('mode-line');
+ A(pv&&pv.style.boxShadow.includes('rgb(255, 0, 0)'),'line style keeps its explicit color: '+(pv&&pv.style.boxShadow));
+ for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();
+ document.title='BEVELTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='beveltest';d.textContent='BEVELTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
// lightness band for a selected covered face and hides it when no face is selected.
if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
diff --git a/scripts/theme-studio/colormath.js b/scripts/theme-studio/colormath.js
index 167a5ea1..2a7328e5 100644
--- a/scripts/theme-studio/colormath.js
+++ b/scripts/theme-studio/colormath.js
@@ -190,4 +190,31 @@ function paletteWarnings(palette, threshold = 0.02, cap = 5) {
return { warnings: pairs.slice(0, cap), overflow: Math.max(0, pairs.length - cap), nearest };
}
-export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, lin, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, oklab2lrgb, inGamut, lrgb2hex, planeCell, paletteWarnings };
+// --- 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) };
+}
+
+export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, lin, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, oklab2lrgb, inGamut, lrgb2hex, planeCell, paletteWarnings, reliefColors };
diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh
index c8e34fe9..0db7faa0 100755
--- a/scripts/theme-studio/run-tests.sh
+++ b/scripts/theme-studio/run-tests.sh
@@ -53,7 +53,7 @@ CHROME=""
for c in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi
done
-HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest contrasttest safetest healtest familytest counttest baseedittest roundtriptest"
+HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest contrasttest safetest healtest familytest counttest baseedittest roundtriptest beveltest"
if [ "$NO_BROWSER" = 1 ]; then
skip_msg "browser hash gates (--no-browser)"
elif [ -z "$CHROME" ]; then
diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs
index 58ce7829..992d35bc 100644
--- a/scripts/theme-studio/test-colormath.mjs
+++ b/scripts/theme-studio/test-colormath.mjs
@@ -13,6 +13,7 @@ import {
srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE,
hex2rgb, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex,
oklab2lrgb, inGamut, lrgb2hex, planeCell, paletteWarnings,
+ reliefColors,
} from './colormath.js';
const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps;
@@ -230,6 +231,37 @@ test('paletteWarnings: threshold is inclusive-exclusive at the boundary', () =>
assert.equal(paletteWarnings(pal, 0.007).warnings.length, 1, 'just above the pair distance');
});
+// Fixtures hand-computed from Emacs 30's xterm.c x_alloc_lighter_color
+// (factor 1.2 / delta 0x8000 highlight, 0.6 / 0x4000 shadow, dark boost
+// below brightness 48000/65535, same-color fallback adds delta).
+test('reliefColors: dark mode-line bg gets the dark boost (Normal)', () => {
+ const { hl, sh } = reliefColors('#30343c');
+ assert.equal(hl, '#71767f');
+ assert.equal(sh, '#0f1116');
+});
+
+test('reliefColors: grey75 brightness is above the boost limit (Normal)', () => {
+ const { hl, sh } = reliefColors('#bfbfbf');
+ assert.equal(hl, '#e5e5e5'); // 1.2x only, no additive boost
+ assert.equal(sh, '#737373'); // 0.6x only
+});
+
+test('reliefColors: pure black hits the same-color fallback for the shadow (Boundary)', () => {
+ const { hl, sh } = reliefColors('#000000');
+ assert.equal(hl, '#4d4d4d'); // boost lifts the highlight off black
+ assert.equal(sh, '#404040'); // 0.6x + boost still black -> fallback adds delta
+});
+
+test('reliefColors: pure white highlight saturates, shadow scales (Boundary)', () => {
+ const { hl, sh } = reliefColors('#ffffff');
+ assert.equal(hl, '#ffffff'); // clamped, fallback also clamps to white
+ assert.equal(sh, '#999999');
+});
+
+test('reliefColors: malformed hex returns null pair (Error)', () => {
+ assert.deepEqual(reliefColors('nonsense'), { hl: null, sh: null });
+});
+
// Guards the one-source-of-truth contract: the page must carry colormath.js's
// body (sans exports) verbatim, so the inlined copy and the tested module cannot
// drift. Requires `python3 generate.py` to have run first.
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+=`<div class="ln" style="background:${rowBg}"><span class="fr" data-face="fringe" style="background:${frng.bg||bg};color:${frng.fg||fg};text-align:center;font-size:10px;overflow:hidden" title="fringe">${L.cont?'&#8618;':''}</span><span class="num" data-face="${nFace}" style="color:${nFg};background:${nBg};${udeco(isc?lnc:ln)}">${i+1}</span><span class="cd">${cd||'&nbsp;'}</span></div>`;
});
let html=`<div class="mbuf" style="display:flex;background:${bg}"><div style="flex:1;min-width:0">${buf}</div><div data-face="vertical-border" title="vertical-border" style="width:3px;flex:0 0 auto;background:${vb.fg||vb.bg||'#2f343a'}"></div></div>`;
- 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+=`<div class="bar" data-face="mode-line" style="background:${ml.bg||fg};color:${ml.fg||bg};${udeco(ml)}${mlbx?';box-shadow:'+mlbx:''}"> init.el (Emacs Lisp) L5 git:main </div>`;
html+=`<div class="bar" data-face="mode-line-inactive" style="background:${mli.bg||bg};color:${mli.fg||fg};${udeco(mli)}${mlibx?';box-shadow:'+mlibx:''}"> *Messages* (Fundamental) </div>`;
html+=`<div class="echo" style="color:${fg}"><span data-face="minibuffer-prompt" style="color:${mb.fg||fg};${udeco(mb)}">I-search:</span> count <span data-face="isearch-fail" style="color:${isf.fg||fg};background:${isf.bg||'transparent'};${udeco(isf)}">zzz [no match]</span></div>`;
@@ -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 `<span data-face="${face}" style="${ofs(app,face)}">${txt}</span>`;}
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)')<bs2.indexOf('rgb(113, 118, 127)'),'pressed swaps the pair (shadow edge first): '+bs2);
+ UIMAP['mode-line'].box={style:'line',width:1,color:'#ff0000'};paintUI('mode-line');
+ A(pv&&pv.style.boxShadow.includes('rgb(255, 0, 0)'),'line style keeps its explicit color: '+(pv&&pv.style.boxShadow));
+ for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();
+ document.title='BEVELTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='beveltest';d.textContent='BEVELTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
// lightness band for a selected covered face and hides it when no face is selected.
if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};