Files
siftlode/frontend/src/index.css
T
peter 3ada34073b fix(theme): text on glass holds 4.5:1 — light --muted kept at 6:1 on the page, tonal fills opaque
Pixel-measured over every scheme x mode x backdrop: the item count fell to 3.8:1 (dark too: its 8%
fill let the toolbar glass and the thumbnails under it through), Channels' tabs to 4.1:1 and the
current Files row to 4.4:1. Light --muted moves 15% toward --fg in every scheme; the badges, the item
count and the current rail row paint opaque bg-tint* fills mixed into the surface. The token contract
now asks 6:1 of --muted on the bare page and checks those three sites use the tints.
2026-10-17 20:06:54 +02:00

1263 lines
54 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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");
/* `overflow-clip` lives in exactly one place, a `.ts` constant (lib/useChipOverflowFit.ts). If source
detection ever stopped covering that file the utility would silently leave the bundle and the
1024px sideways-scroll bug on the chip lanes would come back with every lane green — so it is
anchored here too. */
@source inline("overflow-clip");
/* `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 {
/* A floating glass surface's shadow: the top specular highlight + the drop. One token so a field that
floats (the header search, `shadow-float`) keeps the highlight a menu has — a bare drop-shadow
utility would replace both. */
--shadow-float:
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);
--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);
/* A focus ring's colour: the accent, unless a scope over another ground names its own. */
--color-focus: var(--focus, var(--accent));
/* What marks a choice standing on its ground (a chosen row's tick, a chosen tab's underline): the
accent, unless a scope over another ground names its own. */
--color-chosen: var(--chosen, 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);
--color-rating: var(--rating);
--color-rating-media: var(--rating-media);
--color-live: var(--live);
--color-live-fg: var(--live-fg);
--color-media-chrome: var(--media-chrome);
--color-media-mark: var(--media-mark);
/* A tonal fill under text (a Badge, the item count, the current rail row): its colour mixed into the
surface, OPAQUE — a `bg-fg/8` lets the glass and whatever scrolls under it through, which pixel-measured
the item count down to 3.8:1. theme.contract.test.ts measures each one's text on it. */
--color-tint: color-mix(in srgb, var(--fg) 8%, var(--surface));
--color-tint-accent: color-mix(in srgb, var(--accent) 6%, var(--surface));
--color-tint-warn: color-mix(in srgb, var(--warn) 6%, var(--surface));
--color-tint-current: color-mix(in srgb, var(--accent) 12%, var(--surface));
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
/* `under` is not an app layer: a surface's own layer painted under its content, inside that surface's
`isolate` stacking context (a whole-card row's hover layer, DESIGN.md State layers). */
--z-index-under: -1;
--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;
}
/* 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 */
}
/* ===== Tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design
lab both read, so a decided value is never restated as an arbitrary class. ===== */
/* B3: `act:` — a hover or press that answers only a control that can act: not a disabled one, not a busy
one (FocusSafeButton's aria-disabled). `:disabled` never matches an <a>, so one variant serves a
button and a link alike. A literal variant, never a prefix glued on at runtime: Tailwind emits CSS
only for a class it finds WHOLE in the source, and the `${ACT}hover:…` the primitives used to build
shipped with no rule at all (ui/tailwindScan.contract.test.ts). */
@custom-variant act (&:not(:disabled, [aria-disabled="true"]));
/* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act:
not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose
solid accent is its state and must not turn grey under the pointer. */
@utility state-layer {
&:hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) {
background-color: var(--hover);
}
&:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) {
background-color: var(--press);
}
}
/* A3: the coarse-pointer target (the why is in the tablet note below). A caller's own `min-h-6` /
`min-w-6` (the mouse's 24 floor, B1) is a utility too and sorted after this one, so every control
that carried both stayed 24 on a tablet (measured): on a coarse pointer the target wins,
hence `!important`. (It would lower a caller's LARGER minimum; no control pairs it with one.) */
@utility touch-target {
@media (pointer: coarse) {
min-width: var(--touch-target) !important;
min-height: var(--touch-target) !important;
}
}
/* B3: a filled control mixes its own text colour into its fill (14% hover, 24% pressed). */
@utility state-filled {
background-color: var(--accent);
color: var(--accent-fg);
&:hover:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent));
}
&:active:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent));
}
}
@utility state-danger {
background-color: var(--danger);
color: var(--danger-fg);
&:hover:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger));
}
&:active:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger));
}
}
@utility state-warn {
background-color: var(--warn);
color: var(--warn-fg);
&:hover:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--warn-fg) 14%, var(--warn));
}
&:active:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--warn-fg) 24%, var(--warn));
}
}
/* A4: a control's ≥ 3:1 edge. */
@utility edge-strong {
border-color: var(--edge-strong);
}
/* The marks on the always-dark player chrome (a player, its menus and panels) — the focus ring and
what is chosen — are the media amber, as a light theme's accent is dark there (Slate 2.84:1).
Every class string that paints bg-media-chrome names it or media-scope (theme.contract.test.ts). */
@utility media-marks {
--focus: var(--media-mark);
--chosen: var(--media-mark);
}
/* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field
primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text
never lands on the dark bar. The accent stays the theme's for a choice FILLED with it (its own
text on it holds); a mark standing on the chrome itself is not left to it: a focus ring and a
chosen mark are the media amber and an error the media red, as a light theme's accent and danger
are dark (under 3:1 on the chrome). */
@utility media-scope {
--fg: #ffffff;
--muted: rgb(255 255 255 / 0.62);
--bg: var(--media-chrome);
--border: rgb(255 255 255 / 0.16);
--edge-strong: rgb(255 255 255 / 0.4);
/* A card on the chrome (a segmented control's track): a veil of it, never the theme's card — a
light theme's white under this scope's white text left the unchosen options unreadable. */
--card: rgb(255 255 255 / 0.08);
--danger: var(--media-danger);
--focus: var(--media-mark);
--chosen: var(--media-mark);
color: var(--fg);
}
/* The reading measure for prose (a synopsis, a biography, a channel's description): ~65 characters
a line, inside the 45–75 a reader follows back to the next line's start. A full-width page ran
one to 110 (owner: "shorter lines are easier to follow"). Only where the container can
be wider than that; a narrow panel or popover is its own measure. */
@utility measure {
max-width: 65ch;
}
/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
/* A STICKY header's fade (a table head, the server log's toolbar), inside the scroller it sticks in.
It shows only once content has slid under it: at rest it lay over the first row's top 16 px,
dimming it and hiding the top edge of its keyboard ring (seen live). It fades in over the
first 1rem of scroll by a scroll-driven animation; an engine without one draws no fade rather than
a fade over the resting first row. */
@keyframes fade-edge-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@utility fade-below {
&::after {
content: "";
pointer-events: none;
position: absolute;
inset-inline: 0;
top: 100%;
height: 1rem;
background: linear-gradient(to bottom, var(--bg), transparent);
opacity: 0;
}
@supports (animation-timeline: scroll()) {
&::after {
animation: fade-edge-in linear both;
animation-timeline: scroll(nearest block);
animation-range: 0 1rem;
}
}
}
/* A panel's fixed header and footer (C4: a dialog's). The scroller between them is the
`panel-scroller`; the panel names its timeline for both edges with `panel-scroll-scope`, since they
sit outside it. The header's fade shows once content has slid under it (the first 1rem of scroll);
the footer's while there is more below (it fades out over the last 1rem). A body that fits has no
scroll range, its timeline is inactive and neither fade draws — nor does an engine without scroll
timelines: no fade rather than one over a resting first or last line. */
@utility panel-scroll-scope {
timeline-scope: --panel-scroll;
}
@utility panel-scroller {
scroll-timeline: --panel-scroll block;
}
@keyframes fade-edge-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@utility fade-below-panel {
&::after {
content: "";
pointer-events: none;
position: absolute;
inset-inline: 0;
top: 100%;
height: 1rem;
background: linear-gradient(to bottom, var(--surface), transparent);
opacity: 0;
}
@supports (timeline-scope: none) {
&::after {
animation: fade-edge-in linear both;
animation-timeline: --panel-scroll;
animation-range: 0 1rem;
}
}
}
@utility fade-above-panel {
&::before {
content: "";
pointer-events: none;
position: absolute;
inset-inline: 0;
bottom: 100%;
height: 1rem;
background: linear-gradient(to top, var(--surface), transparent);
opacity: 0;
}
@supports (timeline-scope: none) {
&::before {
animation: fade-edge-out linear both;
animation-timeline: --panel-scroll;
animation-range: calc(100% - 1rem) 100%;
}
}
}
/* B9: the clipped ends of an overflowing chip row. */
@utility fade-edges-x {
mask-image: linear-gradient(
to right,
transparent,
#000 1.75rem,
#000 calc(100% - 1.75rem),
transparent
);
}
/*
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);
}
}
/* 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 this; this is the single biggest a11y win. */
@layer base {
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
[role="tab"]:focus-visible,
[role="slider"]:focus-visible,
summary:focus-visible {
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. A scope over another
ground (media-scope) may name its own ring colour in --focus. */
outline: 2px solid var(--focus, var(--accent));
outline-offset: 2px;
}
/* ui/FocusSafeButton's soft-disabled state (a focused button its caller disabled, or a busy one):
`aria-disabled` instead of the native attribute, so a caller's `disabled:*` utilities do not see
it. One look for all of them, in base so a component's own aria-disabled style wins. */
[data-focus-safe][aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
}
: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="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="dark"][data-scheme="fjord"] 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/fjord.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="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="light"][data-scheme="fjord"] 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/fjord.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);
/* State layers (docs/DESIGN.md B3): hover and pressed are the text colour laid over whatever the
control sits on — the page, a card or glass — so they read in every scheme and theme (it lightens
on dark, darkens on light). Never an opacity dim or a bg-card swap (invisible on a light card).
Applied through the state-layer / state-filled / state-danger utilities below. */
--hover: color-mix(in srgb, var(--fg) 10%, transparent);
--press: color-mix(in srgb, var(--fg) 16%, transparent);
/* A control's edge (DESIGN.md A4): ≥ 3:1 against the page in every scheme and mode, where --border
(a decorative divider) is not. theme.contract.test.ts holds the ratio. */
--edge-strong: color-mix(in srgb, var(--fg) 55%, var(--bg));
/* The coarse-pointer target (DESIGN.md A3): one token, read by .touch-target and the primitives. */
--touch-target: 2.75rem;
}
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, 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: var(--shadow-float);
}
/* 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;
}
/* A list/grid item the keyboard landed on (`lib/listNav`): the SAME solid ring, around the
whole card rather than one control inside it. 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;
}
/* An item with no room round it rings inside its own box — outside, the ring ran under a sticky
table head and past the table's sides, or a clipping card cut it away and the focus vanished.
Every table row is such an item, by its tag; any other says so (`navItemAttrs(…, { inset: true })`:
a log line, a row flush in a clipping card). */
:is(tr, [data-nav-inset])[data-nav-index],
:is(tr, [data-nav-inset])[data-nav-index]:focus-visible,
:is(tr, [data-nav-inset])[data-nav-index]:has([data-nav-open]:focus-visible) {
outline-offset: -2px;
}
/* ===== 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), `aria-selected` (a tab,
ui/Tabs) 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 (a 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 {
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) {
border-color: var(--border);
}
.pill:hover {
color: var(--fg);
}
.pill:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
/* selected — SOLID accent fill (the dominant idiom). (The nav rail's "you are here" is ui/NavRail's
tint + edge bar now; its old `.is-current` recipe is gone.) */
.pill[aria-pressed="true"],
.pill[role="tab"][aria-selected="true"] {
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);
}
/* ===== The minimum-target half of the tablet comfort contract (docs/DESIGN.md) =====
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.
(The rule itself is the `touch-target` utility near the top of this file.) */
/* ===== 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 first
and this half was missed, so a disabled glass button still lifted.
`:not([aria-disabled="true"])` too: a FocusSafeButton that holds the focus is disabled by
aria-disabled, not the native attribute (debt burn 2026-09), and lifted again until this. */
.glass-hover:not(:disabled):not([aria-disabled="true"]):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 keyboard-focused (`:focus-visible` on it or inside it — not any focus: a card handed its focus
back after a mouse trip kept its row up); a touch device keeps the default opacity:1 and the
control is always shown. Requires a `.group` ancestor (Tailwind's group marker). */
@media (hover: hover) {
.reveal {
opacity: 0;
transition: opacity 150ms ease;
}
.group:hover .reveal,
.group:focus-visible .reveal,
.group:has(:focus-visible) .reveal,
.reveal:focus-visible {
opacity: 1;
}
/* The mirror of `.reveal-hover`: a control that exists ONLY on touch, because a mouse reaches the
same action another way — a video or Plex card's "…" beside the title, whose mouse twin sits
in the hover row on the picture (DESIGN.md C1, `ui/reveal.ts`). `display: none`, so it is no
Tab stop either. */
.touch-only {
display: none;
}
}
@media (hover: 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-visible:…`
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
already reachable on touch another way — a card's hover row (its "…" beside the title opens the
same actions: `ui/reveal.ts`), 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;
}
}
.overlay-drawer-start {
animation: drawer-in-start 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes drawer-in-start {
from {
transform: translateX(-1.5rem);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.overlay-drawer,
.overlay-drawer-start {
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;
}
/* A Plex poster card in a grid: the same skip, at a poster's height. */
.cv-poster {
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}
/* ...but not in a carousel. A rail stretches every card to its tallest (the pinned meta line,
ui/MediaCard), and an off-screen card's reserved box WAS the tallest: Home's shelves measured 338px
where the real cards were 170–216. A rail holds a handful of cards; there is nothing to skip. */
.media-rail .cv-card,
.media-rail .cv-poster {
content-visibility: visible;
}
.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;
}
}
/* ===== 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: #515c79;
--accent: #3b5bdb;
--accent-fg: #ffffff;
}
/* Forest — dark green-slate with a cyan accent (DESIGN.md B2: teal read as success) */
html[data-scheme="forest"][data-theme="dark"] {
--bg: #0a1512;
--surface: #0f1f1a;
--card: #12241e;
--border: #1f3a30;
--fg: #e6f0ec;
--muted: #90b1a4;
--accent: #22d3ee;
--accent-fg: #04110d;
}
html[data-scheme="forest"][data-theme="light"] {
--bg: #f1f7f4;
--surface: #ffffff;
--card: #ffffff;
--border: #d6e7e0;
--fg: #102a22;
--muted: #446157;
--accent: #0e7490;
--accent-fg: #ffffff;
}
/* Slate — neutral grey with a fuchsia accent (DESIGN.md B2: orange read as warn) */
html[data-scheme="slate"][data-theme="dark"] {
--bg: #15171c;
--surface: #1b1e25;
--card: #1f232b;
--border: #2c313b;
--fg: #e7e9ee;
--muted: #9aa1ad;
--accent: #e879f9;
--accent-fg: #1a0620;
}
html[data-scheme="slate"][data-theme="light"] {
--bg: #f6f7f9;
--surface: #ffffff;
--card: #ffffff;
--border: #e3e6eb;
--fg: #1c1f26;
--muted: #535963;
--accent: #a21caf;
--accent-fg: #ffffff;
}
/* Matrix — near-black with a lime accent (DESIGN.md B2: the terminal green read as success) */
/* Matrix is moss, not neon (owner, 2026-09-24): the accent sits at Midnight's lightness (OKLCH L 0.67)
with half the old lime's chroma, and the neutrals are barely tinted, so the page doesn't glow green. */
html[data-scheme="matrix"][data-theme="dark"] {
--bg: #0b0e0b;
--surface: #111510;
--card: #161b15;
--border: #2a3226;
--fg: #d9ded6;
--muted: #8f9a8a;
--accent: #86a05a;
--accent-fg: #0c1208;
}
html[data-scheme="matrix"][data-theme="light"] {
--bg: #f4f5f0;
--surface: #ffffff;
--card: #ffffff;
--border: #dfe3d8;
--fg: #161a14;
--muted: #505849;
--accent: #556b2f;
--accent-fg: #ffffff;
}
/* Fjord — the one scheme outside B2's "own hue family" (docs/DESIGN.md decision log, 2026-09-25 trial,
kept 2026-09-27): Midnight's navy with Playnite's calm — a muted steel-blue accent and steel rules
instead of the vivid periwinkle. It shares Midnight's hue family on purpose: the owner lived with both
and kept both, the vivid and the calm. Playnite's own accent (#337599) is 3.7:1 as text, so the accent
is the same hue lifted to 6.5:1. */
html[data-scheme="fjord"][data-theme="dark"] {
--bg: #0d1225;
--surface: #121931;
--card: #161e38;
--border: #25506e;
--fg: #eef1f5;
--muted: #9fb0c4;
--accent: #5aa1cb;
--accent-fg: #0d1225;
}
html[data-scheme="fjord"][data-theme="light"] {
--bg: #f3f6fa;
--surface: #ffffff;
--card: #ffffff;
--border: #c9d6e3;
--fg: #152232;
--muted: #46586e;
--accent: #256b8f;
--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;
--rating: #fbbf24;
}
html[data-theme="light"] {
/* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted
light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */
--danger: #b91c1c;
--danger-fg: #ffffff;
/* A shade under amber-700 (#b45309): that one was 4.58:1 as text on the tinted pages and fell under
4.5 on a warn Badge's own tint (F29). */
--warn: #a14a07;
--warn-fg: #ffffff;
--success: #047857;
--success-fg: #ffffff;
--info: #2563eb;
--info-fg: #ffffff;
--rating: #854d0e; /* yellow-800: yellow-700 fell to 3.7:1 on its own 15% chip */
}
/* ===== Media colours (F16) =====
Theme-INDEPENDENT: they sit on a picture (a thumbnail, a poster's scrim, the player), and the
picture does not change with the theme. `--rating` above is the page's copy of the rating gold, so it
is theme-scoped like a status; it is not a status (a score is not a warning), hence its own token.
theme.contract.test.ts measures all of them. */
:root {
--live: #dc2626; /* a broadcast's red, under --live-fg; ≥ 3:1 as a glyph on every page */
--live-fg: #ffffff;
--rating-media: #fcd34d; /* a score on a poster's black scrim */
--media-chrome: #171717; /* the always-dark player's menus and bars, under white text */
/* A place marked on that chrome or over a video (a playhead, a focused end's ring, the crop
frame's ring, an intro / credits marker): amber in both themes, as the chrome is dark in both. */
--media-mark: #fbbf24;
/* An error said on that chrome (a time field the clip panel cannot read): its edge and its line. */
--media-danger: #f87171;
}
/* 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;
/* Stretched between the insets, whatever the layout asks of the in-flow stage: the side-panel
layout aligns it `self-start`, and Chrome applies a flex item's self-alignment to an absolutely
positioned box too — the fixed stage then took its content's height, 0 px (its content is all
absolutely positioned): a thin line across the window instead of the video. */
align-self: stretch;
justify-self: stretch;
/* rail level: this stage only has to beat the modal chrome it is nested inside (whose own
children top out at the menu level). The modal root is a z-overlay stacking context, so nothing
here escapes it — a higher level would claim an authority this layer neither has nor needs. */
z-index: var(--z-index-rail);
}
/* …and it only fills the viewport while no ancestor has a filter: a `backdrop-filter` makes that
ancestor the fixed stage's containing block. The modal's card is a blurred `.glass` panel,
so while the stage is maximised the card drops its blur — the stage covers the card anyway. Any
depth: the stage sits in a wrapper with the clip panel. `html `
outranks `html[data-theme="dark"] .glass`, which sets the blur again (lib/playerStage.contract.test.ts). */
html .glass:has(.player-stage--max) {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Thin, theme-aware scrollbars */
* {
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 8px;
}