Redesign search action across layouts and themes

This commit is contained in:
Jonathan Sykes
2026-10-11 17:36:45 +08:00
parent a22b8b44c9
commit 3c68e56b21
9 changed files with 168 additions and 47 deletions

96
perf/search-button.mjs Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFile, mkdir } from 'node:fs/promises';
import { extname, resolve } from 'node:path';
import { chromium } from 'playwright';
const root = resolve('frontend');
const shots = resolve('plans/search-button-shots');
await mkdir(shots, { recursive: true });
const mime = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.png':'image/png', '.svg':'image/svg+xml' };
const server = createServer(async (req,res) => {
const path = resolve(root, '.' + (new URL(req.url,'http://local').pathname === '/' ? '/index.html' : new URL(req.url,'http://local').pathname));
if (!path.startsWith(root + '/')) { res.writeHead(403).end(); return; }
try { const body=await readFile(path); res.writeHead(200, {'Content-Type':mime[extname(path)] || 'application/octet-stream'}).end(body); }
catch { res.writeHead(404).end(); }
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
const origin=`http://127.0.0.1:${server.address().port}`;
const layouts=['classic','f7-swipe','glass-stage','bento-hub','sanctuary-stage','broadcaster','contemplative','liturgical','living-hymnal'];
const themes=['dark','light','contrast'];
const only=process.argv.includes('--only')?process.argv[process.argv.indexOf('--only')+1]:null;
const failures=[];
const ratio=(a,b)=>{const l=c=>{c/=255;return c<=.04045?c/12.92:((c+.055)/1.055)**2.4;};const lum=x=>.2126*l(x[0])+.7152*l(x[1])+.0722*l(x[2]);const [hi,lo]=[lum(a),lum(b)].sort((x,y)=>y-x);return(hi+.05)/(lo+.05);};
const fail=(ok,msg)=>{if(!ok)failures.push(msg);};
const browser=await chromium.launch({headless:true});
try {
const context=await browser.newContext({viewport:{width:390,height:844},deviceScaleFactor:2,serviceWorkers:'block'});
await context.route('**/*',route=>route.request().url().startsWith(origin)?route.continue():route.abort());
const page=await context.newPage();
page.on('pageerror',e=>failures.push('page error: '+e.message));
for(const layout of layouts) for(const theme of themes) {
if(only&&only!==`${layout}/${theme}`)continue;
await context.addInitScript(({layout,theme})=>localStorage.setItem('_ytpdata',JSON.stringify({playlists:[],history:[],queue:[],settings:{layout,theme}})),{layout,theme});
await page.setViewportSize({width:390,height:844});
await page.goto(origin,{waitUntil:'domcontentloaded'});
await page.waitForFunction(({layout,theme})=>document.documentElement.dataset.layout===layout&&document.documentElement.dataset.theme===theme, {layout,theme},{timeout:10000});
await page.waitForFunction(()=>document.querySelector('.search-btn')&&getComputedStyle(document.querySelector('.search-btn')).display!=='none');
await page.evaluate(()=>document.fonts.ready);
for(const width of [320,390,768,1280]) {
await page.setViewportSize({width,height:844});
await page.waitForTimeout(60);
const before=await page.locator('.search-btn').boundingBox();
const data=await page.evaluate(()=>{
const btn=document.querySelector('.search-btn'),form=document.querySelector('#searchForm'),bar=document.querySelector('.topbar');
const s=getComputedStyle(btn),formS=getComputedStyle(form),barS=getComputedStyle(bar),rootS=getComputedStyle(document.documentElement);
const canvas=document.createElement('canvas'),ctx=canvas.getContext('2d',{willReadFrequently:true});
const parseRGBA=c=>{ctx.clearRect(0,0,1,1);ctx.fillStyle='#000';ctx.fillStyle=c;ctx.fillRect(0,0,1,1);const p=ctx.getImageData(0,0,1,1).data;return{rgb:[p[0],p[1],p[2]],a:p[3]/255};};
const bg1=parseRGBA(rootS.getPropertyValue('--bg-1')).rgb,bg2=parseRGBA(rootS.getPropertyValue('--bg-2')).rgb;
const composite=(fg,bg,a)=>fg.map((v,i)=>v*a+bg[i]*(1-a));
const layers=[];for(let e=bar;e&&e!==document.documentElement;e=e.parentElement)layers.push(parseRGBA(getComputedStyle(e).backgroundColor));
let barSurface=parseRGBA(rootS.backgroundColor).rgb;for(const layer of layers.reverse())barSurface=composite(layer.rgb,barSurface,layer.a);
const buttonBg=parseRGBA(s.backgroundColor),surface=composite(buttonBg.rgb,barSurface,buttonBg.a);
const border=parseRGBA(s.borderBottomColor).rgb;
return{color:s.color,bg:s.backgroundColor,border:s.borderBottomColor,borderWidth:s.borderBottomWidth,minHeight:s.minHeight,height:btn.getBoundingClientRect().height,width:btn.getBoundingClientRect().width,
formScroll:form.scrollWidth,formClient:form.clientWidth,barScroll:bar.scrollWidth,barClient:bar.clientWidth,right:btn.getBoundingClientRect().right,
fg:parseRGBA(s.color).rgb,surface,bg1,bg2,barSurface,borderRGB:border,icon:getComputedStyle(btn,'::before').display,bentoIcon:!!btn.querySelector('.bento-search-icon'),bentoIconDisplay:btn.querySelector('.bento-search-icon')&&getComputedStyle(btn.querySelector('.bento-search-icon')).display,font:s.fontFamily};
});
const key=`${layout}/${theme}/${width}`;
// Search labels are always rendered over the opaque/token-mixed bg-2 surface.
fail(ratio(data.fg,data.surface)>=4.5,`${key}: label contrast ${ratio(data.fg,data.surface).toFixed(2)}:1 ${JSON.stringify(data)}`);
fail(ratio(data.borderRGB,data.surface)>=3,`${key}: boundary/surface contrast ${ratio(data.borderRGB,data.surface).toFixed(2)}:1 ${JSON.stringify(data)}`);
fail(ratio(data.borderRGB,data.bg1)>=3,`${key}: boundary/topbar contrast ${ratio(data.borderRGB,data.bg1).toFixed(2)}:1 ${JSON.stringify(data)}`);
fail(data.height>=43.5,`${key}: target height ${data.height}`);
fail(data.formScroll<=data.formClient+1,`${key}: search row overflows ${data.formScroll}/${data.formClient}`);
fail(data.barScroll<=data.barClient+1,`${key}: topbar overflows ${data.barScroll}/${data.barClient}`);
fail(data.right<=width+1,`${key}: button clipped at viewport edge (${data.right})`);
if(layout==='bento-hub'&&width<=860) fail(data.bentoIcon&&data.bentoIconDisplay!=='none',`${key}: Bento injected icon missing/hidden: ${data.bentoIconDisplay}`);
const normalPaint=await page.locator('.search-btn').evaluate(e=>{const s=getComputedStyle(e);return s.backgroundColor+'|'+s.borderColor;});
await page.locator('.search-btn').hover();
const hoverPaint=await page.locator('.search-btn').evaluate(e=>{const s=getComputedStyle(e);return s.backgroundColor+'|'+s.borderColor;});
fail(hoverPaint!==normalPaint,`${key}: hover state is not visible`);
await page.mouse.down();
const activeTransform=await page.locator('.search-btn').evaluate(e=>({transform:getComputedStyle(e).transform,active:e.matches(':active')}));
await page.mouse.up();
fail(activeTransform.transform!=='none'&&activeTransform.active,`${key}: active state is not visible ${JSON.stringify(activeTransform)}`);
const hovered=await page.locator('.search-btn').boundingBox();
fail(Math.abs(hovered.width-before.width)<.1&&Math.abs(hovered.height-before.height)<.1,`${key}: hover caused layout shift`);
await page.keyboard.press('Tab');
const outline=await page.locator('.search-btn').evaluate(e=>getComputedStyle(e).outlineStyle);
// Focus can land on earlier toolbar controls, so use programmatic focus-visible through keyboard modality.
await page.locator('.search-btn').focus();
const focus=await page.locator('.search-btn').evaluate(e=>({style:getComputedStyle(e).outlineStyle,width:getComputedStyle(e).outlineWidth}));
fail(focus.style!=='none'&&parseFloat(focus.width)>=2,`${key}: missing focus-visible indicator (${focus.style}/${focus.width}; tab=${outline})`);
await page.locator('.search-btn').evaluate(e=>e.blur());
await page.mouse.move(width-2,4);
if(width===390||width===1280)await page.screenshot({path:resolve(shots,`${layout}-${theme}-${width}.png`),fullPage:false});
await page.locator('.search-btn').evaluate(e=>{e.disabled=true;});
const disabled=await page.locator('.search-btn').evaluate(e=>({opacity:getComputedStyle(e).opacity,cursor:getComputedStyle(e).cursor,borderStyle:getComputedStyle(e).borderStyle}));
fail(disabled.cursor==='not-allowed'&&disabled.borderStyle==='dashed',`${key}: disabled feedback missing ${JSON.stringify(disabled)}`);
await page.locator('.search-btn').evaluate(e=>{e.disabled=false;});
}
}
await context.close();
} finally { await browser.close(); await new Promise(r=>server.close(r)); }
if(failures.length){console.error(failures.join('\n'));process.exitCode=1;}else console.log(`Search button checks passed: ${(only?1:layouts.length*themes.length)*4} layout/theme/width combinations; screenshots saved to ${shots}`);