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.js300
1 files changed, 295 insertions, 5 deletions
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 503d7ea11..fcdfaff00 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -40,10 +40,18 @@ function withSavedState(keys, body){
// present. A is the gate's assertion collector; NAME labels the failure note.
function assertPreviewFaces(A, html, faces, minCount, name, required){
const box=document.createElement('div');box.innerHTML=html;
- const valid=new Set((faces||[]).map(r=>r[0]));
- const used=[...box.querySelectorAll('[data-face]')].map(e=>e.dataset.face);
+ const els=[...box.querySelectorAll('[data-face]')];
+ const used=els.map(e=>e.dataset.face);
A(used.length>=minCount,'preview exercises many faces ('+used.length+')');
- const bad=used.filter(f=>!valid.has(f));
+ // Owner-aware validity: an element's owner is its data-owner-app, defaulting to
+ // this preview's app (the one whose face rows are in FACES) when the attribute is
+ // absent. A package owner's valid faces come from APPS[owner].faces; the @ui
+ // owner's from UIMAP keys. An unknown owner has no face set, so its elements are
+ // flagged -- an intentional off-pane span (real face of a real owner) passes,
+ // while a bad owner fails.
+ const defaultValid=new Set((faces||[]).map(r=>r[0]));
+ const facesOf=owner=>owner==='@ui'?new Set(Object.keys(UIMAP)):(APPS[owner]?new Set(APPS[owner].faces.map(r=>r[0])):null);
+ const bad=els.filter(e=>{const o=e.dataset.ownerApp,valid=o?facesOf(o):defaultValid;return !valid||!valid.has(e.dataset.face);}).map(e=>e.dataset.face);
A(bad.length===0,'every data-face is a real '+name+' face; bad='+bad.join(','));
for(const f of required) A(used.includes(f),'preview includes '+f);
}
@@ -213,6 +221,21 @@ if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMA
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');
}));
+// Cursor-row gate (open with #cursorrowtest): the cursor face honors only fg
+// (the glyph on it) and bg (the cursor color); weight/slant/underline/strike and
+// box are no-ops, so the row mutes them to a dash while non-cursor rows keep them.
+if(location.hash==='#cursorrowtest')gate('cursorrowtest',A=>{
+ buildUITable();
+ const rows=[...document.querySelectorAll('#uibody tr')];
+ const cur=rows.find(r=>r.dataset.face==='cursor');
+ A(!!cur,'cursor row present');
+ A(!!cur.cells[2].querySelector('.cdd'),'cursor keeps the fg swatch');
+ A(!!cur.cells[3].querySelector('.cdd'),'cursor keeps the bg swatch');
+ A(!cur.cells[4].querySelector('.enumdd')&&cur.cells[4].textContent.includes('—'),'cursor mutes the style controls');
+ A(cur.cells[5].textContent.includes('—'),'cursor mutes the box control');
+ const ml=rows.find(r=>r.dataset.face==='mode-line');
+ A(!!ml.cells[4].querySelector('.enumdd'),'non-cursor rows keep the style controls');
+});
// Palette-generator gate (open with #generatortest): previewing is non-mutating,
// clicking a generated tile loads the existing selector, adding creates a normal
// singleton base column, and appending a preview column commits all span members
@@ -802,6 +825,115 @@ if(location.hash==='#gnustest')gate('gnustest',A=>{
assertPreviewFaces(A, renderGnusPreview(), APPS['gnus']&&APPS['gnus'].faces, 20, 'gnus',
['gnus-header-name','gnus-header-from','gnus-header-subject','gnus-cite-1','gnus-cite-attribution','gnus-signature','gnus-button','gnus-emphasis-highlight-words']);
});
+// nerd-icons legend gate (open with #nerdiconstest): nerd-icons is a bespoke
+// filetype-legend app; every glyph span is a real nerd-icons face, the dir row
+// models nerd-icons-yellow, and recoloring a face repaints every row mapped to it.
+if(location.hash==='#nerdiconstest')gate('nerdiconstest',A=>{
+ A(!!APPS['nerd-icons'],'nerd-icons is a registered app');
+ A(APPS['nerd-icons']&&APPS['nerd-icons'].preview==='nerdicons','nerd-icons uses the nerdicons preview renderer');
+ A(!!PACKAGE_PREVIEWS['nerdicons'],'nerdicons renderer registered');
+ const legend=(APPS['nerd-icons']&&APPS['nerd-icons'].legend)||[];
+ A(Array.isArray(legend)&&legend.length>=10,'legend has the curated rows ('+legend.length+')');
+ const dir=legend.find(r=>r.key==='dir');
+ A(dir&&dir.face==='nerd-icons-yellow','dir row models nerd-icons-yellow');
+ // Gallery: the full colored catalog as a grid — one row per color face, rows
+ // ordered by hue so families cluster, each color's distinct icons deduped.
+ const gallery=(APPS['nerd-icons']&&APPS['nerd-icons'].gallery)||[];
+ A(Array.isArray(gallery)&&gallery.length>=30,'gallery has the color groups ('+gallery.length+')');
+ const hues=gallery.map(g=>g.hue);
+ A(hues.every((hu,i)=>i===0||hues[i-1]<=hu),'gallery rows ordered by hue (families cluster)');
+ A(gallery.every(g=>typeof g.face==='string'&&g.face.indexOf('nerd-icons-')===0&&typeof g.hue==='number'&&Array.isArray(g.glyphs)&&g.glyphs.length>0),'every gallery group is a real nerd-icons face with a hue and glyphs');
+ A(gallery.every(g=>g.glyphs.every(e=>e.glyph&&e.name)),'every gallery glyph carries glyph and icon name');
+ A(gallery.every(g=>new Set(g.glyphs.map(e=>e.name)).size===g.glyphs.length),'icons are deduplicated within each color row');
+ if(PACKAGE_PREVIEWS['nerdicons']&&APPS['nerd-icons']){
+ // assertPreviewFaces over the grid — every data-face, across the ~314 deduped
+ // glyph cells and the per-row swatches, is a real nerd-icons face with a valid owner.
+ assertPreviewFaces(A, renderNerdIconsPreview(), APPS['nerd-icons'].faces, 10, 'nerd-icons',
+ ['nerd-icons-purple','nerd-icons-yellow','nerd-icons-blue','nerd-icons-dblue']);
+ // Recoloring a face repaints every element in its row (the swatch + each glyph
+ // cell), since os reads the live registry.
+ withSavedState(['PKGMAP'],()=>{
+ const target='nerd-icons-purple',gGroup=gallery.find(g=>g.face===target);
+ const expected=gGroup?1+gGroup.glyphs.length:0;
+ A(!!gGroup,'gallery has a '+target+' row');
+ PKGMAP['nerd-icons']=PKGMAP['nerd-icons']||{};
+ PKGMAP['nerd-icons'][target]={fg:'#abcdef',bg:null,weight:null,slant:null,inherit:null,height:1,source:'user'};
+ const box=document.createElement('div');box.innerHTML=renderNerdIconsPreview();
+ const els=[...box.querySelectorAll('[data-face="'+target+'"]')];
+ A(els.length===expected,'every '+target+' element rendered, swatch+glyphs ('+els.length+'/'+expected+')');
+ A(els.length>0&&els.every(e=>/#abcdef/i.test(e.getAttribute('style')||'')),'recolor repaints every element in the row');
+ });
+ // Export/import round-trip over an assigned nerd-icons color; the separate
+ // nerd-icons-completion app (dir-face) is untouched by the nerd-icons pane.
+ const m=seedPkgmap();
+ m['nerd-icons']['nerd-icons-blue']={fg:'#123456',bg:null,weight:null,slant:null,inherit:null,height:1,source:'user'};
+ const exp=packagesForExport(m);
+ A(exp['nerd-icons']&&exp['nerd-icons']['nerd-icons-blue']&&exp['nerd-icons']['nerd-icons-blue'].fg==='#123456','assigned nerd-icons color exports');
+ const round=seedPkgmap();mergePackagesInto(round,exp);
+ A(round['nerd-icons']&&round['nerd-icons']['nerd-icons-blue'].fg==='#123456','nerd-icons color re-imports to the same state');
+ A(!(exp['nerd-icons']&&('nerd-icons-completion-dir-face' in exp['nerd-icons'])),'dir-face stays out of the nerd-icons app');
+ }
+ });
+// Preview-pane dropdown gate (open with #previewpanetest): the preview label is a
+// "preview:" dropdown. A single-pane app shows its name disabled; nerd-icons is
+// multi-pane (one pane per font size in pt), enabled, and selecting a size renders
+// the grid at it. Locate is unaffected — the flash targets whatever pane is rendered.
+if(location.hash==='#previewpanetest')gate('previewpanetest',A=>{
+ const np=previewPanes('nerd-icons');
+ A(np.length===NERD_ICON_SIZES_PT.length&&np.length>1,'nerd-icons is multi-pane, one per size ('+np.length+')');
+ A(np.every(p=>typeof p.size==='number'&&/ pt$/.test(p.label)),'each nerd-icons pane carries a pt size and a label');
+ A(NERD_ICON_SIZES_PT[defaultPaneIdx('nerd-icons')]===NERD_ICON_DEFAULT_PT,'nerd-icons defaults to '+NERD_ICON_DEFAULT_PT+' pt');
+ const single=Object.keys(APPS).find(k=>k!=='nerd-icons');
+ A(previewPanes(single).length===1,'a non-nerd-icons app has a single pane ('+single+')');
+ // size drives the rendered glyph font-size; no arg defaults to 14 pt
+ const small=renderNerdIconsPreview(10),big=renderNerdIconsPreview(24);
+ A(/font-size:10pt/.test(small)&&!/font-size:24pt/.test(small),'10 pt pane renders glyphs at 10pt');
+ A(/font-size:24pt/.test(big)&&!/font-size:10pt/.test(big),'24 pt pane renders glyphs at 24pt');
+ A(/font-size:14pt/.test(renderNerdIconsPreview()),'default (no-arg) pane renders glyphs at 14 pt');
+ // gallery-absent fallback: the dropdown must not promise sizes it can't render —
+ // with no gallery, one pane only and the grid falls back to the generic preview.
+ const savedG=APPS['nerd-icons'].gallery;delete APPS['nerd-icons'].gallery;
+ A(previewPanes('nerd-icons').length===1,'no gallery -> single pane (dropdown disabled)');
+ A(!/ni-gallery/.test(renderNerdIconsPreview()),'no gallery -> grid falls back to the generic preview');
+ APPS['nerd-icons'].gallery=savedG;
+ // DOM wiring: dropdown enabled+populated on nerd-icons, disabled on a single-pane app
+ const vs=document.getElementById('viewsel'),saved=vs&&vs.value;
+ if(vs){
+ vs.value='nerd-icons';
+ if(curApp()==='nerd-icons'){
+ PREV_PANE['nerd-icons']=99; // a stale, out-of-range selection
+ buildPkgPreview();
+ const sel=document.getElementById('pkgprevsel');
+ A(+sel.value===defaultPaneIdx('nerd-icons'),'a stale pane index resets to the default');
+ A(!sel.disabled&&sel.options.length===NERD_ICON_SIZES_PT.length,'nerd-icons: dropdown enabled with one option per size');
+ // Left/Right arrows step the size, clamped at the ends.
+ PREV_PANE['nerd-icons']=0;buildPkgPreview();
+ document.getElementById('pkgprevsel').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));
+ A(PREV_PANE['nerd-icons']===1,'ArrowRight steps to the next size');
+ document.getElementById('pkgprevsel').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowLeft',bubbles:true}));
+ document.getElementById('pkgprevsel').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowLeft',bubbles:true}));
+ A(PREV_PANE['nerd-icons']===0,'ArrowLeft steps back and clamps at the first size');
+ // The visible ‹ › buttons step the size too, and clamp.
+ PREV_PANE['nerd-icons']=0;buildPkgPreview();
+ document.getElementById('pkgprevnext').click();
+ A(PREV_PANE['nerd-icons']===1,'the > button steps to the next size');
+ document.getElementById('pkgprevprev').click();
+ document.getElementById('pkgprevprev').click();
+ A(PREV_PANE['nerd-icons']===0,'the < button steps back and clamps at the first size');
+ A(!document.getElementById('pkgprevprev').disabled&&!document.getElementById('pkgprevnext').disabled,'the nav buttons are enabled when multi-pane');
+ // The glyph actually computes to the selected point size (pt -> px): 24 pt = 32 px.
+ PREV_PANE['nerd-icons']=NERD_ICON_SIZES_PT.indexOf(24);buildPkgPreview();
+ const gw=document.querySelector('#pkgpreview .ni-cell > span');
+ const gpx=gw?parseFloat(getComputedStyle(gw).fontSize):0;
+ A(Math.abs(gpx-32)<2,'24 pt glyph computes to ~32 px, so the point size renders to size ('+gpx+' px)');
+ PREV_PANE['nerd-icons']=defaultPaneIdx('nerd-icons');
+ vs.value=single;buildPkgPreview();
+ A(sel.disabled&&sel.options.length===1,'single-pane app: dropdown disabled with one option');
+ A(document.getElementById('pkgprevprev').disabled&&document.getElementById('pkgprevnext').disabled,'single-pane app: the nav buttons are disabled too');
+ }
+ vs.value=saved;buildPkgPreview();
+ }
+});
// picker-distinct gate (open with #pickertest): the color picker panel must stand
// out from the page background. It carries a highlighted gold accent border, and its
// background is meaningfully lighter than the body so the two are easy to tell apart.
@@ -820,7 +952,7 @@ if(location.hash==='#pickertest')gate('pickertest',A=>{
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
if(location.hash==='#boxtest')gate('boxtest',A=>{
- LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
+ LOCKED.clear();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor');const saveBox=UIMAP[f].box; // cursor has no box control by design
UIMAP[f].box=null;buildUITable();
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
A(!!cell.querySelector('.boxcluster'),'box-cluster-present');
@@ -839,7 +971,7 @@ if(location.hash==='#boxtest')gate('boxtest',A=>{
// Style-cluster gate (open with #styletest): the style cell holds a weight
// selector, a slant selector, and box-like underline and strike controls.
if(location.hash==='#styletest')gate('styletest',A=>{
- buildUITable();const f=UI_FACES[0][0];
+ buildUITable();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor'); // cursor row has no style cluster by design
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
A(!!cluster,'style-cluster-present');
@@ -1114,3 +1246,161 @@ if(location.hash==='#savetest'){(async()=>{let ok=true;const notes=[];const A=(c
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);})();}
+// Preview-locate registry gate (open with #locatetest): the cached LOCATE_REG is
+// built over both data-face surfaces, keyed owner-qualified, and rebuilt (no stale
+// entry) when an assignment changes. Grows across the locate phases.
+if(location.hash==='#locatetest')gate('locatetest',A=>withSavedState(['PKGMAP','UIMAP','MAP'],()=>{
+ const app=curApp(),pface=APPS[app].faces[0][0],uface=UI_FACES[0][0];
+ rebuildLocateRegistry();
+ const pkg=locateFaceMeta(app,pface,LOCATE_REG);
+ A(pkg&&pkg.surface==='package'&&pkg.owner===app,'package face is a package-owned registry entry: '+(pkg&&pkg.owner));
+ const ui=locateFaceMeta('@ui',uface,LOCATE_REG);
+ A(ui&&ui.surface==='ui'&&ui.owner==='@ui','ui face is a @ui-owned registry entry: '+(ui&&ui.owner));
+ // owner-qualified: a package face name under the @ui owner must not resolve to
+ // the package entry (and vice versa) — the key carries the owner.
+ A(locateFaceMeta('@ui',pface,LOCATE_REG).unassigned,'a package face under the @ui owner is unassigned, not collided');
+ // rebuild-after-edit: a changed fg shows up only after the registry rebuilds.
+ PKGMAP[app][pface].fg='#abcdef';PKGMAP[app][pface].source='user';
+ rebuildLocateRegistry();
+ A(locateFaceMeta(app,pface,LOCATE_REG).value.fg==='#abcdef','registry rebuild reflects the edited fg, no stale value');
+ // Phase 2a: os delegates to previewSpan, which emits the locate attributes and
+ // classes an on-pane (current-app) span.
+ const box=document.createElement('div');box.innerHTML=os(app,pface,'x');
+ const sp=box.querySelector('[data-face]');
+ A(sp&&sp.dataset.ownerApp===app,'os span carries data-owner-app = the owning app: '+(sp&&sp.dataset.ownerApp));
+ A(sp&&sp.dataset.face===pface,'os span keeps data-face');
+ A(sp&&sp.classList.contains('locate-onpane'),'an on-pane (current-app) span gets the locate-onpane class');
+ const other=Object.keys(APPS).find(k=>k!==app);
+ if(other){const oface=APPS[other].faces[0][0],b2=document.createElement('div');b2.innerHTML=previewSpan(other,oface,'y');const s2=b2.querySelector('[data-face]');
+ A(s2&&s2.dataset.ownerApp===other&&!s2.classList.contains('locate-onpane'),'an off-pane owner span carries its owner and no locate-onpane class');}
+ // Phase 2c: previewSpan renders a @ui face off a package preview in its real
+ // color (the cross-surface path), and marks it off-pane while a package is viewed.
+ const ub=document.createElement('div');ub.innerHTML=previewSpan('@ui',uface,'z');
+ const us=ub.querySelector('[data-face]');
+ const uiFg=effFg(resolveUiAttr(uface,'fg',UIMAP));
+ A(us&&us.dataset.ownerApp==='@ui'&&us.dataset.face===uface,'cross-surface @ui span carries owner @ui + data-face');
+ A(us&&us.getAttribute('style').includes('color:'+uiFg),'cross-surface @ui span renders the ui face effective fg: '+(us&&us.getAttribute('style')));
+ A(us&&!us.classList.contains('locate-onpane'),'a @ui span off a package preview is off-pane (no locate-onpane)');
+ // Phase 2b: the owner-aware assertPreviewFaces accepts intentional off-pane and
+ // @ui spans but rejects a bad owner.
+ {const other2=Object.keys(APPS).find(k=>k!==app);
+ const okHtml=os(app,pface,'a')+(other2?previewSpan(other2,APPS[other2].faces[0][0],'b'):'')+previewSpan('@ui',uface,'c');
+ const probe=(h)=>{let fails=0;assertPreviewFaces((c)=>{if(!c)fails++;},h,APPS[app].faces,1,app,[]);return fails;};
+ A(probe(okHtml)===0,'owner-aware validator accepts intentional off-pane + @ui spans');
+ A(probe('<span data-owner-app="nope" data-face="'+pface+'">x</span>')>0,'owner-aware validator rejects a bad owner');}
+}));
+// Gate-only showcase fixture (open with #showcasetest): a synthetic host
+// package-preview context renders one package-owned off-pane span and one @ui
+// (minibuffer-prompt) off-pane span. Each appears in its owner's real color, is
+// hover-only (no locate-onpane class), and passes the owner-aware validator. No
+// user-facing preview changes -- the first real cross-owner preview (org-agenda or
+// the completion preview) becomes the organic showcase later.
+if(location.hash==='#showcasetest')gate('showcasetest',A=>withSavedState(['PKGMAP','UIMAP','MAP'],()=>{
+ const host=curApp(),other=Object.keys(APPS).find(k=>k!==host);
+ A(!!other,'a second package app exists to own an off-pane span');
+ A(!!UIMAP['minibuffer-prompt'],'minibuffer-prompt is a real UI face');
+ rebuildLocateRegistry();
+ const oface=other&&APPS[other].faces[0][0];
+ const fixture=os(host,APPS[host].faces[0][0],'host')
+ +(other?previewSpan(other,oface,'pkg-offpane'):'')
+ +previewSpan('@ui','minibuffer-prompt','prompt');
+ const box=document.createElement('div');box.innerHTML=fixture;
+ const pkgSpan=other&&box.querySelector('[data-owner-app="'+other+'"]'),uiSpan=box.querySelector('[data-owner-app="@ui"]');
+ if(other){const want=(ofs(other,oface).match(/color:([^;]+)/)||[])[1];
+ A(pkgSpan&&want&&pkgSpan.getAttribute('style').includes('color:'+want),'package-owned off-pane span renders its owner color: '+want);}
+ const uiWant=effFg(resolveUiAttr('minibuffer-prompt','fg',UIMAP));
+ A(uiSpan&&uiSpan.getAttribute('style').includes('color:'+uiWant),'@ui off-pane span renders the minibuffer-prompt color: '+uiWant);
+ A(pkgSpan&&!pkgSpan.classList.contains('locate-onpane'),'package off-pane span is hover-only (no locate-onpane)');
+ A(uiSpan&&!uiSpan.classList.contains('locate-onpane'),'@ui off-pane span is hover-only (no locate-onpane)');
+ let fails=0;assertPreviewFaces((c)=>{if(!c)fails++;},fixture,APPS[host].faces,1,host,[]);
+ A(fails===0,'the owner-aware validator passes the showcase fixture');
+}));
+// Hover gate (open with #locatehovertest): every previewSpan element carries the
+// full locate title (effective value + source note), and hovering an element
+// updates the preview-label info line to "section > face — value", restored on
+// leave. The title is the deterministic fallback; the info line is the immediate
+// surface.
+if(location.hash==='#locatehovertest')gate('locatehovertest',A=>withSavedState(['PKGMAP','UIMAP','MAP'],()=>{
+ const app=curApp(),face=APPS[app].faces[0][0];
+ PKGMAP[app][face]={fg:'#123456',bg:null,inherit:null,source:'user'};
+ rebuildLocateRegistry();
+ const box=document.createElement('div');box.innerHTML=os(app,face,'x');
+ const sp=box.querySelector('[data-face]');
+ A(sp&&sp.getAttribute('title')===formatLocateTitle(locateFaceMeta(app,face,LOCATE_REG)),'span title equals formatLocateTitle: '+(sp&&sp.getAttribute('title')));
+ A(sp&&/fg #123456 \(direct\)/.test(sp.getAttribute('title')),'direct-fg title shows the effective fg + direct note');
+ PKGMAP[app][face]={fg:null,bg:null,inherit:null,source:'cleared'};
+ rebuildLocateRegistry();
+ const cb=document.createElement('div');cb.innerHTML=os(app,face,'x');
+ A(/cleared, rendering as default/.test(cb.querySelector('[data-face]').getAttribute('title')),'cleared face title carries the cleared-rendering note');
+ // Wayfinding is the per-span hover title (above); there is no separate info line.
+}));
+// Click + cursor gate (open with #locateclicktest): an on-pane element carries the
+// locate-onpane class (pointer cursor) and clicking flashes its assignment row via
+// the unified locateClick dispatcher; an off-pane element has no class (default
+// cursor) and clicking flashes nothing. The UI mock's bare spans still flash their
+// row through the same dispatcher (Phase 5 unification).
+if(location.hash==='#locateclicktest')gate('locateclicktest',A=>withSavedState(['PKGMAP','UIMAP','MAP','LOCKED'],()=>{
+ LOCKED.clear();
+ const app=curApp(),face=APPS[app].faces[0][0];
+ buildPkgTable();buildPkgPreview();rebuildLocateRegistry();
+ const p=document.getElementById('pkgpreview');
+ // on-pane: class present, click flashes the assignment row
+ p.innerHTML=os(app,face,'click me');
+ const onSpan=p.querySelector('[data-owner-app]');
+ A(onSpan&&onSpan.classList.contains('locate-onpane'),'on-pane span carries the locate-onpane class (pointer cursor)');
+ const prow=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ if(prow())prow().classList.remove('flash');
+ onSpan.dispatchEvent(new MouseEvent('click',{bubbles:true}));
+ A(prow()&&prow().classList.contains('flash'),'clicking an on-pane span flashes its assignment row');
+ // off-pane: no class, click flashes nothing
+ const other=Object.keys(APPS).find(k=>k!==app);
+ if(other){const oface=APPS[other].faces[0][0];
+ p.innerHTML=previewSpan(other,oface,'off');
+ const offSpan=p.querySelector('[data-owner-app]');
+ A(offSpan&&!offSpan.classList.contains('locate-onpane'),'off-pane span has no locate-onpane class (default cursor)');
+ [...document.querySelectorAll('#pkgbody tr')].forEach(tr=>tr.classList.remove('flash'));
+ offSpan.dispatchEvent(new MouseEvent('click',{bubbles:true}));
+ A(document.querySelectorAll('#pkgbody tr.flash').length===0,'clicking an off-pane span leaves all rows unflashed');}
+ // Phase 5: the UI mock's bare data-face spans still flash their row via locateClick
+ buildUITable();buildMockFrame();
+ const mface=UI_FACES[0][0],mspan=document.querySelector('#mockframe [data-face="'+mface+'"]');
+ if(mspan){const urow=()=>document.querySelector('#uibody tr[data-face="'+mface+'"]');
+ if(urow())urow().classList.remove('flash');
+ mspan.dispatchEvent(new MouseEvent('click',{bubbles:true}));
+ A(urow()&&urow().classList.contains('flash'),'a UI mock span still flashes its row through the unified dispatcher');}
+}));
+// Embedded-font gate (open with #fonttest): the nerd-icons legend, dashboard
+// navigator, and package previews render their glyphs in a real nerd font
+// instead of tofu. Verifies (1) the ThemeStudioNerd @font-face is registered,
+// (2) previewLines actually APPLIES that family — the div is parsed into the DOM
+// and getComputedStyle must resolve to ThemeStudioNerd (a double-quoted family in
+// the inline style attribute silently drops it, so a plain string match would
+// false-pass), and (3) the embedded woff2 loads AND covers the glyph codepoints
+// the previews use — both a BMP glyph (U+F121) and a supplementary-plane Material
+// Design glyph (U+F0474), the range most likely missing from a partial font.
+// Async: it awaits the font load, then appends the verdict (the runner's
+// virtual-time budget covers it).
+if(location.hash==='#fonttest'){
+ const fam='ThemeStudioNerd',notes=[];
+ const bmp='',supp='\u{f0474}';
+ const finish=()=>{const v='FONTTEST '+(notes.length?'FAIL':'PASS');document.title=v;
+ const d=document.createElement('div');d.id='fonttest';
+ d.textContent=v+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);};
+ const registered=[...document.fonts].some(f=>f.family.replace(/["']/g,'')===fam);
+ if(!registered)notes.push('no-fontface');
+ // Parse the actual previewLines output into the DOM and read the resolved
+ // font-family off the rendered element — not a substring of the HTML string. A
+ // double-quoted family name inside the inline style="..." attribute terminates
+ // the attribute early and silently drops the font-family, so a string match
+ // passes while the rendered font is empty; computed style catches that.
+ const probe=document.createElement('div');probe.innerHTML=previewLines(['x']);
+ document.body.appendChild(probe);
+ const inner=probe.firstElementChild;
+ const ff=inner?getComputedStyle(inner).fontFamily:'';
+ if(ff.indexOf(fam)<0)notes.push('previews-font-not-applied('+(ff||'empty')+')');
+ Promise.all([document.fonts.load('16px "'+fam+'"',bmp),document.fonts.load('16px "'+fam+'"',supp)]).then(()=>{
+ if(!document.fonts.check('16px "'+fam+'"',bmp))notes.push('bmp-glyph-missing');
+ if(!document.fonts.check('16px "'+fam+'"',supp))notes.push('supp-glyph-missing');
+ probe.remove();finish();
+ }).catch(e=>{probe.remove();notes.push('load-error:'+(e&&e.message||e));finish();});
+}