Contain recommendation details in glass cards
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user