Verify responsive search actions across themes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
await page.mouse.move(-10,-10);
|
||||||
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});
|
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.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)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user