Verify mobile geometry and themed media icons across browsers

This commit is contained in:
Jonathan Sykes
2026-10-09 14:32:17 +08:00
parent 2001e77cee
commit a497180352
8 changed files with 360 additions and 0 deletions

View File

@@ -335,3 +335,15 @@ No product behavior changes are involved in these measurement corrections.
Keep browser/build/test processes idle during full timing measurements. WebKit's
zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready
null results do not establish playback performance or iPhone audio continuity.
## UI geometry and media icons
```sh
node perf/ui-geometry.mjs
node perf/ui-icons.mjs
node --test frontend/media-icons.test.js
```
Both harnesses run Chromium and WebKit at mobile dimensions with stubbed SpeechRecognition and local Saved/Queue metadata. They serve only the frontend, block service workers and external requests, and never download media. Geometry tests cold and switched layouts, six widths (320–430), mic visible/hidden and update icon visible/hidden: center spread ≤1px, touch targets ≥40px, Saved image/text gap ≥8px, no horizontal overflow or clipped car control. `--quick` limits widths and speech cases; `--browser` selects an engine; `--frontend` selects historical fixtures. `--measure-only --label before` records known broken geometry without exiting nonzero.
Icon checks switch the same page through four layouts × dark/light/contrast, assert SVG definitions, inherited colour, stroke family and save/progress/offline state, and save Queue/Now Playing/navigation shots. Glass Stage intentionally hides bottom navigation, so its full page is recorded instead. Any assertion failure exits nonzero. Shots and raw measurements live in git-ignored plans/ui-shots/. Full production inventory: plans/ui-icon-inventory.md. Native speech recognition, installed-PWA safe-area insets and iPhone rendering still need device verification.

36
perf/ui-fixture.mjs Normal file
View File

@@ -0,0 +1,36 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { resolve, extname } from 'node:path';
export const layouts = ['classic', 'f7-swipe', 'glass-stage', 'bento-hub'];
export const themes = ['dark', 'light', 'contrast'];
export async function serve(frontend = 'frontend') {
const root = resolve(frontend);
const mime = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.svg':'image/svg+xml', '.png':'image/png' };
const server = createServer(async (req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
const path = resolve(root, '.' + (pathname === '/' ? '/index.html' : 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));
return { origin:`http://127.0.0.1:${server.address().port}`, close:() => new Promise(r => server.close(r)) };
}
export async function contextFor(browser, origin, layout = 'classic', theme = 'dark') {
const context = await browser.newContext({ viewport:{width:414,height:896}, deviceScaleFactor:2, isMobile:true, hasTouch:true, serviceWorkers:'block', reducedMotion:'reduce' });
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
await context.addInitScript(({layout,theme}) => {
localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{layout,theme,reduceMotion:true} }));
window.SpeechRecognition = window.webkitSpeechRecognition = function() {};
Object.defineProperty(navigator.connection || navigator, 'saveData', {value:true,configurable:true});
}, {layout,theme});
return context;
}
export async function appearance(page, layout, theme = 'dark') {
await page.evaluate(async ({layout,theme}) => {
if (window.Lazy) await Lazy.load('layout:' + layout);
data.settings.layout = layout; data.settings.theme = theme; applyAppearance();
await document.fonts.ready;
}, {layout,theme});
}

98
perf/ui-geometry.mjs Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env node
// Local static shell, real renderSaved(), deterministic offline-file metadata.
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { resolve, extname } from 'node:path';
import { chromium, webkit } from 'playwright';
const args = process.argv.slice(2);
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;
const root = resolve(option('--frontend', 'frontend'));
const label = option('--label', 'after');
const measureOnly = args.includes('--measure-only');
const shotDir = resolve('plans/ui-shots');
await mkdir(shotDir, { recursive: true });
const mime = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.svg':'image/svg+xml', '.png':'image/png' };
const server = createServer(async (req, res) => {
const path = resolve(root, '.' + (new URL(req.url, 'http://localhost').pathname === '/' ? '/index.html' : new URL(req.url, 'http://localhost').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 widths = args.includes('--quick') ? [414] : [320, 360, 375, 390, 414, 430];
const layouts = ['classic', 'f7-swipe', 'glass-stage', 'bento-hub'];
const results = [], failures = [];
const check = (ok, message) => { if (!ok) failures.push(message); };
try {
for (const [name, engine] of Object.entries({ chromium, webkit }).filter(([name])=>option('--browser','all')==='all'||option('--browser','all')===name)) {
const browser = await engine.launch();
try {
for (const layout of layouts) for (const speech of (args.includes('--quick') ? [true] : [true, false])) for (const entry of ['cold', 'switched']) {
const context = await browser.newContext({ viewport:{width:414,height:896}, deviceScaleFactor:2, isMobile:true, hasTouch:true, serviceWorkers:'block' });
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
await context.addInitScript(({ layout, speech }) => {
localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{ layout } }));
window.SpeechRecognition = window.webkitSpeechRecognition = speech ? function() {} : undefined;
window.addEventListener('unhandledrejection', e=>console.error('rejection:', String(e.reason)));
// Prevent idle warming from concealing the eager/lazy CSS regression.
Object.defineProperty(navigator.connection || navigator, 'saveData', { value:true, configurable:true });
}, { layout:entry==='cold'?layout:(layout==='classic'?'f7-swipe':'classic'), speech });
const page = await context.newPage();
page.on("pageerror", e => console.error(name, layout, e.message));
page.on("console", m=>{if(m.text().startsWith("rejection:")) console.error(m.text());});
await page.goto(origin);
await page.waitForFunction(() => document.activeElement.id === 'searchInput');
// Historical shell's optional integrations can delay toolbar setup on
// a static server; run its original initializer with inert callbacks.
await page.evaluate(() => { if (!document.querySelector('[aria-label="Car mode"]')) CarMode.configure({ input:document.getElementById('searchInput'), search(){}, toast(){}, state:()=>({paused:true}), action(){} }); });
await page.evaluate(() => { document.querySelector('[aria-label="Car mode"]').id='carModeBtn'; const mic=document.querySelector('[aria-label="Voice search"]'); if(mic)mic.id='voiceSearchBtn'; });
await page.waitForSelector('#carModeBtn');
if (speech) await page.waitForSelector('#voiceSearchBtn');
await page.evaluate(async layout => { if(window.Lazy) await Lazy.load('layout:' + layout); data.settings.layout=layout; applyAppearance(); await document.fonts.ready; }, layout);
// Seed the actual Saved renderer, without media downloads or server access.
await page.evaluate(async () => {
const videos = ['Revealing Lilith (origins of abortion, ancient goddesses, & the final chapter)', 'How my Trouble Has Always Been a Divine Setup | Perry Stone', 'Official live video ' + 'UnbrokenLongTitle'.repeat(12)].map((title,i) => ({id:'geometry00'+i,title,thumbnail:'/icons/icon-192.png',channel:'Geometry fixture'}));
data.history = videos;
API.cacheList = async () => ({ok:true,items:videos.map((v,i)=>({id:v.id,size:(3-i)*100000000})),total:600000000});
view = {type:'saved'}; render();
});
await page.waitForSelector('.saved-card .card-title');
await page.waitForTimeout(550); // card entrance animation
for (const width of widths) {
await page.setViewportSize({width,height:896});
for (const update of [false, true]) {
await page.evaluate(show => document.querySelector('#updateIconBtn')?.classList.toggle('hidden', !show), update);
const geometry = await page.evaluate(() => {
const rect = n => { const r=n.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,cy:r.y+r.height/2}; };
const ids=['#sidebarToggle','#searchInput','#voiceSearchBtn','.search-btn','#carModeBtn','#updateIconBtn'];
const top = Object.fromEntries(ids.map(id=>[id,document.querySelector(id)]).filter(([,n])=>n&&n.getBoundingClientRect().width).map(([id,n])=>[id,rect(n)]));
const rows=[...document.querySelectorAll('.saved-card')].map(n=>({thumb:rect(n.querySelector('.thumb')),image:rect(n.querySelector('.thumb img')),info:rect(n.querySelector('.card-info')),title:rect(n.querySelector('.card-title'))}));
return {top,rows,scrollWidth:document.documentElement.scrollWidth};
});
const key=`${name}/${layout}/${entry}/${width}/speech=${speech}/update=${update}`;
const centers=Object.values(geometry.top).map(r=>r.cy);
geometry.centerSpread=Math.max(...centers)-Math.min(...centers);
geometry.gaps=geometry.rows.map(r=>r.info.x-Math.max(r.thumb.right,r.image.right));
check(geometry.centerSpread<=1,`${key}: center spread ${geometry.centerSpread}`);
for (const id of ['#voiceSearchBtn','#carModeBtn']) if(geometry.top[id]) check(geometry.top[id].width>=40&&geometry.top[id].height>=40,`${key}: cramped ${id}`);
for (const gap of geometry.gaps) check(gap>=8,`${key}: thumbnail/title gap ${gap}`);
check(geometry.top['#searchInput'].width>=24,`${key}: search input collapsed`);
check(geometry.top['#carModeBtn'].right<=width-8,`${key}: car clipped at edge`);
check(geometry.scrollWidth<=width,`${key}: horizontal page overflow`);
results.push({browser:name,layout,entry,width,speech,update,...geometry});
if(width===414&&speech&&!update) await page.screenshot({path:resolve(shotDir,`${label}-${name}-${layout}-${entry}.png`)});
}
}
// Loading the optional feature must not resize or move the toolbar.
await context.close();
}
} finally { await browser.close(); }
}
} finally { await new Promise(r=>server.close(r)); }
await writeFile(resolve(shotDir,`${label}-measurements.json`),JSON.stringify({results,failures},null,2));
console.log(`${label}: ${results.length} cases; ${failures.length} failed checks; measurements/screenshots in plans/ui-shots/`);
if(failures.length) console.log(failures.slice(0,12).join('\n'));
if(!measureOnly) assert.equal(failures.length,0,'UI geometry checks failed');

96
perf/ui-icons.mjs Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { chromium, webkit } from 'playwright';
import { serve, layouts, themes, contextFor, appearance } from './ui-fixture.mjs';
const args = process.argv.slice(2);
const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key)+1] : fallback;
const shots = resolve('plans/ui-shots');
await mkdir(shots, {recursive:true});
const host = await serve(option('--frontend','frontend'));
const results = [];
try {
for (const [name,engine] of Object.entries({chromium,webkit}).filter(([name]) => option('--browser','all') === 'all' || name === option('--browser','all'))) {
const browser = await engine.launch();
try {
const context = await contextFor(browser,host.origin);
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(host.origin);
await page.waitForFunction(() => document.activeElement.id === 'searchInput');
await page.evaluate(() => {
CarMode.configure({input:els.searchInput,search(){},toast(){},state:()=>({paused:true}),action(){}});
const videos = ['Saved offline fixture','Downloading fixture','Streaming fixture'].map((title,i) => ({id:'iconcheck0'+i,title,channel:'Icon fixture',thumbnail:'/icons/icon-192.png',duration:240}));
data.queue = videos; data.history = videos;
cachedIds.add(videos[0].id); downloading.add(videos[1].id);
current = {meta:videos[0]}; view = {type:'queue'}; render();
els.playerPane.classList.remove('empty','home-over');
els.placeholder.classList.add('hidden'); els.controls.classList.remove('hidden');
document.querySelector('#nowPlayingMeta').classList.remove('hidden');
updateNowPlayingActions();
});
await page.waitForSelector('.card.cached .saved-badge');
// Reuse this live page: layout changes must repaint the same SVG instances.
for (const layout of layouts) for (const theme of themes) {
await appearance(page,layout,theme);
await page.addStyleTag({content:'*,*::before,*::after { animation:none !important; transition:none !important; }'});
const measurements = await page.evaluate(() => {
const glyph = /[⬇⤓⇩💾📥↓]/u;
const bad = [...document.querySelectorAll('button,a,.saved-badge,.empty-icon,.pl-offline-badge,.home-pl-offline')].filter(n => glyph.test(n.textContent)).map(n=>n.outerHTML);
const selectors = ['.card.cached .saved-badge','.card.downloading .saved-badge','#saveBtn','#exportBtn','.bottom-nav-btn[data-view="saved"]','.bottom-nav-btn[data-view="downloads"]','.nav-item[data-view="saved"]','.nav-item[data-view="downloads"]'];
const icons = selectors.map(selector => {
const el=document.querySelector(selector), svg=el.querySelector('svg');
const active=[...el.querySelectorAll('svg')].find(n=>getComputedStyle(n).display!=='none') || svg;
const style=getComputedStyle(active), parent=getComputedStyle(el);
const uses=[...active.querySelectorAll('use')].filter(n=>getComputedStyle(n).display!=='none');
return {selector,hasSvg:!!svg,text:active.textContent,stroke:style.stroke,color:style.color,parentColor:parent.color,weight:style.strokeWidth,uses:uses.map(n=>n.getAttribute('href')),resolved:uses.every(n=>!!document.querySelector(n.getAttribute('href')))};
});
const shapes = [...document.querySelectorAll('#media-download .media-shape')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.classList[1]);
const probe=document.createElement('span');probe.style.color='var(--accent-bright)';document.body.appendChild(probe);
const accent=getComputedStyle(probe).color;probe.remove();
return {bad,icons,shapes,accent};
});
assert.deepEqual(measurements.bad,[],`${name}/${layout}/${theme}: glyph icon`);
assert.deepEqual(measurements.shapes,[`media-shape-${layout}`],`${layout}: wrong layout drawing`);
for (const icon of measurements.icons) {
assert(icon.hasSvg && icon.resolved && !icon.text.trim(),`${icon.selector}: invalid SVG`);
assert.equal(icon.stroke,icon.color,`${icon.selector}: ignores currentColor`);
assert.equal(icon.color,icon.parentColor,`${icon.selector}: ignores control colour`);
assert.equal(parseFloat(icon.weight),({'classic':1.8,'f7-swipe':2,'glass-stage':1.9,'bento-hub':1.8})[layout],`${icon.selector}: wrong stroke family`);
}
if(layout!=='bento-hub') assert.equal(measurements.icons[0].color,measurements.accent,'badge ignores theme token');
assert.deepEqual(measurements.icons[0].uses,['#media-offline']);
assert.deepEqual(measurements.icons[1].uses,['#media-progress']);
const label=`icons-${name}-${layout}-${theme}`;
await page.locator('#listPane').screenshot({path:resolve(shots,`${label}-queue.png`)});
// Bento Hub shows either the list or player on phones.
await page.evaluate(() => document.querySelector('#listPane').classList.add('hidden'));
await page.locator('.np-actions').screenshot({path:resolve(shots,`${label}-playing.png`)});
// Glass Stage replaces bottom navigation with its floating chrome.
const nav = page.locator('.bottom-nav');
if (await nav.isVisible()) await nav.screenshot({path:resolve(shots,`${label}-nav.png`)});
else await page.screenshot({path:resolve(shots,`${label}-nav-hidden.png`)});
// State updates must keep the SVG and reveal the corresponding concept.
const states=await page.evaluate(() => {
const id=current.meta.id;
const visible=()=>{ els.saveBtn.getBoundingClientRect(); return [...els.saveBtn.querySelectorAll('use')].filter(n=>getComputedStyle(n).display!=='none').map(n=>n.getAttribute('href')); };
cachedIds.delete(id);updateNowPlayingActions();const normal=visible();
downloading.add(id);updateNowPlayingActions();const progress=visible();
downloading.delete(id);cachedIds.add(id);updateNowPlayingActions();const done=visible();
markCardCacheState('iconcheck02','downloading');markCardCacheState('iconcheck02','cached');
return {normal,progress,done};
});
assert.deepEqual(states,{normal:['#media-download'],progress:['#media-progress'],done:['#media-offline']},`${name}/${layout}/${theme}: save states`);
await page.evaluate(() => document.querySelector('#listPane').classList.remove('hidden'));
results.push({browser:name,layout,theme,...measurements});
}
assert.deepEqual(errors,[],'script errors');
await context.close();
} finally { await browser.close(); }
}
} finally { await host.close(); }
await writeFile(resolve(shots,'icon-measurements.json'),JSON.stringify(results,null,2));
console.log(`Media icons: ${results.length} layout/theme cases passed; Queue, Now Playing and navigation screenshots in plans/ui-shots/`);