Merge recommendation card fix

This commit is contained in:
Jonathan Sykes
2026-10-13 08:09:25 +08:00
28 changed files with 263 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
---
target: Glass Stage recommendation card
total_score: 28
max_score: 40
na_heuristics:
p0_count: 0
p1_count: 0
target_identity: "file:/home/josh/development/personal/ytplayer-codex/wt-reccard/frontend/theme-glass.css"
target_fingerprint: "sha256:90049d33097797770fee252881753514e2ebfa90005d6c1f1b1157eba22e8474"
target_path: /home/josh/development/personal/ytplayer-codex/wt-reccard/frontend/theme-glass.css
timestamp: 2026-10-12T23-30-24Z
slug: frontend-theme-glass-css
---
Method: dual-agent (A: /root/rec_card_design_critique · B: /root/rec_card_detector_evidence)
## Design Health Score
| # | Heuristic | Score | Key issue |
|---|---|---:|---|
| 1 | Visibility of System Status | 3 | Recommendation reason is visible in-card. |
| 2 | Match System / Real World | 3 | Artwork, channel and listening reason map to familiar media cards. |
| 3 | User Control and Freedom | 3 | Queue and overflow actions remain distinct. |
| 4 | Consistency and Standards | 3 | Glass treatment is scoped; other layouts retain existing presentation. |
| 5 | Error Prevention | 3 | Missing artwork/reason remain structured and contained. |
| 6 | Recognition Rather Than Recall | 3 | Reason line explains why each item is recommended. |
| 7 | Flexibility and Efficiency | 3 | Queue is one-tap; secondary actions remain available. |
| 8 | Aesthetic and Minimalist Design | 3 | Unified surface adds clarity without extra controls. |
| 9 | Error Recovery | 2 | No card-specific recovery beyond Refresh for the section. |
| 10 | Help and Documentation | 2 | Actions rely on concise labels rather than help text. |
| **Total** | | **28/40** | Good; light-theme material could read more translucent. |
## Design Specificity Verdict
The card now reads as a Glass Stage surface through frosted token color, rounded shell, thin outline, and warm accent actions. In light mode it is nearly opaque, favoring legibility over visible translucency. Deterministic scan ran once on `frontend/index.html`; visible global findings were existing low-contrast, gradient-text, marquee, dark-glow, and token-advisory findings. Output was truncated, and none of the visible findings identified the changed card CSS. No detector overlay was injected; Playwright screenshots supplied the visual evidence.
## Overall Impression
The shell now contains artwork, title, channel, reason and actions as one coherent card. Long text wraps safely and the 44px actions remain easy to target.
## What's Working
- All card children fit within the rounded card at the tested widths.
- Empty artwork has a token-based color treatment; real art fills a 16:9 frame.
- Focus rings are inset so shell clipping does not hide keyboard focus.
## Priority Issues
- [P2] Light Glass Stage surface is nearly opaque; increase translucency only if the text AA checks remain passing.
- [P3] Refresh is section-level; no separate recovery action is needed for individual cards.
## Cognitive Load and Emotional Journey
Low-to-moderate: image → title/channel → reason → queue action follows a clear scan path. The artwork leads, the reason reassures relevance, and Queue provides an immediate next action.
## Persona Red Flags and Minor Notes
At 390px the Queue label is compact but readable. Classic/Bento/F7 screenshots show the existing styles; no shared CSS was changed.
Questions skipped: the task specified the target visual language, layout scope, and acceptance criteria.

View File

@@ -384,3 +384,69 @@ html[data-layout="glass-stage"] .search-btn {
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 12%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 12%, transparent);
} }
html[data-layout="glass-stage"] .search-btn:hover { background: color-mix(in srgb, var(--search-action-surface) 94%, var(--accent)); border-color: var(--accent); color: var(--search-action-foreground); transform: none; } html[data-layout="glass-stage"] .search-btn:hover { background: color-mix(in srgb, var(--search-action-surface) 94%, var(--accent)); border-color: var(--accent); color: var(--search-action-foreground); transform: none; }
/* Recommendation cards: one glass surface contains the complete interaction. */
html[data-layout="glass-stage"] .rec-grid { align-items: stretch; gap: 16px 14px; }
html[data-layout="glass-stage"] .rec-item {
display: flex; flex-direction: column; min-width: 0; height: 100%; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--text) 45%, transparent); border-radius: 20px;
background: color-mix(in srgb, var(--bg-1) 92%, transparent);
-webkit-backdrop-filter: blur(22px) saturate(1.25); backdrop-filter: blur(22px) saturate(1.25);
box-shadow: var(--shadow);
}
html[data-layout="glass-stage"] .rec-play {
display: flex; flex: none; flex-direction: column; align-items: stretch; gap: 0;
width: 100%; min-width: 0; overflow: hidden; padding: 0; border: 0; border-radius: 0;
background: transparent; box-shadow: none; color: var(--text); text-align: left;
}
html[data-layout="glass-stage"] .rec-play:hover { background: transparent; }
html[data-layout="glass-stage"] .rec-cover {
flex: none; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 0;
background: var(--bg-2);
}
html[data-layout="glass-stage"] .rec-cover:has(img[src=""]) {
background: radial-gradient(ellipse at 50% 42%, color-mix(in srgb, var(--accent) 30%, var(--bg-2)), var(--bg-2) 72%);
}
html[data-layout="glass-stage"] .rec-cover img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; }
html[data-layout="glass-stage"] .rec-cover img[src=""] { display: none; }
html[data-layout="glass-stage"] .rec-title {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
margin: 0; padding: 15px 14px 0; color: var(--text); font: 650 14px/1.4 var(--ui);
overflow-wrap: anywhere;
}
html[data-layout="glass-stage"] .rec-channel {
display: block; min-width: 0; padding: 5px 14px 0; color: var(--text-2);
font: 400 12px/1.45 var(--ui); overflow-wrap: anywhere;
}
html[data-layout="glass-stage"] .rec-reason {
align-self: flex-start; max-width: calc(100% - 28px); margin: 12px 14px 0; padding: 6px 10px;
border: 1px solid color-mix(in srgb, var(--text) 24%, transparent); border-radius: 12px;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-1)); color: var(--text-2);
font: 500 11px/1.4 var(--ui); overflow-wrap: anywhere;
}
html[data-layout="glass-stage"] .rec-actions {
display: flex; flex: none; align-items: stretch; gap: 8px; width: 100%; margin-top: auto; padding: 12px 14px 14px;
}
html[data-layout="glass-stage"] .rec-actions button {
display: inline-flex; flex: none; align-items: center; justify-content: center;
min-width: 44px; min-height: 44px; padding: 0 12px;
border: 1px solid color-mix(in srgb, var(--text) 28%, transparent); border-radius: 14px;
background: var(--bg-2); color: var(--text); font: 600 13px/1.2 var(--ui); white-space: nowrap;
cursor: pointer; transition: background-color .16s ease, border-color .16s ease, transform .16s ease;
}
html[data-layout="glass-stage"] .rec-actions button:first-child {
flex: 1 1 auto; min-width: 0; border-color: color-mix(in srgb, var(--accent) 32%, var(--bg-1));
background: color-mix(in srgb, var(--accent) 16%, var(--bg-1));
}
html[data-layout="glass-stage"] .rec-actions button:last-child { width: 44px; padding: 0; }
html[data-layout="glass-stage"] .rec-actions button:hover {
border-color: var(--accent-bright); background: color-mix(in srgb, var(--accent) 24%, var(--bg-1));
}
html[data-layout="glass-stage"] .rec-actions button:last-child:hover { background: var(--bg-3); }
html[data-layout="glass-stage"] .rec-play:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-bright); }
html[data-layout="glass-stage"] .rec-actions button:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-bright); }
html[data-layout="glass-stage"] .rec-refresh:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 3px; }
html[data-layout="glass-stage"] .rec-item :is(.rec-title,.rec-channel,.rec-reason) { overflow-wrap: anywhere; }
@media (prefers-reduced-motion: reduce) {
html[data-layout="glass-stage"] .rec-actions button { transition: none; }
}

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` 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 for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons
remain platform-rendered through the existing MediaSession handlers. 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB