From 1d8b9f9eec00db6c41123475918626fc8721a3bc Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 9 Jun 2026 19:01:48 -0500 Subject: feat(theme-studio): show worst-case contrast for overlay faces The five covered overlay faces (region, hl-line, highlight, lazy-highlight, isearch) now show the worst-case floor over their foreground set instead of one fg/bg pair. The cell starts with "worst:", names the limiting foreground by role and hex, then gives the ratio and a PASS/FAIL verdict, so a tint that clears the default text but fails the darkest token can't hide. The foreground set is the live syntax-token colors plus the default fg; the verdict is WCAG AA by default. Package and other UI rows keep their single-pair readout. A syntax-color edit now repaints the covered faces, since their floors depend on the whole token palette, not just their own fg/bg. An out-of-scope face falls through to the single-pair cell, and a face whose set resolves empty reads "no fg set" rather than a bogus ratio. Phase 4 of the palette-ramps spec. A #contrasttest browser gate pins the readout, the keyword-blue limiting case, the single-pair fallback, and the no-set message. --- scripts/theme-studio/app.js | 51 ++++++++++++++++++++++++++++++++-- scripts/theme-studio/run-tests.sh | 2 +- scripts/theme-studio/theme-studio.html | 51 ++++++++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 5 deletions(-) (limited to 'scripts/theme-studio') diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 52bc4b6f..9fb509ac 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -121,7 +121,7 @@ function buildTable(){ const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt'; function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';} function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);} - const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}}); + const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();}); styleEx();styleCr(); const lkTd=mkLockCell(kind,[dd]); // style buttons @@ -778,8 +778,31 @@ function genericPreview(app){let h='
no fg set'; + const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL'; + const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict; + return `${esc(s)}`; +} +// 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); - const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}} + 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=''; for(const [face,label,ex] of UI_FACES){ @@ -973,3 +996,27 @@ if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c PALETTE=save;selectedIdx=null;renderPalette();closeRamp(); document.title='RAMPTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Worst-case readout gate (open with #contrasttest): a covered overlay face shows +// the floor over its foreground set and names the limiting foreground, an +// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set". +if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP)); + MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000'; + UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false}; + buildUITable(); + const cell=document.getElementById('uicr-region'); + A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent)); + A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent)); + A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict'); + 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'); + 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)); + MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[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)); + 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(); + 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);} diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh index 2f074fe3..cd466f4b 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 ramptest" +HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest contrasttest" if [ "$NO_BROWSER" = 1 ]; then skip_msg "browser hash gates (--no-browser)" elif [ -z "$CHROME" ]; then diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index bdde3091..21a38268 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -642,7 +642,7 @@ function buildTable(){ const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt'; function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';} function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);} - const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}}); + const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();}); styleEx();styleCr(); const lkTd=mkLockCell(kind,[dd]); // style buttons @@ -1299,8 +1299,31 @@ function genericPreview(app){let h='
no fg set'; + const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL'; + const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict; + return `${esc(s)}`; +} +// 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); - const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}} + 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=''; for(const [face,label,ex] of UI_FACES){ @@ -1494,4 +1517,28 @@ if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c PALETTE=save;selectedIdx=null;renderPalette();closeRamp(); document.title='RAMPTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Worst-case readout gate (open with #contrasttest): a covered overlay face shows +// the floor over its foreground set and names the limiting foreground, an +// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set". +if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP)); + MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000'; + UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false}; + buildUITable(); + const cell=document.getElementById('uicr-region'); + A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent)); + A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent)); + A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict'); + 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'); + 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)); + MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[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)); + 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(); + 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);} \ No newline at end of file -- cgit v1.2.3