@import "tailwindcss"; /* `inline` is load-bearing: the color vars (--bg/--accent/… below) are redefined at runtime per html[data-scheme]/[data-theme], so utilities must inline the value and resolve it at the USE site, not reference a once-computed --color-* token. This also makes the v4 opacity modifier (bg-accent/40 → color-mix(… var(--accent) 40% …)) pick up the active scheme's color. Same pattern as shadcn's v4 theme setup. */ @theme inline { --color-bg: var(--bg); --color-surface: var(--surface); --color-card: var(--card); --color-border: var(--border); --color-fg: var(--fg); --color-muted: var(--muted); --color-accent: var(--accent); --color-accent-fg: var(--accent-fg); --font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; --z-index-base: 10; --z-index-menu: 20; --z-index-chrome: 30; --z-index-paneltab: 34; --z-index-panel: 35; --z-index-rail: 40; --z-index-overlay: 50; --z-index-popover: 60; --z-index-tooltip: 100; --z-index-tuner: 9999; } /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the same as it did with Tailwind CSS v3. If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentcolor); } } /* R9 focus foundation: one accent focus ring for keyboard users on every interactive element that doesn't define its own. In @layer base so any component/utility focus style (.selectable, an input's focus:ring, a menu item's data-active highlight + outline-hidden) overrides it. Non-input controls app-wide had no visible keyboard focus before R9; this is the single biggest a11y win. */ @layer base { button:focus-visible, a:focus-visible, [role="button"]:focus-visible, [role="tab"]:focus-visible, summary:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); outline-offset: 2px; } } :root { --font-scale: 1.06; /* Vertical space the floating top header needs to clear (top offset 12px + pill height 44px + bottom breathing room ~12px). Module content pads its top by this so nothing hides under the header; kept in one place since App and Playlists both reference it. */ --hdr-h: 68px; /* ===== Glass tunables — every value that materially drives the look lives here so it can be tuned in ONE place. ===== */ /* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome (nav/header/dialogs/filters/settings — where there's nothing behind the glass to refract). Rich-backdrop surfaces override these via `.glass-media` below. */ --glass-blur: 20px; /* backdrop blur radius */ --glass-saturate: 1.8; /* backdrop saturation */ --glass-dark-bright: 1.25; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */ --glass-surface-alpha: 94%; /* .glass panel translucency (higher = more solid = "adaptive") */ --glass-card-alpha: 94%; /* .glass-card translucency */ --glass-menu-alpha: 98%; /* .glass-menu translucency */ --glass-border-alpha: 78%; /* .glass border opacity */ --glass-inset: 16%; /* top specular highlight strength (#fff N%) */ --glass-edge: 10%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */ --glass-scrim: 14%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */ --ambient: 1.1; /* multiplier on the ambient accent backdrop pools */ /* Per-scheme background image dim (see frontend/public/backdrops/): how strongly the faded --bg overlay hides the image — higher = fainter. Only in effect while data-backdrop="on". */ --bg-fade: 60%; } /* ===== "Glass over image" — the translucent tier. Applies whenever a real image sits behind the surfaces: app-wide when the per-scheme background image is on (`html[data-backdrop="on"]`), and on the Plex art-backed detail views (`.glass-media`, which have their own art backdrop even when the global image is off). When there's nothing behind the glass (image off / perf), the :root defaults keep it solid & readable. ===== */ html[data-backdrop="on"], .glass-media { /* Solidified after user feedback (2026-08-08): 50%/10px let the feed read straight THROUGH the nav and popovers — distractingly transparent. Higher alpha + a stronger blur keep the backdrop image showing through as soft colour without the content behind punching through. */ --glass-blur: 14px; --glass-saturate: 1.6; --glass-dark-bright: 1.2; --glass-surface-alpha: 72%; --glass-card-alpha: 80%; --glass-menu-alpha: 85%; --glass-border-alpha: 80%; --glass-edge: 25%; --glass-inset: 15%; --glass-scrim: 30%; } /* Light theme carries no per-scheme background image, so the near-opaque :root (adaptive) glass reads as flat white and every panel becomes an opaque island. Give light its OWN translucent tier — one central override, every .glass* surface — so the ambient tint (and content) shows through and glass stays glassy in light too. (Perf mode below still forces solid + no blur.) */ html[data-theme="light"] { --glass-surface-alpha: 62%; --glass-card-alpha: 74%; --glass-menu-alpha: 86%; } /* Per-scheme background image, painted on (the app root is transparent so it shows through the whole app and the glass refracts it). A faded --bg overlay keeps it subtle; `fixed` so it doesn't scroll. Dark theme uses the dark backdrops, light theme the lighter set in /light/. Only when the backdrop is on (the "Background image" setting; off in perf). The [data-theme] qualifier also out-specifies the ambient `background` shorthand (which would else reset background-size). */ html[data-backdrop="on"][data-theme="dark"][data-scheme="midnight"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/midnight.svg"); } html[data-backdrop="on"][data-theme="dark"][data-scheme="forest"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/forest.svg"); } html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/slate.svg"); } html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/youtube.svg"); } html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/starship.svg"); } html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/matrix.svg"); } html[data-backdrop="on"][data-theme="light"][data-scheme="midnight"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/light/midnight.svg"); } html[data-backdrop="on"][data-theme="light"][data-scheme="forest"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/light/forest.svg"); } html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/light/slate.svg"); } html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/light/youtube.svg"); } html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/light/starship.svg"); } html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body { background-image: linear-gradient( color-mix(in srgb, var(--bg) var(--bg-fade), transparent), color-mix(in srgb, var(--bg) var(--bg-fade), transparent) ), url("/backdrops/light/matrix.svg"); } html[data-backdrop="on"][data-theme="dark"] body, html[data-backdrop="on"][data-theme="light"] body { background-size: cover; background-position: center; background-repeat: no-repeat; background-attachment: fixed; } /* Make native controls (date picker, scrollbars) follow the theme. */ html[data-theme="dark"] { color-scheme: dark; } html[data-theme="light"] { color-scheme: light; } html { font-size: calc(16px * var(--font-scale)); background: var(--bg); } body { margin: 0; color: var(--fg); /* Ambient backdrop so translucent "glass" surfaces have soft color to refract. Kept subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav, header and dialogs are all frosted glass and refract it. */ background: radial-gradient( 1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent), transparent 60% ), radial-gradient( 1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent), transparent 55% ), radial-gradient( 900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60% ), var(--bg); background-attachment: fixed; } /* Hide the native scrollbar while keeping the element scrollable (wheel/trackpad/keyboard). Used on the nav rail so a high browser-zoom overflow scrolls without showing a scrollbar. */ .no-scrollbar { scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* legacy Edge/IE */ } .no-scrollbar::-webkit-scrollbar { display: none; /* Chromium/WebKit */ } /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */ /* In @layer components so the recipe classes below (.selectable) compose with utilities instead of silently beating them the way these rules did while unlayered. (The ~9 dead `shadow-*` that used to sit on glass surfaces were REMOVED in R9, not activated: the tuned glass box-shadow — including its inset top-edge highlight — is the intended depth, and a shadow-* utility would atomically replace it and drop that highlight.) The perf-mode and :hover overrides below stay UNLAYERED on purpose. */ @layer components { .glass { /* Frosted overlay surface: translucent enough that the blurred backdrop shows through (the glassy look), opaque enough that the background isn't distracting. The strong blur turns whatever is behind into soft colour, not a sharp image. Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */ background: linear-gradient( color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent) ), color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent); border-top-color: color-mix( in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent) ); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent), 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-card { /* No backdrop-filter here: cards render in bulk (feed grid) over a solid background where blur adds almost nothing visually but is GPU-expensive and triggers reflow. Translucency + border + depth keep the look; blur is reserved for .glass overlays. */ background: linear-gradient( color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent) ), color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); border-top-color: color-mix( in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 65%, transparent) ); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent), 0 8px 22px -14px rgba(0, 0, 0, 0.45); } .glass-menu { /* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs), so they must be near-opaque to stay readable — only a hint of translucency + the blur. */ background: linear-gradient( color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent) ), color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); border-top-color: color-mix( in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent) ); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent), 0 18px 44px -16px rgba(0, 0, 0, 0.6); } /* R9: standard interactive-row recipe. The hover tint menu/list rows already use, PLUS the app-wide focus-visible ring that was missing on non-input controls (buttons, rows, menu items) — the biggest single a11y win. In @layer components so a consumer's own utilities still win; the accent ring renders correctly under v4 (color-mix), matching the input ring in ui/form.tsx. */ .selectable { transition: background-color 0.15s ease, color 0.15s ease; } .selectable:hover { background-color: var(--card); } .selectable:focus-visible { /* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */ outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); outline-offset: -2px; } } .glass-hover:hover { border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), 0 14px 30px -14px rgba(0, 0, 0, 0.5); } /* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs which sit over the feed. Strengthen the pools and let a touch more bleed through the glass. (The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */ html[data-theme="dark"] body { background: radial-gradient( 1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent), transparent 60% ), radial-gradient( 1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent), transparent 55% ), radial-gradient( 900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60% ), var(--bg); background-attachment: fixed; } /* Dark glass reads faint because the blurred backdrop is itself dark; lift its brightness so the frosted sheen shows even over dark UI (over colourful content it just looks richer). */ html[data-theme="dark"] .glass, html[data-theme="dark"] .glass-menu { backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright)); } /* Toasts surface bottom-left (by the nav's notification bell), off the conventional top-right; a brighter rim draws the eye there. Most needed in dark mode, where the default glass border (dark navy) all but vanishes against the backdrop. */ html[data-theme="dark"] .toast-card { border-color: color-mix(in srgb, #fff 50%, transparent); } /* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */ html[data-perf="1"] .glass, html[data-perf="1"] .glass-menu, html[data-perf="1"] .glass-card { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--surface); box-shadow: 0 2px 8px -4px rgba(0, 0, 0, 0.4); } html[data-perf="1"] body { background: var(--bg); } /* Skip layout/paint for off-screen feed cards so long lists stay smooth while scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar stays stable; `auto` lets the browser remember each card's real size after it has rendered once. */ .cv-card { content-visibility: auto; contain-intrinsic-size: auto 320px; } .cv-row { content-visibility: auto; contain-intrinsic-size: auto 96px; } /* ===== Motion ===== */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes overlayIn { from { opacity: 0; } to { opacity: 1; } } @keyframes panelIn { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes popIn { from { opacity: 0; transform: translateY(-6px) scale(0.97); } to { opacity: 1; transform: none; } } /* One-shot attention flash for a chat dock window on a new message. Inset glow so the window's own overflow-hidden doesn't clip it; pulses twice then fades. */ @keyframes chatFlash { 0% { box-shadow: inset 0 0 0 0 transparent; } 18% { box-shadow: inset 0 0 0 3px var(--accent), inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent); } 40% { box-shadow: inset 0 0 0 1px transparent; } 62% { box-shadow: inset 0 0 0 3px var(--accent), inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent); } 100% { box-shadow: inset 0 0 0 0 transparent; } } .chat-flash { animation: chatFlash 1.1s ease-out; } /* ===== Color schemes (accent + neutrals), each with dark + light ===== */ /* Midnight — deep navy/slate with blue accent (default) */ html[data-scheme="midnight"][data-theme="dark"] { --bg: #0b1020; --surface: #121a2e; --card: #161f38; --border: #243049; --fg: #e6e9f0; --muted: #97a3c0; --accent: #6d8cff; --accent-fg: #0b1020; } html[data-scheme="midnight"][data-theme="light"] { --bg: #f4f6fc; --surface: #ffffff; --card: #ffffff; --border: #dde3f0; --fg: #1a2238; --muted: #5b6685; --accent: #3b5bdb; --accent-fg: #ffffff; } /* Forest — dark slate with teal/green accent */ html[data-scheme="forest"][data-theme="dark"] { --bg: #0a1512; --surface: #0f1f1a; --card: #12241e; --border: #1f3a30; --fg: #e6f0ec; --muted: #90b1a4; --accent: #2dd4bf; --accent-fg: #04110d; } html[data-scheme="forest"][data-theme="light"] { --bg: #f1f7f4; --surface: #ffffff; --card: #ffffff; --border: #d6e7e0; --fg: #102a22; --muted: #4d6b60; --accent: #0d9488; --accent-fg: #ffffff; } /* Slate — neutral grey with warm orange accent (muted in dark) */ html[data-scheme="slate"][data-theme="dark"] { --bg: #15171c; --surface: #1b1e25; --card: #1f232b; --border: #2c313b; --fg: #e7e9ee; --muted: #9aa1ad; --accent: #e8913c; --accent-fg: #1a1206; } html[data-scheme="slate"][data-theme="light"] { --bg: #f6f7f9; --surface: #ffffff; --card: #ffffff; --border: #e3e6eb; --fg: #1c1f26; --muted: #5d636e; --accent: #d97706; --accent-fg: #ffffff; } /* YouTube — near-black with red accent */ html[data-scheme="youtube"][data-theme="dark"] { --bg: #0f0f0f; --surface: #181818; --card: #1f1f1f; --border: #303030; --fg: #f1f1f1; --muted: #aaaaaa; --accent: #ff3b46; --accent-fg: #ffffff; } html[data-scheme="youtube"][data-theme="light"] { --bg: #ffffff; --surface: #f9f9f9; --card: #ffffff; --border: #e5e5e5; --fg: #0f0f0f; --muted: #606060; --accent: #ff0033; --accent-fg: #ffffff; } /* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */ html[data-scheme="starship"][data-theme="dark"] { --bg: #050b16; --surface: #0b1728; --card: #101f36; --border: #1f3557; --fg: #dfeaff; --muted: #8aa6cc; --accent: #38b6ff; --accent-fg: #04101f; } html[data-scheme="starship"][data-theme="light"] { --bg: #eef4fb; --surface: #ffffff; --card: #ffffff; --border: #d3e0f0; --fg: #0b1b30; --muted: #4a5f7d; --accent: #0b6bd6; --accent-fg: #ffffff; } /* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */ html[data-scheme="matrix"][data-theme="dark"] { --bg: #030a06; --surface: #08130d; --card: #0b1c12; --border: #163d27; --fg: #cfe8d6; --muted: #6fae86; --accent: #46b87c; --accent-fg: #04170c; } html[data-scheme="matrix"][data-theme="light"] { --bg: #f0f7f1; --surface: #ffffff; --card: #ffffff; --border: #cfe6d6; --fg: #08130b; --muted: #4c6b56; --accent: #12a150; --accent-fg: #ffffff; } /* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */ @keyframes toastbar { from { transform: scaleX(1); } to { transform: scaleX(0); } } /* The in-app player stage filling the screen: either because something fullscreened it (YouTube's own button hands the lock to the iframe, not to us — but keep this for any browser that does), or via our own F-key maximise. `--max` deliberately does NOT use the Fullscreen API: while we hold the browser's lock, the cross-origin YouTube embed never learns it is fullscreen and its own fullscreen button stops working (see PlayerModal.toggleFullscreen). */ .player-stage:fullscreen, .player-stage--max { aspect-ratio: auto; border-radius: 0; } /* A fullscreened element is already sized by the browser; the viewport units are only for the `:fullscreen` path, where the stage is not positioned. */ .player-stage:fullscreen { width: 100vw; height: 100vh; } /* Sizing, precisely: the element keeps Tailwind's `w-full` from its base className, so the WIDTH is `100%` of the containing block — which, for a fixed element, is the viewport minus any classic scrollbar. The HEIGHT comes from `inset: 0` (top+bottom, once `aspect-ratio` is released above). `right: 0` is therefore ignored (over-constrained: left + width win) — harmless, but don't read this rule as inset-sized. What had to go are the viewport units it used to inherit: `100vw` INCLUDES the scrollbar (horizontal overflow) and `100vh` includes a mobile browser's retracting chrome, which pushes the bottom edge — YouTube's own control bar — off screen. */ .player-stage--max { position: fixed; inset: 0; /* rail-level (40): this stage only has to beat the modal chrome it is nested inside (whose own children top out at z-menu/20). The modal root is a z-overlay stacking context, so nothing here escapes it — 9999 is the glass tuner's reserved level and claimed an authority this layer neither has nor needs. */ z-index: 40; } /* Thin, theme-aware scrollbars */ * { scrollbar-color: var(--border) transparent; } *::-webkit-scrollbar { width: 10px; height: 10px; } *::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; }