Merge final UI batch into beta
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Performance Baseline Harness (`perf/`)
|
||||
|
||||
`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.
|
||||
It saves the 390px and 1280px review screenshots under ignored
|
||||
`plans/search-button-final-shots/`.
|
||||
|
||||
Automated performance baseline and regression harness for the **ytplayer PWA**.
|
||||
|
||||
The harness drives the real Bun backend server (`server/server.js`) serving static Brotli-compressed assets, combined with Playwright browser automation on Chromium and WebKit.
|
||||
@@ -510,3 +515,9 @@ save/retry POST actions remain user initiated.
|
||||
```bash
|
||||
node perf/retry.mjs --browser all --profile lossy
|
||||
```
|
||||
Recommendation cards: `node perf/rec-card.mjs` exercises long titles/reasons,
|
||||
missing artwork, card containment, 16:9 cover sizing, focus visibility, and text
|
||||
contrast in Glass Stage at 320/390/768/1280px and dark/light/contrast themes. It
|
||||
also captures Classic, Bento Hub, and Framework7 Swipe at 390/1280px to guard
|
||||
their existing card presentation. Screenshots are written to
|
||||
`plans/rec-card-shots/`.
|
||||
|
||||
138
perf/rec-card.mjs
Normal file
138
perf/rec-card.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/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/rec-card-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', '.webmanifest':'application/manifest+json' };
|
||||
const server = createServer(async (req, res) => {
|
||||
const pathname = new URL(req.url, 'http://local').pathname;
|
||||
const file = resolve(root, `.${pathname === '/' ? '/index.html' : pathname}`);
|
||||
if (!file.startsWith(root + '/')) { res.writeHead(403).end(); return; }
|
||||
try { const body = await readFile(file); res.writeHead(200, { 'Content-Type': mime[extname(file)] || 'application/octet-stream' }).end(body); }
|
||||
catch { res.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const layouts = ['glass-stage', 'classic', 'bento-hub', 'f7-swipe'];
|
||||
const themes = ['dark', 'light', 'contrast'];
|
||||
const failures = [];
|
||||
const svg = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><defs><linearGradient id="g" x2="0" y2="1"><stop stop-color="#ffb38e"/><stop offset=".48" stop-color="#49324b"/><stop offset="1" stop-color="#09141c"/></linearGradient></defs><path fill="url(#g)" d="M0 0h600v600H0z"/><circle cx="300" cy="250" r="126" fill="#ef915e" opacity=".65"/><path d="M0 445 170 300l120 100 104-168 206 213v155H0z" fill="#141a2c"/></svg>');
|
||||
const thumb = `data:image/svg+xml,${svg}`;
|
||||
const cards = [
|
||||
{ id:'one', title:'You Have Said — A Long Live Worship Recording with a Carefully Wrapped Title', channel:'Cathedral of Praise Worship', reason:'One of your most played', thumbnail:thumb },
|
||||
{ id:'two', title:'A Second Recommendation with a Long Title to Verify the Two-Line Clamp', channel:'The Choir and Orchestra', reason:'Because you saved several long-form worship sessions', thumbnail:thumb },
|
||||
{ id:'three', title:'Missing artwork and reason', channel:'Grace Community', reason:'', thumbnail:'' },
|
||||
{ id:'four', title:'Fourth card with the normal metadata length', channel:'Hesed Worship', reason:'A familiar favorite', thumbnail:thumb },
|
||||
{ id:'five', title:'Fifth card', channel:'Worship Archive', reason:'A discovery', thumbnail:thumb },
|
||||
{ id:'six', title:'Sixth card', channel:'Live Sessions', reason:'More from your listening', thumbnail:thumb },
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, serviceWorkers: 'block' });
|
||||
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
|
||||
await context.route('**/api/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, results: [] }) }));
|
||||
await context.route('**/api/recommendations*', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, results: cards }) }));
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => failures.push(`pageerror: ${error.message}`));
|
||||
page.on('requestfailed', request => failures.push(`requestfailed: ${request.url()} ${request.failure()?.errorText}`));
|
||||
page.on('response', response => {
|
||||
if (response.url().includes('/api/recommendations') && response.status() >= 400) failures.push(`recommendations response: ${response.status()} ${response.url()}`);
|
||||
});
|
||||
const matrix = [];
|
||||
for (const layout of layouts) for (const theme of themes) {
|
||||
const widths = layout === 'glass-stage' ? [320, 390, 768, 1280] : [390, 1280];
|
||||
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 });
|
||||
try { await page.waitForFunction(() => document.querySelectorAll('#homeRecommendations .rec-item').length === 6, null, { timeout: 5000 }); }
|
||||
catch (error) { console.error('render debug', JSON.stringify({ layout, theme, errors: failures, state: await page.evaluate(() => ({ title: document.title, layout: document.documentElement.dataset.layout, theme: document.documentElement.dataset.theme, body: document.body.innerText.slice(0, 500), recommendation: document.querySelector('#homeRecommendations')?.outerHTML.slice(0, 1000) })) })); throw error; }
|
||||
await page.locator('#homeRecommendations img').first().evaluate(img => img.decode().catch(() => {}));
|
||||
for (const width of widths) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.waitForTimeout(50);
|
||||
const metrics = await page.locator('#homeRecommendations').evaluate(section => {
|
||||
const rgba = value => {
|
||||
const channels = (value.match(/[\d.]+/g) || []).map(Number);
|
||||
if (value.startsWith('color(srgb')) return [channels[0] * 255, channels[1] * 255, channels[2] * 255, channels[3] ?? 1];
|
||||
return [channels[0], channels[1], channels[2], channels[3] ?? 1];
|
||||
};
|
||||
const rgb = value => rgba(value).slice(0, 3);
|
||||
const luminance = value => {
|
||||
const [r,g,b] = rgb(value).map(channel => channel / 255).map(channel => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4);
|
||||
return .2126 * r + .7152 * g + .0722 * b;
|
||||
};
|
||||
const contrast = (a,b) => {
|
||||
const [r,g,bl,alpha] = rgba(b), composite = `rgb(${r * alpha + 255 * (1-alpha)},${g * alpha + 255 * (1-alpha)},${bl * alpha + 255 * (1-alpha)})`;
|
||||
const x = luminance(a), y = luminance(composite); return (Math.max(x,y) + .05) / (Math.min(x,y) + .05);
|
||||
};
|
||||
const rect = el => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: r.height, width: r.width }; };
|
||||
const cards = [...section.querySelectorAll('.rec-item')].map(item => {
|
||||
const play = item.querySelector('.rec-play'), cover = item.querySelector('.rec-cover'), img = cover.querySelector('img');
|
||||
const actions = item.querySelector('.rec-actions'), reason = item.querySelector('.rec-reason');
|
||||
const title = item.querySelector('.rec-title'), channel = item.querySelector('.rec-channel');
|
||||
const textColor = getComputedStyle(title).color, surface = getComputedStyle(item).backgroundColor;
|
||||
const shellStyle = getComputedStyle(item);
|
||||
const actionButtons = [...actions.querySelectorAll('button')].map(button => ({
|
||||
rect:rect(button), contrast:contrast(getComputedStyle(button).color, getComputedStyle(button).backgroundColor)
|
||||
}));
|
||||
return {
|
||||
item:rect(item), play:rect(play), cover:rect(cover), image:rect(img), reason:rect(reason), actions:rect(actions),
|
||||
title:rect(title), channel:rect(channel), scrollWidth:item.scrollWidth, clientWidth:item.clientWidth,
|
||||
imageFit:getComputedStyle(img).objectFit, titleClamp:getComputedStyle(title).webkitLineClamp,
|
||||
textColor, surface, reasonColor:getComputedStyle(reason).color, reasonSurface:getComputedStyle(reason).backgroundColor,
|
||||
shellBorderWidth:parseFloat(shellStyle.borderTopWidth), shellRadius:parseFloat(shellStyle.borderTopLeftRadius),
|
||||
textContrast:contrast(textColor, surface), reasonContrast:contrast(getComputedStyle(reason).color, getComputedStyle(reason).backgroundColor),
|
||||
actionButtons, coverRatio:rect(cover).width / rect(cover).height, hasImage:!!img.getAttribute('src'),
|
||||
fallbackSurface:getComputedStyle(cover).backgroundImage
|
||||
};
|
||||
});
|
||||
return { cards, gridColumns:getComputedStyle(section.querySelector('.rec-grid')).gridTemplateColumns };
|
||||
});
|
||||
matrix.push({ layout, theme, width, ...metrics });
|
||||
for (const card of metrics.cards) {
|
||||
const bottom = card.item.bottom;
|
||||
for (const [name, box] of Object.entries({ play:card.play, cover:card.cover, title:card.title, channel:card.channel, reason:card.reason, actions:card.actions })) {
|
||||
if (box.bottom > bottom + 1) failures.push(`${layout}/${theme}/${width}: ${name} spills ${Math.ceil(box.bottom - bottom)}px past rec-item`);
|
||||
}
|
||||
if (card.scrollWidth > card.clientWidth + 1) failures.push(`${layout}/${theme}/${width}: rec-item horizontal overflow ${card.scrollWidth}/${card.clientWidth}`);
|
||||
if (layout === 'glass-stage') {
|
||||
if (card.shellBorderWidth < 1 || card.shellRadius < 16) failures.push(`${layout}/${theme}/${width}: unified glass shell is missing its rounded border`);
|
||||
if (card.textContrast < 4.5 || card.reasonContrast < 4.5 || card.actionButtons.some(button => button.contrast < 4.5)) failures.push(`${layout}/${theme}/${width}: text contrast below 4.5:1`);
|
||||
if (card.actionButtons.some(button => button.rect.width < 44 || button.rect.height < 44)) failures.push(`${layout}/${theme}/${width}: action target below 44px`);
|
||||
if (card.titleClamp !== '2') failures.push(`${layout}/${theme}/${width}: title clamp is ${card.titleClamp}`);
|
||||
if (Math.abs(card.coverRatio - 16 / 9) > .03) failures.push(`${layout}/${theme}/${width}: cover aspect ratio ${card.coverRatio}`);
|
||||
if (card.imageFit !== 'cover') failures.push(`${layout}/${theme}/${width}: thumbnail does not cover-fit`);
|
||||
if (!card.hasImage && card.fallbackSurface === 'none') failures.push(`${layout}/${theme}/${width}: missing-art fallback has no visual treatment`);
|
||||
}
|
||||
}
|
||||
if (layout === 'glass-stage') {
|
||||
const focusStyles = await page.locator('#homeRecommendations .rec-play').first().evaluate(button => {
|
||||
button.focus();
|
||||
const playShadow = getComputedStyle(button).boxShadow;
|
||||
const action = button.closest('.rec-item').querySelector('.rec-actions button'); action.focus();
|
||||
return { playShadow, actionShadow:getComputedStyle(action).boxShadow };
|
||||
});
|
||||
if (focusStyles.playShadow === 'none' || focusStyles.actionShadow === 'none') failures.push(`${layout}/${theme}/${width}: keyboard focus cue missing`);
|
||||
}
|
||||
if (width === 390 || width === 1280) {
|
||||
await page.setViewportSize({ width, height: 2000 });
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.locator('#homeRecommendations').scrollIntoViewIfNeeded();
|
||||
await page.locator('#homeRecommendations .rec-grid').screenshot({
|
||||
path: resolve(shots, `${layout}-${theme}-${width}.png`),
|
||||
style: '.topbar,#bottomNav,#miniBar { display: none !important; }'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ cases:matrix.length, cards:matrix.reduce((n, entry) => n + entry.cards.length, 0), failures, matrix }, null, 2));
|
||||
await context.close();
|
||||
} finally { await browser.close(); await new Promise(resolveClose => server.close(resolveClose)); }
|
||||
if (failures.length) process.exitCode = 1;
|
||||
119
perf/search-button.mjs
Normal file
119
perf/search-button.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/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-final-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');
|
||||
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);
|
||||
for(const width of [320,390,768,1280]) {
|
||||
await page.setViewportSize({width,height:844});
|
||||
await page.waitForTimeout(60);
|
||||
await page.mouse.move(-10,-10);
|
||||
await page.waitForTimeout(20);
|
||||
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,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}`;
|
||||
// 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)}`);
|
||||
if(layout!=='f7-swipe') {
|
||||
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.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') {
|
||||
fail(data.bentoIcon&&data.bentoIconDisplay!=='none',`${key}: Bento injected icon missing/hidden: ${data.bentoIconDisplay}`);
|
||||
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)}`);
|
||||
else fail(data.bentoLabelRect?.width>0&&data.bentoLabelRect?.clip==='none',`${key}: desktop Bento label is not visible ${JSON.stringify(data)}`);
|
||||
}
|
||||
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();
|
||||
} 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}`);
|
||||
Reference in New Issue
Block a user