Merge final UI batch into beta

This commit is contained in:
Jonathan Sykes
2026-10-13 08:12:25 +08:00
35 changed files with 499 additions and 51 deletions

4
.gitignore vendored
View File

@@ -50,3 +50,7 @@ plans/ui-shots/
# Transport regression screenshots # Transport regression screenshots
plans/transport-shots/ plans/transport-shots/
# Search button visual review screenshots
plans/search-button-shots/
plans/search-button-final-shots/

View File

@@ -21,6 +21,18 @@
], ],
"createdAt": "2026-10-05T18:33:43.625Z", "createdAt": "2026-10-05T18:33:43.625Z",
"reason": "Framework7 swipeout reveal retains its existing width transition for the action area" "reason": "Framework7 swipeout reveal retains its existing width transition for the action area"
},
{
"rule": "design-system-font",
"value": "plus jakarta sans",
"createdAt": "2026-10-11T09:31:13.372Z",
"reason": "Existing Bento Hub layout intentionally declares Plus Jakarta Sans in theme-bento-hub.css; the Search redesign preserves that established layout typography."
},
{
"rule": "overused-font",
"value": "plus jakarta sans",
"createdAt": "2026-10-13T00:04:58.925Z",
"reason": "Existing Bento Hub brand typeface retained; this scoped search-action change does not alter its typography."
} }
] ]
} }

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

@@ -23,6 +23,8 @@
--accent-bright: #ff6a52; --accent-bright: #ff6a52;
--accent-deep: #d4321d; --accent-deep: #d4321d;
--accent-glow: rgba(255, 75, 50, 0.45); --accent-glow: rgba(255, 75, 50, 0.45);
--search-action-surface: #16161b;
--search-action-foreground: #f5f3f0;
--display: "Bricolage Grotesque", Georgia, serif; --display: "Bricolage Grotesque", Georgia, serif;
--ui: "Hanken Grotesk", system-ui, sans-serif; --ui: "Hanken Grotesk", system-ui, sans-serif;
@@ -386,23 +388,6 @@ body::after {
background: var(--bg-1); background: var(--bg-1);
box-shadow: 0 0 0 4px rgba(255, 75, 50, 0.12); box-shadow: 0 0 0 4px rgba(255, 75, 50, 0.12);
} }
.search-btn {
background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep));
color: #fff;
border: none;
padding: 0 24px;
border-radius: 11px;
font-family: var(--ui);
font-weight: 700;
font-size: 14px;
letter-spacing: 0.01em;
cursor: pointer;
box-shadow: 0 8px 22px -8px var(--accent-glow);
transition: transform 0.16s var(--ease), box-shadow 0.16s;
}
.search-btn:hover { transform: translateY(-1px); box-shadow: 0 12px 28px -8px var(--accent-glow); }
.search-btn:active { transform: translateY(0); }
.body { flex: 1; display: flex; overflow: hidden; } .body { flex: 1; display: flex; overflow: hidden; }
/* ===================== Player pane ===================== */ /* ===================== Player pane ===================== */
@@ -1805,6 +1790,8 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
--text: #1b1a18; --text: #1b1a18;
--text-2: #4a4843; --text-2: #4a4843;
--text-dim: #7c776e; --text-dim: #7c776e;
--search-action-surface: #efece6;
--search-action-foreground: #1b1a18;
--shadow: 0 18px 50px -18px rgba(0, 0, 0, 0.25); --shadow: 0 18px 50px -18px rgba(0, 0, 0, 0.25);
} }
:root[data-theme="light"] body::after { opacity: 0.02; } :root[data-theme="light"] body::after { opacity: 0.02; }
@@ -1830,6 +1817,8 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
--accent-bright: #ff8a6c; --accent-bright: #ff8a6c;
--accent-deep: #ff5a3c; --accent-deep: #ff5a3c;
--accent-glow: rgba(255, 90, 60, 0.85); --accent-glow: rgba(255, 90, 60, 0.85);
--search-action-surface: #0a0a0a;
--search-action-foreground: #ffffff;
} }
:root[data-theme="contrast"] body::after { display: none; } :root[data-theme="contrast"] body::after { display: none; }
:root[data-theme="contrast"] body::before { display: none; } :root[data-theme="contrast"] body::before { display: none; }
@@ -3115,6 +3104,8 @@ html[data-layout="liturgical"] {
--accent-bright: #a83028; --accent-bright: #a83028;
--accent-deep: #6b1c14; --accent-deep: #6b1c14;
--accent-glow: rgba(139, 38, 29, 0.35); --accent-glow: rgba(139, 38, 29, 0.35);
--search-action-surface: var(--bg-2);
--search-action-foreground: var(--text);
--display: Georgia, 'Times New Roman', serif; --display: Georgia, 'Times New Roman', serif;
--ui: Georgia, 'Times New Roman', serif; --ui: Georgia, 'Times New Roman', serif;
--shadow: 0 18px 50px -12px rgba(0, 0, 0, 0.2); --shadow: 0 18px 50px -12px rgba(0, 0, 0, 0.2);
@@ -4366,17 +4357,45 @@ html[data-layout="f7-swipe"] .media-shape-f7-swipe,
html[data-layout="glass-stage"] .media-shape-glass-stage, html[data-layout="glass-stage"] .media-shape-glass-stage,
html[data-layout="bento-hub"] .media-shape-bento-hub { display:inline; } html[data-layout="bento-hub"] .media-shape-bento-hub { display:inline; }
/* ===== Search button — one icon + label, restyled per layout and colour theme ===== */ /* Search action: token-backed surfaces, one accessible focus treatment. */
.search-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; } .search-btn {
display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; gap: 7px;
min-width: 76px; min-height: 44px; padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--search-action-foreground) 45%, var(--accent-deep)); border-radius: 9px;
background: var(--search-action-surface); color: var(--search-action-foreground);
font: 700 14px/1 var(--ui); letter-spacing: .01em; white-space: nowrap;
box-shadow: none; cursor: pointer;
transition: background-color .14s ease, border-color .14s ease, transform .12s ease;
}
.search-btn::before { .search-btn::before {
content: ""; width: 17px; height: 17px; flex: none; background: currentColor; content: ""; width: 17px; height: 17px; flex: none; background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5'/%3E%3Cpath d='m16 16 5 5'/%3E%3C/svg%3E") center / contain no-repeat; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5'/%3E%3Cpath d='m16 16 5 5'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5'/%3E%3Cpath d='m16 16 5 5'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5'/%3E%3Cpath d='m16 16 5 5'/%3E%3C/svg%3E") center / contain no-repeat;
} }
.search-btn { box-shadow: 0 8px 22px -8px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, .28); } .search-btn:hover { background: var(--bg-3); border-color: var(--accent); transform: none; }
.search-btn:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 3px; } .search-btn:active { transform: scale(.98); }
:where(:root[data-theme="light"]) .search-btn { background: var(--accent-deep); box-shadow: 0 2px 8px -2px rgba(0, 0, 0, .35), inset 0 1px 0 rgba(255, 255, 255, .25); } .search-btn:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 2px; }
:where(:root[data-theme="light"]) .search-btn:hover { background: var(--accent); box-shadow: 0 4px 12px -3px rgba(0, 0, 0, .4); } html[data-layout] .search-btn:disabled { opacity: 1; background: var(--bg-1); border: 1px dashed var(--search-action-foreground); color: var(--search-action-foreground); cursor: not-allowed; }
:where(:root[data-theme="contrast"]) .search-btn { background: #fff; color: #000; border: 2px solid #fff; box-shadow: none; } .search-btn:disabled:hover { transform: none; }
:where(:root[data-theme="contrast"]) .search-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); box-shadow: none; } html[data-layout="classic"] .search-btn { border-radius: 7px; background: transparent; border-width: 1.5px; }
:where(:root[data-theme="contrast"]) .search-btn:focus-visible { outline: 3px solid #fff; outline-offset: 3px; } html[data-layout="f7-swipe"] .search-btn { min-width: 64px; padding-inline: 10px; border-color: transparent; border-bottom-color: color-mix(in srgb, var(--search-action-foreground) 45%, var(--accent-deep)); border-radius: 5px 5px 2px 2px; background: transparent; font-size: 16px; font-weight: 600; }
html[data-layout="f7-swipe"] .search-btn::before { display: none; }
html[data-layout="glass-stage"] .search-btn { border-radius: 999px; background: color-mix(in srgb, var(--search-action-surface) 88%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.35); backdrop-filter: blur(14px) saturate(1.35); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--search-action-foreground) 12%, transparent); }
html[data-layout="bento-hub"] .search-btn { min-width: 44px; border-radius: 999px; border-color: color-mix(in srgb, var(--search-action-foreground) 45%, var(--accent-deep)); background: var(--search-action-surface); color: var(--search-action-foreground); font: 700 13px/1 var(--ui); }
html[data-layout="bento-hub"] .search-btn:hover { background: var(--bg-3); border-color: var(--accent); transform: none; }
html[data-layout="bento-hub"] .search-btn::before { display: none; }
html[data-layout="sanctuary-stage"] .search-btn { border-radius: 4px; border-width: 1px 1px 2px; letter-spacing: .055em; text-transform: uppercase; }
html[data-layout="liturgical"] .search-btn { border-color: color-mix(in srgb, var(--search-action-foreground) 70%, var(--accent-deep)); border-radius: 3px; border-width: 1px 1px 2px; font-family: var(--display); font-weight: 700; letter-spacing: .02em; }
html[data-layout="liturgical"] .search-btn:hover { background: var(--bg-2); border-color: var(--accent); color: var(--search-action-foreground); }
html[data-layout="living-hymnal"] .search-btn { border-radius: 10px; border-width: 1px 1px 1px 3px; }
html[data-layout="broadcaster"] .search-btn { border-radius: 3px; border-width: 0 0 2px; letter-spacing: .06em; text-transform: uppercase; }
html[data-layout="contemplative"] .search-btn { border-color: var(--line); border-bottom-color: color-mix(in srgb, var(--search-action-foreground) 45%, var(--accent-deep)); border-radius: 3px; background: transparent; font-family: var(--display); font-weight: 600; }
html[data-layout="contemplative"] .search-btn:hover { background: var(--bg-3); border-color: var(--accent); color: var(--search-action-foreground); }
html[data-layout] .search-btn:active { transform: scale(.98); }
@media (max-width: 430px) {
.topbar { min-width: 0; gap: 6px; }
.search-form { min-width: 0; gap: 5px; }
.search-btn { min-width: 64px; padding-inline: 9px; gap: 5px; }
.search-btn::before { width: 15px; height: 15px; }
}
@media (prefers-reduced-motion: reduce) { .search-btn { transition: none; } }

View File

@@ -51,7 +51,19 @@ html[data-layout="bento-hub"] .sidebar-footer { border-top:1px solid var(--line)
html[data-layout="bento-hub"] .topbar { background:var(--bg-1); border-bottom:1px solid var(--line); padding:12px 20px; gap:12px; backdrop-filter:none; } html[data-layout="bento-hub"] .topbar { background:var(--bg-1); border-bottom:1px solid var(--line); padding:12px 20px; gap:12px; backdrop-filter:none; }
html[data-layout="bento-hub"] .search-form { max-width:640px; gap:8px; } html[data-layout="bento-hub"] .search-form { max-width:640px; gap:8px; }
html[data-layout="bento-hub"] #searchInput { border:1px solid var(--line); border-radius:999px; background:var(--bg-2); padding:12px 18px; font-size:14px; min-height:44px; box-shadow:none; } html[data-layout="bento-hub"] #searchInput { border:1px solid var(--line); border-radius:999px; background:var(--bg-2); padding:12px 18px; font-size:14px; min-height:44px; box-shadow:none; }
html[data-layout="bento-hub"] .search-btn { border:0; border-radius:999px; min-height:44px; background:var(--accent); color:var(--on-accent); font:700 13px var(--ui); box-shadow:none; } html[data-layout="bento-hub"] #searchForm .search-btn { width:auto; min-width:96px; min-height:44px; flex:0 1 auto; padding:0 13px; gap:8px; border:1px solid var(--accent); border-radius:12px; background:var(--accent); color:var(--on-accent); font:700 13px/1 var(--ui); box-shadow:var(--shadow-sm); }
html[data-layout="bento-hub"] #searchForm .search-btn:hover { background:var(--accent-bright); border-color:var(--accent-bright); color:var(--on-accent); transform:none; }
html[data-layout="bento-hub"] #searchForm .search-btn:active { transform:translateY(1px); box-shadow:none; }
html[data-layout="bento-hub"] #searchForm .search-btn:focus-visible { outline:2px solid var(--accent-sec); outline-offset:3px; }
html[data-layout="bento-hub"][data-theme="light"] #searchForm .search-btn { background:var(--accent-deep); border-color:var(--accent-deep); }
html[data-layout="bento-hub"][data-theme="light"] #searchForm .search-btn:hover { background:var(--bg-3); border-color:var(--accent-deep); color:var(--text); }
html[data-layout="bento-hub"] #searchForm .search-btn:disabled { background:var(--bg-2); border-color:var(--line-bright); color:var(--text-2); box-shadow:none; cursor:not-allowed; }
html[data-layout="bento-hub"] #searchForm .bento-search-icon { width:18px; height:18px; display:block; flex:none; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; }
html[data-layout="bento-hub"] #searchForm .bento-search-label { display:inline; position:static; width:auto; height:auto; padding:0; margin:0; overflow:visible; clip:auto; white-space:nowrap; }
@media(max-width:860px) {
html[data-layout="bento-hub"] #searchForm .search-btn { min-width:44px; width:44px; flex:0 0 44px; padding:0; border-radius:14px; }
html[data-layout="bento-hub"] #searchForm .bento-search-label { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; }
}
html[data-layout="bento-hub"] :is(.sidebar-toggle,.icon-btn,.update-icon-btn) { min-width:44px; width:44px; min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text-2); } html[data-layout="bento-hub"] :is(.sidebar-toggle,.icon-btn,.update-icon-btn) { min-width:44px; width:44px; min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text-2); }
html[data-layout="bento-hub"] .bottom-nav { height:calc(64px + env(safe-area-inset-bottom)); background:var(--bg-glass); border-top:1px solid var(--line); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); padding:0 4px env(safe-area-inset-bottom); } html[data-layout="bento-hub"] .bottom-nav { height:calc(64px + env(safe-area-inset-bottom)); background:var(--bg-glass); border-top:1px solid var(--line); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); padding:0 4px env(safe-area-inset-bottom); }
html[data-layout="bento-hub"] .bottom-nav-btn { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; padding:8px 0; min-height:64px; color:var(--text-2); } html[data-layout="bento-hub"] .bottom-nav-btn { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; padding:8px 0; min-height:64px; color:var(--text-2); }
@@ -329,8 +341,8 @@ html[data-layout="bento-hub"] .shortcut-overlay { background:rgba(0,0,0,.75); ba
html[data-layout="bento-hub"] .shortcut-panel { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); } html[data-layout="bento-hub"] .shortcut-panel { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); }
html[data-layout="bento-hub"] .shortcut-key { border-radius:6px; background:var(--bg-2); color:var(--text); font:500 12px var(--mono); } html[data-layout="bento-hub"] .shortcut-key { border-radius:6px; background:var(--bg-2); color:var(--text); font:500 12px var(--mono); }
html[data-layout="bento-hub"] :is(.modal-list button,.mc-row,.export-actions .btn) { min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text); border-color:var(--line); } html[data-layout="bento-hub"] :is(.modal-list button,.mc-row,.export-actions .btn) { min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text); border-color:var(--line); }
html[data-layout="bento-hub"] .bento-search-label { position:absolute; width:1px; height:1px; padding:0; margin:-1px; clip-path:inset(50%); overflow:hidden; white-space:nowrap; } html[data-layout="bento-hub"] .bento-search-label { position:static; width:auto; height:auto; padding:0; margin:0; clip-path:none; overflow:visible; white-space:nowrap; }
html[data-layout="bento-hub"] .bento-search-icon { display:none; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } html[data-layout="bento-hub"] .bento-search-icon { display:block; width:18px; height:18px; flex:none; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; }
html[data-layout="bento-hub"] .saved-badge .glass-control-icon { width:14px; height:14px; } html[data-layout="bento-hub"] .saved-badge .glass-control-icon { width:14px; height:14px; }
html[data-layout="bento-hub"] .card.downloading .saved-badge::before { content:''; } html[data-layout="bento-hub"] .card.downloading .saved-badge::before { content:''; }
@media(max-width:1023px) { @media(max-width:1023px) {
@@ -348,8 +360,8 @@ html[data-layout="bento-hub"] .card.downloading .saved-badge::before { content:'
html[data-layout="bento-hub"] .list-pane { flex:none!important; min-height:100%; overflow:visible; padding-bottom:16px; } html[data-layout="bento-hub"] .list-pane { flex:none!important; min-height:100%; overflow:visible; padding-bottom:16px; }
html[data-layout="bento-hub"] .cards { flex:none; overflow:visible; } html[data-layout="bento-hub"] .cards { flex:none; overflow:visible; }
html[data-layout="bento-hub"] .player-pane.empty.home-playlists .player-stage { max-height:none; } html[data-layout="bento-hub"] .player-pane.empty.home-playlists .player-stage { max-height:none; }
html[data-layout="bento-hub"] .search-btn { display:grid; place-items:center; flex:0 0 44px; width:44px; min-width:44px; padding:0; border-radius:999px; } html[data-layout="bento-hub"] .search-btn { display:grid; place-items:center; flex:0 0 44px; width:44px; min-width:44px; padding:0; border-radius:12px; }
html[data-layout="bento-hub"] .bento-search-icon { display:block; } html[data-layout="bento-hub"] .bento-search-label { position:absolute; width:1px; height:1px; padding:0; margin:-1px; clip-path:inset(50%); overflow:hidden; white-space:nowrap; }
html[data-layout="bento-hub"] .search-form { min-width:0; flex:1; gap:8px; } html[data-layout="bento-hub"] .search-form { min-width:0; flex:1; gap:8px; }
html[data-layout="bento-hub"] #searchInput { min-width:0; padding-inline:12px; } html[data-layout="bento-hub"] #searchInput { min-width:0; padding-inline:12px; }
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:1fr; } html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:1fr; }

View File

@@ -65,8 +65,6 @@ html[data-layout="classic"] .topbar {
} }
html[data-layout="classic"] .search-form { width: 100%; min-width: 0; max-width: 860px; gap: 8px; } html[data-layout="classic"] .search-form { width: 100%; min-width: 0; max-width: 860px; gap: 8px; }
html[data-layout="classic"] #searchInput { min-width: 0; font-size: 16px; padding: 11px 14px; min-height: 46px; border-radius: 8px; background: var(--bg-1); box-shadow: none; } html[data-layout="classic"] #searchInput { min-width: 0; font-size: 16px; padding: 11px 14px; min-height: 46px; border-radius: 8px; background: var(--bg-1); box-shadow: none; }
html[data-layout="classic"] .search-btn { min-height: 46px; padding: 0 22px; border-radius: 8px; background: var(--bg-3); color: var(--text); box-shadow: none; transform: none; }
html[data-layout="classic"] .search-btn:hover { background: var(--accent-deep); color: #fff; }
html[data-layout="classic"] .sidebar-toggle { min-width: 44px; min-height: 44px; border-radius: 8px; box-shadow: none; } html[data-layout="classic"] .sidebar-toggle { min-width: 44px; min-height: 44px; border-radius: 8px; box-shadow: none; }
html[data-layout="classic"] .player-pane { html[data-layout="classic"] .player-pane {
container-type: inline-size; container-type: inline-size;
@@ -236,10 +234,18 @@ html[data-layout="classic"] .card:has(.card-note-btn) .card-menu { grid-column:
html[data-layout="classic"] .card:has(.card-note-btn) .card-del { grid-column: 4; grid-row: 2; } html[data-layout="classic"] .card:has(.card-note-btn) .card-del { grid-column: 4; grid-row: 2; }
html[data-layout="classic"] .card:has(.card-note-btn) { column-gap: 8px; } html[data-layout="classic"] .card:has(.card-note-btn) { column-gap: 8px; }
/* Search button: outlined rectangle that fills with the accent on hover. */ /* Restored from a22b8b4: the Classic action is an outlined accent button. */
html[data-layout="classic"] .search-btn { background: transparent; color: var(--accent); border: 1.5px solid var(--accent); box-shadow: none; } html[data-layout="classic"] #searchForm .search-btn {
html[data-layout="classic"] .search-btn:hover { background: var(--accent); color: #fff; transform: none; box-shadow: none; } min-height:46px; padding:0 22px; border-radius:8px; border:1.5px solid var(--accent);
html[data-layout="classic"][data-theme="contrast"] .search-btn { background: #fff; color: #000; border-color: #fff; } background:transparent; color:var(--accent); box-shadow:none; transform:none; gap:8px;
html[data-layout="classic"][data-theme="contrast"] .search-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); color: #000; } }
html[data-layout="classic"][data-theme="light"] .search-btn { color: var(--accent-deep); border-color: var(--accent-deep); } html[data-layout="classic"] #searchForm .search-btn::before {
html[data-layout="classic"][data-theme="light"] .search-btn:hover { background: var(--accent-deep); color: #fff; } -webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5'/%3E%3Cpath d='m16 16 5 5'/%3E%3C/svg%3E");
mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5'/%3E%3Cpath d='m16 16 5 5'/%3E%3C/svg%3E");
}
html[data-layout="classic"] #searchForm .search-btn:hover { background:var(--accent); color:var(--bg); transform:none; box-shadow:none; }
html[data-layout="classic"][data-theme="contrast"] #searchForm .search-btn { background:transparent; color:var(--accent-bright); border-color:var(--accent-bright); }
html[data-layout="classic"][data-theme="contrast"] #searchForm .search-btn:hover { background:var(--accent-bright); border-color:var(--accent-bright); color:#000; }
html[data-layout="classic"][data-theme="light"] #searchForm .search-btn { color:var(--accent-deep); border-color:var(--accent-deep); }
html[data-layout="classic"][data-theme="light"] #searchForm .search-btn:hover { background:var(--accent-deep); color:#fff; }
html[data-layout="classic"] #searchForm .search-btn:disabled { background:var(--bg-1); color:var(--text-dim); border:1px dashed var(--accent-deep); cursor:not-allowed; }

View File

@@ -206,7 +206,17 @@ html[data-layout="f7-swipe"] #searchInput {
box-shadow: none; font: 400 17px/1.25 var(--ui, system-ui, sans-serif); box-shadow: none; font: 400 17px/1.25 var(--ui, system-ui, sans-serif);
} }
html[data-layout="f7-swipe"] .search-form::before { content: ''; position: absolute; left: 13px; top: 12px; z-index: 1; width: 18px; height: 18px; background: var(--text-dim); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m16 16 5 5' stroke='black' stroke-width='2'/%3E%3C/svg%3E") center/contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m16 16 5 5' stroke='black' stroke-width='2'/%3E%3C/svg%3E") center/contain no-repeat; pointer-events: none; } html[data-layout="f7-swipe"] .search-form::before { content: ''; position: absolute; left: 13px; top: 12px; z-index: 1; width: 18px; height: 18px; background: var(--text-dim); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m16 16 5 5' stroke='black' stroke-width='2'/%3E%3C/svg%3E") center/contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='10.5' cy='10.5' r='6.5' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m16 16 5 5' stroke='black' stroke-width='2'/%3E%3C/svg%3E") center/contain no-repeat; pointer-events: none; }
html[data-layout="f7-swipe"] .search-btn { min-height: 42px; padding: 0 8px; border: 0; background: transparent; color: var(--accent); font-size: 16px; font-weight: 500; box-shadow: none; } html[data-layout="f7-swipe"] #searchForm .search-btn {
min-width:64px; min-height:44px; padding:0 10px; border:0; border-radius:9px;
background:transparent; color:var(--accent-bright); font:600 17px/1 var(--ui,system-ui,sans-serif);
box-shadow:none; transform:none; transition:background-color .14s ease,color .14s ease;
}
html[data-layout="f7-swipe"][data-theme="light"] #searchForm .search-btn { color:var(--accent-deep); }
html[data-layout="f7-swipe"] #searchForm .search-btn:hover { background:color-mix(in srgb,var(--accent) 12%,transparent); }
html[data-layout="f7-swipe"] #searchForm .search-btn:active { background:color-mix(in srgb,var(--accent) 20%,transparent); transform:none; }
html[data-layout="f7-swipe"] #searchForm .search-btn:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
html[data-layout="f7-swipe"] #searchForm .search-btn:disabled { color:var(--text); background:var(--bg-2); border:0; cursor:not-allowed; }
@media (prefers-reduced-motion: reduce) { html[data-layout="f7-swipe"] #searchForm .search-btn { transition:none; } }
html[data-layout="f7-swipe"] #updateIconBtn { width: 40px; height: 40px; } html[data-layout="f7-swipe"] #updateIconBtn { width: 40px; height: 40px; }
html[data-layout="f7-swipe"] .search-recent { top: calc(100% + 8px); border: 1px solid var(--f7-hairline); border-radius: 14px; background: var(--bg-1); box-shadow: 0 12px 36px rgb(0 0 0 / .22); overflow: hidden; } html[data-layout="f7-swipe"] .search-recent { top: calc(100% + 8px); border: 1px solid var(--f7-hairline); border-radius: 14px; background: var(--bg-1); box-shadow: 0 12px 36px rgb(0 0 0 / .22); overflow: hidden; }
html[data-layout="f7-swipe"] .search-recent > * { border-radius: 0; border: 0; border-bottom: 1px solid var(--f7-hairline); background: transparent; min-height: 48px; } html[data-layout="f7-swipe"] .search-recent > * { border-radius: 0; border: 0; border-bottom: 1px solid var(--f7-hairline); background: transparent; min-height: 48px; }

View File

@@ -376,14 +376,79 @@ html[data-layout="glass-stage"] #controls .ch-badge { cursor: pointer; font-fami
html[data-layout="glass-stage"] #controls .ch-badge:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); } html[data-layout="glass-stage"] #controls .ch-badge:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
html[data-layout="glass-stage"] #controls .ch-badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } html[data-layout="glass-stage"] #controls .ch-badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Search button: frosted pill, accent-tinted, with a soft glow. */ /* Search action uses a frosted token surface; label contrast remains stable. */
html[data-layout="glass-stage"] .search-btn { html[data-layout="glass-stage"] .search-btn {
background: color-mix(in srgb, var(--accent) 30%, transparent); color: var(--text); background: color-mix(in srgb, var(--search-action-surface) 88%, transparent); color: var(--search-action-foreground);
border: 1px solid color-mix(in srgb, var(--accent-bright) 55%, transparent); border-radius: 999px; border: 1px solid color-mix(in srgb, var(--search-action-foreground) 45%, var(--accent-deep)); border-radius: 999px;
-webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4);
box-shadow: 0 6px 22px -8px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, .3); 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(--accent) 48%, transparent); transform: translateY(-1px); } 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"][data-theme="contrast"] .search-btn { background: #fff; color: #000; border-color: #fff; }
html[data-layout="glass-stage"][data-theme="light"] .search-btn { background: color-mix(in srgb, var(--accent-deep) 88%, transparent); color: #fff; border-color: color-mix(in srgb, var(--accent-deep) 70%, #fff); } /* Recommendation cards: one glass surface contains the complete interaction. */
html[data-layout="glass-stage"][data-theme="light"] .search-btn:hover { background: var(--accent-deep); } 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; }
}
/* "NOW PLAYING" sits bottom-right of the card so it never covers the row's controls. */
html[data-layout="glass-stage"] .card.playing::after { top: auto; bottom: 12px; right: 18px; pointer-events: none; }

View File

@@ -1,5 +1,10 @@
# Performance Baseline Harness (`perf/`) # 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**. 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. 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 ```bash
node perf/retry.mjs --browser all --profile lossy 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
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;

119
perf/search-button.mjs Normal file
View 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}`);

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