Redesign Classic layout for desktop and mobile

This commit is contained in:
Jonathan Sykes
2026-10-03 05:41:45 +08:00
parent 54fcd12119
commit 343eb53d89
14 changed files with 1155 additions and 25 deletions

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ node_modules/
# runtime/local artifacts # runtime/local artifacts
server/data/ server/data/
test-results/ test-results/
.impeccable/review/
.claude/worktrees/ .claude/worktrees/
server/public server/public

3
.impeccable/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"buildPath": "code"
}

328
.impeccable/design.json Normal file
View File

@@ -0,0 +1,328 @@
{
"schemaVersion": 2,
"generatedAt": "2026-10-02T21:37:12.817947+00:00",
"title": "Design System: YT Player — Classic",
"extensions": {
"colorMeta": {
"accent": {
"role": "primary",
"displayName": "Accent"
},
"accent-bright": {
"role": "primary",
"displayName": "Accent Bright"
},
"accent-deep": {
"role": "primary",
"displayName": "Accent Deep"
},
"bg": {
"role": "neutral",
"displayName": "Bg"
},
"bg-1": {
"role": "neutral",
"displayName": "Bg 1"
},
"bg-2": {
"role": "neutral",
"displayName": "Bg 2"
},
"bg-3": {
"role": "neutral",
"displayName": "Bg 3"
},
"line": {
"role": "neutral",
"displayName": "Line"
},
"line-soft": {
"role": "neutral",
"displayName": "Line Soft"
},
"text": {
"role": "neutral",
"displayName": "Text"
},
"text-2": {
"role": "neutral",
"displayName": "Text 2"
},
"text-dim": {
"role": "neutral",
"displayName": "Text Dim"
},
"on-primary": {
"role": "neutral",
"displayName": "On Primary"
},
"on-play": {
"role": "neutral",
"displayName": "On Play"
},
"light-bg": {
"role": "neutral",
"displayName": "Light Bg"
},
"light-bg-1": {
"role": "neutral",
"displayName": "Light Bg 1"
},
"light-bg-2": {
"role": "neutral",
"displayName": "Light Bg 2"
},
"light-bg-3": {
"role": "neutral",
"displayName": "Light Bg 3"
},
"light-line": {
"role": "neutral",
"displayName": "Light Line"
},
"light-line-soft": {
"role": "neutral",
"displayName": "Light Line Soft"
},
"light-text": {
"role": "neutral",
"displayName": "Light Text"
},
"light-text-2": {
"role": "neutral",
"displayName": "Light Text 2"
},
"light-text-dim": {
"role": "neutral",
"displayName": "Light Text Dim"
},
"light-accent": {
"role": "primary",
"displayName": "Light Accent"
},
"light-accent-bright": {
"role": "primary",
"displayName": "Light Accent Bright"
},
"light-accent-deep": {
"role": "primary",
"displayName": "Light Accent Deep"
},
"contrast-bg": {
"role": "neutral",
"displayName": "Contrast Bg"
},
"contrast-bg-2": {
"role": "neutral",
"displayName": "Contrast Bg 2"
},
"contrast-bg-3": {
"role": "neutral",
"displayName": "Contrast Bg 3"
},
"contrast-line": {
"role": "neutral",
"displayName": "Contrast Line"
},
"contrast-line-soft": {
"role": "neutral",
"displayName": "Contrast Line Soft"
},
"contrast-text-dim": {
"role": "neutral",
"displayName": "Contrast Text Dim"
},
"contrast-accent": {
"role": "primary",
"displayName": "Contrast Accent"
},
"contrast-accent-bright": {
"role": "primary",
"displayName": "Contrast Accent Bright"
}
},
"typographyMeta": {
"display": {
"displayName": "Display",
"purpose": "Empty-home heading."
},
"headline": {
"displayName": "Headline",
"purpose": "Now-playing media title."
},
"title": {
"displayName": "Title",
"purpose": "Browsing heading."
},
"body": {
"displayName": "Body",
"purpose": "Interface text."
},
"label": {
"displayName": "Label",
"purpose": "Suggestion chips."
},
"media-title": {
"displayName": "Media Title",
"purpose": "Browsing media titles."
},
"time": {
"displayName": "Time",
"purpose": "Tabular transport timestamps."
}
},
"shadows": [
{
"name": "dialog-dark",
"value": "0 18px 50px -12px rgba(0, 0, 0, 0.7)",
"purpose": "Inherited dialog and sheet elevation in dark mode."
},
{
"name": "dialog-light",
"value": "0 18px 50px -18px rgba(0, 0, 0, 0.25)",
"purpose": "Inherited dialog and sheet elevation in light mode."
},
{
"name": "playing-edge",
"value": "inset 2px 0 var(--accent)",
"purpose": "Selected media row indicator, not ambient elevation."
}
],
"motion": [
{
"name": "ease",
"value": "cubic-bezier(0.22, 1, 0.36, 1)",
"purpose": "Inherited interface easing."
},
{
"name": "color-state",
"value": "background 0.16s, color 0.16s",
"purpose": "Recurring inherited color state transition."
},
{
"name": "reduced-duration",
"value": "0.001ms",
"purpose": "Explicit preference and operating-system reduced motion."
}
],
"breakpoints": [
{
"name": "stacked-max",
"value": "1200px"
},
{
"name": "mobile-max",
"value": "860px"
},
{
"name": "xl-navigation-max",
"value": "420px"
},
{
"name": "narrow-thumbnail-max",
"value": "380px"
},
{
"name": "transport-wide-container-min",
"value": "620px"
},
{
"name": "transport-narrow-container-max",
"value": "300px"
}
]
},
"components": [
{
"name": "Filled action",
"kind": "button",
"refersTo": "button-primary",
"description": "Classic list action.",
"html": "<button class=\"ds-primary\">Play all</button>",
"css": ".ds-primary { background:var(--accent-deep,#c6442f);color:#fff;border:0;border-radius:8px;min-height:44px;padding:8px 12px;font:800 14px var(--ui,\"Hanken Grotesk\",system-ui,sans-serif);cursor:pointer;transition:background .16s,color .16s; }.ds-primary:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }"
},
{
"name": "Search action",
"kind": "button",
"refersTo": "button-search",
"description": "Quiet search submission, accented on hover.",
"html": "<button class=\"ds-search\">Search</button>",
"css": ".ds-search { background:var(--bg-3,#2b2e33);color:var(--text,#f3f1ed);border:0;border-radius:8px;min-height:46px;padding:0 22px;font:600 14px var(--ui,\"Hanken Grotesk\",system-ui,sans-serif);cursor:pointer;transition:background .16s,color .16s; }.ds-search:hover { background:var(--accent-deep,#c6442f);color:#fff; }.ds-search:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }"
},
{
"name": "Play control",
"kind": "button",
"refersTo": "button-play",
"description": "Circular primary transport.",
"html": "<button class=\"ds-play\" aria-label=\"Play\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"m8 5 11 7-11 7z\" fill=\"currentColor\"/></svg></button>",
"css": ".ds-play { display:grid;place-items:center;width:64px;height:64px;border:0;border-radius:50%;background:var(--accent,#f06b53);color:#171719;cursor:pointer;transition:background .16s; }.ds-play:hover { background:var(--accent-bright,#ff947e); }.ds-play:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }"
},
{
"name": "Search field",
"kind": "input",
"refersTo": "input-search",
"description": "Full-width media query field.",
"html": "<input class=\"ds-input\" aria-label=\"Search media\" placeholder=\"Search YouTube…\">",
"css": ".ds-input { background:var(--bg-1,#18191c);color:var(--text,#f3f1ed);border:1px solid var(--line,#42464d);border-radius:8px;min-height:46px;padding:11px 14px;font:16px var(--ui,\"Hanken Grotesk\",system-ui,sans-serif);caret-color:var(--accent,#f06b53);transition:border-color .2s,background .2s; }.ds-input::placeholder { color:var(--text-dim,#a1a5ac);opacity:1; }.ds-input:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }"
},
{
"name": "Suggestion chip",
"kind": "chip",
"refersTo": "chip",
"description": "Media search suggestion.",
"html": "<button class=\"ds-chip\">Jazz</button>",
"css": ".ds-chip { min-height:44px;padding:10px 14px;background:var(--bg-2,#202226);border:1px solid var(--line-soft,#2b2e33);border-radius:8px;color:var(--text,#f3f1ed);font:500 14px var(--ui,\"Hanken Grotesk\",system-ui,sans-serif);cursor:pointer;transition:background .16s,border-color .16s; }.ds-chip:hover { border-color:var(--accent,#f06b53); }.ds-chip:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }"
},
{
"name": "Active navigation",
"kind": "nav",
"refersTo": "nav-active",
"description": "Sidebar selection with stroked SVG.",
"html": "<button class=\"ds-nav\" aria-current=\"page\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 5 5\"/></svg>Search</button>",
"css": ".ds-nav { display:flex;align-items:center;gap:12px;min-height:44px;padding:10px 12px;border:0;border-radius:var(--radius-sm,8px);background:var(--bg-3,#2b2e33);color:var(--text,#f3f1ed);font:14px var(--ui,\"Hanken Grotesk\",system-ui,sans-serif);cursor:pointer;transition:color .18s,background .18s; }.ds-nav svg { fill:none;stroke:var(--accent,#f06b53);stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round; }.ds-nav:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }"
},
{
"name": "Playing media row",
"kind": "card",
"refersTo": "media-row",
"description": "Divider-separated selected media without a raster fixture.",
"html": "<div class=\"ds-row\" tabindex=\"0\"><div class=\"ds-thumb\" aria-label=\"Media thumbnail area\"></div><div><div class=\"ds-title\">Media title</div><div class=\"ds-channel\">Channel</div></div><button class=\"ds-row-menu\" aria-label=\"Media actions\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"5\" r=\"1.5\"/><circle cx=\"12\" cy=\"12\" r=\"1.5\"/><circle cx=\"12\" cy=\"19\" r=\"1.5\"/></svg></button></div>",
"css": ".ds-row { display:grid;grid-template-columns:88px minmax(0,1fr) 44px;gap:12px;padding:12px 8px;border-bottom:1px solid var(--line-soft,#2b2e33);background:var(--bg-3,#2b2e33);box-shadow:inset 2px 0 var(--accent,#f06b53);color:var(--text,#f3f1ed);font-family:var(--ui,\"Hanken Grotesk\",system-ui,sans-serif); }.ds-thumb { width:88px;aspect-ratio:16/9;border-radius:6px;background:var(--bg-2,#202226); }.ds-title { font-size:14px;font-weight:600;line-height:1.45; }.ds-channel { font-size:12px;color:var(--text-dim,#a1a5ac);margin-top:5px; }.ds-row-menu { min-width:44px;min-height:44px;background:transparent;color:var(--text,#f3f1ed);border:0;cursor:pointer; }.ds-row-menu svg { fill:currentColor; }.ds-row:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }.ds-row-menu:focus-visible { outline:2px solid var(--accent,#f06b53);outline-offset:3px; }"
}
],
"narrative": {
"northStar": "Quiet Media Studio",
"overview": "Classic is a matte charcoal media workspace with warm white type and restrained vermilion. Watching and listening share the same visual priority; playback, browsing, and library controls remain legible together. This system applies only when the Classic layout is selected. Other theme families retain their own visual authority.\n\nThe implemented world uses tonal surfaces, fine dividers, bundled typography, and generous control targets. Light, high contrast, text scale, compact density, and reduced motion are supported appearance preferences. Source evidence is frontend/theme-classic.css, with inherited typography and preference values from frontend/styles.css and frontend/refresh.css; the approved direction lives in .impeccable/surfaces/frontend-theme-classic-css.md.",
"keyCharacteristics": [
"Matte tonal layers and fine dividers.",
"Restrained vermilion for playback, actions, and selection.",
"Bricolage headings, Hanken interface text, JetBrains timing.",
"Responsive workspace with persistent access to playback."
],
"rules": [
{
"name": "The Classic Scope Rule",
"body": "Apply these rules only to Classic; shared markup does not make them rules for other layout families.",
"section": "colors"
},
{
"name": "The Tonal Surface Rule",
"body": "Use surface tone and fine dividers for resting depth; reserve the inherited ambient shadow for dialogs and sheets.",
"section": "elevation"
}
],
"dos": [
"Do keep Classic overrides scoped to the selected Classic layout.",
"Do use tonal surfaces and fine dividers for workspace hierarchy.",
"Do preserve light, high contrast, text scale, density, safe areas, and reduced motion.",
"Do use inline SVG for navigation icons and retain visible keyboard focus.",
"Do use media thumbnails as user content."
],
"donts": [
"Don't add ambient glow or grain to Classic.",
"Don't force empty home into the video aspect ratio.",
"Don't hide row actions until hover or reduce established control targets to fit a crowded layout.",
"Don't transfer this visual world to other layout families."
]
}
}

View File

@@ -0,0 +1,26 @@
---
version: 1
slug: "frontend-theme-classic-css"
primary_target: "frontend/theme-classic.css"
related_targets: ["frontend/index.html","frontend/app.js"]
---
# Classic surface
Mode: Operate. Scope: Classic in the shared web frontend, mobile browser/PWA and desktop. Preserve features and all other layout themes. User approved code-first implementation of the full redesign plan.
## Direction contract
THESIS: A quiet media workspace gives watching and listening equal attention. Empty home is useful content, never content trapped in a video frame.
OWN-WORLD: Matte charcoal, warm white type, restrained vermilion, 12px corners, fine dividers. Bundled Hanken UI, Bricolage headings, JetBrains timing. No ambient glow or grain. Respect light and high-contrast preferences.
STORY: Search or choose a playlist, play, then browse without losing playback. Existing features and factual copy survive.
FIRST VIEWPORT: Desktop has a 232px library sidebar, flexible video/workspace and 340–420px browsing pane under search. Phones have search above content, centered transport under video, and existing navigation at the bottom. Empty home replaces the video proportions with a fully visible introduction or playlist collection.
FORM: User-pinned quiet studio workspace; concept seed 6fcc153a was run, but its assignment and catalog alternatives do not override the explicitly approved plan. Signature interaction: scrolling from playback into browsing reveals the existing mini-player above navigation; selecting it returns to playback without moving the document viewport.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
Constraints: no generated artwork needed; existing media thumbnails are user content. Existing untracked mockups remain untouched. No deployment.

243
DESIGN.md Normal file
View File

@@ -0,0 +1,243 @@
---
name: "YT Player — Classic"
description: "Quiet matte studio for watching and listening; applies only to the Classic layout."
colors:
accent: "#f06b53"
accent-bright: "#ff947e"
accent-deep: "#c6442f"
bg: "#121315"
bg-1: "#18191c"
bg-2: "#202226"
bg-3: "#2b2e33"
line: "#42464d"
line-soft: "#2b2e33"
text: "#f3f1ed"
text-2: "#c3c4c7"
text-dim: "#a1a5ac"
on-primary: "#ffffff"
on-play: "#171719"
light-bg: "#f4f2ee"
light-bg-1: "#ffffff"
light-bg-2: "#efece6"
light-bg-3: "#e5e0d8"
light-line: "#d8d2c8"
light-line-soft: "#e6e1d9"
light-text: "#1b1a18"
light-text-2: "#4a4843"
light-text-dim: "#625e59"
light-accent: "#ff4b32"
light-accent-bright: "#ff6a52"
light-accent-deep: "#b43623"
contrast-bg: "#000000"
contrast-bg-2: "#0a0a0a"
contrast-bg-3: "#161616"
contrast-line: "#ffffff"
contrast-line-soft: "#9a9a9a"
contrast-text-dim: "#d8d8d8"
contrast-accent: "#ff5a3c"
contrast-accent-bright: "#ff8a6c"
typography:
display:
fontFamily: "\"Bricolage Grotesque\", Georgia, serif"
fontSize: "clamp(36px, 4vw, 60px)"
fontWeight: 800
lineHeight: 1.05
letterSpacing: "-0.035em"
headline:
fontFamily: "\"Bricolage Grotesque\", Georgia, serif"
fontSize: "clamp(22px, 2vw, 30px)"
fontWeight: 700
lineHeight: 1.25
letterSpacing: "-0.02em"
title:
fontFamily: "\"Bricolage Grotesque\", Georgia, serif"
fontSize: "26px"
fontWeight: 700
lineHeight: 1.2
body:
fontFamily: "\"Hanken Grotesk\", system-ui, sans-serif"
fontSize: "14px"
lineHeight: 1.45
label:
fontFamily: "\"Hanken Grotesk\", system-ui, sans-serif"
fontSize: "14px"
fontWeight: 500
media-title:
fontFamily: "\"Hanken Grotesk\", system-ui, sans-serif"
fontSize: "14px"
fontWeight: 600
lineHeight: 1.45
time:
fontFamily: "\"JetBrains Mono\", ui-monospace, monospace"
fontSize: "12px"
rounded:
flat: "0px"
thumbnail: "6px"
sm: "8px"
md: "12px"
circle: "50%"
spacing:
small: "8px"
control-gap: "14px"
compact: "16px"
standard: "24px"
components:
button-primary:
backgroundColor: "{colors.accent-deep}"
textColor: "{colors.on-primary}"
rounded: "{rounded.sm}"
padding: "8px 12px"
button-search:
backgroundColor: "{colors.bg-3}"
textColor: "{colors.text}"
rounded: "{rounded.sm}"
padding: "0 22px"
button-play:
backgroundColor: "{colors.accent}"
textColor: "{colors.on-play}"
rounded: "{rounded.circle}"
width: "64px"
height: "64px"
input-search:
backgroundColor: "{colors.bg-1}"
textColor: "{colors.text}"
rounded: "{rounded.sm}"
padding: "11px 14px"
chip:
backgroundColor: "{colors.bg-2}"
textColor: "{colors.text}"
typography: "{typography.label}"
rounded: "{rounded.sm}"
padding: "10px 14px"
nav-active:
backgroundColor: "{colors.bg-3}"
textColor: "{colors.text}"
padding: "10px 12px"
media-row:
textColor: "{colors.text}"
rounded: "{rounded.flat}"
padding: "12px 8px"
panel:
backgroundColor: "{colors.bg-1}"
rounded: "{rounded.md}"
---
# Design System: YT Player — Classic
## Overview
**Creative North Star: "Quiet Media Studio"**
Classic is a matte charcoal media workspace with warm white type and restrained vermilion. Watching and listening share the same visual priority; playback, browsing, and library controls remain legible together. This system applies only when the Classic layout is selected. Other theme families retain their own visual authority.
The implemented world uses tonal surfaces, fine dividers, bundled typography, and generous control targets. Light, high contrast, text scale, compact density, and reduced motion are supported appearance preferences. Source evidence is frontend/theme-classic.css, with inherited typography and preference values from frontend/styles.css and frontend/refresh.css; the approved direction lives in .impeccable/surfaces/frontend-theme-classic-css.md.
**Key Characteristics:**
- Matte tonal layers and fine dividers.
- Restrained vermilion for playback, actions, and selection.
- Bricolage headings, Hanken interface text, JetBrains timing.
- Responsive workspace with persistent access to playback.
## Colors
The default palette is matte charcoal with warm white text and a restrained vermilion accent. Frontmatter values are normative; unprefixed tokens describe Classic dark mode, while light and contrast tokens record implemented preference variants.
### Primary
- **Vermilion:** accent marks playback, focus, active navigation icons, selection, and the playing row edge. Accent bright is the play hover and active lyric emphasis; accent deep is the filled action and brand surface.
- **Action white / play charcoal:** the two implemented foregrounds for filled actions and circular transport.
### Neutral
- **Studio charcoal:** bg is the workspace; bg-1 is sidebar, browsing, fields, and support panels; bg-2 is chips, secondary actions, and mini-player; bg-3 carries active navigation, tabs, and playing rows.
- **Fine dividers:** line-soft separates rows and regions; line marks stronger edges, including the mini-player boundary.
- **Warm white / secondary / muted:** text, text-2, and text-dim distinguish titles, supporting copy, and metadata.
- **Light preference:** warm paper workspace, white panels, pale neutral layers, and dark text. Classic overrides muted text and deep accent for this preference; the other light accent values are inherited.
- **High contrast preference:** black primary surfaces, white foregrounds and strong lines; bg-1 inherits the same black as bg, text and text-2 inherit white, and accent-deep shares contrast-accent. These aliases are not separate color primitives.
**The Classic Scope Rule.** Apply these rules only to Classic; shared markup does not make them rules for other layout families.
## Typography
**Display Font:** bundled Bricolage Grotesque, with Georgia and serif fallbacks.
**Body Font:** bundled Hanken Grotesk, with system-ui and sans-serif fallbacks.
**Label/Mono Font:** bundled JetBrains Mono, with ui-monospace and monospace fallbacks, for timing and data.
Headings provide character; interface text stays compact and readable. Timing uses tabular numerals.
### Hierarchy
- **Display:** empty-home heading; the frontmatter clamp becomes a fixed (42px) on the mobile layout.
- **Headline:** now-playing title, with wrapping permitted for long media names.
- **Title:** browsing heading; playlist home uses (28px), reducing to (26px) on mobile.
- **Body:** interface baseline; empty-home supporting copy uses (17px), (1.65) leading, and (45ch) maximum width, reducing to (16px) on mobile.
- **Label:** chips and related compact actions; navigation captions stay at 11px, including narrow screens.
- **Media title:** semibold titles in browsing rows; supporting channel metadata uses (12px).
- **Time:** transport timestamps in the mono family with tabular numbers.
Font preferences scale the whole UI to (0.9), (1), (1.12), or (1.26). Classic compensates the application height for that zoom.
## Layout
Desktop uses a (232px) library sidebar, a flexible playback workspace, and a browsing pane sized with `clamp(340px, 29%, 420px)`. Search spans up to (860px). Workspace padding is the standard spacing token; compact density uses the compact token and halves row block padding from (12px) to (6px).
At viewport widths of (1200px) and below, playback and browsing stack in one scrolling body. At (860px) and below, the workspace becomes one column with mobile navigation and safe-area-aware padding. Home playlists use a two-column grid on mobile; desktop uses automatically filled columns with a (138px) minimum. Empty home has natural height rather than video proportions. Above (1200px), the playing video width is capped at `min(100%, calc(42dvh * 16 / 9))`.
Transport responds to its own container: at (620px) and above it uses two columns with seek and quick actions spanning both; at (300px) and below it uses five equal transport columns and (44px) controls. At (420px) and below with extra-large text, bottom navigation becomes a three-column grid. Below (380px), the thumbnail width remains (88px); navigation captions retain (11px).
Browsing rows use an (88px) thumbnail column, flexible text, and a (44px) action column. Most actions have at least (44px) targets; search uses (46px), quick transport actions (48px), and mobile navigation at least (58px) height. The main play control uses the frontmatter size, reduces to (56px) in the wide transport container, and (44px) in the narrowest container.
The mini-player sits above mobile navigation and below the desktop workspace. Navigation, mini-player, and section-rail heights feed a combined clearance used by content and toasts. Returning from the mini-player brings playback into view without changing the document viewport.
## Elevation & Depth
Resting workspace surfaces are flat. Classic disables ambient bleed, grain, blur on navigation and mini-player, hero animation, card entrance animation, and thumbnail hover zoom. The playing row uses a narrow inset accent edge rather than a glow. Dialogs, shortcut panels, and more sheets retain the inherited soft ambient shadow; dark and light shadow values are recorded in the sidecar.
**The Tonal Surface Rule.** Use surface tone and fine dividers for resting depth; reserve the inherited ambient shadow for dialogs and sheets.
## Shapes
Panels and the video stage use the medium radius; fields, chips, action buttons, tabs, and navigation details use the small radius. Thumbnails use the thumbnail radius. Browsing rows have flat corners and a bottom divider. The primary transport remains circular. The section rail rounds only its upper corners; mini-player surfaces remain flat across their width.
## Components
### Buttons
Quiet surface buttons use bg-2 or bg-3 with primary text. The search action changes from bg-3 to accent-deep with white text on hover. Filled list and empty-state actions use accent-deep and white. Circular play uses accent with its dark foreground and changes to accent-bright on hover. Controls keep their established target sizes. Keyboard focus uses an accent outline (2px) with (3px) offset.
### Chips
Suggestion chips use bg-2, primary text, a line-soft border, the small radius, and label typography. Home capability descriptors remain unboxed text rather than duplicating action-chip surfaces.
### Cards / Containers
Media results are divider-separated rows rather than floating cards. Actions stay visible. The playing row uses bg-3 and an inset accent line (2px). Notes, up-next, and related containers use bg-1 and the medium radius; playlist collections keep natural height with no fading overflow mask.
### Inputs / Fields
Search uses bg-1, primary text, the small radius, and a (46px) minimum height. Placeholder text uses text-dim at full opacity and the caret uses accent. Settings fields retain at least (44px) height. Focus receives the shared visible outline.
### Navigation
Sidebar entries have (44px) minimum height, restrained spacing, inline stroked SVG icons (20px), and no decorative leading active marker. Active entries use bg-3 with primary text and an accent icon. Mobile navigation uses a matte bg-1 surface, muted inactive text, and an accent icon in the active entry; safe-area padding and text-scale layout remain part of the component.
### Playback and Mini-player
Playback combines a video stage, centered compact transport, timing, wrapped quick actions, and metadata. The mini-player uses bg-2 with a top line, no blur or shadow, and usable (44px) targets. The section rail and toast placement share the measured bottom clearance. Classic section-rail actions use the same inline stroked SVG vocabulary as navigation; other layouts retain their existing icons.
State transitions inherit the existing short interface timings; the sidecar records the inherited easing and recurring color transition. Explicit reduced motion and the operating-system preference reduce animation and transition durations to (0.001ms), with one animation iteration. The explicit preference also sets automatic scrolling.
## Do's and Don'ts
### Do:
- **Do** keep Classic overrides scoped to the selected Classic layout.
- **Do** use tonal surfaces and fine dividers for workspace hierarchy.
- **Do** preserve light, high contrast, text scale, density, safe areas, and reduced motion.
- **Do** use inline SVG for navigation icons and retain visible keyboard focus.
- **Do** use media thumbnails as user content.
### Don't:
- **Don't** add ambient glow or grain to Classic.
- **Don't** force empty home into the video aspect ratio.
- **Don't** hide row actions until hover or reduce established control targets to fit a crowded layout.
- **Don't** transfer this visual world to other layout families.

34
PRODUCT.md Normal file
View File

@@ -0,0 +1,34 @@
# YT Player
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
People watching videos and listening to music on desktop and mobile. The confirmed Classic brief gives watching and listening equal priority.
## Product Purpose
Search and play YouTube media, organize playlists, and keep saved media available offline. The web PWA and desktop wrappers share the same frontend.
## Capabilities and Constraints
Preserve playback, playlists, queue, lyrics, notes, A–B loops, audio-only mode, offline storage, sharing, and settings. The frontend is plain HTML, CSS, and JavaScript with a Bun/Hono web backend. Classic is the default layout; other layouts share markup and must retain their behavior. Appearance settings include dark, light, contrast, font scale, density, performance, and reduced motion.
## Brand Commitments
Keep the YT Player name and existing factual copy. The approved Classic redesign uses a quiet dark studio direction. Other layout themes remain independent.
## Evidence on Hand
Existing frontend, bundled fonts, local icons, and browser tests. No invented customer claims or production data.
## Product Principles
- Playback and browsing must remain reachable together.
- Mobile browser and installed PWA should offer consistent Classic navigation.
- Empty, populated, and playing states each need an appropriate layout.
- User data and appearance preferences survive theme changes.

View File

@@ -0,0 +1,22 @@
# Classic redesign verification
## Scope
Classic's responsive web UI, including desktop, mobile browser and simulated installed-PWA navigation. Synthetic playlist/media metadata was used for browser checks and screenshots; no production profiles or playlists were changed. No deployment was performed.
## Automated checks
- `npx playwright test --config playwright.classic.config.js`: 11 passed. Covers empty home at 320/390/768/1024/1280/1440px, navigation during a populated player state, mini-player return, advanced controls, theme switching, rotation, extra-large text at 320px, and cached Classic CSS on offline reload.
- `node --test --test-isolation=none frontend/*.test.js`: 72 passed after rebasing onto the latest remote changes (65 passed before the rebase). Explicitly disabling process isolation exposes individual test results in this environment; the default isolated runner initially reported only nine file-level passes. The latest suite ran outside the sandbox because the WebAssembly fallback test spawns Node.
- Existing portrait-PWA, viewport-anchor and fullscreen smoke tests run in Chromium: 9 passed, 4 failed. The same tests against unchanged HEAD frontend assets produced the identical 9/4 result. The four baseline failures are the fullscreen fallback/audio-only expectations and the portrait rotation/wide layout checks that expect the empty-home list pane to be visible.
- `node --check frontend/app.js` and `git diff --check` passed.
## Visual evidence
Local browser captures live under `.impeccable/review/` (ignored build evidence): desktop/mobile player and empty home, mobile playlists and settings, desktop light appearance, and a 320px phone with extra-large text. They use labeled synthetic preview artwork. The final capture pass measured no main-pane or control-deck horizontal overflow and an app height equal to the viewport for every captured size.
Impeccable's detector ran once against the changed UI targets. Its findings refer to the shared index and inherited styling, including other themes; the report is not proof of active Classic contrast or full accessibility conformance. The independent reviewer requested SVG section shortcuts and readable footer captions. Both fixes were confirmed resolved with a `ship` verdict covering the scored fixes, recorded in `.impeccable/review/finish-review.md`. The replacement reviewer inspected local captures directly after the external vision tool stalled; the original reviewer had stopped at a usage limit.
## Limits
No real iOS/Android device or WebKit browser run was available. Media fixture checks validate layout/navigation, not a live YouTube stream, audio synchronization, or native fullscreen. Other layout themes retain their styles; regression checks cover switching and data preservation, not exhaustive visual audits of every theme.

View File

@@ -6533,6 +6533,13 @@ const Gestures = (() => {
* and reopen. Fold state is per device and rides in settings. * and reopen. Fold state is per device and rides in settings.
* ========================================================================== */ * ========================================================================== */
const SectionRail = (() => { const SectionRail = (() => {
const CLASSIC_ICONS = {
player: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
meta: '<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
notes: '<path d="M14 3H4v18h16V11M9 15l2-5 7-7 3 3-7 7z"/>',
related: '<path d="m10 14 4-4m-5 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m0 10a4 4 0 0 0 6 0l4-4a4 4 0 0 0-6-6l-1 1"/>',
list: '<path d="M9 6h12M9 12h12M9 18h12M3 6h1M3 12h1M3 18h1"/>',
};
const SECTIONS = [ const SECTIONS = [
{ id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' }, { id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' },
{ id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true }, { id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true },
@@ -6617,7 +6624,7 @@ const SectionRail = (() => {
btn.className = 'sec-rail-btn'; btn.className = 'sec-rail-btn';
btn.dataset.sec = sec.id; btn.dataset.sec = sec.id;
btn.setAttribute('aria-label', sec.label); btn.setAttribute('aria-label', sec.label);
btn.innerHTML = `<span aria-hidden="true">${sec.icon}</span><span class="sec-rail-label">${escapeHtml(sec.label)}</span>`; btn.innerHTML = `<span class="legacy-nav-icon" aria-hidden="true">${sec.icon}</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true">${CLASSIC_ICONS[sec.id]}</svg><span class="sec-rail-label">${escapeHtml(sec.label)}</span>`;
btn.addEventListener('click', () => onTap(sec, btn)); btn.addEventListener('click', () => onTap(sec, btn));
r.appendChild(btn); r.appendChild(btn);
st.btns.set(sec.id, btn); st.btns.set(sec.id, btn);
@@ -7170,7 +7177,11 @@ const Party = (() => {
function render() { function render() {
const panel = $('partyPanel'); const panel = $('partyPanel');
const chip = $('navPartyBtn'); const chip = $('navPartyBtn');
if (chip) chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party'; if (chip) {
const label = chip.querySelector('.nav-label');
if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party';
}
if (!panel || panel.classList.contains('hidden')) return; if (!panel || panel.classList.contains('hidden')) return;
const nameOk = !!myName(); const nameOk = !!myName();
if (!inParty()) { if (!inParty()) {
@@ -10200,6 +10211,63 @@ function isPortraitPWA() {
return _portraitPwaMQ.matches; return _portraitPwaMQ.matches;
} }
// Classic uses one scroll container at phone and intermediate widths in both
// browser and installed mode. Read CSS so navigation follows the actual layout.
function isStackedPlayerLayout() {
if (document.documentElement.dataset.layout === 'classic') {
return getComputedStyle(document.documentElement).getPropertyValue('--classic-stacked').trim() === '1';
}
return isPortraitPWA();
}
function playerScrollBehavior() {
return data.settings.reduceMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth';
}
function setupClassicLayout() {
const root = document.documentElement;
const search = $('searchInput');
const originalPlaceholder = search.getAttribute('placeholder');
const bars = [$('bottomNav'), $('miniBar'), $('sectionRail')].filter(Boolean);
const heightVars = ['--classic-nav-height', '--classic-mini-height', '--classic-rail-height'];
let pending = 0;
function measure() {
pending = 0;
const classic = root.dataset.layout === 'classic';
search.placeholder = classic ? 'Search YouTube…' : originalPlaceholder;
if (!classic) return;
for (const [index, el] of bars.entries()) {
const height = el.getClientRects().length ? el.offsetHeight : 0;
root.style.setProperty(heightVars[index], height + 'px');
}
}
function schedule() {
if (!pending) pending = requestAnimationFrame(measure);
}
const observer = new MutationObserver(schedule);
observer.observe(root, { attributes: true, attributeFilter: ['data-layout', 'data-font', 'data-density'] });
bars.forEach(el => observer.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
if (typeof ResizeObserver !== 'undefined') {
const resize = new ResizeObserver(schedule);
bars.forEach(el => resize.observe(el));
}
window.addEventListener('resize', schedule);
if (window.visualViewport) window.visualViewport.addEventListener('resize', schedule);
// Use the actual clipped control deck, not document scroll position: desktop
// panes and the mobile body have different scroll owners.
if (typeof IntersectionObserver !== 'undefined') {
const mini = $('miniBar');
const visibility = new IntersectionObserver(([entry]) => {
if (root.dataset.layout !== 'classic' || !current || !current.meta) return;
if (entry.isIntersecting) {
if (!mini.classList.contains('hidden')) hideMiniBar();
} else if (mini.classList.contains('hidden')) showMiniBar();
}, { threshold: 0 });
visibility.observe($('controls'));
}
measure();
}
function applyPortraitPwaClass() { function applyPortraitPwaClass() {
const app = document.querySelector('.app'); const app = document.querySelector('.app');
if (!app) return; if (!app) return;
@@ -10213,10 +10281,10 @@ function applyPortraitPwaClass() {
// In portrait mode, .body is the single scroll container (player pane on top, // In portrait mode, .body is the single scroll container (player pane on top,
// list pane below). Scrolling it to 0 brings the player fully into view. // list pane below). Scrolling it to 0 brings the player fully into view.
function scrollPlayerIntoViewPortrait() { function scrollPlayerIntoViewPortrait() {
if (!isPortraitPWA()) return; if (!isStackedPlayerLayout()) return;
const body = document.querySelector('.body'); const body = document.querySelector('.body');
if (body) { if (body) {
body.scrollTo({ top: 0, behavior: 'smooth' }); body.scrollTo({ top: 0, behavior: playerScrollBehavior() });
} }
} }
@@ -10226,14 +10294,14 @@ function scrollPlayerIntoViewPortrait() {
// fresh view sits invisibly below the fold and every nav tap looks dead // fresh view sits invisibly below the fold and every nav tap looks dead
// (the mobile "can't navigate while playing" bug). // (the mobile "can't navigate while playing" bug).
function scrollListIntoViewPortrait() { function scrollListIntoViewPortrait() {
if (!isPortraitPWA()) return; if (!isStackedPlayerLayout()) return;
const body = document.querySelector('.body'); const body = document.querySelector('.body');
const listPane = document.querySelector('.list-pane'); const listPane = document.querySelector('.list-pane');
// offsetParent is null while the pane is hidden (pre-first-search) — its // offsetParent is null while the pane is hidden (pre-first-search) — its
// rect would be all zeros and scroll the body to a bogus offset. // rect would be all zeros and scroll the body to a bogus offset.
if (!body || !listPane || !listPane.offsetParent) return; if (!body || !listPane || !listPane.offsetParent) return;
const top = body.scrollTop + listPane.getBoundingClientRect().top - body.getBoundingClientRect().top; const top = body.scrollTop + listPane.getBoundingClientRect().top - body.getBoundingClientRect().top;
body.scrollTo({ top, behavior: 'smooth' }); body.scrollTo({ top, behavior: playerScrollBehavior() });
} }
function setupPortraitPwaWatcher() { function setupPortraitPwaWatcher() {
@@ -10855,7 +10923,7 @@ document.querySelectorAll('.chip').forEach((c) => {
// .body pane (the designated scroll container) — scrollIntoView() also // .body pane (the designated scroll container) — scrollIntoView() also
// scrolls overflow:hidden ancestors up to <html> on iOS, leaving the // scrolls overflow:hidden ancestors up to <html> on iOS, leaving the
// document offset and fixed-element hit testing broken. // document offset and fixed-element hit testing broken.
if (isPortraitPWA()) scrollPlayerIntoViewPortrait(); if (isStackedPlayerLayout()) scrollPlayerIntoViewPortrait();
else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}); });
// Opens the set-list view for whatever is already playing. No playlist arg, // Opens the set-list view for whatever is already playing. No playlist arg,
@@ -11774,6 +11842,7 @@ async function boot() {
wireUI(); wireUI();
wireShortcutHelp(); wireShortcutHelp();
setupPortraitPwaWatcher(); setupPortraitPwaWatcher();
setupClassicLayout();
setupViewportAnchorGuard(); setupViewportAnchorGuard();
try { try {
const loaded = await API.loadData(); const loaded = await API.loadData();

View File

@@ -25,6 +25,7 @@
<link rel="stylesheet" href="service-ui.css" /> <link rel="stylesheet" href="service-ui.css" />
<link rel="stylesheet" href="refresh.css" /> <link rel="stylesheet" href="refresh.css" />
<link rel="stylesheet" href="theme-glass.css" /> <link rel="stylesheet" href="theme-glass.css" />
<link rel="stylesheet" href="theme-classic.css" />
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -35,17 +36,17 @@
</div> </div>
<nav class="nav"> <nav class="nav">
<button class="nav-item active" data-view="search">🔍 Search</button> <button class="nav-item active" data-view="search"><span class="legacy-nav-icon" aria-hidden="true">🔍</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg> Search</button>
<button class="nav-item" data-view="queue">▶ Queue <span id="navQueueCount" class="nav-badge hidden">0</span></button> <button class="nav-item" data-view="queue"><span class="legacy-nav-icon" aria-hidden="true">▶</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="m8 5 11 7-11 7z"/></svg> Queue <span id="navQueueCount" class="nav-badge hidden">0</span></button>
<button class="nav-item" data-view="history">🕘 History</button> <button class="nav-item" data-view="history"><span class="legacy-nav-icon" aria-hidden="true">🕘</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> History</button>
<button class="nav-item" data-view="saved">💾 Saved</button> <button class="nav-item" data-view="saved"><span class="legacy-nav-icon" aria-hidden="true">💾</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2zm2 0v6h10V3M7 21v-8h10v8"/></svg> Saved</button>
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button> <button class="nav-item" data-view="downloads"><span class="legacy-nav-icon" aria-hidden="true">⬇</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg> Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
<button class="nav-item" data-view="library">📁 Library</button> <button class="nav-item" data-view="library"><span class="legacy-nav-icon" aria-hidden="true">📁</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h7l2 3h9v11H3z"/></svg> Library</button>
<button class="nav-item" data-view="notes">📝 Notes</button> <button class="nav-item" data-view="notes"><span class="legacy-nav-icon" aria-hidden="true">📝</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H4v18h16V11M9 15l2-5 7-7 3 3-7 7z"/></svg> Notes</button>
<button class="nav-item" data-view="stats">📊 Stats</button> <button class="nav-item" data-view="stats"><span class="legacy-nav-icon" aria-hidden="true">📊</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="M4 21V11h4v10M10 21V3h4v18M16 21V7h4v14"/></svg> Stats</button>
<button class="nav-item" data-view="settings">⚙ Settings</button> <button class="nav-item" data-view="settings"><span class="legacy-nav-icon" aria-hidden="true">⚙</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="m9 3-1 3-3 1-2 3 2 2-1 3 2 3 3-1 2 3h3l1-3 3-1 2-3-2-2 1-3-2-3-3 1-2-3z"/></svg> Settings</button>
<button class="nav-item" id="navPartyBtn" type="button" title="Watch together with friends">🎉 Watch party</button> <button class="nav-item" id="navPartyBtn" type="button" title="Watch together with friends"><span class="legacy-nav-icon" aria-hidden="true">🎉</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="m4 20 4-13 9 9zM14 3v4m4 1 3-2m-2 5 3 2"/></svg> <span class="nav-label">Watch party</span></button>
<button class="nav-item" id="navRemoteBtn" type="button" title="Control this screen from a phone, or use this phone as a remote">📱 Remote</button> <button class="nav-item" id="navRemoteBtn" type="button" title="Control this screen from a phone, or use this phone as a remote"><span class="legacy-nav-icon" aria-hidden="true">📱</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 18h4"/></svg> Remote</button>
</nav> </nav>
<div class="pl-header"> <div class="pl-header">
@@ -96,6 +97,7 @@
<form id="searchForm" class="search-form"> <form id="searchForm" class="search-form">
<input <input
id="searchInput" id="searchInput"
aria-label="Search YouTube"
type="text" type="text"
placeholder="Search YouTube… (no login, no ads)" placeholder="Search YouTube… (no login, no ads)"
autocomplete="off" autocomplete="off"
@@ -180,7 +182,7 @@
<!-- Audio-only: loudness waveform drawn behind the transparent range input --> <!-- Audio-only: loudness waveform drawn behind the transparent range input -->
<canvas id="waveCanvas" class="wave-canvas" aria-hidden="true"></canvas> <canvas id="waveCanvas" class="wave-canvas" aria-hidden="true"></canvas>
<span id="waveTip" class="wave-tip hidden"></span> <span id="waveTip" class="wave-tip hidden"></span>
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" /> <input id="seek" aria-label="Playback position" class="seek" type="range" min="0" max="1000" value="0" />
<div id="seekMarkers" class="seek-markers" aria-hidden="true"></div> <div id="seekMarkers" class="seek-markers" aria-hidden="true"></div>
<div id="abIndicator" class="ab-indicator hidden"> <div id="abIndicator" class="ab-indicator hidden">
<span id="abALabel" class="ab-label">A: --</span> <span id="abALabel" class="ab-label">A: --</span>
@@ -574,29 +576,29 @@
<nav id="bottomNav" class="bottom-nav" aria-label="Main navigation"> <nav id="bottomNav" class="bottom-nav" aria-label="Main navigation">
<button class="bottom-nav-btn active" data-view="search" aria-label="Search"> <button class="bottom-nav-btn active" data-view="search" aria-label="Search">
<span class="bottom-nav-icon">🔍</span> <span class="bottom-nav-icon"><span class="legacy-nav-icon" aria-hidden="true">🔍</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg></span>
<span class="bottom-nav-label">Search</span> <span class="bottom-nav-label">Search</span>
</button> </button>
<button class="bottom-nav-btn" data-view="queue" aria-label="Queue"> <button class="bottom-nav-btn" data-view="queue" aria-label="Queue">
<span class="bottom-nav-icon">▶</span> <span class="bottom-nav-icon"><span class="legacy-nav-icon" aria-hidden="true">▶</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="m8 5 11 7-11 7z"/></svg></span>
<span class="bottom-nav-label">Queue</span> <span class="bottom-nav-label">Queue</span>
<span id="bnQueueCount" class="bottom-nav-badge hidden">0</span> <span id="bnQueueCount" class="bottom-nav-badge hidden">0</span>
</button> </button>
<button class="bottom-nav-btn" data-view="history" aria-label="History"> <button class="bottom-nav-btn" data-view="history" aria-label="History">
<span class="bottom-nav-icon">🕘</span> <span class="bottom-nav-icon"><span class="legacy-nav-icon" aria-hidden="true">🕘</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></span>
<span class="bottom-nav-label">History</span> <span class="bottom-nav-label">History</span>
</button> </button>
<button class="bottom-nav-btn" data-view="saved" aria-label="Saved"> <button class="bottom-nav-btn" data-view="saved" aria-label="Saved">
<span class="bottom-nav-icon">💾</span> <span class="bottom-nav-icon"><span class="legacy-nav-icon" aria-hidden="true">💾</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2zm2 0v6h10V3M7 21v-8h10v8"/></svg></span>
<span class="bottom-nav-label">Saved</span> <span class="bottom-nav-label">Saved</span>
</button> </button>
<button class="bottom-nav-btn" data-view="downloads" aria-label="Downloads"> <button class="bottom-nav-btn" data-view="downloads" aria-label="Downloads">
<span class="bottom-nav-icon">⬇</span> <span class="bottom-nav-icon"><span class="legacy-nav-icon" aria-hidden="true">⬇</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/></svg></span>
<span class="bottom-nav-label">Downloads</span> <span class="bottom-nav-label">Downloads</span>
<span id="bnDlCount" class="bottom-nav-badge hidden">0</span> <span id="bnDlCount" class="bottom-nav-badge hidden">0</span>
</button> </button>
<button class="bottom-nav-btn" data-view="settings" aria-label="Settings"> <button class="bottom-nav-btn" data-view="settings" aria-label="Settings">
<span class="bottom-nav-icon">⚙</span> <span class="bottom-nav-icon"><span class="legacy-nav-icon" aria-hidden="true">⚙</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="m9 3-1 3-3 1-2 3 2 2-1 3 2 3 3-1 2 3h3l1-3 3-1 2-3-2-2 1-3-2-3-3 1-2-3z"/></svg></span>
<span class="bottom-nav-label">More</span> <span class="bottom-nav-label">More</span>
</button> </button>
</nav> </nav>

View File

@@ -59,6 +59,7 @@ const SHELL = [
'/service-ui.css', '/service-ui.css',
'/refresh.css', '/refresh.css',
'/theme-glass.css', '/theme-glass.css',
'/theme-classic.css',
'/flag-ui.js', '/flag-ui.js',
'/export.js', '/export.js',
'/async-guard.js', '/async-guard.js',

228
frontend/theme-classic.css Normal file
View File

@@ -0,0 +1,228 @@
/* Classic: a media workspace. Keep all overrides inside this layout. */
html[data-layout="classic"] {
--radius: 12px;
--radius-sm: 8px;
--classic-stacked: 0;
--classic-zoom: 1;
--classic-space: 24px;
--classic-nav-height: 0px;
--classic-mini-height: 0px;
--classic-rail-height: 0px;
--classic-clearance: calc(var(--classic-nav-height) + var(--classic-mini-height) + var(--classic-rail-height));
--accent-glow: transparent;
}
html[data-layout="classic"][data-font="small"] { --classic-zoom: .9; }
html[data-layout="classic"][data-font="large"] { --classic-zoom: 1.12; }
html[data-layout="classic"][data-font="xl"] { --classic-zoom: 1.26; }
html[data-layout="classic"][data-theme="dark"] {
--bg: #121315;
--bg-1: #18191c;
--bg-2: #202226;
--bg-3: #2b2e33;
--line: #42464d;
--line-soft: #2b2e33;
--text: #f3f1ed;
--text-2: #c3c4c7;
--text-dim: #a1a5ac;
--accent: #f06b53;
--accent-bright: #ff947e;
--accent-deep: #c6442f;
}
html[data-layout="classic"][data-theme="light"] { --text-dim: #625e59; --accent-deep: #b43623; }
html[data-layout="classic"][data-theme] .hero-title { background: none; -webkit-text-fill-color: var(--text); }
html[data-layout="classic"] body::before,
html[data-layout="classic"] body::after { display: none; }
html[data-layout="classic"] ::selection { background: var(--accent); color: var(--bg); }
html[data-layout="classic"] :is(input, textarea) { caret-color: var(--accent); }
html[data-layout="classic"] :is(button, a, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
html[data-layout="classic"] :is(button, input, select, textarea) { font-family: var(--ui); }
html[data-layout="classic"] :is(input, textarea)::placeholder { color: var(--text-dim); opacity: 1; }
html[data-layout="classic"] .app { grid-template-columns: 232px minmax(0, 1fr); height: calc(100dvh / var(--classic-zoom)); }
html[data-layout="classic"] .main { min-width: 0; min-height: 0; padding-bottom: var(--classic-mini-height); }
html[data-layout="classic"] .body { min-height: 0; flex-direction: row; }
html[data-layout="classic"] .sidebar {
background: var(--bg-1); padding: 28px 12px 20px;
border-right: 1px solid var(--line-soft);
}
html[data-layout="classic"] .brand { font-size: 21px; padding: 0 12px 28px; gap: 10px; }
html[data-layout="classic"] .brand-mark { background: var(--accent-deep); box-shadow: none; border-radius: 8px; }
html[data-layout="classic"] .nav { gap: 2px; }
html[data-layout="classic"] .nav-item { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 12px; letter-spacing: 0; }
html[data-layout="classic"] .nav-item::before { display: none; }
html[data-layout="classic"] .nav-item.active { background: var(--bg-3); color: var(--text); }
html[data-layout="classic"] .nav-item.active .classic-nav-icon { color: var(--accent); }
html[data-layout="classic"] .nav-badge { margin-left: auto; }
html[data-layout="classic"] .legacy-nav-icon { display: none; }
html[data-layout="classic"] .classic-nav-icon { display: inline-block; flex: none; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
html[data-layout="classic"] .pl-header { margin-top: 16px; font: 600 13px var(--ui); letter-spacing: 0; text-transform: none; }
html[data-layout="classic"] .playlist-item { min-height: 44px; border-radius: 8px; }
html[data-layout="classic"] .topbar {
background: var(--bg); border-bottom: 1px solid var(--line-soft); backdrop-filter: none;
padding: 18px var(--classic-space); gap: 12px; flex: none;
}
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;
flex: 1 1 0; min-width: 0; min-height: 0; gap: 0;
padding: var(--classic-space); padding-bottom: calc(var(--classic-space) + var(--classic-mini-height));
overflow-y: auto;
}
html[data-layout="classic"] .player-stage { border-radius: 12px; box-shadow: none; flex: none; }
html[data-layout="classic"] .player-pane:not(.empty) .player-stage { box-shadow: none; }
html[data-layout="classic"] .player-pane.empty .player-stage {
aspect-ratio: auto; min-height: 0; max-height: none; flex: none; overflow: visible; background: transparent;
}
html[data-layout="classic"] .player-pane.empty #video { display: none; }
html[data-layout="classic"] .player-placeholder {
position: relative; inset: auto; width: 100%; padding: 48px 16px; background: transparent; text-align: left;
}
html[data-layout="classic"] .hero { align-items: flex-start; max-width: 620px; gap: 24px; animation: none; }
html[data-layout="classic"] .ph-logo { width: 52px; height: 52px; border-radius: 12px; background: var(--accent-deep); color: #fff; box-shadow: none; border: 0; animation: none; font-size: 20px; }
html[data-layout="classic"] .hero-title { font-size: clamp(36px, 4vw, 60px); line-height: 1.05; letter-spacing: -0.035em; background: none; color: var(--text); -webkit-text-fill-color: var(--text); margin: 0; }
html[data-layout="classic"] .hero-tagline { font-size: 17px; line-height: 1.65; max-width: 45ch; color: var(--text-2); text-align: left; margin: 0; }
html[data-layout="classic"] .hero-pills { justify-content: flex-start; gap: 8px 16px; }
html[data-layout="classic"] .hero-pill { padding: 0; background: none; border: 0; font: 500 13px var(--ui); color: var(--text-2); box-shadow: none; }
html[data-layout="classic"] .hero-suggests { justify-content: flex-start; gap: 8px; padding-top: 24px; border-top: 1px solid var(--line-soft); width: 100%; }
html[data-layout="classic"] .hero-suggests-label { flex-basis: 100%; text-align: left; font: 600 14px var(--ui); text-transform: none; letter-spacing: 0; }
html[data-layout="classic"] .chip { min-height: 44px; padding: 10px 14px; background: var(--bg-2); border: 1px solid var(--line-soft); border-radius: 8px; font: 500 14px var(--ui); color: var(--text); transform: none; }
html[data-layout="classic"] .player-placeholder.showing-playlists { padding: 8px 0 24px; }
html[data-layout="classic"] .home-pl { overflow: visible; max-height: none; animation: none; gap: 24px; }
html[data-layout="classic"] .home-pl-title { font-size: 28px; }
html[data-layout="classic"] .home-pl-new { min-height: 44px; padding: 8px 14px; }
html[data-layout="classic"] .home-pl-grid { overflow: visible; grid-template-columns: repeat(auto-fill, minmax(138px, 1fr)); gap: 24px 16px; max-height: none; mask-image: none; -webkit-mask-image: none; }
html[data-layout="classic"] .home-pl-name { font: 600 14px/1.4 var(--ui); }
html[data-layout="classic"] #controls { padding: 18px 0; margin-top: 8px; border: 0; border-bottom: 1px solid var(--line-soft); border-radius: 0; background: transparent; box-shadow: none; gap: 14px; }
html[data-layout="classic"] #controls .deck-main { grid-template-columns: repeat(5, auto); gap: 14px; }
html[data-layout="classic"] #controls .deck-main .skip10 { display: grid; }
html[data-layout="classic"] #controls .deck-main .ctrl { width: 48px; height: 48px; border-radius: 12px; }
html[data-layout="classic"] #controls .deck-main .ctrl.play { width: 64px; height: 64px; border-radius: 50%; background: var(--accent); color: #171719; box-shadow: none; }
html[data-layout="classic"] #controls .deck-main .ctrl.play:hover { background: var(--accent-bright); }
html[data-layout="classic"] #controls .ctrl.active svg { filter: none; }
html[data-layout="classic"] #controls .time { font: 12px var(--mono); font-variant-numeric: tabular-nums; }
html[data-layout="classic"] #controls .deck-vol { padding: 0; gap: 12px; }
html[data-layout="classic"] #controls .vol-pod { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; }
html[data-layout="classic"] #controls .deck-vol .ctrl { width: 44px; height: 44px; }
html[data-layout="classic"] #controls .dk-quick { flex-wrap: wrap; gap: 8px; }
html[data-layout="classic"] #controls .dk-quick #fsBtn { min-height: 48px; height: auto; border-radius: 8px; background: var(--bg-2); }
html[data-layout="classic"] #controls .dk-more { min-height: 48px; border-radius: 8px; padding: 8px 12px; }
html[data-layout="classic"] #nowPlayingMeta { margin-top: 24px; gap: 18px; }
html[data-layout="classic"] #nowPlayingMeta .np-title { font-size: clamp(22px, 2vw, 30px); line-height: 1.25; letter-spacing: -0.02em; overflow-wrap: anywhere; }
html[data-layout="classic"] #nowPlayingMeta .np-channel { color: var(--text-2); font-size: 14px; }
html[data-layout="classic"] #nowPlayingMeta .np-actions :is(.np-btn, #saveBtn, #queueBtn, #addPlaylistBtn, #addBtn, #serviceBtn) { flex: 1 1 120px; min-height: 44px; height: auto; padding: 10px 12px; border-radius: 8px; font-size: 13px; background: var(--bg-2); color: var(--text); box-shadow: none; white-space: normal; }
html[data-layout="classic"] #nowPlayingMeta .np-actions #saveBtn.done { color: var(--text); background: var(--bg-3); }
html[data-layout="classic"] .np-more { border-radius: 8px; min-height: 44px; background: transparent; box-shadow: none; }
html[data-layout="classic"] :is(.notes-panel, .upnext, .related-panel) { border-radius: 12px; background: var(--bg-1); box-shadow: none; margin-top: 24px; }
html[data-layout="classic"] .notes-tabs { flex-wrap: wrap; padding: 8px; gap: 4px; border-bottom: 1px solid var(--line-soft); }
html[data-layout="classic"] .notes-tab { min-height: 44px; padding: 8px 10px; font: 600 13px var(--ui); letter-spacing: 0; text-transform: none; border-radius: 8px; }
html[data-layout="classic"] .notes-tab.active { color: var(--text); background: var(--bg-3); box-shadow: none; }
html[data-layout="classic"] .lyr-line.active { background: var(--bg-3); box-shadow: none; color: var(--accent-bright); }
html[data-layout="classic"] .list-pane { flex: 0 0 clamp(340px, 29%, 420px); width: auto; min-width: 0; min-height: 0; background: var(--bg-1); overflow-y: auto; padding-bottom: var(--classic-mini-height); }
html[data-layout="classic"] .list-header { padding: 24px 20px 16px; gap: 16px; }
html[data-layout="classic"] .list-header h2 { font-size: 26px; line-height: 1.2; }
html[data-layout="classic"] #listActions button { min-height: 44px; height: auto; border-radius: 8px; padding: 8px 12px; }
html[data-layout="classic"] #listActions button.la-primary { background: var(--accent-deep); color: #fff; box-shadow: none; font-size: 14px; }
html[data-layout="classic"] :is(.list-icon-btn, .list-title-actions .list-icon-btn) { width: 44px; height: 44px; border-radius: 8px; }
html[data-layout="classic"] .cards { flex: none; overflow: visible; padding: 4px 12px 24px; gap: 0; }
html[data-layout="classic"] .card { display: grid; grid-template-columns: 88px minmax(0, 1fr) 44px; padding: 12px 8px; gap: 0 12px; border-radius: 0; border: 0; border-bottom: 1px solid var(--line-soft); animation: none; box-shadow: none; }
html[data-layout="classic"] .card .thumb { grid-column: 1; grid-row: 1 / span 2; width: 88px; }
html[data-layout="classic"] .card-info { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
html[data-layout="classic"] .card-menu { grid-column: 3; grid-row: 1; }
html[data-layout="classic"] .card-del { grid-column: 3; grid-row: 2; }
html[data-layout="classic"] .batch-check { position: absolute; left: 8px; top: 12px; z-index: 2; }
html[data-layout="classic"] .card.playing { background: var(--bg-3); box-shadow: inset 2px 0 var(--accent); }
html[data-layout="classic"] .card.playing::after { display: none; }
html[data-layout="classic"] .thumb { width: 100px; border-radius: 6px; align-self: flex-start; }
html[data-layout="classic"] .card:hover .thumb img { transform: none; }
html[data-layout="classic"] .card-title { font-size: 14px; line-height: 1.45; }
html[data-layout="classic"] .card-channel { font-size: 12px; color: var(--text-dim); }
html[data-layout="classic"] :is(.card-menu, .card-del) { min-width: 44px; min-height: 44px; opacity: 1; }
html[data-layout="classic"] .settings { padding: 8px 20px 28px; overflow: visible; }
html[data-layout="classic"] .settings :is(input:not([type="checkbox"]):not([type="range"]), select, button) { min-height: 44px; max-width: 100%; }
html[data-layout="classic"] :is(.modal, .shortcut-panel, .more-sheet) { background: var(--bg-1); border-radius: 12px; max-width: 100%; box-shadow: var(--shadow); }
html[data-layout="classic"] :is(.modal, .more-sheet) button { min-height: 44px; }
html[data-layout="classic"] .mini-bar { left: 232px; bottom: 0; background: var(--bg-2); border-top: 1px solid var(--line); padding: 0; }
html[data-layout="classic"] .mini-bar-inner { background: var(--bg-2); border-radius: 0; box-shadow: none; backdrop-filter: none; padding: 10px 20px; }
html[data-layout="classic"] .mini-bar button { min-width: 44px; min-height: 44px; }
html[data-layout="classic"] .bottom-nav { display: none; background: var(--bg-1); backdrop-filter: none; }
html[data-layout="classic"] .bottom-nav-icon { background: none; border-radius: 0; box-shadow: none; }
html[data-layout="classic"] .bottom-nav-btn.active .bottom-nav-icon { background: none; color: var(--accent); box-shadow: none; }
html[data-layout="classic"] .bottom-nav-label { font: 500 11px var(--ui); letter-spacing: 0; }
html[data-layout="classic"] .sec-rail { top: auto; left: 50%; right: auto; bottom: calc(var(--classic-nav-height) + var(--classic-mini-height)); transform: translateX(-50%); flex-direction: row; flex-wrap: wrap; justify-content: center; width: max-content; max-width: calc(100% - 24px); gap: 4px; padding: 4px; border: 0; border-radius: 8px 8px 0 0; background: var(--bg-2); box-shadow: none; backdrop-filter: none; }
html[data-layout="classic"] body:has(#miniBar:not(.hidden)) .sec-rail { bottom: calc(var(--classic-nav-height) + var(--classic-mini-height)); transform: translateX(-50%); }
html[data-layout="classic"] .sec-rail-btn { width: 44px; height: 44px; border-radius: 8px; }
html[data-layout="classic"] .sec-rail-label { right: auto; bottom: 48px; }
html[data-layout="classic"] .toast-container { bottom: calc(var(--classic-clearance) + 16px); }
html[data-layout="classic"] .empty-state { animation: none; padding: 32px 20px; }
html[data-layout="classic"] .empty-cta { background: var(--accent-deep); color: #fff; min-height: 44px; box-shadow: none; transform: none; }
html[data-layout="classic"][data-density="compact"] { --classic-space: 16px; }
html[data-layout="classic"][data-density="compact"] .card { padding-block: 6px; }
html[data-layout="classic"][data-density="compact"] .thumb { width: 88px; }
@media (min-width: 1201px) {
html[data-layout="classic"] .player-pane:not(.empty) .player-stage { width: min(100%, calc(42dvh * 16 / 9)); align-self: center; }
}
@container (min-width: 620px) {
html[data-layout="classic"] #controls { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px 24px; }
html[data-layout="classic"] #controls :is(.seek-row, .dk-quick) { grid-column: 1 / -1; }
html[data-layout="classic"] #controls .deck-main { gap: 8px; justify-content: start; }
html[data-layout="classic"] #controls .deck-main .ctrl { width: 44px; height: 44px; }
html[data-layout="classic"] #controls .deck-main .ctrl.play { width: 56px; height: 56px; }
}
@container (max-width: 300px) {
html[data-layout="classic"] #controls .deck-main { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
html[data-layout="classic"] #controls .deck-main .ctrl,
html[data-layout="classic"] #controls .deck-main .ctrl.play { width: 44px; height: 44px; }
}
@media (max-width: 420px) {
html[data-layout="classic"][data-font="xl"] .bottom-nav { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* CSS owns the stacked breakpoint; JS reads this flag for scrolling. */
@media (max-width: 1200px) {
html[data-layout="classic"] { --classic-stacked: 1; }
html[data-layout="classic"] .main { padding-bottom: var(--classic-clearance); }
html[data-layout="classic"] .body { flex-direction: column; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; scroll-padding-block: 16px; padding-bottom: 24px; }
html[data-layout="classic"] .player-pane { flex: none; width: 100%; overflow: visible; padding-bottom: 24px; }
html[data-layout="classic"] .list-pane { flex: none; width: 100%; min-height: 100%; overflow: visible; border-left: 0; border-top: 1px solid var(--line-soft); padding-bottom: 24px; }
html[data-layout="classic"] .cards { max-height: none; overflow: visible; }
html[data-layout="classic"] .player-placeholder { padding-block: 32px; }
}
@media (max-width: 860px) {
html[data-layout="classic"] { --classic-space: 16px; }
html[data-layout="classic"] .app { grid-template-columns: minmax(0, 1fr); }
html[data-layout="classic"] .topbar { padding: calc(10px + env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 10px max(12px, env(safe-area-inset-left)); gap: 8px; }
html[data-layout="classic"] .search-form { gap: 6px; }
html[data-layout="classic"] .search-btn { padding-inline: 12px; font-size: 13px; }
html[data-layout="classic"] #searchInput { padding-inline: 10px; }
html[data-layout="classic"] .sidebar { padding-top: calc(24px + env(safe-area-inset-top)); padding-bottom: calc(24px + env(safe-area-inset-bottom)); z-index: 350; }
html[data-layout="classic"] .sidebar-backdrop { z-index: 340; }
html[data-layout="classic"] .player-pane { padding-inline: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right)); }
html[data-layout="classic"] .player-placeholder { padding: 24px 8px 32px; }
html[data-layout="classic"] .hero { gap: 24px; }
html[data-layout="classic"] .hero-title { font-size: 42px; }
html[data-layout="classic"] .hero-tagline { font-size: 16px; }
html[data-layout="classic"] .hero-pills { gap: 8px 12px; }
html[data-layout="classic"] .home-pl-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
html[data-layout="classic"] .home-pl-title { font-size: 26px; }
html[data-layout="classic"] .bottom-nav { display: flex; padding: 6px env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
html[data-layout="classic"] .bottom-nav-btn { min-width: 44px; min-height: 58px; padding: 6px 0 8px; gap: 5px; color: var(--text-dim); }
html[data-layout="classic"] .bottom-nav-btn.active { color: var(--text); }
html[data-layout="classic"] .mini-bar { left: 0; bottom: var(--classic-nav-height); }
html[data-layout="classic"] .mini-bar-inner { padding: 8px max(12px, env(safe-area-inset-right)) 8px max(12px, env(safe-area-inset-left)); gap: 8px; }
html[data-layout="classic"] .list-header { padding: 24px 16px 16px; }
html[data-layout="classic"] .settings { padding-inline: 16px; }
html[data-layout="classic"] :is(.modal-backdrop, .shortcut-overlay) { padding: 16px; }
html[data-layout="classic"] .shortcut-panel { min-width: 0; width: 100%; padding: 24px; }
}
@media (max-width: 380px) {
html[data-layout="classic"] .thumb { width: 88px; }
}

View File

@@ -0,0 +1,14 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
testMatch: /classic-layout\.spec\.js/,
timeout: 30000,
workers: 2,
use: { baseURL: 'http://localhost:8092', browserName: 'chromium', serviceWorkers: 'block' },
webServer: {
command: 'npx serve frontend -l 8092 --no-clipboard',
port: 8092,
reuseExistingServer: !process.env.CI,
},
});

View File

@@ -0,0 +1,103 @@
const { test, expect } = require('@playwright/test');
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
for (const width of [320, 390, 768, 1024, 1280, 1440]) {
test(`empty home is unclipped at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await openClassic(page);
const result = await page.evaluate(() => {
const stage = document.querySelector('.player-stage').getBoundingClientRect();
const hero = document.querySelector('.hero').getBoundingClientRect();
const chips = [...document.querySelectorAll('.hero .chip')].map(el => el.getBoundingClientRect());
return { stageBottom: stage.bottom, heroBottom: hero.bottom, stageTop: stage.top, heroTop: hero.top,
chipsFit: chips.every(r => r.right <= innerWidth && r.left >= 0 && r.bottom <= stage.bottom),
overflow: document.querySelector('.main').scrollWidth > document.querySelector('.main').clientWidth + 1 };
});
expect(result.heroTop).toBeGreaterThanOrEqual(result.stageTop);
expect(result.heroBottom).toBeLessThanOrEqual(result.stageBottom + 1);
expect(result.chipsFit).toBe(true);
expect(result.overflow).toBe(false);
await expect(page.locator('#bottomNav')).toBeVisible({ visible: width <= 860 });
});
}
for (const standalone of [false, true]) {
test(`navigation during playback and mini-player return (${standalone ? 'PWA' : 'browser'})`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openClassic(page, { populated: true, standalone });
await showPlayingFixture(page);
await expect(page.locator('#back10Btn')).toBeVisible();
await expect(page.locator('#fwd10Btn')).toBeVisible();
await page.locator('#bottomNav [data-view="settings"]').click();
await expect(page.locator('.settings')).toBeVisible();
await expect.poll(() => page.locator('.list-pane').evaluate(el => el.getBoundingClientRect().top)).toBeLessThan(130);
await expect(page.locator('#miniBar')).toBeVisible();
const boxes = await page.evaluate(() => ({
mini: document.getElementById('miniBar').getBoundingClientRect().bottom,
nav: document.getElementById('bottomNav').getBoundingClientRect().top,
}));
expect(boxes.mini).toBeLessThanOrEqual(boxes.nav + 1);
await page.locator('.body').evaluate(el => { el.scrollTop = el.scrollHeight; });
const last = page.locator('.settings .set-group').last();
await expect(last).toBeInViewport();
expect(await last.evaluate(el => el.getBoundingClientRect().bottom)).toBeLessThanOrEqual(boxes.nav);
await page.locator('#miniTitle').click();
await expect.poll(() => page.locator('.body').evaluate(el => el.scrollTop)).toBe(0);
expect(await page.evaluate(() => window.scrollY)).toBe(0);
await page.locator('#ctlMoreBtn').click();
await expect(page.locator('#modal')).toBeVisible();
});
}
test('playlist, appearance settings, rotation, and layout switching preserve data', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await openClassic(page, { populated: true });
await expect(page.locator('#homePlGrid')).toContainText('Studio sessions');
await showPlayingFixture(page);
expect(await page.locator('.list-pane').evaluate(el => el.getBoundingClientRect().width)).toBeGreaterThanOrEqual(340);
expect(await page.locator('.card-info').first().evaluate(el => el.getBoundingClientRect().width)).toBeGreaterThan(120);
const original = await page.evaluate(() => JSON.stringify(data.playlists));
for (const theme of ['light', 'contrast', 'dark']) {
await page.evaluate(theme => { data.settings.theme = theme; applyAppearance(); }, theme);
expect(await page.locator('.hero-title').evaluate(el => getComputedStyle(el).webkitTextFillColor)).not.toBe('rgba(0, 0, 0, 0)');
}
for (const layout of ['glass-stage', 'sanctuary-stage', 'classic']) {
await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); }, layout);
await expect(page.locator('html')).toHaveAttribute('data-layout', layout);
expect(await page.evaluate(() => JSON.stringify(data.playlists))).toBe(original);
}
await page.setViewportSize({ width: 844, height: 390 });
await page.locator('#bottomNav [data-view="history"]').click();
await expect(page.locator('#listTitle')).toHaveText('History');
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => { data.settings.fontScale = 'xl'; data.settings.density = 'compact'; applyAppearance(); });
await expect(page.locator('#bottomNav [data-view="settings"]')).toBeInViewport();
expect(await page.evaluate(() => document.querySelector('.main').scrollWidth <= document.querySelector('.main').clientWidth + 1)).toBe(true);
});
test('320px with extra-large text keeps navigation and controls reachable', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 844 });
await openClassic(page, { populated: true, settings: { fontScale: 'xl' } });
await showPlayingFixture(page);
await expect(page.locator('#bottomNav [data-view="settings"]')).toBeInViewport();
expect(await page.locator('#controls').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
expect(await page.locator('.app').evaluate(el => Math.abs(el.getBoundingClientRect().height - innerHeight))).toBeLessThan(2);
expect(await page.locator('#bottomNav').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await page.locator('#bottomNav [data-view="settings"]').click();
await expect(page.locator('.settings')).toBeVisible();
});
test.describe('Classic offline shell', () => {
test.use({ serviceWorkers: 'allow' });
test('caches the Classic stylesheet and restores it offline', async ({ page, context }) => {
await openClassic(page);
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await page.waitForFunction(() => !!navigator.serviceWorker.controller);
expect(await page.evaluate(async () => !!(await caches.match('/theme-classic.css')))).toBe(true);
await context.setOffline(true);
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-layout', 'classic');
expect(await page.locator('html').evaluate(el => getComputedStyle(el).getPropertyValue('--bg').trim())).toBe('#121315');
});
});

View File

@@ -0,0 +1,56 @@
// Synthetic UI fixtures: exercise real rendering and navigation, not streaming.
async function openClassic(page, { populated = false, settings = {}, standalone = false } = {}) {
await page.route('**/api/**', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ ok: true, results: [], items: [], holders: [], data: null }),
}));
await page.route('**/fixture-cover.svg', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360"><rect width="640" height="360" fill="#343b42"/><text x="42" y="150" fill="#f3f1ed" font-family="sans-serif" font-size="46">Studio session</text><text x="44" y="200" fill="#c3c4c7" font-family="sans-serif" font-size="20">Synthetic preview fixture</text></svg>',
}));
await page.addInitScript(({ populated, settings, standalone }) => {
if (standalone) {
const match = window.matchMedia.bind(window);
window.matchMedia = query => query.includes('display-mode: standalone')
? match(query.replace(/\(display-mode: standalone\)/g, '(min-width: 0px)')) : match(query);
}
const videos = Array.from({ length: 12 }, (_, i) => ({
id: 'preview' + String(i).padStart(4, '0'),
title: ['A quiet morning — live studio session', 'Late afternoon, piano and strings', 'An extended conversation about making music and finding a little room to listen'][i % 3],
channel: 'Preview fixtures', duration: 240 + i * 36, thumbnail: '/fixture-cover.svg',
}));
localStorage.setItem('_ytpdata', JSON.stringify({
settings: { layout: 'classic', reduceMotion: true, p2pShare: false, p2pReceive: false, ...settings },
playlists: populated ? [{ id: 'studio', name: 'Studio sessions', videos }] : [],
history: populated ? videos : [], queue: populated ? videos : [],
}));
}, { populated, settings, standalone });
await page.goto('/');
await page.waitForFunction(() => document.documentElement.dataset.layout === 'classic' && document.querySelectorAll('.smart-pl-list .playlist-item').length > 0);
await page.locator('#searchInput').blur();
}
async function showPlayingFixture(page) {
await page.evaluate(() => {
const videos = data.playlists[0].videos;
current = { meta: videos[0], qualities: [], audioUrl: '' };
queue = videos;
queueIndex = 0;
queueSource = 'playlist:studio';
view = { type: 'playlist', id: 'studio' };
document.getElementById('playerPane').classList.remove('empty');
document.getElementById('playerPlaceholder').classList.add('hidden');
document.getElementById('controls').classList.remove('hidden');
document.getElementById('nowPlayingMeta').classList.remove('hidden');
document.getElementById('npTitle').textContent = videos[0].title;
document.getElementById('npChannel').textContent = videos[0].channel;
document.getElementById('artFallback').classList.remove('hidden');
document.getElementById('artImg').src = '/fixture-cover.svg';
updateNowPlayingActions();
render();
renderUpNext();
document.querySelector('.body').scrollTop = 0;
});
}
module.exports = { openClassic, showPlayingFixture };