Files
siftlode/frontend/src/index.css
T
peter 45654fc7ec fix(player): F maximises without the Fullscreen API, so YouTube's own controls work
Root cause, measured in Chrome/Brave/Firefox alike (so: spec behaviour, not a
browser bug): a cross-origin embed reads its fullscreen state from ITS OWN
document. While we hold the browser's fullscreen lock — on our wrapper OR on the
iframe element — the YouTube player never learns it is fullscreen, so its own
fullscreen button stays in the "enter" state and clicking it changes nothing
visible. Every other YouTube control works, which is exactly what the user saw.

So we stop taking the lock: F now toggles a CSS layer (`player-stage--max`) that
fills the viewport. The page stays non-fullscreen, so the embed's own fullscreen
button behaves natively, and our own overlays and shortcuts keep working because
our DOM is what's on screen. Escape steps out of maximise before closing.

Verified end to end in real Chrome:
- F -> stage 1920x889, document.fullscreenElement null (no lock taken)
- YouTube's own fullscreen button -> real fullscreen (innerHeight 889 -> 1024,
  fullscreenElement becomes the IFRAME) and its icon flips to "exit"
- clicking it again -> back to the maximised view, our class still applied
- wheel volume while maximised: 100 -> 85, with the level flash visible

Drops the "YouTube fullscreen" toolbar button added earlier today: with the lock
gone, YouTube's own button does that job correctly.
2026-07-24 02:46:42 +02:00

613 lines
20 KiB
CSS

@tailwind base;
@tailwind components;
@tailwind utilities;
: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 {
--glass-blur: 10px;
--glass-saturate: 1.6;
--glass-dark-bright: 1.2;
--glass-surface-alpha: 50%;
--glass-card-alpha: 60%;
--glass-menu-alpha: 66%;
--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 <body> (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) ===== */
.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);
}
.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 overlayOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes panelIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes panelOut {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@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 {
width: 100vw;
height: 100vh;
aspect-ratio: auto;
border-radius: 0;
}
.player-stage--max {
position: fixed;
inset: 0;
z-index: 9999; /* the tuner level — above the modal chrome it is nested in */
}
/* Thin, theme-aware scrollbars */
* {
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 8px;
}