Contain recommendation details in glass cards

This commit is contained in:
Jonathan Sykes
2026-10-13 07:30:37 +08:00
parent 3c68e56b21
commit c600415ebb
29 changed files with 269 additions and 0 deletions

View File

@@ -489,3 +489,10 @@ remote toggles against a mocked relay. Transport previews and remote screenshots
are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs`
for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons
remain platform-rendered through the existing MediaSession handlers.
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
View 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;