Verify responsive search actions across themes

This commit is contained in:
Jonathan Sykes
2026-10-13 08:08:25 +08:00
parent 49c7ce9403
commit 640b9b0b33
3 changed files with 52 additions and 28 deletions

1
.gitignore vendored
View File

@@ -53,3 +53,4 @@ plans/transport-shots/
# Search button visual review screenshots # Search button visual review screenshots
plans/search-button-shots/ plans/search-button-shots/
plans/search-button-final-shots/

View File

@@ -3,7 +3,7 @@
`node perf/search-button.mjs` checks Search-button contrast, states, target size, `node perf/search-button.mjs` checks Search-button contrast, states, target size,
and row overflow across all nine layouts, three colour themes, and four widths. and row overflow across all nine layouts, three colour themes, and four widths.
It saves the 390px and 1280px review screenshots under ignored It saves the 390px and 1280px review screenshots under ignored
`plans/search-button-shots/`. `plans/search-button-final-shots/`.
Automated performance baseline and regression harness for the **ytplayer PWA**. Automated performance baseline and regression harness for the **ytplayer PWA**.

View File

@@ -6,7 +6,7 @@ import { extname, resolve } from 'node:path';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
const root = resolve('frontend'); const root = resolve('frontend');
const shots = resolve('plans/search-button-shots'); const shots = resolve('plans/search-button-final-shots');
await mkdir(shots, { recursive: true }); 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 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 server = createServer(async (req,res) => {
@@ -36,10 +36,15 @@ try {
await page.goto(origin,{waitUntil:'domcontentloaded'}); 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(({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.waitForFunction(()=>document.querySelector('.search-btn')&&getComputedStyle(document.querySelector('.search-btn')).display!=='none');
const stylesheet={classic:'theme-classic.css','f7-swipe':'theme-f7.css','glass-stage':'theme-glass.css','bento-hub':'theme-bento-hub.css'}[layout];
if(stylesheet) await page.waitForFunction(name=>[...document.styleSheets].some(s=>s.href&&s.href.includes(name)),stylesheet,{timeout:10000});
if(layout==='bento-hub') await page.waitForFunction(()=>document.querySelector('.bento-search-icon')&&document.querySelector('.bento-search-label'),null,{timeout:10000});
await page.evaluate(()=>document.fonts.ready); await page.evaluate(()=>document.fonts.ready);
for(const width of [320,390,768,1280]) { for(const width of [320,390,768,1280]) {
await page.setViewportSize({width,height:844}); await page.setViewportSize({width,height:844});
await page.waitForTimeout(60); await page.waitForTimeout(60);
await page.mouse.move(-10,-10);
await page.waitForTimeout(20);
const before=await page.locator('.search-btn').boundingBox(); const before=await page.locator('.search-btn').boundingBox();
const data=await page.evaluate(()=>{ const data=await page.evaluate(()=>{
const btn=document.querySelector('.search-btn'),form=document.querySelector('#searchForm'),bar=document.querySelector('.topbar'); const btn=document.querySelector('.search-btn'),form=document.querySelector('#searchForm'),bar=document.querySelector('.topbar');
@@ -54,42 +59,60 @@ try {
const border=parseRGBA(s.borderBottomColor).rgb; 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, 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, 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}; 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,bentoLabel:btn.querySelector('.bento-search-label')&&getComputedStyle(btn.querySelector('.bento-search-label')).display,bentoLabelRect:btn.querySelector('.bento-search-label')&&(()=>{const r=btn.querySelector('.bento-search-label').getBoundingClientRect();return{width:r.width,height:r.height,clip:getComputedStyle(btn.querySelector('.bento-search-label')).clipPath}})(),font:s.fontFamily};
}); });
const key=`${layout}/${theme}/${width}`; const key=`${layout}/${theme}/${width}`;
// Search labels are always rendered over the opaque/token-mixed bg-2 surface. // 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.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)}`); if(layout!=='f7-swipe') {
fail(ratio(data.borderRGB,data.bg1)>=3,`${key}: boundary/topbar contrast ${ratio(data.borderRGB,data.bg1).toFixed(2)}:1 ${JSON.stringify(data)}`); const boundary=Math.max(ratio(data.borderRGB,data.surface),ratio(data.borderRGB,data.barSurface));
fail(boundary>=3,`${key}: boundary contrast ${boundary.toFixed(2)}:1 ${JSON.stringify(data)}`);
}
fail(data.height>=43.5,`${key}: target height ${data.height}`); 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.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.barScroll<=data.barClient+1,`${key}: topbar overflows ${data.barScroll}/${data.barClient}`);
fail(data.right<=width+1,`${key}: button clipped at viewport edge (${data.right})`); 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}`); if(layout==='bento-hub') {
const normalPaint=await page.locator('.search-btn').evaluate(e=>{const s=getComputedStyle(e);return s.backgroundColor+'|'+s.borderColor;}); fail(data.bentoIcon&&data.bentoIconDisplay!=='none',`${key}: Bento injected icon missing/hidden: ${data.bentoIconDisplay}`);
await page.locator('.search-btn').hover(); if(width<=860) fail(data.width<=48&&data.bentoLabelRect?.clip!=='none',`${key}: compact Bento label is not clipped into an icon tile ${JSON.stringify(data)}`);
const hoverPaint=await page.locator('.search-btn').evaluate(e=>{const s=getComputedStyle(e);return s.backgroundColor+'|'+s.borderColor;}); else fail(data.bentoLabelRect?.width>0&&data.bentoLabelRect?.clip==='none',`${key}: desktop Bento label is not visible ${JSON.stringify(data)}`);
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 page.mouse.move(-10,-10);
if(width===390||width===1280)await page.screenshot({path:resolve(shots,`${layout}-${theme}-${width}.png`),fullPage:false});
}
await page.setViewportSize({width:390,height:844});
await page.mouse.move(-10,-10);
const key=`${layout}/${theme}`;
const button=page.locator('.search-btn');
const beforeInteraction=await button.evaluate(e=>({width:e.offsetWidth,height:e.offsetHeight}));
const normalPaint=await button.evaluate(e=>{const s=getComputedStyle(e);return s.backgroundColor+'|'+s.borderColor+'|'+s.color;});
await button.hover();
await page.waitForTimeout(180);
const hoverPaint=await button.evaluate(e=>{const s=getComputedStyle(e);return{paint:s.backgroundColor+'|'+s.borderColor+'|'+s.color,hover:e.matches(':hover')};});
fail(hoverPaint.hover&&hoverPaint.paint!==normalPaint,`${key}: hover state is not visible (${normalPaint} -> ${JSON.stringify(hoverPaint)})`);
await page.mouse.down();
await page.waitForTimeout(180);
const active=await button.evaluate(e=>{const s=getComputedStyle(e);return{transform:s.transform,background:s.backgroundColor,color:s.color,active:e.matches(':active')}});
await page.mouse.up();
fail(active.active&&(active.transform!=='none'||`${active.background}|${active.color}`!==hoverPaint.paint),`${key}: active state is not visible ${JSON.stringify(active)}`);
const bounds=await button.evaluate(e=>({width:e.offsetWidth,height:e.offsetHeight}));
fail(Math.abs(bounds.width-beforeInteraction.width)<.1&&Math.abs(bounds.height-beforeInteraction.height)<.1,`${key}: interaction changed button dimensions`);
await page.keyboard.press('Tab');
await button.focus();
const focus=await button.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})`);
await button.evaluate(e=>{e.blur();e.disabled=true;});
const disabled=await button.evaluate(e=>({opacity:getComputedStyle(e).opacity,cursor:getComputedStyle(e).cursor,borderStyle:getComputedStyle(e).borderStyle}));
fail(disabled.cursor==='not-allowed'&&(layout==='f7-swipe'?disabled.borderStyle==='none':layout==='bento-hub'?disabled.borderStyle==='solid':disabled.borderStyle==='dashed'),`${key}: disabled feedback missing ${JSON.stringify(disabled)}`);
await button.evaluate(e=>{e.disabled=false;});
}
await page.emulateMedia({reducedMotion:'reduce'});
for(const layout of ['classic','f7-swipe','bento-hub']) {
await context.addInitScript(layout=>localStorage.setItem('_ytpdata',JSON.stringify({playlists:[],history:[],queue:[],settings:{layout,theme:'dark'}})),layout);
await page.goto(origin,{waitUntil:'domcontentloaded'});
await page.waitForFunction(name=>[...document.styleSheets].some(s=>s.href&&s.href.includes(name)),{classic:'theme-classic.css','f7-swipe':'theme-f7.css','bento-hub':'theme-bento-hub.css'}[layout],{timeout:10000});
const duration=await page.locator('.search-btn').evaluate(e=>getComputedStyle(e).transitionDuration);
fail(duration.split(',').every(t=>parseFloat(t)<=0.001),`${layout}/reduced-motion: transition duration ${duration}`);
} }
await context.close(); await context.close();
} finally { await browser.close(); await new Promise(r=>server.close(r)); } } finally { await browser.close(); await new Promise(r=>server.close(r)); }