diff --git a/.gitignore b/.gitignore index a37e8ff..2f062fb 100755 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.impeccable/config.json b/.impeccable/config.json index 570fb0e..87d7c31 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -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." } ] } diff --git a/.impeccable/critique/2026-10-12T23-30-24Z__frontend-theme-glass-css.md b/.impeccable/critique/2026-10-12T23-30-24Z__frontend-theme-glass-css.md new file mode 100644 index 0000000..9402635 --- /dev/null +++ b/.impeccable/critique/2026-10-12T23-30-24Z__frontend-theme-glass-css.md @@ -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. diff --git a/frontend/styles.css b/frontend/styles.css index f0ce080..41bcf84 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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; } } diff --git a/frontend/theme-bento-hub.css b/frontend/theme-bento-hub.css index eae713e..5feb735 100644 --- a/frontend/theme-bento-hub.css +++ b/frontend/theme-bento-hub.css @@ -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; } diff --git a/frontend/theme-classic.css b/frontend/theme-classic.css index 198ccc8..f9d8252 100644 --- a/frontend/theme-classic.css +++ b/frontend/theme-classic.css @@ -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; } diff --git a/frontend/theme-f7.css b/frontend/theme-f7.css index e006b39..67d644c 100644 --- a/frontend/theme-f7.css +++ b/frontend/theme-f7.css @@ -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; } diff --git a/frontend/theme-glass.css b/frontend/theme-glass.css index 033a834..ce09b72 100644 --- a/frontend/theme-glass.css +++ b/frontend/theme-glass.css @@ -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; } diff --git a/perf/README.md b/perf/README.md index da6af3a..b9fc4e6 100644 --- a/perf/README.md +++ b/perf/README.md @@ -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/`. diff --git a/perf/rec-card.mjs b/perf/rec-card.mjs new file mode 100644 index 0000000..42ab70d --- /dev/null +++ b/perf/rec-card.mjs @@ -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(''); +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; diff --git a/perf/search-button.mjs b/perf/search-button.mjs new file mode 100644 index 0000000..dae57e7 --- /dev/null +++ b/perf/search-button.mjs @@ -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}`); diff --git a/plans/rec-card-shots/bento-hub-contrast-1280.png b/plans/rec-card-shots/bento-hub-contrast-1280.png new file mode 100644 index 0000000..b8a12b3 Binary files /dev/null and b/plans/rec-card-shots/bento-hub-contrast-1280.png differ diff --git a/plans/rec-card-shots/bento-hub-contrast-390.png b/plans/rec-card-shots/bento-hub-contrast-390.png new file mode 100644 index 0000000..572d1ff Binary files /dev/null and b/plans/rec-card-shots/bento-hub-contrast-390.png differ diff --git a/plans/rec-card-shots/bento-hub-dark-1280.png b/plans/rec-card-shots/bento-hub-dark-1280.png new file mode 100644 index 0000000..74b6681 Binary files /dev/null and b/plans/rec-card-shots/bento-hub-dark-1280.png differ diff --git a/plans/rec-card-shots/bento-hub-dark-390.png b/plans/rec-card-shots/bento-hub-dark-390.png new file mode 100644 index 0000000..3e55f9d Binary files /dev/null and b/plans/rec-card-shots/bento-hub-dark-390.png differ diff --git a/plans/rec-card-shots/bento-hub-light-1280.png b/plans/rec-card-shots/bento-hub-light-1280.png new file mode 100644 index 0000000..9047780 Binary files /dev/null and b/plans/rec-card-shots/bento-hub-light-1280.png differ diff --git a/plans/rec-card-shots/bento-hub-light-390.png b/plans/rec-card-shots/bento-hub-light-390.png new file mode 100644 index 0000000..6bf40eb Binary files /dev/null and b/plans/rec-card-shots/bento-hub-light-390.png differ diff --git a/plans/rec-card-shots/classic-contrast-1280.png b/plans/rec-card-shots/classic-contrast-1280.png new file mode 100644 index 0000000..b138e6b Binary files /dev/null and b/plans/rec-card-shots/classic-contrast-1280.png differ diff --git a/plans/rec-card-shots/classic-contrast-390.png b/plans/rec-card-shots/classic-contrast-390.png new file mode 100644 index 0000000..08eaff7 Binary files /dev/null and b/plans/rec-card-shots/classic-contrast-390.png differ diff --git a/plans/rec-card-shots/classic-dark-1280.png b/plans/rec-card-shots/classic-dark-1280.png new file mode 100644 index 0000000..7fda3a1 Binary files /dev/null and b/plans/rec-card-shots/classic-dark-1280.png differ diff --git a/plans/rec-card-shots/classic-dark-390.png b/plans/rec-card-shots/classic-dark-390.png new file mode 100644 index 0000000..3618081 Binary files /dev/null and b/plans/rec-card-shots/classic-dark-390.png differ diff --git a/plans/rec-card-shots/classic-light-1280.png b/plans/rec-card-shots/classic-light-1280.png new file mode 100644 index 0000000..4483919 Binary files /dev/null and b/plans/rec-card-shots/classic-light-1280.png differ diff --git a/plans/rec-card-shots/classic-light-390.png b/plans/rec-card-shots/classic-light-390.png new file mode 100644 index 0000000..2ef2360 Binary files /dev/null and b/plans/rec-card-shots/classic-light-390.png differ diff --git a/plans/rec-card-shots/f7-swipe-contrast-1280.png b/plans/rec-card-shots/f7-swipe-contrast-1280.png new file mode 100644 index 0000000..8735a38 Binary files /dev/null and b/plans/rec-card-shots/f7-swipe-contrast-1280.png differ diff --git a/plans/rec-card-shots/f7-swipe-contrast-390.png b/plans/rec-card-shots/f7-swipe-contrast-390.png new file mode 100644 index 0000000..1627828 Binary files /dev/null and b/plans/rec-card-shots/f7-swipe-contrast-390.png differ diff --git a/plans/rec-card-shots/f7-swipe-dark-1280.png b/plans/rec-card-shots/f7-swipe-dark-1280.png new file mode 100644 index 0000000..516f260 Binary files /dev/null and b/plans/rec-card-shots/f7-swipe-dark-1280.png differ diff --git a/plans/rec-card-shots/f7-swipe-dark-390.png b/plans/rec-card-shots/f7-swipe-dark-390.png new file mode 100644 index 0000000..7989643 Binary files /dev/null and b/plans/rec-card-shots/f7-swipe-dark-390.png differ diff --git a/plans/rec-card-shots/f7-swipe-light-1280.png b/plans/rec-card-shots/f7-swipe-light-1280.png new file mode 100644 index 0000000..cd4ee8f Binary files /dev/null and b/plans/rec-card-shots/f7-swipe-light-1280.png differ diff --git a/plans/rec-card-shots/f7-swipe-light-390.png b/plans/rec-card-shots/f7-swipe-light-390.png new file mode 100644 index 0000000..a9d223f Binary files /dev/null and b/plans/rec-card-shots/f7-swipe-light-390.png differ diff --git a/plans/rec-card-shots/glass-stage-contrast-1280.png b/plans/rec-card-shots/glass-stage-contrast-1280.png new file mode 100644 index 0000000..f864ab9 Binary files /dev/null and b/plans/rec-card-shots/glass-stage-contrast-1280.png differ diff --git a/plans/rec-card-shots/glass-stage-contrast-390.png b/plans/rec-card-shots/glass-stage-contrast-390.png new file mode 100644 index 0000000..69343ab Binary files /dev/null and b/plans/rec-card-shots/glass-stage-contrast-390.png differ diff --git a/plans/rec-card-shots/glass-stage-dark-1280.png b/plans/rec-card-shots/glass-stage-dark-1280.png new file mode 100644 index 0000000..7dd393c Binary files /dev/null and b/plans/rec-card-shots/glass-stage-dark-1280.png differ diff --git a/plans/rec-card-shots/glass-stage-dark-390.png b/plans/rec-card-shots/glass-stage-dark-390.png new file mode 100644 index 0000000..7de7056 Binary files /dev/null and b/plans/rec-card-shots/glass-stage-dark-390.png differ diff --git a/plans/rec-card-shots/glass-stage-light-1280.png b/plans/rec-card-shots/glass-stage-light-1280.png new file mode 100644 index 0000000..5125acb Binary files /dev/null and b/plans/rec-card-shots/glass-stage-light-1280.png differ diff --git a/plans/rec-card-shots/glass-stage-light-390.png b/plans/rec-card-shots/glass-stage-light-390.png new file mode 100644 index 0000000..6f48f95 Binary files /dev/null and b/plans/rec-card-shots/glass-stage-light-390.png differ