Files
siftlode/frontend/src/index.css
T
peter 23ac884b42 fix(e4): address deep-review findings — bookmark popstate, focus trap, touch a11y
Deep-review (6 dimensions, 3-verifier adversarial pass) surfaced 7 findings, all
verified; fixed here.

- NavigationProvider + history.ts (medium, correctness): the popstate handlers
  restored the open channel / Plex sub-view from history.state ONLY, but a fresh
  ?chan / ?v bookmark lives in the URL with no marker in state — so any overlay's
  programmatic history.back() (closing a modal/player) silently closed the
  bookmarked channel or collapsed the sub-view to the grid. Give onPop the same
  URL fallback the initializers already use (history.ts extracts a shared readView
  so seed and popstate can't diverge again).
- plexCards SeasonCard (medium, a11y): the "In progress" season badge was a
  reveal-idle swap-badge → display:none on touch, and a season has no progress bar,
  so an in-progress season was indistinguishable from unwatched on touch. Moved it
  to the top-left (the toggle owns top-right) and made it always visible.
- plexCards watched toggle (low, a11y / WCAG 1.4.1): watched state was color-only
  on touch (green vs white CheckCircle2). New WatchedIcon distinguishes by SHAPE —
  CheckCircle2 (watched) vs an empty Circle (not) — used by all three card toggles.
- Modal.tsx (low, a11y): the focus trap leaked on Shift+Tab when focus rested on
  the panel container itself (Node.contains(self) is true, so the pull-back branch
  never ran). Guard now also checks `active === panel`.
- index.css (low, a11y): .reveal-idle faded only on .group:hover though .reveal is
  revealed on hover AND focus-within → the badge overlapped the control on keyboard
  focus. Added the focus-within fade.
- PlexBrowse.tsx (low, correctness): the ?v=playlist decoder accepted a non-integer
  id (Number.isFinite); tightened to Number.isInteger(n) && n > 0, matching the ?pl
  guard.

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): ?v=show and
?chan bookmarks survive a modal open+close (were collapsing/closing before); Modal
Shift+Tab from the panel stays inside; in-progress season badge visible on touch;
watched toggle shows an empty-circle-vs-check shape difference; 0 console errors.
2026-08-20 03:26:19 +02:00

933 lines
36 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");
/* `inline` is load-bearing: the color vars (--bg/--accent/… below) are redefined at
runtime per html[data-scheme]/[data-theme], so utilities must inline the value and
resolve it at the USE site, not reference a once-computed --color-* token. This also
makes the v4 opacity modifier (bg-accent/40 → color-mix(… var(--accent) 40% …)) pick
up the active scheme's color. Same pattern as shadcn's v4 theme setup. */
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-card: var(--card);
--color-border: var(--border);
--color-fg: var(--fg);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-fg: var(--accent-fg);
--color-info: var(--info);
--color-info-fg: var(--info-fg);
--color-danger: var(--danger);
--color-danger-fg: var(--danger-fg);
--color-warn: var(--warn);
--color-warn-fg: var(--warn-fg);
--color-success: var(--success);
--color-success-fg: var(--success-fg);
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--z-index-base: 10;
--z-index-menu: 20;
--z-index-chrome: 30;
--z-index-paneltab: 34;
--z-index-panel: 35;
--z-index-rail: 40;
--z-index-overlay: 50;
--z-index-popover: 60;
--z-index-tooltip: 100;
--z-index-tuner: 9999;
}
/* Extra-small text steps in rem, so these labels scale with --font-scale like every other
text (the ~124 px-frozen text-[10/11px] labels did NOT). Font-size ONLY — no paired
--text-*--line-height — to match the `text-[Npx]` arbitraries they replace and avoid any
vertical-rhythm shift.
Convention: the two COMMON small steps get names (below); the rare one-offs (12/13/8/9px)
stay as `text-[Nrem]` arbitraries rather than growing the token set — DON'T use `text-xs`
for 12px, it carries a line-height these labels never had. New small labels: reach for a
token first, an arbitrary only for a genuinely one-off size. */
@theme {
--text-2xs: 0.6875rem; /* 11px @ base — the most common frozen label */
--text-3xs: 0.625rem; /* 10px @ base */
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
/* R9 focus foundation: one accent focus ring for keyboard users on every interactive element that
doesn't define its own. In @layer base so any component/utility focus style (.selectable, an input's
focus:ring, a menu item's data-active highlight + outline-hidden) overrides it. Non-input controls
app-wide had no visible keyboard focus before R9; this is the single biggest a11y win. */
@layer base {
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
[role="tab"]:focus-visible,
summary:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: 2px;
}
}
:root {
--font-scale: 1.06;
/* Vertical space the floating top header needs to clear (top offset ~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;
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
tuned in ONE place. ===== */
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
(nav/header/dialogs/filters/settings — where there's nothing behind the glass to refract).
Rich-backdrop surfaces override these via `.glass-media` below. */
--glass-blur: 20px; /* backdrop blur radius */
--glass-saturate: 1.8; /* backdrop saturation */
--glass-dark-bright: 1.25; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */
--glass-surface-alpha: 94%; /* .glass panel translucency (higher = more solid = "adaptive") */
--glass-card-alpha: 94%; /* .glass-card translucency */
--glass-menu-alpha: 98%; /* .glass-menu translucency */
--glass-border-alpha: 78%; /* .glass border opacity */
--glass-inset: 16%; /* top specular highlight strength (#fff N%) */
--glass-edge: 10%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */
--glass-scrim: 14%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */
--ambient: 1.1; /* multiplier on the ambient accent backdrop pools */
/* Per-scheme background image dim (see frontend/public/backdrops/): how strongly the faded --bg
overlay hides the image — higher = fainter. Only in effect while data-backdrop="on". */
--bg-fade: 60%;
}
/* ===== "Glass over image" — the translucent tier. Applies whenever a
real image sits behind the surfaces: app-wide when the per-scheme background image is on
(`html[data-backdrop="on"]`), and on the Plex art-backed detail views (`.glass-media`, which
have their own art backdrop even when the global image is off). When there's nothing behind the
glass (image off / perf), the :root defaults keep it solid & readable. ===== */
/* App-wide background image. Chrome stays readable — panels (.glass surface) near-opaque at 90% and
menus fully solid at 100% (a popover over the feed must not read through) — while the FEED CARDS
(.glass-card) are intentionally translucent at 50% so the per-scheme backdrop image gently shows
THROUGH the cards without hurting text. SEPARATE from .glass-media below (Plex art tier). (Tuned by
the user, 2026-08-09: 90/50/100 + inset 10 — "the image just barely shows through, text still
readable". Supersedes the 2026-08-08 uniform-88% pass, which killed the image-through-cards look.) */
html[data-backdrop="on"] {
--glass-blur: 22px;
--glass-saturate: 1.6;
--glass-dark-bright: 1.2;
--glass-surface-alpha: 90%;
--glass-card-alpha: 50%;
--glass-menu-alpha: 100%;
--glass-border-alpha: 80%;
--glass-edge: 25%;
--glass-inset: 10%;
--glass-scrim: 30%;
}
/* Plex art-backed detail views: the glass sits over the item's OWN poster/art, which is MEANT to show
through richly. Its PANELS/MENUS are more translucent than the app-wide tier above (72/85 vs 90/100),
but its cards are actually MORE solid (80 vs the app-wide 50) — the app-wide feed deliberately lets
its backdrop show through the cards, while here the poster reads through the panels instead. */
.glass-media {
--glass-blur: 14px;
--glass-saturate: 1.6;
--glass-dark-bright: 1.2;
--glass-surface-alpha: 72%;
--glass-card-alpha: 80%;
--glass-menu-alpha: 85%;
--glass-border-alpha: 80%;
--glass-edge: 25%;
--glass-inset: 15%;
--glass-scrim: 30%;
}
/* Light theme carries no per-scheme background image, so the near-opaque :root (adaptive) glass
reads as flat white and every panel becomes an opaque island. Give light its OWN translucent tier
— one central override, every .glass* surface — so the ambient tint (and content) shows through
and glass stays glassy in light too. (Perf mode below still forces solid + no blur.) */
html[data-theme="light"] {
--glass-surface-alpha: 62%;
--glass-card-alpha: 74%;
--glass-menu-alpha: 86%;
}
/* Per-scheme background image, painted on <body> (the app root is transparent so it shows through
the whole app and the glass refracts it). A faded --bg overlay keeps it subtle; `fixed` so it
doesn't scroll. Dark theme uses the dark backdrops, light theme the lighter set in /light/. Only
when the backdrop is on (the "Background image" setting; off in perf). The [data-theme] qualifier
also out-specifies the ambient `background` shorthand (which would else reset background-size). */
html[data-backdrop="on"][data-theme="dark"][data-scheme="midnight"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/midnight.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="forest"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/forest.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/slate.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/youtube.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/starship.svg");
}
html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/matrix.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="midnight"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/midnight.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="forest"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/forest.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/slate.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/youtube.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/starship.svg");
}
html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body {
background-image:
linear-gradient(
color-mix(in srgb, var(--bg) var(--bg-fade), transparent),
color-mix(in srgb, var(--bg) var(--bg-fade), transparent)
),
url("/backdrops/light/matrix.svg");
}
html[data-backdrop="on"][data-theme="dark"] body,
html[data-backdrop="on"][data-theme="light"] body {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
}
/* Make native controls (date picker, scrollbars) follow the theme. */
html[data-theme="dark"] {
color-scheme: dark;
}
html[data-theme="light"] {
color-scheme: light;
}
html {
/* Snap the scaled root font-size to a whole pixel. The base is rem-derived app-wide, so a fractional
root (e.g. 16px * 1.02 = 16.32px) makes every box height fractional — and when a reflow changes a
section's row count, the sub-pixel remainder shifts everything below it by ~0.5px, a visible 1px
jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */
font-size: round(calc(16px * var(--font-scale)), 1px);
background: var(--bg);
/* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`),
so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens
on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every
hoverable row (the .selectable recipe + DataTable rows). */
--hover: color-mix(in srgb, var(--fg) 9%, transparent);
}
body {
margin: 0;
color: var(--fg);
/* Ambient backdrop so translucent "glass" surfaces have soft color to refract. Kept
subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav,
header and dialogs are all frosted glass and refract it. */
background:
radial-gradient(
1100px 620px at 10% -10%,
color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent),
transparent 60%
),
radial-gradient(
1000px 720px at 115% 10%,
color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent),
transparent 55%
),
radial-gradient(
900px 640px at 50% 118%,
color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent),
transparent 60%
),
var(--bg);
background-attachment: fixed;
}
/* Hide the native scrollbar while keeping the element scrollable (wheel/trackpad/keyboard).
Used on the nav rail so a high browser-zoom overflow scrolls without showing a scrollbar. */
.no-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* legacy Edge/IE */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chromium/WebKit */
}
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
/* In @layer components so the recipe classes below (.selectable) compose with utilities instead of
silently beating them the way these rules did while unlayered. (The ~9 dead `shadow-*` that used to
sit on glass surfaces were REMOVED in R9, not activated: the tuned glass box-shadow — including its
inset top-edge highlight — is the intended depth, and a shadow-* utility would atomically replace it
and drop that highlight.) The perf-mode and :hover overrides below stay UNLAYERED on purpose. */
@layer components {
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
through (the glassy look), opaque enough that the background isn't distracting.
The strong blur turns whatever is behind into soft colour, not a sharp image.
Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 65%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
}
.glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable — only a hint of translucency + the blur. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 80%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
/* R9: standard interactive-row recipe. The hover tint menu/list rows already use, PLUS the
app-wide focus-visible ring that was missing on non-input controls (buttons, rows, menu items) —
the biggest single a11y win. In @layer components so a consumer's own utilities still win; the
accent ring renders correctly under v4 (color-mix), matching the input ring in ui/form.tsx. */
.selectable {
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.selectable:hover {
background-color: var(--hover);
}
.selectable:focus-visible {
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: -2px;
}
/* ===== R10 S4 · Selection-paint recipes =====
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
which drifted over time. These recipes OWN that paint, keyed on the semantic ARIA the control
already carries — the JSX just toggles `aria-pressed` (a segment/toggle) or `aria-current` (the
single current item) and the CSS paints, so one control's selected look can never diverge from
the next's. Geometry (padding / radius / border-width / font) stays a per-site utility since it
legitimately varies. NO focus-visible here on purpose: the `@layer base` rule above already rings
every button/a/[role]; re-adding it would duplicate that (an R9 review finding). Because these sit
in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
.pill,
.is-current {
color: var(--muted);
transition:
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease; /* box-shadow: an on-state shadow (Tabs) fades; transform: an active:translate press eases */
}
/* Resting border colour for the plain pills. NOT applied to a glass-card-resting pill (`.pill.glass-card`),
whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the
site also adds a border-width utility. */
.pill:not(.glass-card),
.is-current {
border-color: var(--border);
}
.pill:hover,
.is-current:hover {
color: var(--fg);
}
.pill:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.is-current:hover {
background-color: var(--card);
}
/* selected / current — SOLID accent fill (the dominant idiom). The `:not([aria-current="false"])`
guard matters: `.is-current` is a shared primitive and a consumer using the common
`aria-current={x ? "true" : "false"}` idiom would otherwise paint a NON-current row as current. */
.pill[aria-pressed="true"],
.is-current[aria-current]:not([aria-current="false"]) {
background-color: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
/* selected — SOFT tint variant (the plex layout toggle keeps a glass-card resting look, so its
"on" state is a subtle accent tint rather than the solid fill). */
.pill[aria-pressed="true"][data-soft="true"] {
background-color: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
/* .chip — an applied tag / filter token (rounded, tinted, self-contained: display + geometry +
paint). The tint is the resting look (a filter is "on" whenever the chip exists); removable
variants add their own × button as a child.
.chip-solid is intentionally a PAINT-ONLY modifier (bg/text/border colour, no display or
geometry): it sits on a site's OWN chip geometry so a truncating single-element chip
(ChannelTags) isn't forced into inline-flex, which would break its text-overflow. Use it WITH a
site's own geometry (`rounded-full`, optional `border`) or on top of `.chip`, never bare. */
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 9999px;
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
background-color: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.chip-solid {
background-color: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
/* .btn-{tone} — solid ACTION-button paint (the "primary/danger/warn action", distinct from the
selection recipes above which are "selected among options"). Each sets its tone fill + contrasting
fg; all share ONE standard hover-dim. Like .chip-solid this owns paint (+ that single hover
behaviour), NOT geometry — a site keeps its own padding/radius/font/gap/disabled:opacity/shadow.
Migrated sites MUST drop `bg-{tone} text-{tone}-fg hover:opacity-90` so these @layer-components
rules aren't beaten by those utilities. No focus-visible — @layer base already rings every button. */
.btn-primary {
background-color: var(--accent);
color: var(--accent-fg);
}
.btn-danger {
background-color: var(--danger);
color: var(--danger-fg);
}
.btn-warn {
background-color: var(--warn);
color: var(--warn-fg);
}
.btn-primary:hover:not(:disabled),
.btn-danger:hover:not(:disabled),
.btn-warn:hover:not(:disabled) {
opacity: 0.9;
}
}
.glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent),
0 14px 30px -14px rgba(0, 0, 0, 0.5);
}
/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
.reveal-idle {
transition: opacity 150ms ease;
}
@media (hover: hover) {
.reveal {
opacity: 0;
transition: opacity 150ms ease;
}
.group:hover .reveal,
.group:focus-within .reveal,
.reveal:focus-visible {
opacity: 1;
}
/* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard
focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same
corner for a keyboard user. */
.group:hover .reveal-idle,
.group:focus-within .reveal-idle {
opacity: 0;
}
}
@media (hover: none) {
.reveal-idle {
display: none;
}
}
/* 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;
}
/* Skip layout/paint for off-screen feed cards so long lists stay smooth while
scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar
stays stable; `auto` lets the browser remember each card's real size after it
has rendered once. */
.cv-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
.cv-row {
content-visibility: auto;
contain-intrinsic-size: auto 96px;
}
/* ===== Motion ===== */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes panelIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes popIn {
from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
/* One-shot attention flash for a chat dock window on a new message. Inset glow so the
window's own overflow-hidden doesn't clip it; pulses twice then fades. */
@keyframes chatFlash {
0% {
box-shadow: inset 0 0 0 0 transparent;
}
18% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
40% {
box-shadow: inset 0 0 0 1px transparent;
}
62% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
100% {
box-shadow: inset 0 0 0 0 transparent;
}
}
.chat-flash {
animation: chatFlash 1.1s ease-out;
}
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
/* Midnight — deep navy/slate with blue accent (default) */
html[data-scheme="midnight"][data-theme="dark"] {
--bg: #0b1020;
--surface: #121a2e;
--card: #161f38;
--border: #243049;
--fg: #e6e9f0;
--muted: #97a3c0;
--accent: #6d8cff;
--accent-fg: #0b1020;
}
html[data-scheme="midnight"][data-theme="light"] {
--bg: #f4f6fc;
--surface: #ffffff;
--card: #ffffff;
--border: #dde3f0;
--fg: #1a2238;
--muted: #5b6685;
--accent: #3b5bdb;
--accent-fg: #ffffff;
}
/* Forest — dark slate with teal/green accent */
html[data-scheme="forest"][data-theme="dark"] {
--bg: #0a1512;
--surface: #0f1f1a;
--card: #12241e;
--border: #1f3a30;
--fg: #e6f0ec;
--muted: #90b1a4;
--accent: #2dd4bf;
--accent-fg: #04110d;
}
html[data-scheme="forest"][data-theme="light"] {
--bg: #f1f7f4;
--surface: #ffffff;
--card: #ffffff;
--border: #d6e7e0;
--fg: #102a22;
--muted: #4d6b60;
--accent: #0d9488;
--accent-fg: #ffffff;
}
/* Slate — neutral grey with warm orange accent (muted in dark) */
html[data-scheme="slate"][data-theme="dark"] {
--bg: #15171c;
--surface: #1b1e25;
--card: #1f232b;
--border: #2c313b;
--fg: #e7e9ee;
--muted: #9aa1ad;
--accent: #e8913c;
--accent-fg: #1a1206;
}
html[data-scheme="slate"][data-theme="light"] {
--bg: #f6f7f9;
--surface: #ffffff;
--card: #ffffff;
--border: #e3e6eb;
--fg: #1c1f26;
--muted: #5d636e;
--accent: #d97706;
--accent-fg: #ffffff;
}
/* YouTube — near-black with red accent */
html[data-scheme="youtube"][data-theme="dark"] {
--bg: #0f0f0f;
--surface: #181818;
--card: #1f1f1f;
--border: #303030;
--fg: #f1f1f1;
--muted: #aaaaaa;
--accent: #ff3b46;
--accent-fg: #ffffff;
}
html[data-scheme="youtube"][data-theme="light"] {
--bg: #ffffff;
--surface: #f9f9f9;
--card: #ffffff;
--border: #e5e5e5;
--fg: #0f0f0f;
--muted: #606060;
--accent: #ff0033;
--accent-fg: #ffffff;
}
/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */
html[data-scheme="starship"][data-theme="dark"] {
--bg: #050b16;
--surface: #0b1728;
--card: #101f36;
--border: #1f3557;
--fg: #dfeaff;
--muted: #8aa6cc;
--accent: #38b6ff;
--accent-fg: #04101f;
}
html[data-scheme="starship"][data-theme="light"] {
--bg: #eef4fb;
--surface: #ffffff;
--card: #ffffff;
--border: #d3e0f0;
--fg: #0b1b30;
--muted: #4a5f7d;
--accent: #0b6bd6;
--accent-fg: #ffffff;
}
/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */
html[data-scheme="matrix"][data-theme="dark"] {
--bg: #030a06;
--surface: #08130d;
--card: #0b1c12;
--border: #163d27;
--fg: #cfe8d6;
--muted: #6fae86;
--accent: #46b87c;
--accent-fg: #04170c;
}
html[data-scheme="matrix"][data-theme="light"] {
--bg: #f0f7f1;
--surface: #ffffff;
--card: #ffffff;
--border: #cfe6d6;
--fg: #08130b;
--muted: #4c6b56;
--accent: #12a150;
--accent-fg: #ffffff;
}
/* ===== Semantic status colors (danger / warn / success) =====
Scheme-INDEPENDENT, theme-scoped: a status color is recognised by its conventional
hue (red=danger), so it stays constant across schemes and only shifts by theme to
hold contrast — bright on the near-black dark bg, darkened for ≥4.5:1 on the white
light bg. The scheme blocks above deliberately do NOT set these (lower specificity
here is fine — no scheme overrides them). Per-scheme override only if a real clash
surfaces. `-fg` is readable text ON a solid status fill. */
html[data-theme="dark"] {
--danger: #f87171;
--danger-fg: #2a0a0a;
--warn: #fbbf24;
--warn-fg: #1f1400;
--success: #34d399;
--success-fg: #052016;
--info: #60a5fa;
--info-fg: #08172e;
}
html[data-theme="light"] {
--danger: #dc2626;
--danger-fg: #ffffff;
--warn: #b45309;
--warn-fg: #ffffff;
--success: #047857;
--success-fg: #ffffff;
--info: #2563eb;
--info-fg: #ffffff;
}
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@keyframes toastbar {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
/* The in-app player stage filling the screen: either because something fullscreened it (YouTube's
own button hands the lock to the iframe, not to us — but keep this for any browser that does),
or via our own F-key maximise. `--max` deliberately does NOT use the Fullscreen API: while we
hold the browser's lock, the cross-origin YouTube embed never learns it is fullscreen and its own
fullscreen button stops working (see PlayerModal.toggleFullscreen). */
.player-stage:fullscreen,
.player-stage--max {
aspect-ratio: auto;
border-radius: 0;
}
/* A fullscreened element is already sized by the browser; the viewport units are only for the
`:fullscreen` path, where the stage is not positioned. */
.player-stage:fullscreen {
width: 100vw;
height: 100vh;
}
/* Sizing, precisely: the element keeps Tailwind's `w-full` from its base className, so the WIDTH is
`100%` of the containing block — which, for a fixed element, is the viewport minus any classic
scrollbar. The HEIGHT comes from `inset: 0` (top+bottom, once `aspect-ratio` is released above).
`right: 0` is therefore ignored (over-constrained: left + width win) — harmless, but don't read
this rule as inset-sized. What had to go are the viewport units it used to inherit: `100vw`
INCLUDES the scrollbar (horizontal overflow) and `100vh` includes a mobile browser's retracting
chrome, which pushes the bottom edge — YouTube's own control bar — off screen. */
.player-stage--max {
position: fixed;
inset: 0;
/* rail-level (40): this stage only has to beat the modal chrome it is nested inside (whose own
children top out at z-menu/20). The modal root is a z-overlay stacking context, so nothing
here escapes it — 9999 is the glass tuner's reserved level and claimed an authority this
layer neither has nor needs. */
z-index: 40;
}
/* Thin, theme-aware scrollbars */
* {
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 8px;
}