aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/browser-gates.js
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio/browser-gates.js')
-rw-r--r--scripts/theme-studio/browser-gates.js199
1 files changed, 166 insertions, 33 deletions
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 86ec37e9f..5d747b1d8 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -102,13 +102,13 @@ if(location.hash==='#locktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
// package tables still sort. Guards the unified sort for the later stages.
if(location.hash==='#sorttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
const ddVals=tb=>[...document.querySelectorAll('#'+tb+' tr:not(.detailrow)')].map(tr=>{const dd=tr.cells[2].querySelector('.cdd');return dd?(dd.dataset.val||''):'';});
- const txtVals=tb=>[...document.querySelectorAll('#'+tb+' tr:not(.detailrow)')].map(tr=>tr.cells[0].innerText.trim().toLowerCase());
+ const txtVals=tb=>[...document.querySelectorAll('#'+tb+' tr:not(.detailrow)')].map(tr=>tr.cells[1].innerText.trim().toLowerCase());
const asc=a=>a.every((v,i)=>i===0||a[i-1]<=v),desc=a=>a.every((v,i)=>i===0||a[i-1]>=v);
buildTable();
srtTable('legbody',2);A(asc(ddVals('legbody')),'legbody-color-asc');
srtTable('legbody',2);A(desc(ddVals('legbody')),'legbody-color-desc');
- srtTable('legbody',0);A(asc(txtVals('legbody')),'legbody-elements-asc');
- buildUITable();srtTable('uibody',0);A(asc(txtVals('uibody')),'uibody-face-asc');
+ srtTable('legbody',1);A(asc(txtVals('legbody')),'legbody-elements-asc');
+ buildUITable();srtTable('uibody',1);A(asc(txtVals('uibody')),'uibody-face-asc');
buildPkgTable();srtTable('pkgbody',2);A(asc(ddVals('pkgbody')),'pkgbody-fg-asc');
document.title='SORTTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='sorttest';d.textContent='SORTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
@@ -135,7 +135,7 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
const missing=UI_FACES.map(f=>f[0]).filter(f=>!Q('[data-face="'+f+'"]'));
A(missing.length===0,'all UI faces are represented in live buffer preview: '+missing.join(','));
buildTable();buildUITable();buildPkgTable();
- [['#legbody tr[data-kind="kw"]',5],['#uibody tr[data-face="mode-line"]',7],['#pkgbody tr',8]].forEach(([sel,idx])=>{
+ [['#legbody tr[data-kind="kw"]',5],['#uibody tr[data-face="mode-line"]',5],['#pkgbody tr',5]].forEach(([sel,idx])=>{
const cell=document.querySelector(sel)?.cells[idx],ctl=cell&&cell.querySelector('.boxctl');
A(cell&&ctl&&ctl.getBoundingClientRect().width<=cell.getBoundingClientRect().width,'box control fits its table cell for '+sel);
});
@@ -155,15 +155,16 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(curNum&&/font-weight:\s*700/.test(curNum.getAttribute('style')||''),'line-number-honors-weight');
UIMAP['region'].weight=null;UIMAP['region'].slant=null;UIMAP['region'].underline=null;buildUITable();
const regionRow=[...document.querySelectorAll('#uibody tr')].find(r=>r.dataset.face==='region');
- const uiWeight=regionRow.querySelector('select.stylesel');
- A(uiWeight&&uiWeight.value==='','ui weight select starts empty when model is unset');
- uiWeight.value='bold';uiWeight.dispatchEvent(new Event('change'));
- A(UIMAP['region'].weight==='bold','ui weight select writes the model');
+ const pickEnum=(dd,label)=>{dd.click();const o=[..._ddPop.querySelectorAll('.enumopt')].find(b=>b.textContent===label);if(o)o.click();};
+ const uiWeight=regionRow.querySelector('.enumdd');
+ A(uiWeight&&uiWeight.dataset.val==='','ui weight dropdown starts empty when model is unset');
+ pickEnum(uiWeight,'bold');
+ A(UIMAP['region'].weight==='bold','ui weight dropdown writes the model');
const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].weight=null;buildPkgTable();
- const pkgWeight=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"] select.stylesel');
- A(pkgWeight()&&pkgWeight().value==='','pkg weight select starts empty when model is unset');
- pkgWeight().value='heavy';pkgWeight().dispatchEvent(new Event('change'));
- A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight select writes the model and marks the face edited');
+ const pkgWeight=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"] .enumdd');
+ A(pkgWeight()&&pkgWeight().dataset.val==='','pkg weight dropdown starts empty when model is unset');
+ pickEnum(pkgWeight(),'heavy');
+ A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited');
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,
@@ -298,14 +299,12 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i
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));
+ A(cell&&/^\d+\.\d$/.test(cell.textContent.trim()),'region shows a bare worst-case number (no PASS/FAIL word): '+(cell&&cell.textContent));
A(cell&&!cell.textContent.includes('#67809c'),'compact readout omits limiting fg details: '+(cell&&cell.textContent));
A(cell&&cell.title.includes('kw (keyword) #67809c'),'hover names failing keyword blue: '+(cell&&cell.title));
- const badge=document.querySelector('#uiprev-region .crerr');
- A(badge&&badge.textContent.trim()===cell.textContent.trim(),'region preview shows failing contrast badge: '+(badge&&badge.textContent));
- A(badge&&badge.title.includes('kw (keyword) #67809c'),'preview badge hover carries failures: '+(badge&&badge.title));
- const firstFail=badge&&badge.title.split('\n')[1];
- A(firstFail&&firstFail.includes('kw (keyword) #67809c'),'failures are sorted from worst first: '+firstFail);
+ A(!document.querySelector('#uiprev-region .crerr'),'region preview no longer carries a failing-contrast badge');
+ const firstFail=cell.title.split('\n')[1];
+ A(firstFail&&firstFail.includes('kw (keyword) #67809c'),'failures are sorted from worst first (in the cell hover): '+firstFail);
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');
@@ -329,7 +328,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i
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));
const tApp=Object.keys(APPS)[0],tFace=APPS[tApp].faces[0][0],savePF=JSON.parse(JSON.stringify(PKGMAP[tApp][tFace]));
Object.assign(PKGMAP[tApp][tFace],{fg:'#112233',bg:'#aabbcc',inherit:null});buildPkgTable();
- const prow=document.querySelector('#pkgbody tr[data-face="'+tFace+'"]'),pcell=prow&&prow.children[5];
+ const prow=document.querySelector('#pkgbody tr[data-face="'+tFace+'"]'),pcell=prow&&prow.children[6];
A(pcell&&Math.abs(parseFloat(pcell.textContent)-twoWant)<0.06,'pkg two-color face rates own fg-on-bg: got '+(pcell&&pcell.textContent.trim())+' want '+twoWant.toFixed(1));
PKGMAP[tApp][tFace]=savePF;buildPkgTable();
// A ground-bg change must not clobber a face's own preview bg, must leave a
@@ -381,11 +380,11 @@ if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(bs3&&bs3.includes('rgb(255, 42, 42)')&&bs3.includes('rgb(143, 0, 0)'),'released style derives relief from explicit box color: '+bs3);
PALETTE=[['#ff0000','red','red'],['#30343c','slate','slate']];
buildUITable();
- const mlrow=document.querySelector('#uibody tr[data-face="mode-line"]'),boxCell=mlrow&&mlrow.cells[7],lineBtn=boxCell&&boxCell.querySelector('.boxbtn[data-style="line"]'),boxDd=boxCell&&boxCell.querySelector('.cdd');
+ const mlrow=document.querySelector('#uibody tr[data-face="mode-line"]'),boxCell=mlrow&&mlrow.cells[5],lineBtn=boxCell&&boxCell.querySelector('.boxbtn[data-style="line"]'),boxDd=boxCell&&boxCell.querySelector('.cdd');
if(lineBtn&&boxDd){lineBtn.click();boxDd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(UIMAP['mode-line'].box&&UIMAP['mode-line'].box.color==='#ff0000','UI box color dropdown writes box.color');
const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].box={style:'line',width:1,color:null};buildPkgTable();
- const prow=document.querySelector('#pkgbody tr[data-face="'+face+'"]'),pbox=prow&&prow.cells[8],pdd=pbox&&pbox.querySelector('.cdd');
+ const prow=document.querySelector('#pkgbody tr[data-face="'+face+'"]'),pbox=prow&&prow.cells[5],pdd=pbox&&pbox.querySelector('.cdd');
if(pdd){pdd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(PKGMAP[app][face].box&&PKGMAP[app][face].box.color==='#ff0000','package box color dropdown writes box.color');
PALETTE=saveP;PKGMAP=savePK;for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();buildPkgTable();
@@ -700,8 +699,9 @@ if(location.hash==='#viewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
const d=document.createElement('div');d.id='viewtest';d.textContent='VIEWTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
// Non-default-marker gate (open with #ndtest): a per-face setting cell gets the
// .nd corner flag only when its value differs from the face's seed default. Cell
-// order in a pkg row: 0 label, 1 lock, 2 fg, 3 bg, 4 style, 5 contrast, 6 inherit,
-// 7 size, 8 box.
+// order in a pkg row: 0 lock, 1 label, 2 fg, 3 bg, 4 style, 5 box, 6 contrast.
+// inherit + height live in the row expander, so a non-default height flags the
+// expander toggle (exp-nd) rather than an inline cell.
if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
LOCKED.clear();
const app=curApp(),row=APPS[app].faces[0],face=row[0];
@@ -710,12 +710,12 @@ if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){
A(tr0&&![...tr0.cells].some(c=>c.classList.contains('nd')),'default-face-has-no-marker');
PKGMAP[app][face].height=1.7;PKGMAP[app][face].source='user';buildPkgTable();
const tr1=document.querySelector('#pkgbody tr[data-face="'+face+'"]');
- A(tr1.cells[7].classList.contains('nd'),'nondefault-height-marks-size-box');
+ A(tr1.querySelector('.exptoggle').classList.contains('exp-nd'),'nondefault-height-flags-expander');
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].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-weight-marks-style-box');
- A(!tr2.cells[7].classList.contains('nd'),'restored-height-unmarks-size-box');
+ A(!tr2.querySelector('.exptoggle').classList.contains('exp-nd'),'restored-height-unflags-expander');
PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
document.title='NDTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='ndtest';d.textContent='NDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
@@ -723,7 +723,7 @@ if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){
// bare colored number (no PASS/FAIL word); the WCAG verdict lives in the hover.
if(location.hash==='#crtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
- const cell=document.querySelector('#pkgbody tr[data-face="'+face+'"]').cells[5];
+ const cell=document.querySelector('#pkgbody tr[data-face="'+face+'"]').cells[6];
const span=cell&&cell.querySelector('span');
A(span&&/^\d+\.\d$/.test(span.textContent.trim()),'contrast cell is a bare number: '+(span&&span.textContent));
A(span&&!/PASS|FAIL/.test(span.textContent),'no PASS/FAIL word in the contrast cell');
@@ -814,7 +814,7 @@ if(location.hash==='#pickertest'){let ok=true;const notes=[];const A=(c,n)=>{if(
if(location.hash==='#boxtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
UIMAP[f].box=null;buildUITable();
- const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[7];
+ const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
A(!!cell.querySelector('.boxcluster'),'box-cluster-present');
A(cell.querySelectorAll('.boxbtn').length===4,'four-box-buttons');
const dd=cell.querySelector('.cstep');
@@ -836,10 +836,20 @@ if(location.hash==='#styletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
A(!!cluster,'style-cluster-present');
- const sels=cluster?cluster.querySelectorAll('select.stylesel'):[];
- A(sels.length===2,'weight-and-slant-selectors-present');
- A(sels[0]&&[...sels[0].options].some(o=>o.value==='semibold'),'weight-selector-offers-the-curated-range');
- A(sels[1]&&[...sels[1].options].some(o=>o.value==='oblique'),'slant-selector-offers-oblique');
+ const dds=cluster?cluster.querySelectorAll('.enumdd'):[];
+ A(dds.length===2,'weight-and-slant-custom-dropdowns-present');
+ dds[0]&&dds[0].click();
+ const wopts=_ddPop?[..._ddPop.querySelectorAll('.enumopt')]:[];
+ A(wopts.some(b=>b.textContent==='semibold'),'weight-dropdown-spells-out-the-curated-range: '+wopts.map(b=>b.textContent).join(','));
+ const wbold=wopts.find(b=>b.textContent==='bold');
+ A(wbold&&wbold.style.fontWeight==='700','weight-options-preview-their-own-weight: bold renders 700, got '+(wbold&&wbold.style.fontWeight));
+ closeColorDropdown();
+ dds[1]&&dds[1].click();
+ const sopts=_ddPop?[..._ddPop.querySelectorAll('.enumopt')]:[];
+ A(sopts.some(b=>b.textContent==='oblique'),'slant-dropdown-offers-oblique: '+sopts.map(b=>b.textContent).join(','));
+ const sital=sopts.find(b=>b.textContent==='italic');
+ A(sital&&sital.style.fontStyle==='italic','slant-options-preview-their-own-slant: italic renders italic');
+ closeColorDropdown();
A(cluster&&cluster.querySelectorAll('.boxctl').length===1,'strike-control-in-row-underline-moved-to-expander');
document.title='STYLETEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='styletest';d.textContent='STYLETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
@@ -881,12 +891,119 @@ if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
UIMAP['region']=JSON.parse(JSON.stringify(DEFAULT_UIMAP['region']));UIMAP['region'].overline={color:null};buildUITable();
const ndbtn=document.querySelector('#uibody tr[data-face="region"] .exptoggle');
A(ndbtn&&ndbtn.classList.contains('exp-nd'),'collapsed-toggle-flags-a-hidden-non-default-attr');
- // package expander omits inherit/height (they have inline columns)
+ // package expander now exposes inherit + height (folded out of inline columns)
buildPkgTable();const pface=APPS[curApp()].faces[0][0];
const pdetail=document.querySelector('#pkgbody tr.detailrow[data-detail-for="'+pface+'"]');
- A(pdetail&&!pdetail.querySelector('select.detailsel'),'package-expander-omits-inherit');
+ A(pdetail&&pdetail.querySelector('select.detailsel'),'package-expander-offers-inherit');
document.title='EXPANDTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='expandtest';d.textContent='EXPANDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+// Height-clamp gate (open with #heighttest): the expander height field coerces a
+// typed value into [HEIGHT_MIN,HEIGHT_MAX] and writes the clamped number back, so
+// an out-of-range type/paste can't reach the model. Guards the fact that an
+// <input type=number> min/max only constrain its steppers, never typed text.
+if(location.hash==='#heighttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const face=UI_FACES[0][0],save=JSON.parse(JSON.stringify(UIMAP[face]));
+ buildUITable();
+ const hin=()=>document.querySelector('#uibody tr.detailrow[data-detail-for="'+face+'"] .hstep');
+ const typeHeight=(v)=>{const h=hin();h.value=v;h.dispatchEvent(new Event('change'));};
+ typeHeight('5');
+ A(UIMAP[face].height===HEIGHT_MAX,'above-max-clamps-to-ceiling: '+UIMAP[face].height);
+ A(hin().value===''+HEIGHT_MAX,'field-shows-the-clamped-ceiling: '+hin().value);
+ typeHeight('0.05');
+ A(UIMAP[face].height===HEIGHT_MIN,'below-floor-clamps-to-floor: '+UIMAP[face].height);
+ typeHeight('1.2');
+ A(UIMAP[face].height===1.2,'in-range-value-passes-through: '+UIMAP[face].height);
+ typeHeight('');
+ A(UIMAP[face].height===null,'blank-unsets-to-null: '+UIMAP[face].height);
+ UIMAP[face]=save;buildUITable();
+ document.title='HEIGHTTEST '+(ok?'PASS':'FAIL');
+ const hd=document.createElement('div');hd.id='heighttest';hd.textContent='HEIGHTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(hd);}
+// Language-dropdown gate (open with #langtest): the language list is sorted
+// alphabetically with Elisp pinned as the default selection, and the ‹ › arrows
+// step the selection (clamped, no wrap).
+if(location.hash==='#langtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ buildLangSel();
+ const s=document.getElementById('langsel');
+ const labels=[...s.options].map(o=>o.value);
+ const sorted=[...labels].sort((a,b)=>a.localeCompare(b));
+ A(JSON.stringify(labels)===JSON.stringify(sorted),'languages are alphabetical: '+labels.join(','));
+ A(s.value==='Elisp','Elisp is the default selection: '+s.value);
+ s.selectedIndex=0;stepLang(-1);
+ A(s.selectedIndex===0,'prev clamps at the first language');
+ stepLang(1);
+ A(s.selectedIndex===1,'next steps forward one');
+ s.selectedIndex=s.options.length-1;stepLang(1);
+ A(s.selectedIndex===s.options.length-1,'next clamps at the last language');
+ document.title='LANGTEST '+(ok?'PASS':'FAIL');
+ const ld=document.createElement('div');ld.id='langtest';ld.textContent='LANGTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ld);}
+// View-lock-indicator gate (open with #viewlocktest): the view dropdown prefixes a
+// lock glyph on a view whose every element is locked, and clears it otherwise.
+if(location.hash==='#viewlocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ LOCKED.clear();updateViewLockIndicators();
+ const s=document.getElementById('viewsel'),codeOpt=()=>[...s.options].find(o=>o.value==='@code');
+ A(codeOpt()&&!codeOpt().textContent.startsWith('🔒'),'unlocked view shows no lock glyph: '+(codeOpt()&&codeOpt().textContent));
+ syntaxLockKeys().forEach(k=>LOCKED.add(k));updateViewLockIndicators();
+ A(codeOpt()&&codeOpt().textContent.startsWith('🔒'),'fully-locked view shows the lock glyph: '+(codeOpt()&&codeOpt().textContent));
+ A(codeOpt()&&codeOpt().textContent.includes('color/code assignments'),'glyph prefixes the base label, not replaces it');
+ LOCKED.delete(syntaxLockKeys()[0]);updateViewLockIndicators();
+ A(codeOpt()&&!codeOpt().textContent.startsWith('🔒'),'unlocking one element clears the glyph');
+ LOCKED.clear();updateViewLockIndicators();
+ document.title='VIEWLOCKTEST '+(ok?'PASS':'FAIL');
+ const vd=document.createElement('div');vd.id='viewlocktest';vd.textContent='VIEWLOCKTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(vd);}
+// Detail-hover gate (open with #detailhovertest): every label in the expander
+// detail row carries an explanatory hover, the way the table-header labels do.
+if(location.hash==='#detailhovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ buildUITable();
+ const f=UI_FACES[0][0],detail=document.querySelector('#uibody tr.detailrow[data-detail-for="'+f+'"]');
+ const fields=detail?[...detail.querySelectorAll('.detailfield')]:[];
+ A(fields.length>0,'detail row has fields');
+ A(fields.every(g=>g.title&&g.title.length>0),'every detail field has a hover: '+fields.map(g=>g.querySelector('span').textContent+(g.title?'+':'-')).join(' '));
+ const inh=fields.find(g=>g.querySelector('span').textContent==='inherit');
+ A(inh&&/inherit/i.test(inh.title),'inherit field hover mentions inheritance: '+(inh&&inh.title));
+ document.title='DETAILHOVERTEST '+(ok?'PASS':'FAIL');
+ const dh=document.createElement('div');dh.id='detailhovertest';dh.textContent='DETAILHOVERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(dh);}
+// Expand/collapse-all gate (open with #expandalltest): the header toggle opens or
+// closes every row's detail at once, the per-row triangles track state (▶ closed,
+// ▼ open), and the header button's label follows the aggregate.
+if(location.hash==='#expandalltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ buildUITable();
+ const tb=document.getElementById('uibody'),btn=document.getElementById('uiexpandall');
+ const details=()=>[...tb.querySelectorAll('tr.detailrow')];
+ const open=()=>details().filter(d=>d.style.display!=='none').length;
+ const firstTog=()=>tb.querySelector('.exptoggle');
+ A(firstTog()&&firstTog().textContent==='▶','row toggle starts collapsed (▶): '+(firstTog()&&firstTog().textContent));
+ A(btn&&btn.textContent.indexOf('▶')===0&&/expand all/.test(btn.textContent),'button starts ▶ expand all: '+(btn&&btn.textContent));
+ toggleAllExpanded('uiexpandall');
+ A(open()===details().length&&open()>0,'expand all opens every row: '+open()+'/'+details().length);
+ A(firstTog().textContent==='▼','row toggles flip to ▼ after expand all');
+ A(btn.textContent.indexOf('▼')===0&&/collapse all/.test(btn.textContent),'button flips to ▼ collapse all: '+btn.textContent);
+ toggleAllExpanded('uiexpandall');
+ A(open()===0,'collapse all closes every row');
+ A(firstTog().textContent==='▶','row toggles return to ▶ after collapse all');
+ firstTog().click();
+ A(open()===1,'a single row toggle opens just that row');
+ A(btn.textContent.indexOf('▼')===0,'button reflects a single open row as ▼ collapse all');
+ document.title='EXPANDALLTEST '+(ok?'PASS':'FAIL');
+ const ea=document.createElement('div');ea.id='expandalltest';ea.textContent='EXPANDALLTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ea);}
+// Expander-persistence gate (open with #expandpersisttest): a package edit rebuilds
+// the whole table, so an open expander must reopen instead of collapsing under the
+// user. Editing a value inside the open expander must not close the row.
+if(location.hash==='#expandpersisttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ EXPANDED.clear();
+ const app=curApp(),face=APPS[app].faces[0][0];buildPkgTable();
+ const row=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ const detail=()=>document.querySelector('#pkgbody tr.detailrow[data-detail-for="'+face+'"]');
+ A(detail()&&detail().style.display==='none','expander starts collapsed');
+ row().querySelector('.exptoggle').click();
+ A(detail()&&detail().style.display!=='none','expander opens on toggle');
+ const hin=detail().querySelector('.hstep');hin.value='1.4';hin.dispatchEvent(new Event('change'));
+ A(detail()&&detail().style.display!=='none','expander stays open after an in-expander edit rebuilds the row');
+ A(PKGMAP[app][face].height===1.4,'the in-expander edit still wrote the model');
+ row().querySelector('.exptoggle').click();buildPkgTable();
+ A(detail()&&detail().style.display==='none','a collapsed expander stays collapsed across a rebuild');
+ EXPANDED.clear();buildPkgTable();
+ document.title='EXPANDPERSISTTEST '+(ok?'PASS':'FAIL');
+ const ep=document.createElement('div');ep.id='expandpersisttest';ep.textContent='EXPANDPERSISTTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(ep);}
// Palette default-state gate (open with #paldefaulttest): the studio opens with
// the palette collapsed to base colors so the span tints don't crowd the first
// view. initApp() ran at page load, so the live toggle reflects the opening state.
@@ -988,3 +1105,19 @@ if(location.hash==='#hovertest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(pkgCell&&pkgCell.title===FACE_DOCS[docFace]+'\n\n'+docFace,'package cat cell shows docstring on top of the face name: '+(pkgCell&&JSON.stringify(pkgCell.title)));}
document.title='HOVERTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='hovertest';d.textContent='HOVERTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+// Export via the File System Access API (open with #savetest): exportTheme writes
+// the theme JSON straight to the picked file handle and closes it, so re-exporting
+// overwrites in place instead of the browser uniquifying to "name (1).json".
+if(location.hash==='#savetest'){(async()=>{let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ let written='',closed=false,pickerArgs=null;
+ const orig=window.showSaveFilePicker;
+ window.showSaveFilePicker=async(opts)=>{pickerArgs=opts;return {name:'WIP.json',createWritable:async()=>({write:async d=>{written+=d;},close:async()=>{closed=true;}})};};
+ try{
+ await exportTheme();
+ A(written===JSON.stringify(exportObj(),null,1),'export writes the theme JSON to the picked file');
+ A(closed,'writable stream is closed so the file is committed');
+ A(pickerArgs&&/\.json$/.test(pickerArgs.suggestedName||''),'picker suggests a .json name: '+(pickerArgs&&pickerArgs.suggestedName));
+ }catch(e){A(false,'exportTheme threw: '+e.message);}
+ finally{window.showSaveFilePicker=orig;}
+ document.title='SAVETEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='savetest';d.textContent='SAVETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);})();}