Files
siftlode/frontend/src/index.css
T

1053 lines
43 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@import "tailwindcss";
/* Guarantee the full status fill-text set exists even before first use, so any
bg-{status} fill always has a readable text-{status}-fg partner available
(Tailwind v4 only emits a utility when it sees it used; success-fg has no fill
consumer today). Keeps the semantic token set complete + future-proof. */
@source inline("text-danger-fg text-warn-fg text-success-fg text-info-fg");
/* `overflow-clip` lives in exactly one place, a `.ts` constant (lib/useChipOverflowFit.ts). If source
detection ever stopped covering that file the utility would silently leave the bundle and the
1024px sideways-scroll bug on the chip lanes would come back with every lane green — so it is
anchored here too. */
@source inline("overflow-clip");
/* `inline` is load-bearing: the color vars (--bg/--accent/… below) are redefined at
runtime per html[data-scheme]/[data-theme], so utilities must inline the value and
resolve it at the USE site, not reference a once-computed --color-* token. This also
makes the v4 opacity modifier (bg-accent/40 → color-mix(… var(--accent) 40% …)) pick
up the active scheme's color. Same pattern as shadcn's v4 theme setup. */
@theme inline {
--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;
}
/* 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 */
}
/* ===== E28 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: 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"]) {
background-color: var(--hover);
}
&:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) {
background-color: var(--press);
}
}
/* 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);
}
/* 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. */
@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);
}
}
@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);
}
}
@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);
}
}
/* 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);
}
}
/* R9 focus foundation: one accent focus ring for keyboard users on every interactive element that
doesn't define its own. In @layer base so any component/utility focus style (.selectable, an input's
focus:ring, a menu item's data-active highlight + outline-hidden) overrides it. Non-input controls
app-wide had no visible keyboard focus before R9; this is the single biggest a11y win. */
@layer base {
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
[role="tab"]:focus-visible,
summary:focus-visible {
/* Solid accent (E11 D2 = B): the 60% mix of R9 all but vanished on the dark glass. */
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* 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="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="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 in R9, not activated: the tuned glass box-shadow — including its
inset top-edge highlight — is the intended depth, and a shadow-* utility would atomically replace it
and drop that highlight.) The perf-mode and :hover overrides below stay UNLAYERED on purpose. */
@layer components {
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
through (the glassy look), opaque enough that the background isn't distracting.
The strong blur turns whatever is behind into soft colour, not a sharp image.
Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 65%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
}
.glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable — only a hint of translucency + the blur. */
background:
linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
),
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-top-color: color-mix(
in srgb,
#fff var(--glass-edge),
color-mix(in srgb, var(--border) 80%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
/* R9: standard interactive-row recipe. The hover tint menu/list rows already use, PLUS the
app-wide focus-visible ring that was missing on non-input controls (buttons, rows, menu items) —
the biggest single a11y win. In @layer components so a consumer's own utilities still win; the
accent ring renders correctly under v4 (color-mix), matching the input ring in ui/form.tsx. */
.selectable {
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.selectable:hover {
background-color: var(--hover);
}
.selectable:focus-visible {
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* E11 S4 · a list/grid item the keyboard landed on (`lib/listNav`): the SAME solid ring, around the
whole card rather than one control inside it (D2 = B). It is focus-visible only, so a mouse click
never paints it, and it is a ring rather than a fill, so it never reads like the hover tint. The
scroll margins are what `scrollIntoView({block:"nearest"})` honours: they keep a card stepped to
from sliding under the floating header (`--hdr-h`) or flush against the bottom edge. */
[data-nav-index] {
scroll-margin-top: calc(var(--hdr-h, 0px) + 0.75rem);
scroll-margin-bottom: 0.75rem;
/* Sideways in a rail: clear its edge fade and the paging arrows (Carousel), not just the edge. */
scroll-margin-inline: 2.5rem;
/* A transparent resting ring of the SAME geometry: the video card transitions `all`, so without
it the ring animated in from the initial outline (3px, currentColor) — a white flash on every
`j`, measured in Chrome. Now only the colour fades in. */
outline: 2px solid transparent;
outline-offset: 2px;
}
/* The card's own "open" element (a Plex poster is a role=button Tab stop, a video card's title is a
link) stands for the card: reached by Tab it rings the whole card too, not just itself — otherwise
Tab showed a ring round the poster alone and the first arrow moved it round the card. */
[data-nav-index]:focus-visible,
[data-nav-index]:has([data-nav-open]:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
[data-nav-index] [data-nav-open]:focus-visible {
outline-color: transparent;
}
/* ===== R10 S4 · Selection-paint recipes =====
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
which drifted over time. These recipes OWN that paint, keyed on the semantic ARIA the control
already carries — the JSX just toggles `aria-pressed` (a segment/toggle) or `aria-current` (the
single current item) and the CSS paints, so one control's selected look can never diverge from
the next's. Geometry (padding / radius / border-width / font) stays a per-site utility since it
legitimately varies. NO focus-visible here on purpose: the `@layer base` rule above already rings
every button/a/[role]; re-adding it would duplicate that (an R9 review finding). Because these sit
in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
.pill {
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 since E28 S3d; its old `.is-current` recipe is gone.) */
.pill[aria-pressed="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);
}
/* ===== E10 S1c · 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. */
@media (pointer: coarse) {
.touch-target {
min-width: var(--touch-target);
min-height: var(--touch-target);
}
}
/* ===== E10 S2 · rows that answer a right-click / long-press (useContextMenu) =====
The long-press path competes with two browser behaviours that fire at the same gesture:
iOS Safari's own callout (Copy / Share over a link or image) and the text-selection handles.
Both would appear ON TOP of our menu, so the attribute the hook stamps on the row turns them
off. Deliberately NOT `user-select: none` on a precise pointer — selecting a video title with
the mouse is legitimate, and only the touch path has the conflict. */
[data-context-menu-target] {
-webkit-touch-callout: none;
}
@media (pointer: coarse) {
[data-context-menu-target] {
-webkit-user-select: none;
user-select: none;
}
}
}
/* `:not(:disabled)` for the same reason ui/Button prefixes every variant hover with `enabled:` — a
disabled control must not advertise a click that does nothing. The Tailwind half was gated in E10
review round 1 and this half was missed, so a disabled glass button still lifted (round 2).
`: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 focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
.reveal-idle {
transition: opacity 150ms ease;
}
@media (hover: hover) {
.reveal {
opacity: 0;
transition: opacity 150ms ease;
}
.group:hover .reveal,
.group:focus-within .reveal,
.reveal:focus-visible {
opacity: 1;
}
/* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard
focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same
corner for a keyboard user. */
.group:hover .reveal-idle,
.group:focus-within .reveal-idle {
opacity: 0;
}
}
@media (hover: none) {
.reveal-idle {
display: none;
}
/* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch),
a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an
invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…`
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
already reachable on touch another way — VideoCard's play overlay (tapping the card plays/resumes)
and the Carousel arrows (swiping scrolls the strip) — where a permanent scrim/edge button is not
wanted. Group-agnostic (encodes only the touch removal), so it works under any `.group`/`group\/…`. */
.reveal-hover {
display: none;
}
}
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.
(The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */
html[data-theme="dark"] body {
background:
radial-gradient(
1100px 620px at 10% -10%,
color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent),
transparent 60%
),
radial-gradient(
1000px 720px at 115% 10%,
color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent),
transparent 55%
),
radial-gradient(
900px 640px at 50% 118%,
color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent),
transparent 60%
),
var(--bg);
background-attachment: fixed;
}
/* Dark glass reads faint because the blurred backdrop is itself dark; lift its brightness so
the frosted sheen shows even over dark UI (over colourful content it just looks richer). */
html[data-theme="dark"] .glass,
html[data-theme="dark"] .glass-menu {
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate))
brightness(var(--glass-dark-bright));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate))
brightness(var(--glass-dark-bright));
}
/* Toasts surface bottom-left (by the nav's notification bell), off the conventional
top-right; a brighter rim draws the eye there. Most needed in dark mode, where the
default glass border (dark navy) all but vanishes against the backdrop. */
html[data-theme="dark"] .toast-card {
border-color: color-mix(in srgb, #fff 50%, transparent);
}
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
html[data-perf="1"] .glass,
html[data-perf="1"] .glass-menu,
html[data-perf="1"] .glass-card {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--surface);
box-shadow: 0 2px 8px -4px rgba(0, 0, 0, 0.4);
}
html[data-perf="1"] body {
background: var(--bg);
}
/* Full-viewport modal scrim blur, as a named recipe so perf mode can DROP it. This is the most
expensive blur in the app (viewport-sized); as a raw `backdrop-blur-xs` utility on each overlay
it survived data-perf while every .glass surface had its blur dropped. Overlays keep their own
bg-black/NN; this adds only the (perf-aware) 4px blur, matching backdrop-blur-xs. */
.overlay-backdrop {
backdrop-filter: blur(4px);
}
html[data-perf="1"] .overlay-backdrop {
backdrop-filter: none;
}
/* Right-edge drawer (Modal placement="drawer"). The slide is what makes it read as a panel that
came from the side rather than a dialog that happens to be right-aligned — so it is part of the
affordance, not decoration. Honours prefers-reduced-motion, like every other motion here. */
.overlay-drawer {
animation: drawer-in 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes drawer-in {
from {
transform: translateX(1.5rem);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.overlay-drawer {
animation: none;
}
}
/* Skip layout/paint for off-screen feed cards so long lists stay smooth while
scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar
stays stable; `auto` lets the browser remember each card's real size after it
has rendered once. */
.cv-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
.cv-row {
content-visibility: auto;
contain-intrinsic-size: auto 96px;
}
/* ===== Motion ===== */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes panelIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes popIn {
from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
/* One-shot attention flash for a chat dock window on a new message. Inset glow so the
window's own overflow-hidden doesn't clip it; pulses twice then fades. */
@keyframes chatFlash {
0% {
box-shadow: inset 0 0 0 0 transparent;
}
18% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
40% {
box-shadow: inset 0 0 0 1px transparent;
}
62% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
100% {
box-shadow: inset 0 0 0 0 transparent;
}
}
.chat-flash {
animation: chatFlash 1.1s ease-out;
}
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
/* Midnight — deep navy/slate with blue accent (default) */
html[data-scheme="midnight"][data-theme="dark"] {
--bg: #0b1020;
--surface: #121a2e;
--card: #161f38;
--border: #243049;
--fg: #e6e9f0;
--muted: #97a3c0;
--accent: #6d8cff;
--accent-fg: #0b1020;
}
html[data-scheme="midnight"][data-theme="light"] {
--bg: #f4f6fc;
--surface: #ffffff;
--card: #ffffff;
--border: #dde3f0;
--fg: #1a2238;
--muted: #5b6685;
--accent: #3b5bdb;
--accent-fg: #ffffff;
}
/* Forest — dark green-slate with a cyan accent (E28 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: #4d6b60;
--accent: #0e7490;
--accent-fg: #ffffff;
}
/* Slate — neutral grey with a fuchsia accent (E28 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: #5d636e;
--accent: #a21caf;
--accent-fg: #ffffff;
}
/* Matrix — near-black with a lime accent (E28 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: #5a6352;
--accent: #556b2f;
--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"] {
/* 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;
--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;
}