@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 (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; }