Merge final UI batch into beta
4
.gitignore
vendored
@@ -50,3 +50,7 @@ plans/ui-shots/
|
||||
|
||||
# Transport regression screenshots
|
||||
plans/transport-shots/
|
||||
|
||||
# Search button visual review screenshots
|
||||
plans/search-button-shots/
|
||||
plans/search-button-final-shots/
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
],
|
||||
"createdAt": "2026-10-05T18:33:43.625Z",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -23,6 +23,8 @@
|
||||
--accent-bright: #ff6a52;
|
||||
--accent-deep: #d4321d;
|
||||
--accent-glow: rgba(255, 75, 50, 0.45);
|
||||
--search-action-surface: #16161b;
|
||||
--search-action-foreground: #f5f3f0;
|
||||
|
||||
--display: "Bricolage Grotesque", Georgia, serif;
|
||||
--ui: "Hanken Grotesk", system-ui, sans-serif;
|
||||
@@ -386,23 +388,6 @@ body::after {
|
||||
background: var(--bg-1);
|
||||
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; }
|
||||
|
||||
/* ===================== Player pane ===================== */
|
||||
@@ -1805,6 +1790,8 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
--text: #1b1a18;
|
||||
--text-2: #4a4843;
|
||||
--text-dim: #7c776e;
|
||||
--search-action-surface: #efece6;
|
||||
--search-action-foreground: #1b1a18;
|
||||
--shadow: 0 18px 50px -18px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
: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-deep: #ff5a3c;
|
||||
--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::before { display: none; }
|
||||
@@ -3115,6 +3104,8 @@ html[data-layout="liturgical"] {
|
||||
--accent-bright: #a83028;
|
||||
--accent-deep: #6b1c14;
|
||||
--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;
|
||||
--ui: Georgia, 'Times New Roman', serif;
|
||||
--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="bento-hub"] .media-shape-bento-hub { display:inline; }
|
||||
|
||||
/* ===== Search button — one icon + label, restyled per layout and colour theme ===== */
|
||||
.search-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
/* Search action: token-backed surfaces, one accessible focus treatment. */
|
||||
.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 {
|
||||
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;
|
||||
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.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:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 3px; }
|
||||
: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); }
|
||||
:where(:root[data-theme="light"]) .search-btn:hover { background: var(--accent); box-shadow: 0 4px 12px -3px rgba(0, 0, 0, .4); }
|
||||
:where(:root[data-theme="contrast"]) .search-btn { background: #fff; color: #000; border: 2px solid #fff; box-shadow: none; }
|
||||
:where(:root[data-theme="contrast"]) .search-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); box-shadow: none; }
|
||||
:where(:root[data-theme="contrast"]) .search-btn:focus-visible { outline: 3px solid #fff; outline-offset: 3px; }
|
||||
.search-btn:hover { background: var(--bg-3); border-color: var(--accent); transform: none; }
|
||||
.search-btn:active { transform: scale(.98); }
|
||||
.search-btn:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 2px; }
|
||||
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; }
|
||||
.search-btn:disabled:hover { transform: none; }
|
||||
html[data-layout="classic"] .search-btn { border-radius: 7px; background: transparent; border-width: 1.5px; }
|
||||
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; } }
|
||||
|
||||
@@ -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"] .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"] .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"] .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); }
|
||||
@@ -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-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"] .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-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-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: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"] .card.downloading .saved-badge::before { content:''; }
|
||||
@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"] .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"] .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"] .bento-search-icon { display:block; }
|
||||
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-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"] #searchInput { min-width:0; padding-inline:12px; }
|
||||
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:1fr; }
|
||||
|
||||
@@ -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"] #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"] .player-pane {
|
||||
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) { column-gap: 8px; }
|
||||
|
||||
/* Search button: outlined rectangle that fills with the accent on hover. */
|
||||
html[data-layout="classic"] .search-btn { background: transparent; color: var(--accent); border: 1.5px solid var(--accent); box-shadow: none; }
|
||||
html[data-layout="classic"] .search-btn:hover { background: var(--accent); color: #fff; transform: none; box-shadow: none; }
|
||||
html[data-layout="classic"][data-theme="contrast"] .search-btn { background: #fff; color: #000; border-color: #fff; }
|
||||
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"][data-theme="light"] .search-btn:hover { background: var(--accent-deep); color: #fff; }
|
||||
/* Restored from a22b8b4: the Classic action is an outlined accent button. */
|
||||
html[data-layout="classic"] #searchForm .search-btn {
|
||||
min-height:46px; padding:0 22px; border-radius:8px; border:1.5px solid var(--accent);
|
||||
background:transparent; color:var(--accent); box-shadow:none; transform:none; gap:8px;
|
||||
}
|
||||
html[data-layout="classic"] #searchForm .search-btn::before {
|
||||
-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; }
|
||||
|
||||
@@ -206,7 +206,17 @@ html[data-layout="f7-swipe"] #searchInput {
|
||||
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-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"] .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; }
|
||||
|
||||
@@ -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: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 {
|
||||
background: color-mix(in srgb, var(--accent) 30%, transparent); color: var(--text);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-bright) 55%, transparent); border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--search-action-surface) 88%, transparent); color: var(--search-action-foreground);
|
||||
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);
|
||||
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"][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); }
|
||||
html[data-layout="glass-stage"][data-theme="light"] .search-btn:hover { background: var(--accent-deep); }
|
||||
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; }
|
||||
}
|
||||
/* "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; }
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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**.
|
||||
|
||||
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
|
||||
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
@@ -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
@@ -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}`);
|
||||
BIN
plans/rec-card-shots/bento-hub-contrast-1280.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
plans/rec-card-shots/bento-hub-contrast-390.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
plans/rec-card-shots/bento-hub-dark-1280.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
plans/rec-card-shots/bento-hub-dark-390.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
plans/rec-card-shots/bento-hub-light-1280.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
plans/rec-card-shots/bento-hub-light-390.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
plans/rec-card-shots/classic-contrast-1280.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
plans/rec-card-shots/classic-contrast-390.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
plans/rec-card-shots/classic-dark-1280.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
plans/rec-card-shots/classic-dark-390.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
plans/rec-card-shots/classic-light-1280.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
plans/rec-card-shots/classic-light-390.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
plans/rec-card-shots/f7-swipe-contrast-1280.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
plans/rec-card-shots/f7-swipe-contrast-390.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
plans/rec-card-shots/f7-swipe-dark-1280.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
plans/rec-card-shots/f7-swipe-dark-390.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
plans/rec-card-shots/f7-swipe-light-1280.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
plans/rec-card-shots/f7-swipe-light-390.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
plans/rec-card-shots/glass-stage-contrast-1280.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
plans/rec-card-shots/glass-stage-contrast-390.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
plans/rec-card-shots/glass-stage-dark-1280.png
Normal file
|
After Width: | Height: | Size: 412 KiB |
BIN
plans/rec-card-shots/glass-stage-dark-390.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
plans/rec-card-shots/glass-stage-light-1280.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
plans/rec-card-shots/glass-stage-light-390.png
Normal file
|
After Width: | Height: | Size: 152 KiB |