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.
1263 lines
54 KiB
CSS
1263 lines
54 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");
|
||
|
||
/* `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;
|
||
}
|