Owner report: on the Plex browse grid, Tab onto the first card ringed only the poster, and the first arrow key then moved the ring round the whole card. The poster is the card's own role=button Tab stop, so Tab gave it the app-wide button ring, while listNav's arrows focus the card root (D2: the ring goes round the whole item). The card's "open" element (`data-nav-open` — the Plex poster, a video card's title link) now stands for the card: when it holds keyboard focus the card root wears the ring and the element's own ring is transparent. Controls inside the card (watched toggle, actions) keep their own ring. Measured in Chrome (jsdom evaluates neither :focus-visible nor :has): Plex browse, Tab from the sort control → poster focused, card outline 2px accent, poster outline transparent; → moves to card 1 with the same ring. Feed, Tab onto a card's title link → the card is ringed, the link is not.
1040 lines
41 KiB
CSS
1040 lines
41 KiB
CSS
@import "tailwindcss";
|
||
|
||
/* Guarantee the full status fill-text set exists even before first use, so any
|
||
bg-{status} fill always has a readable text-{status}-fg partner available
|
||
(Tailwind v4 only emits a utility when it sees it used; success-fg has no fill
|
||
consumer today). Keeps the semantic token set complete + future-proof. */
|
||
@source inline("text-danger-fg text-warn-fg text-success-fg text-info-fg");
|
||
|
||
/* `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);
|
||
--color-info: var(--info);
|
||
--color-info-fg: var(--info-fg);
|
||
--color-danger: var(--danger);
|
||
--color-danger-fg: var(--danger-fg);
|
||
--color-warn: var(--warn);
|
||
--color-warn-fg: var(--warn-fg);
|
||
--color-success: var(--success);
|
||
--color-success-fg: var(--success-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;
|
||
}
|
||
|
||
/* Extra-small text steps in rem, so these labels scale with --font-scale like every other
|
||
text (the ~124 px-frozen text-[10/11px] labels did NOT). Font-size ONLY — no paired
|
||
--text-*--line-height — to match the `text-[Npx]` arbitraries they replace and avoid any
|
||
vertical-rhythm shift.
|
||
Convention: the two COMMON small steps get names (below); the rare one-offs (12/13/8/9px)
|
||
stay as `text-[Nrem]` arbitraries rather than growing the token set — DON'T use `text-xs`
|
||
for 12px, it carries a line-height these labels never had. New small labels: reach for a
|
||
token first, an arbitrary only for a genuinely one-off size. */
|
||
@theme {
|
||
--text-2xs: 0.6875rem; /* 11px @ base — the most common frozen label */
|
||
--text-3xs: 0.625rem; /* 10px @ base */
|
||
}
|
||
|
||
/*
|
||
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 {
|
||
/* Solid accent (E11 D2 = B): the 60% mix of R9 all but vanished on the dark glass. */
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
}
|
||
|
||
:root {
|
||
--font-scale: 1.06;
|
||
|
||
/* Vertical space the floating top header needs to clear (top offset ~0.75rem + pill height ~2.75rem
|
||
+ bottom breathing room ~0.75rem). In rem so it grows with --font-scale in lock-step with the
|
||
rem-sized header pills — a px value let content slide under the header at scale 1.3. Kept in one
|
||
place since App and Playlists both reference it. */
|
||
--hdr-h: 4.25rem;
|
||
|
||
/* Width of the app scroller's scrollbar gutter (scrollbar-gutter: stable reserves it on the right).
|
||
Measured once at startup (App) and written here; the floating header and the toolbar band pad
|
||
their right edge by it so the fixed chrome lines up with the scrolled cards, not 15px past them.
|
||
Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */
|
||
--sbw: 0px;
|
||
|
||
/* Max width of a page's content column (cards / table). The floating header + toolbar band read it
|
||
so they centre on the SAME max-width the content uses, instead of spanning the whole column while
|
||
a max-w page centres its grid. Each page sets it via PageShell's `maxWidth`; `none` = full width. */
|
||
--content-mw: none;
|
||
|
||
/* ===== 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. ===== */
|
||
/* App-wide background image. Chrome stays readable — panels (.glass surface) near-opaque at 90% and
|
||
menus fully solid at 100% (a popover over the feed must not read through) — while the FEED CARDS
|
||
(.glass-card) are intentionally translucent at 50% so the per-scheme backdrop image gently shows
|
||
THROUGH the cards without hurting text. SEPARATE from .glass-media below (Plex art tier). (Tuned by
|
||
the user, 2026-08-09: 90/50/100 + inset 10 — "the image just barely shows through, text still
|
||
readable". Supersedes the 2026-08-08 uniform-88% pass, which killed the image-through-cards look.) */
|
||
html[data-backdrop="on"] {
|
||
--glass-blur: 22px;
|
||
--glass-saturate: 1.6;
|
||
--glass-dark-bright: 1.2;
|
||
--glass-surface-alpha: 90%;
|
||
--glass-card-alpha: 50%;
|
||
--glass-menu-alpha: 100%;
|
||
--glass-border-alpha: 80%;
|
||
--glass-edge: 25%;
|
||
--glass-inset: 10%;
|
||
--glass-scrim: 30%;
|
||
}
|
||
/* Plex art-backed detail views: the glass sits over the item's OWN poster/art, which is MEANT to show
|
||
through richly. Its PANELS/MENUS are more translucent than the app-wide tier above (72/85 vs 90/100),
|
||
but its cards are actually MORE solid (80 vs the app-wide 50) — the app-wide feed deliberately lets
|
||
its backdrop show through the cards, while here the poster reads through the panels instead. */
|
||
.glass-media {
|
||
--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 <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 {
|
||
/* Snap the scaled root font-size to a whole pixel. The base is rem-derived app-wide, so a fractional
|
||
root (e.g. 16px * 1.02 = 16.32px) makes every box height fractional — and when a reflow changes a
|
||
section's row count, the sub-pixel remainder shifts everything below it by ~0.5px, a visible 1px
|
||
jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */
|
||
font-size: round(calc(16px * var(--font-scale)), 1px);
|
||
background: var(--bg);
|
||
/* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`),
|
||
so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens
|
||
on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every
|
||
hoverable row (the .selectable recipe + DataTable rows). */
|
||
--hover: color-mix(in srgb, var(--fg) 9%, transparent);
|
||
}
|
||
|
||
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(--hover);
|
||
}
|
||
.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 var(--accent);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
/* E11 S4 · a list/grid item the keyboard landed on (`lib/listNav`): the SAME solid ring, around the
|
||
whole card rather than one control inside it (D2 = B). It is focus-visible only, so a mouse click
|
||
never paints it, and it is a ring rather than a fill, so it never reads like the hover tint. The
|
||
scroll margins are what `scrollIntoView({block:"nearest"})` honours: they keep a card stepped to
|
||
from sliding under the floating header (`--hdr-h`) or flush against the bottom edge. */
|
||
[data-nav-index] {
|
||
scroll-margin-top: calc(var(--hdr-h, 0px) + 0.75rem);
|
||
scroll-margin-bottom: 0.75rem;
|
||
/* Sideways in a rail: clear its edge fade and the paging arrows (Carousel), not just the edge. */
|
||
scroll-margin-inline: 2.5rem;
|
||
/* A transparent resting ring of the SAME geometry: the video card transitions `all`, so without
|
||
it the ring animated in from the initial outline (3px, currentColor) — a white flash on every
|
||
`j`, measured in Chrome. Now only the colour fades in. */
|
||
outline: 2px solid transparent;
|
||
outline-offset: 2px;
|
||
}
|
||
/* The card's own "open" element (a Plex poster is a role=button Tab stop, a video card's title is a
|
||
link) stands for the card: reached by Tab it rings the whole card too, not just itself — otherwise
|
||
Tab showed a ring round the poster alone and the first arrow moved it round the card. */
|
||
[data-nav-index]:focus-visible,
|
||
[data-nav-index]:has([data-nav-open]:focus-visible) {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
[data-nav-index] [data-nav-open]:focus-visible {
|
||
outline-color: transparent;
|
||
}
|
||
|
||
/* ===== R10 S4 · Selection-paint recipes =====
|
||
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
|
||
bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
|
||
which drifted over time. These recipes OWN that paint, keyed on the semantic ARIA the control
|
||
already carries — the JSX just toggles `aria-pressed` (a segment/toggle) or `aria-current` (the
|
||
single current item) and the CSS paints, so one control's selected look can never diverge from
|
||
the next's. Geometry (padding / radius / border-width / font) stays a per-site utility since it
|
||
legitimately varies. NO focus-visible here on purpose: the `@layer base` rule above already rings
|
||
every button/a/[role]; re-adding it would duplicate that (an R9 review finding). Because these sit
|
||
in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
|
||
border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
|
||
data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
|
||
.pill,
|
||
.is-current {
|
||
color: var(--muted);
|
||
transition:
|
||
color 0.15s ease,
|
||
background-color 0.15s ease,
|
||
border-color 0.15s ease,
|
||
box-shadow 0.15s ease,
|
||
transform 0.15s ease; /* box-shadow: an on-state shadow (Tabs) fades; transform: an active:translate press eases */
|
||
}
|
||
/* Resting border colour for the plain pills. NOT applied to a glass-card-resting pill (`.pill.glass-card`),
|
||
whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the
|
||
site also adds a border-width utility. */
|
||
.pill:not(.glass-card),
|
||
.is-current {
|
||
border-color: var(--border);
|
||
}
|
||
.pill:hover,
|
||
.is-current:hover {
|
||
color: var(--fg);
|
||
}
|
||
.pill:hover {
|
||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||
}
|
||
.is-current:hover {
|
||
background-color: var(--card);
|
||
}
|
||
/* selected / current — SOLID accent fill (the dominant idiom). The `:not([aria-current="false"])`
|
||
guard matters: `.is-current` is a shared primitive and a consumer using the common
|
||
`aria-current={x ? "true" : "false"}` idiom would otherwise paint a NON-current row as current. */
|
||
.pill[aria-pressed="true"],
|
||
.is-current[aria-current]:not([aria-current="false"]) {
|
||
background-color: var(--accent);
|
||
color: var(--accent-fg);
|
||
border-color: var(--accent);
|
||
}
|
||
/* selected — SOFT tint variant (the plex layout toggle keeps a glass-card resting look, so its
|
||
"on" state is a subtle accent tint rather than the solid fill). */
|
||
.pill[aria-pressed="true"][data-soft="true"] {
|
||
background-color: color-mix(in srgb, var(--accent) 14%, transparent);
|
||
color: var(--accent);
|
||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||
}
|
||
|
||
/* .chip — an applied tag / filter token (rounded, tinted, self-contained: display + geometry +
|
||
paint). The tint is the resting look (a filter is "on" whenever the chip exists); removable
|
||
variants add their own × button as a child.
|
||
.chip-solid is intentionally a PAINT-ONLY modifier (bg/text/border colour, no display or
|
||
geometry): it sits on a site's OWN chip geometry so a truncating single-element chip
|
||
(ChannelTags) isn't forced into inline-flex, which would break its text-overflow. Use it WITH a
|
||
site's own geometry (`rounded-full`, optional `border`) or on top of `.chip`, never bare. */
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
border-radius: 9999px;
|
||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||
background-color: color-mix(in srgb, var(--accent) 10%, transparent);
|
||
color: var(--accent);
|
||
}
|
||
.chip-solid {
|
||
background-color: var(--accent);
|
||
color: var(--accent-fg);
|
||
border-color: var(--accent);
|
||
}
|
||
/* .btn-{tone} — solid ACTION-button paint (the "primary/danger/warn action", distinct from the
|
||
selection recipes above which are "selected among options"). Each sets its tone fill + contrasting
|
||
fg; all share ONE standard hover-dim. Like .chip-solid this owns paint (+ that single hover
|
||
behaviour), NOT geometry — a site keeps its own padding/radius/font/gap/disabled:opacity/shadow.
|
||
Migrated sites MUST drop `bg-{tone} text-{tone}-fg hover:opacity-90` so these @layer-components
|
||
rules aren't beaten by those utilities. No focus-visible — @layer base already rings every button. */
|
||
.btn-primary {
|
||
background-color: var(--accent);
|
||
color: var(--accent-fg);
|
||
}
|
||
.btn-danger {
|
||
background-color: var(--danger);
|
||
color: var(--danger-fg);
|
||
}
|
||
.btn-warn {
|
||
background-color: var(--warn);
|
||
color: var(--warn-fg);
|
||
}
|
||
.btn-primary:hover:not(:disabled),
|
||
.btn-danger:hover:not(:disabled),
|
||
.btn-warn:hover:not(:disabled) {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* ===== E10 S1c · the minimum-target half of the 1024x768 comfort contract =====
|
||
A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and
|
||
not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on
|
||
this app's real second device — a tablet — is exactly where it is needed.
|
||
|
||
min-* rather than width/height so it can only ever grow a control, never shrink one, and so a
|
||
`w-8 h-8` utility on the same element still loses to it (min-width beats width in CSS).
|
||
Growing the BOX rather than painting an invisible ::after hit area is deliberate: an expanded
|
||
hit area on two icon buttons sitting 4px apart would overlap, and the overlap fires the WRONG
|
||
button — a worse failure than a small target. Real space cannot overlap.
|
||
|
||
2.75rem, not 44px: the whole app is rem-derived off `--font-scale`, so a target defined in px
|
||
would be the one control that ignores the user's text-size setting. */
|
||
@media (pointer: coarse) {
|
||
.touch-target {
|
||
min-width: 2.75rem;
|
||
min-height: 2.75rem;
|
||
}
|
||
}
|
||
|
||
/* ===== E10 S2 · rows that answer a right-click / long-press (useContextMenu) =====
|
||
The long-press path competes with two browser behaviours that fire at the same gesture:
|
||
iOS Safari's own callout (Copy / Share over a link or image) and the text-selection handles.
|
||
Both would appear ON TOP of our menu, so the attribute the hook stamps on the row turns them
|
||
off. Deliberately NOT `user-select: none` on a precise pointer — selecting a video title with
|
||
the mouse is legitimate, and only the touch path has the conflict. */
|
||
[data-context-menu-target] {
|
||
-webkit-touch-callout: none;
|
||
}
|
||
@media (pointer: coarse) {
|
||
[data-context-menu-target] {
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
}
|
||
}
|
||
/* `:not(:disabled)` for the same reason ui/Button prefixes every variant hover with `enabled:` — a
|
||
disabled control must not advertise a click that does nothing. The Tailwind half was gated in E10
|
||
review round 1 and this half was missed, so a disabled glass button still lifted (round 2). */
|
||
.glass-hover:not(:disabled):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);
|
||
}
|
||
|
||
/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
|
||
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
|
||
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
|
||
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
|
||
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
|
||
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
|
||
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
|
||
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
|
||
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
|
||
.reveal-idle {
|
||
transition: opacity 150ms ease;
|
||
}
|
||
@media (hover: hover) {
|
||
.reveal {
|
||
opacity: 0;
|
||
transition: opacity 150ms ease;
|
||
}
|
||
.group:hover .reveal,
|
||
.group:focus-within .reveal,
|
||
.reveal:focus-visible {
|
||
opacity: 1;
|
||
}
|
||
/* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard
|
||
focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same
|
||
corner for a keyboard user. */
|
||
.group:hover .reveal-idle,
|
||
.group:focus-within .reveal-idle {
|
||
opacity: 0;
|
||
}
|
||
}
|
||
@media (hover: none) {
|
||
.reveal-idle {
|
||
display: none;
|
||
}
|
||
/* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch),
|
||
a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an
|
||
invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…`
|
||
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
|
||
already reachable on touch another way — VideoCard's play overlay (tapping the card plays/resumes)
|
||
and the Carousel arrows (swiping scrolls the strip) — where a permanent scrim/edge button is not
|
||
wanted. Group-agnostic (encodes only the touch removal), so it works under any `.group`/`group\/…`. */
|
||
.reveal-hover {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* Full-viewport modal scrim blur, as a named recipe so perf mode can DROP it. This is the most
|
||
expensive blur in the app (viewport-sized); as a raw `backdrop-blur-xs` utility on each overlay
|
||
it survived data-perf while every .glass surface had its blur dropped. Overlays keep their own
|
||
bg-black/NN; this adds only the (perf-aware) 4px blur, matching backdrop-blur-xs. */
|
||
.overlay-backdrop {
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
html[data-perf="1"] .overlay-backdrop {
|
||
backdrop-filter: none;
|
||
}
|
||
|
||
/* Right-edge drawer (Modal placement="drawer"). The slide is what makes it read as a panel that
|
||
came from the side rather than a dialog that happens to be right-aligned — so it is part of the
|
||
affordance, not decoration. Honours prefers-reduced-motion, like every other motion here. */
|
||
.overlay-drawer {
|
||
animation: drawer-in 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||
}
|
||
@keyframes drawer-in {
|
||
from {
|
||
transform: translateX(1.5rem);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.overlay-drawer {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* ===== Semantic status colors (danger / warn / success) =====
|
||
Scheme-INDEPENDENT, theme-scoped: a status color is recognised by its conventional
|
||
hue (red=danger), so it stays constant across schemes and only shifts by theme to
|
||
hold contrast — bright on the near-black dark bg, darkened for ≥4.5:1 on the white
|
||
light bg. The scheme blocks above deliberately do NOT set these (lower specificity
|
||
here is fine — no scheme overrides them). Per-scheme override only if a real clash
|
||
surfaces. `-fg` is readable text ON a solid status fill. */
|
||
html[data-theme="dark"] {
|
||
--danger: #f87171;
|
||
--danger-fg: #2a0a0a;
|
||
--warn: #fbbf24;
|
||
--warn-fg: #1f1400;
|
||
--success: #34d399;
|
||
--success-fg: #052016;
|
||
--info: #60a5fa;
|
||
--info-fg: #08172e;
|
||
}
|
||
html[data-theme="light"] {
|
||
--danger: #dc2626;
|
||
--danger-fg: #ffffff;
|
||
--warn: #b45309;
|
||
--warn-fg: #ffffff;
|
||
--success: #047857;
|
||
--success-fg: #ffffff;
|
||
--info: #2563eb;
|
||
--info-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;
|
||
}
|