feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)

This commit is contained in:
2026-09-25 00:34:35 +02:00
parent db10bfbbca
commit c47c193ad1
89 changed files with 818 additions and 478 deletions
+2
View File
@@ -97,6 +97,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
| FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
+11 -1
View File
@@ -171,7 +171,15 @@ Also in force:
- **Surface per role (B5):** a dialog or panel is `.glass` r16; a card or picker `.glass-card` r12; a menu
`.glass-menu` r12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip `.glass-menu` r8.
Translucency comes only from the tiers (the app backdrop, Plex media, light); a per-site alpha override is a
violation.
violation. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`,
`ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over
undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass
class is a lint error.
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
A genre that filters the library is a chip; the same genre where it can't is a badge.
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
`IconLink`: still an `<a>`, wearing the button's own face.
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
@@ -338,6 +346,8 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+45
View File
@@ -159,6 +159,48 @@ const inputPrimitive = {
};
},
};
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
// role table and the control primitives that paint a glass BUTTON face.
const SURFACE_OWNERS = new Set([
"surfaces.ts",
"buttonStyles.ts", // the `glass` button face
"SelectTrigger.tsx",
"fieldStyles.ts", // the header search floats over the page
]);
const GLASS_TIER = /^glass(-card|-menu)?$/;
const surfaceRole = {
meta: {
type: "problem",
messages: {
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
const check = (node, text) => {
if (typeof text !== "string") return;
const words = text.split(/\s+/).filter(Boolean);
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
// fragment that a space joins to its neighbour.
if (words.length < 2 && text === text.trim()) return;
const tier = words.find((w) => GLASS_TIER.test(w));
if (tier) context.report({ node, messageId: "raw", data: { tier } });
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
@@ -236,6 +278,7 @@ const localRules = {
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"literal-a11y-text": literalA11yText,
},
};
@@ -292,6 +335,7 @@ export default tseslint.config(
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
@@ -456,6 +500,7 @@ export default tseslint.config(
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
"local/input-primitive": "off",
"local/surface-role": "off",
},
},
prettier,
+2 -1
View File
@@ -5,6 +5,7 @@ import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { SURFACE } from "./ui/surfaces";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
@@ -56,7 +57,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
</span>
<PopoverSurface
popover={pop}
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
+2 -1
View File
@@ -14,6 +14,7 @@ import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
@@ -117,7 +118,7 @@ export default function AccountPanel({
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
{t("users.account.access")}
</div>
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
<AccessRow
icon={Mail}
label={t("users.account.email")}
@@ -9,6 +9,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { SURFACE } from "./ui/surfaces";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
@@ -81,7 +82,7 @@ function OverflowPopover({
<PopoverSurface
popover={pop}
ariaLabel={name}
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-48 max-w-72 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
{chips.map((f) => (
@@ -90,7 +91,7 @@ function OverflowPopover({
role="menuitem"
onSelect={f.remove}
label={f.label}
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
>
<span className="flex-1 truncate">{f.label}</span>
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
+2 -1
View File
@@ -11,6 +11,7 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -111,7 +112,7 @@ export default function AddToPlaylist({
<ListPlus />
</IconButton>
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
+5 -4
View File
@@ -45,6 +45,7 @@ import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -231,7 +232,7 @@ function UsersRoles({ me }: { me: Me }) {
{rows.map((u) => {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<div key={u.id} className={`${SURFACE.card} flex items-center gap-2.5 p-2.5`}>
<Pressable
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
@@ -429,7 +430,7 @@ function PendingAccessRowView({
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{row.email}</div>
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
@@ -829,7 +830,7 @@ function AdminDemo() {
{rows.length > 0 && (
<div className="space-y-1.5 mb-3">
{rows.map((r) => (
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div key={r.id} className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.email}</div>
{r.added_by && (
@@ -916,7 +917,7 @@ function InviteRow({
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
+3 -6
View File
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
function displayName(code: string, lang: string, type: "region" | "language"): string {
@@ -101,9 +102,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<div className="flex flex-wrap gap-1.5">
{/* Future search epic: these topic chips become clickable filters. */}
{topics.map((tp) => (
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
{tp}
</span>
<Badge key={tp}>{tp}</Badge>
))}
</div>
</div>
@@ -113,9 +112,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
{kw}
</span>
<Badge key={i}>{kw}</Badge>
))}
</div>
</div>
@@ -4,6 +4,7 @@ import { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
import { navItemAttrs } from "../lib/listNav";
import { SURFACE } from "./ui/surfaces";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
@@ -60,7 +61,7 @@ export default function ChannelLayoutGrid<T>({
<div
{...rowProps?.(row)}
{...navItemAttrs(index)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
className={`${SURFACE.card} p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 font-medium">
@@ -15,6 +15,7 @@ import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
@@ -90,7 +91,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
<PopoverSurface
popover={pop}
ariaLabel={t("channels.stats.label")}
className="glass-menu w-60 rounded-xl p-2 text-xs animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-60 p-2 text-xs animate-[popIn_0.16s_ease]`}
>
{rows.map((r) => (
<div
+5 -4
View File
@@ -11,6 +11,7 @@ import { IconButton } from "./ui/IconButton";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -175,7 +176,7 @@ function SuggestedChipsLane({
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{suggestions.slice(visible).map((s) => (
<SuggestedChip
@@ -227,7 +228,7 @@ function TagEditorMenu({
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
@@ -458,7 +459,7 @@ export function ChannelTagsControl({
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{attached.map((tg) => (
<TagChip
@@ -473,7 +474,7 @@ export function ChannelTagsControl({
{/* Click: the shared editor (toggle existing + create inline). */}
<PopoverSurface
popover={editPop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
>
<TagEditorMenu
userTags={userTags}
+4 -1
View File
@@ -18,6 +18,7 @@ import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
@@ -75,7 +76,9 @@ export default function ChatDock({ meId }: { meId: number }) {
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
<div
className={`${SURFACE.panel} pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden`}
>
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div
+2 -1
View File
@@ -20,6 +20,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -195,7 +196,7 @@ export default function ConfigPanel() {
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className={`${SURFACE.panel} p-4 mb-4`}>
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
+3 -2
View File
@@ -8,6 +8,7 @@ import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilte
import { Button } from "./ui/Button";
import { SelectTrigger } from "./ui/SelectTrigger";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
@@ -110,7 +111,7 @@ export default function DateFilterSelect({
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.datePresetLabel")}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
{options.map((o) => {
@@ -122,7 +123,7 @@ export default function DateFilterSelect({
checked={on}
onSelect={() => pick(o.value)}
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+5 -2
View File
@@ -29,6 +29,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The debug recorder (E16): Start → reproduce → Stop.
//
@@ -152,7 +153,7 @@ function RecorderPill() {
<div
role="group"
aria-label={t("recorder.pill.label")}
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
hidden ? "invisible" : ""
}`}
>
@@ -304,7 +305,9 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
}
};
return (
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
<div
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
>
<label htmlFor="debug-step-label" className="text-sm text-muted">
{t("recorder.pill.stepPrompt", { n })}
</label>
+4 -7
View File
@@ -44,6 +44,7 @@ import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
export default function DownloadCenter() {
const me = useMe();
@@ -159,13 +160,9 @@ export default function DownloadCenter() {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
const saveBtn = (job: DownloadJob) => (
<a
href={api.downloadFileUrl(job.id)}
title={t("downloads.actions.download")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
<Download className="w-4 h-4" />
</a>
<IconLink href={api.downloadFileUrl(job.id)} label={t("downloads.actions.download")}>
<Download />
</IconLink>
);
return (
@@ -3,6 +3,7 @@ import ChannelLink from "./ChannelLink";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
@@ -48,7 +49,7 @@ export default function ExcludedChannelList({
<div
key={c.id}
{...navItemAttrs(i)}
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
className={`${SURFACE.card} flex items-center gap-2 px-3 py-2`}
>
<div className="min-w-0 flex-1">
<ChannelLink
+3 -2
View File
@@ -7,6 +7,7 @@ import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { Button } from "./ui/Button";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
@@ -95,7 +96,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
if (view === "reset")
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
@@ -133,7 +134,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
);
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold text-sm">
+2 -4
View File
@@ -4,6 +4,7 @@ import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
@@ -67,10 +68,7 @@ export default function LanguageSwitcher({
)}
</Pressable>
<PopoverSurface
popover={pop}
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-40 animate-[popIn_0.16s_ease]`}>
{LANGUAGES.map((l) => (
<PopoverItem
key={l.code}
+3 -2
View File
@@ -20,6 +20,7 @@ import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
type View =
| "list"
@@ -146,7 +147,7 @@ function ConversationList({
) : q.isLoading && !q.data ? (
<LoadingState />
) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
) : (
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
// opens; the pop-out button beside it keeps its own Enter.
@@ -248,7 +249,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
) : q.isLoading ? (
<LoadingState />
) : users.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
{t("messages.noPeople")}
</div>
) : (
+3 -2
View File
@@ -21,6 +21,7 @@ import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -202,7 +203,7 @@ export default function NavSidebar({
// The slot is exactly the card's width so the filter tab/bar docks flush against its right edge.
<div className="relative shrink-0 w-14">
<nav
className="glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 my-3 w-14 px-2"
className={`${SURFACE.panel} absolute top-0 bottom-0 left-0 z-rail flex flex-col py-3 my-3 w-14 px-2`}
aria-label={t("nav.primary")}
>
<div
@@ -270,7 +271,7 @@ export default function NavSidebar({
<PopoverSurface
popover={acctPop}
ariaLabel={name}
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-60 p-3 animate-[popIn_0.16s_ease]`}
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
@@ -45,6 +45,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./navigationContext";
import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { SURFACE } from "./ui/surfaces";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
@@ -142,7 +143,7 @@ export default function NotificationsPanel() {
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<div className={`${SURFACE.panel} p-4 flex items-center gap-3`}>
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
@@ -182,7 +183,7 @@ export default function NotificationsPanel() {
) : q.isLoading && !q.data && clientItems.length === 0 ? (
<LoadingState />
) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")}
</div>
@@ -299,7 +300,7 @@ function NotificationRow({
}
return (
<div
className={`glass-card rounded-xl p-3.5 flex items-start gap-3 transition ${
className={`${SURFACE.card} p-3.5 flex items-start gap-3 transition ${
n.read ? "opacity-70" : ""
}`}
>
@@ -385,7 +386,7 @@ function ClientActivityRow({
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
<div
className={`glass-card rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
className={`${SURFACE.card} p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
+2 -1
View File
@@ -12,6 +12,7 @@ import { useOverlayOrder } from "./ui/overlayOrder";
import { useLayer } from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import { IconButton } from "./ui/IconButton";
import { SURFACE } from "./ui/surfaces";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
@@ -171,7 +172,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
className={`${SURFACE.panel} relative w-[min(94vw,460px)] p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]`}
>
<IconButton
size="md"
+8 -1
View File
@@ -3,6 +3,8 @@ import { Command } from "lucide-react";
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
import { Pressable } from "./ui/Pressable";
import clsx from "clsx";
import { fieldClass } from "./ui/fieldStyles";
// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
@@ -50,7 +52,12 @@ export function PaletteLauncher() {
<Pressable
onClick={openPalette}
title={t("palette.open")}
className="pointer-events-auto glass-menu flex h-11 items-center gap-2 rounded-2xl px-3 text-sm text-muted transition hover:text-fg"
// The search box's own frame (a 40 glass pill with the field edge): on a page without a search
// this is where the search would be, and it opens the same place.
className={clsx(
fieldClass({ size: "lg", pill: true, floating: true }),
"pointer-events-auto flex items-center gap-2 text-muted hover:text-fg",
)}
>
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{t("palette.title")}</span>
+2 -1
View File
@@ -64,6 +64,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -592,7 +593,7 @@ export default function PlayerModal({
<div
ref={cardRef}
tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl outline-hidden"
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full overflow-y-auto outline-hidden`}
onClick={(e) => e.stopPropagation()}
>
<div
@@ -5,6 +5,7 @@ import { Info } from "lucide-react";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
// The playlists module shows fewer lists than youtube.com does, and without a word about it that
// reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front).
@@ -36,7 +37,7 @@ export default function PlaylistLimitsHint() {
<PopoverSurface
popover={pop}
ariaLabel={t("playlists.limits.title")}
className="glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl p-3.5 text-xs leading-relaxed text-fg animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-80 max-w-[calc(100vw-2rem)] p-3.5 text-xs leading-relaxed text-fg animate-[popIn_0.16s_ease]`}
>
<p className="mb-2 font-semibold">{t("playlists.limits.title")}</p>
<ul className="list-disc space-y-1.5 pl-4 text-muted">
+2 -1
View File
@@ -46,6 +46,7 @@ import NavGrid from "./ui/NavGrid";
import { Chip } from "./ui/Chip";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -553,7 +554,7 @@ export default function PlexBrowse() {
{personCards.map((p) => (
<div
key={p.name}
className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5"
className={`${SURFACE.card} flex items-center gap-1 py-1.5 pl-1.5 pr-2.5`}
>
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
<Pressable
@@ -11,6 +11,7 @@ import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
@@ -190,7 +191,7 @@ export default function PlexCollectionEditor({
return (
<div
key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
className={`${SURFACE.card} flex items-center gap-2 px-2 py-1.5 text-sm`}
>
<Pressable
role="checkbox"
@@ -241,7 +242,7 @@ export default function PlexCollectionEditor({
{eligible.map((c) => (
<div
key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
className={`${SURFACE.card} flex items-center gap-2 px-2 py-1.5 text-sm`}
>
<span className="min-w-0 flex-1 truncate">{c.title}</span>
<span className="shrink-0 text-xs text-muted">
@@ -25,6 +25,8 @@ import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { useReturnFocus } from "./ui/useReturnFocus";
import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
@@ -294,13 +296,10 @@ export default function PlexCollectionView({
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold leading-tight">{d.title}</h1>
{readOnly && (
<span
className="inline-flex items-center gap-1 rounded-full bg-surface px-2 py-0.5 text-2xs text-muted"
title={t("plex.collection.readOnly")}
>
<Lock className="h-3 w-3" />
<Badge title={t("plex.collection.readOnly")}>
<Lock />
{t("plex.collection.readOnly")}
</span>
</Badge>
)}
</div>
)}
@@ -478,7 +477,7 @@ function AddItemPanel({
}
return (
<div className="glass-card mb-6 rounded-2xl p-4">
<div className={`${SURFACE.panel} mb-6 p-4`}>
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 text-muted" />
<Input
+13 -15
View File
@@ -18,6 +18,8 @@ import { navItemAttrs } from "../lib/listNav";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -196,7 +198,7 @@ function FactsPanel({
});
if (rows.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
{rows.map((r) => (
@@ -221,13 +223,13 @@ function KeywordsPanel({
const { t } = useTranslation();
if (keywords.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
<div className="flex flex-wrap gap-1.5">
{keywords.map((k) => (
<Filterable
key={k}
className="rounded-full bg-surface px-2.5 py-1 text-xs text-muted hover:text-accent"
tag
title={onFilter ? t("plex.info.filterKeyword", { name: k }) : undefined}
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
>
@@ -251,7 +253,7 @@ function CrewPanel({
const { t } = useTranslation();
if (crew.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
so a fixed small column count keeps names from over-spreading. */}
@@ -291,16 +293,11 @@ function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: bo
.filter(Boolean)
.join(" · ");
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
}`}
title={t(`plex.playable.${cls}`)}
>
<Badge onDark={overlay} title={t(`plex.playable.${cls}`)}>
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
{sub && <span className="opacity-70">{sub}</span>}
</span>
</Badge>
);
}
@@ -416,7 +413,7 @@ export default function PlexInfo({
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
<div className={overlay ? "" : `${SURFACE.panel} p-4 sm:p-6`}>
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
@@ -557,7 +554,8 @@ export default function PlexInfo({
{detail.genres.map((g) => (
<Filterable
key={g}
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
@@ -653,7 +651,7 @@ export default function PlexInfo({
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}>
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
@@ -729,7 +727,7 @@ export default function PlexInfo({
{detail.collections
?.filter((col) => !hiddenSources.includes(col.source))
.map((col) => (
<div key={col.id} className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}>
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{col.title}
+2 -1
View File
@@ -10,6 +10,7 @@ import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -153,7 +154,7 @@ export default function PlexPlaylistAdd({
aria-checked={all ? true : partial ? "mixed" : false}
onClick={() => toggle(p)}
disabled={busy}
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm disabled:opacity-50 ${
className={`${SURFACE.card} flex w-full items-center gap-2 px-2 py-1.5 text-sm disabled:opacity-50 ${
all ? "text-accent" : ""
}`}
>
+5 -8
View File
@@ -43,6 +43,7 @@ import {
} from "./ui/sortableA11y";
import { Input } from "./ui/Input";
import { useReturnFocus } from "./ui/useReturnFocus";
import { SURFACE } from "./ui/surfaces";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -147,7 +148,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`}
className={`flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
>
<DragHandle
label={t("plex.playlist.reorderItem", { title: ep.title })}
@@ -202,11 +203,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
};
const dense = ctx.layout === "tree";
return (
<div
ref={setNodeRef}
style={style}
className="glass-card flex items-center gap-3 rounded-xl p-2"
>
<div ref={setNodeRef} style={style} className={`${SURFACE.card} flex items-center gap-3 p-2`}>
<DragHandle
label={t("plex.playlist.reorderItem", { title: item.title })}
{...attributes}
@@ -256,7 +253,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
: t("plex.playlist.seasonUnknown");
return (
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
<div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}>
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
<div className="flex items-center gap-2.5 p-2">
<DragHandle
@@ -524,7 +521,7 @@ export default function PlexPlaylistView({
{t("plex.backToLibrary")}
</Button>
<div className="glass rounded-2xl p-4 sm:p-6">
<div className={`${SURFACE.panel} p-4 sm:p-6`}>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
{renaming ? (
+3 -2
View File
@@ -10,6 +10,7 @@ import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
@@ -139,7 +140,7 @@ export default function PlexSearchBox() {
id={listId}
role="listbox"
aria-label={t("plex.suggest.aria")}
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto animate-[popIn_0.16s_ease]`}
>
{suggestions.length === 0 && suggestQ.isFetching && (
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
@@ -154,7 +155,7 @@ export default function PlexSearchBox() {
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
onMouseEnter={() => setHi(i)}
onClick={() => pick(i)}
className={`flex w-full items-center gap-3 rounded-xl p-1.5 transition ${
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${
hi === i ? "bg-border/60" : "hover:bg-border/40"
}`}
>
+3 -8
View File
@@ -12,6 +12,7 @@ import FilterSelect from "./ui/FilterSelect";
import { IconButton } from "./ui/IconButton";
import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
@@ -111,10 +112,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
{t("downloads.profiles.builtin")}
</div>
{builtins.map((p) => (
<div
key={p.id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
>
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
<span className="flex-1 truncate">{p.name}</span>
</div>
@@ -125,10 +123,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</div>
)}
{mine.map((p) => (
<div
key={p.id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
>
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
<span className="flex-1 truncate">{p.name}</span>
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
<Pencil />
+13 -11
View File
@@ -5,19 +5,21 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useSavedViews } from "./savedViewsContext";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { SelectTrigger } from "./ui/SelectTrigger";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
// checked) + a "Manage views…" entry that opens the Personal Views modal, where the editing lives.
export default function SavedViewsPopover({
trigger,
className,
toolbar = false,
ariaLabel,
placement = "bottom-start",
}: {
trigger: ReactNode;
className?: string;
/** The toolbar's view chip: the A5 dropdown trigger around `trigger`, instead of a glyph. */
toolbar?: boolean;
ariaLabel?: string;
placement?: "bottom-start" | "bottom-end";
}) {
@@ -28,16 +30,16 @@ export default function SavedViewsPopover({
const label = ariaLabel ?? t("views.saved");
return (
<>
{/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */}
{className ? (
<Pressable
{toolbar ? (
<SelectTrigger
context="toolbar"
open={open}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={label}
className={className}
>
{trigger}
</Pressable>
</SelectTrigger>
) : (
<IconButton
variant="ghost-accent"
@@ -51,7 +53,7 @@ export default function SavedViewsPopover({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-56 animate-[popIn_0.16s_ease]`}
>
{views.length === 0 ? (
<p className="px-2 py-1.5 text-2xs leading-snug text-muted">{t("views.empty")}</p>
@@ -67,7 +69,7 @@ export default function SavedViewsPopover({
setOpen(false);
}}
label={v.name}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
v.id === activeViewId ? "text-fg" : "text-muted"
}`}
>
@@ -84,7 +86,7 @@ export default function SavedViewsPopover({
// Opens a dialog: close first, or the menu's focus return steals its focus (closeThenAct).
onSelect={() => closeThenAct(() => setOpen(false), openModal)}
label={t("views.manage")}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
>
<Settings2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="flex-1">{t("views.manage")}</span>
+6 -5
View File
@@ -29,6 +29,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -171,7 +172,7 @@ function JobRow({
}
return (
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
<StatusDot k={statusKey(job)} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
@@ -296,7 +297,7 @@ function MaintenanceSettings({
const dirty = String(m.revalidate_batch) !== val;
return (
<div className="glass-card rounded-xl p-4">
<div className={`${SURFACE.card} p-4`}>
<div className="flex items-center gap-2 text-sm flex-wrap">
<Database className="w-4 h-4 text-muted shrink-0" />
<Tooltip hint={t("scheduler.maintenance.batchHint")}>
@@ -355,7 +356,7 @@ function Stat({
sub?: string;
}) {
return (
<div className="glass-card rounded-xl p-3">
<div className={`${SURFACE.card} p-3`}>
<div className="flex items-center gap-2 text-muted text-xs">
<Icon className="w-4 h-4 shrink-0" />
<Tooltip hint={hint ?? ""}>
@@ -485,7 +486,7 @@ export default function Scheduler() {
return (
<div className="p-4 max-w-4xl w-full mx-auto space-y-4">
{/* Status strip */}
<div className="glass rounded-2xl p-4 flex flex-wrap items-center gap-3">
<div className={`${SURFACE.panel} p-4 flex flex-wrap items-center gap-3`}>
<Activity className="w-5 h-5 text-accent shrink-0" />
<div className="min-w-0">
<div className="font-semibold">{t("scheduler.title")}</div>
@@ -620,7 +621,7 @@ export default function Scheduler() {
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.quotaTitle")}
</div>
<div className="glass-card rounded-xl p-4">
<div className={`${SURFACE.card} p-4`}>
<div className="flex items-center gap-2 text-sm">
<Gauge className="w-4 h-4 text-muted shrink-0" />
<span>
+2 -3
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { Bookmark, ChevronDown } from "lucide-react";
import { Bookmark } from "lucide-react";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./savedViewsContext";
@@ -14,7 +14,7 @@ export default function SelectedViewChip() {
return (
<SavedViewsPopover
ariaLabel={t("views.saved")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-fg transition hover:border-accent/60"
toolbar
trigger={
<>
<Bookmark className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
@@ -26,7 +26,6 @@ export default function SelectedViewChip() {
aria-label={t("views.modified")}
/>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</>
}
/>
+2 -1
View File
@@ -27,6 +27,7 @@ import { useMe } from "../lib/useMe";
import { Pressable } from "./ui/Pressable";
import { Slider } from "./ui/NativeControls";
import { Input, Textarea } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
@@ -71,7 +72,7 @@ export default function SettingsPanel() {
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl overflow-hidden">
<div className={`${SURFACE.panel} overflow-hidden`}>
<div className="flex flex-col sm:flex-row">
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
Tabs). Active is a clear accent pill. */}
+3 -2
View File
@@ -6,6 +6,7 @@ import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
// setup token comes from the URL the app printed to the container logs (?token=…); every step
@@ -158,7 +159,7 @@ export default function SetupWizard() {
))}
</div>
<div className="glass rounded-2xl p-6">
<div className={`${SURFACE.panel} p-6`}>
{step === "intro" && (
<Section title={t("setup.intro.title")} desc={t("setup.intro.body")} />
)}
@@ -330,7 +331,7 @@ function Section({
function TokenError() {
const { t } = useTranslation();
return (
<div className="max-w-md glass rounded-2xl p-6 text-center">
<div className={`${SURFACE.panel} max-w-md p-6 text-center`}>
<h2 className="text-lg font-semibold">{t("setup.token.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2">{t("setup.token.body")}</p>
</div>
+10 -44
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -12,9 +12,10 @@ import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input, InnerInput } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
import UserPicker from "./UserPicker";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
@@ -25,27 +26,13 @@ const NO_RECIPIENTS: ShareRecipient[] = [];
function UserShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients });
const list = recipients.data ?? NO_RECIPIENTS;
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return list.slice(0, 8);
return list
.filter(
(u) =>
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
)
.slice(0, 8);
}, [q, list]);
const share = useMutation({
mutationFn: (email: string) => api.shareDownload(job.id, email),
onSuccess: (r) => {
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
setQ("");
setOpen(false);
},
onError: (e) => notifyActionError(e, t("downloads.share.failed")),
});
@@ -56,36 +43,15 @@ function UserShare({ job }: { job: DownloadJob }) {
<UserPlus className="w-4 h-4 text-muted" /> {t("downloads.share.userSection")}
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
<div className="relative">
<Input
className="w-full"
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
<UserPicker
users={list}
loading={recipients.isLoading}
onPick={(u) => {
if (!share.isPending) share.mutate(u.email);
}}
onFocus={() => setOpen(true)}
// Close on blur, but after a tick so a click on a dropdown row still registers.
onBlur={() => setTimeout(() => setOpen(false), 150)}
label={t("downloads.share.userSection")}
placeholder={t("downloads.share.pickPlaceholder")}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<Pressable
key={u.id}
layer
onClick={() => share.mutate(u.email)}
disabled={share.isPending}
className="w-full px-3 py-2 text-sm flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</Pressable>
))}
</div>
)}
</div>
{recipients.data && list.length === 0 && (
<p className="text-xs text-muted mt-1">{t("downloads.share.noUsers")}</p>
)}
@@ -142,7 +108,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
);
return (
<div className="rounded-lg glass-card p-2.5 space-y-2">
<div className={`${SURFACE.card} p-2.5 space-y-2`}>
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
+3 -2
View File
@@ -18,6 +18,7 @@ import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system";
@@ -275,7 +276,7 @@ function SystemStats() {
) : (
<>
{/* Daily totals (instance-wide, Pacific days) */}
<div className="glass-card rounded-xl p-3 mb-4">
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="text-xs text-muted mb-2">
{t("stats.dailyTotal", {
count: data.range_days,
@@ -324,7 +325,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
return (
<div className="glass-card rounded-xl p-3">
<div className={`${SURFACE.card} p-3`}>
<Pressable
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
+3 -2
View File
@@ -11,6 +11,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -124,7 +125,7 @@ export default function SyncStatus({
// Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here.
title={countsText}
className="glass-menu glass-hover relative flex items-center gap-1.5 px-2.5 h-11 rounded-2xl text-xs text-muted"
className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted`}
>
{active ? (
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
@@ -140,7 +141,7 @@ export default function SyncStatus({
<PopoverSurface
popover={pop}
ariaLabel={countsText}
className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-64 p-3 text-xs text-muted animate-[popIn_0.16s_ease]`}
>
<div
title={t("header.sync.countTooltip")}
+2 -1
View File
@@ -6,6 +6,7 @@ import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { SURFACE } from "./ui/surfaces";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
@@ -121,7 +122,7 @@ export default function TagFilterChips({
<PopoverSurface
popover={pop}
ariaLabel={moreLabel}
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
+2 -4
View File
@@ -15,6 +15,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
@@ -73,10 +74,7 @@ function TagRow({
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
<Trash2 />
</IconButton>
<PopoverSurface
popover={pop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}>
<div className="text-2xs uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
+55
View File
@@ -0,0 +1,55 @@
import Combobox from "./ui/Combobox";
// Pick one registered user by name or email: the download quota's "any user" picker and the share
// dialog's recipient. Both used to hand-roll an input over an absolute list that only a mouse could
// reach (no arrow keys, no listbox for a screen reader, closed by a blur timer); this is the shared
// Combobox in single-select, which brings all of that. A pick is an ACTION here, not a value to show,
// so nothing stays selected: the field clears for the next one.
export interface PickableUser {
id: number;
email: string;
display_name?: string | null;
}
export default function UserPicker<U extends PickableUser>({
users,
onPick,
label,
placeholder,
loading,
}: {
users: readonly U[];
onPick: (u: U) => void;
label: string;
placeholder: string;
loading?: boolean;
}) {
const byId = new Map(users.map((u) => [String(u.id), u]));
return (
<Combobox
label={label}
placeholder={placeholder}
multiple={false}
loading={loading}
selected={[]}
// The label carries both, so the client filter matches a name OR an email.
options={users.map((u) => ({
value: String(u.id),
label: u.display_name ? `${u.display_name} · ${u.email}` : u.email,
}))}
onChange={(next) => {
const u = next[0] != null ? byId.get(next[0]) : undefined;
if (u) onPick(u);
}}
renderOption={(o) => {
const u = byId.get(o.value);
return (
<span className="flex min-w-0 flex-col">
<span className="truncate">{u?.display_name || u?.email}</span>
{u?.display_name && <span className="truncate text-xs text-muted">{u.email}</span>}
</span>
);
}}
/>
);
}
+3 -2
View File
@@ -28,6 +28,7 @@ import { isPlainLeftClick } from "../lib/nav";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "./ui/IconButton";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
function Actions({
video,
@@ -517,7 +518,7 @@ function VideoCard({
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
// jumping as the pointer crosses it is a lot more restless than a card doing it.
const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";
const rowShell = `cv-row group ${SURFACE.card} glass-hover relative transition`;
if (spec.family === "row") {
// A table without being one. Every column is a FIXED width, which is what makes the same datum
@@ -647,7 +648,7 @@ function VideoCard({
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`,
"flex flex-col",
spec.dense ? "p-2" : "p-2.5",
watched && "opacity-55",
+2 -1
View File
@@ -14,6 +14,7 @@ import { markReported } from "../lib/api/core";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
@@ -567,7 +568,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* selected-segment editor */}
{sel && (
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg glass-card px-3 py-2">
<div className={`${SURFACE.card} flex flex-wrap items-center gap-2 text-sm px-3 py-2`}>
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
<Button
variant="outline"
+4 -4
View File
@@ -10,6 +10,8 @@ import Avatar from "./ui/Avatar";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
@@ -112,7 +114,7 @@ export default function VideoInfoCard({
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
className="glass w-85 max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
className={`${SURFACE.popover} w-85 max-w-[92vw] overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
>
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
@@ -135,9 +137,7 @@ export default function VideoInfoCard({
{tagNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tagNames.map((tg) => (
<span key={tg.id} className="glass-card px-2 py-0.5 rounded-full text-2xs">
{tg.name}
</span>
<Badge key={tg.id}>{tg.name}</Badge>
))}
</div>
)}
+2 -1
View File
@@ -4,6 +4,7 @@ import clsx from "clsx";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { SelectTrigger } from "./ui/SelectTrigger";
import { SURFACE } from "./ui/surfaces";
export interface ViewOption<T extends string> {
id: T;
@@ -76,7 +77,7 @@ export default function ViewSwitcher<T extends string>({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
>
{/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to — otherwise they read as independent checkable items. */}
+10 -14
View File
@@ -7,6 +7,8 @@ import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -265,7 +267,7 @@ export default function WatchPage() {
{status === "password" && (
<div className="flex-1 grid place-items-center">
<div className="w-full max-w-sm rounded-2xl glass p-6">
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
</div>
@@ -305,7 +307,7 @@ export default function WatchPage() {
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. */
<div className="flex-1 grid place-items-center">
<div className="w-full max-w-md rounded-2xl glass p-6 text-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
@@ -318,12 +320,9 @@ export default function WatchPage() {
.join(" · ")}
</div>
{meta.allow_download && (
<a
href={meta.file_url}
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-4 py-2 text-sm font-medium transition"
>
<Download className="w-4 h-4" /> {T.download}
</a>
<LinkButton href={meta.file_url} icon={<Download />} className="mt-5">
{T.download}
</LinkButton>
)}
</div>
</div>
@@ -397,12 +396,9 @@ export default function WatchPage() {
{T.repeat}
</Chip>
{meta.allow_download && (
<a
href={meta.file_url}
className="inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
>
<Download className="w-4 h-4" /> {T.download}
</a>
<LinkButton href={meta.file_url} icon={<Download />}>
{T.download}
</LinkButton>
)}
</div>
</div>
+8 -9
View File
@@ -37,6 +37,8 @@ import { MODE_URL, type Mode } from "./welcomeMode";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
@@ -198,7 +200,7 @@ const FEATURES: { key: string; icon: LucideIcon }[] = [
function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
const { t } = useTranslation();
return (
<div className="glass-card rounded-2xl p-5">
<div className={`${SURFACE.panel} p-5`}>
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center mb-3">
<Icon className="w-5 h-5" />
</div>
@@ -225,7 +227,7 @@ function Preview({
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
<div className={`${SURFACE.card} overflow-hidden ${className ?? ""}`}>
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label}
</div>
@@ -236,7 +238,7 @@ function Preview({
<Pressable
onClick={() => onOpen?.(src, label)}
aria-label={label}
className={`group relative glass-card rounded-2xl overflow-hidden block cursor-zoom-in ${className ?? ""}`}
className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`}
>
<img
src={src}
@@ -566,7 +568,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
<div className={`${SURFACE.panel} p-6 sm:p-7 w-full max-w-md lg:justify-self-end`}>
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
{StateIcon && (
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
@@ -748,12 +750,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
<span className="h-px flex-1 bg-border" />
</div>
{googleEnabled && (
<a
href="/auth/login"
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
>
<LinkButton href="/auth/login" size="lg" fullWidth>
{t("welcome.auth.google")}
</a>
</LinkButton>
)}
{!showDemo ? (
<Button
+3 -2
View File
@@ -10,6 +10,7 @@ import { Chip } from "./ui/Chip";
import { Button } from "./ui/Button";
import CountBadge from "./ui/CountBadge";
import { useDebounced } from "../lib/useDebounced";
import { SURFACE } from "./ui/surfaces";
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
@@ -133,7 +134,7 @@ export default function YearFilter({
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.year.moreLabel")}
className="glass-menu min-w-44 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
>
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
@@ -173,7 +174,7 @@ export default function YearFilter({
setOpen(false);
}}
label={String(y.year)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+10 -44
View File
@@ -11,9 +11,10 @@ import { Button } from "./ui/Button";
import { StatusPill } from "./downloadRow";
import { jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
import UserPicker from "./UserPicker";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
// list. Split out of DownloadCenter (self-contained, admin-only surface).
@@ -137,52 +138,17 @@ function QuotaModal({
// footprint list below only shows users who already have files).
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const list = (users.data ?? []).filter((u) => !u.is_demo);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
const base = s
? list.filter(
(u) =>
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
)
: list;
return base.slice(0, 8);
}, [q, list]);
const list = useMemo(() => (users.data ?? []).filter((u) => !u.is_demo), [users.data]);
return (
<div className="relative w-full sm:w-72">
<Input
className="w-full"
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
<div className="w-full sm:w-72">
<UserPicker
users={list}
loading={users.isLoading}
onPick={(u) => onPick({ id: u.id, email: u.email })}
label={t("downloads.admin.quotaPickPlaceholder")}
placeholder={t("downloads.admin.quotaPickPlaceholder")}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<Pressable
key={u.id}
layer
onClick={() => {
onPick({ id: u.id, email: u.email });
setQ("");
setOpen(false);
}}
className="w-full px-3 py-2 text-sm flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</Pressable>
))}
</div>
)}
</div>
);
}
@@ -205,7 +171,7 @@ export function AdminSystem() {
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
],
].map(([label, val]) => (
<div key={label} className="rounded-xl glass-card p-3">
<div key={label} className={`${SURFACE.card} p-3`}>
<div className="text-xs text-muted">{label}</div>
<div className="text-lg font-semibold mt-0.5">{val}</div>
</div>
+3 -2
View File
@@ -20,6 +20,7 @@ import { failCopyKeys } from "../lib/downloadFailure";
import { jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
@@ -226,7 +227,7 @@ export function DownloadRow({
return (
<div
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
className={`${SURFACE.card} flex gap-3 p-2.5 glass-hover transition`}
>
<Thumb job={job} />
<div className="min-w-0 flex-1">
@@ -323,7 +324,7 @@ export function UsageBar() {
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return (
<div className="rounded-xl glass-card p-3 mb-4">
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
@@ -13,6 +13,7 @@ import { setLanguage, type LangCode } from "../../i18n";
import { formatDate } from "../../lib/format";
import { send } from "../../lib/http";
import LanguageSwitcher from "../LanguageSwitcher";
import { SURFACE } from "../ui/surfaces";
// The day the text last changed materially — bump it with every edit of either body.
const LAST_UPDATED = "2026-09-17";
@@ -86,7 +87,7 @@ export default function LegalLayout({ title, children }: { title: string; childr
{/* The landing page's own picker, so the public pages switch language one way. */}
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
</div>
<div className="glass rounded-2xl p-8">
<div className={`${SURFACE.panel} p-8`}>
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-xs text-muted mt-1 mb-6">
{t("legal.lastUpdated", { date: formatDate(LAST_UPDATED, i18n.language) })}
+2 -1
View File
@@ -15,6 +15,7 @@ import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -108,7 +109,7 @@ function NewCollectionPanel({
}
return (
<div className="glass-card mb-6 rounded-2xl p-4">
<div className={`${SURFACE.panel} mb-6 p-4`}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
+8 -5
View File
@@ -8,6 +8,7 @@ import { navItemAttrs } from "../lib/listNav";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
@@ -79,7 +80,7 @@ export function PlexPosterCard({
onOpen();
}
}}
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
>
<img
src={card.thumb}
@@ -260,7 +261,7 @@ export function SeasonCard({
onOpen();
}
}}
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
>
<img
src={se.thumb}
@@ -348,7 +349,7 @@ export function CastStrip({
const clickable = !!(onOpenPerson || onFilter);
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
@@ -423,7 +424,7 @@ export function RelatedStrip({
const { t } = useTranslation();
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
@@ -436,7 +437,9 @@ export function RelatedStrip({
onClick={() => onOpen(r.id)}
className="group w-32.5 shrink-0"
>
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
<div
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200`}
>
<img
src={r.thumb}
alt=""
+19 -2
View File
@@ -12,6 +12,9 @@ import { useDismiss } from "../lib/useDismiss";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { Chip } from "./ui/Chip";
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
@@ -25,13 +28,27 @@ export function Filterable({
onClick,
title,
className,
tag = false,
onDark = false,
children,
}: {
onClick?: () => void;
title?: string;
className?: string;
/** A genre / keyword tag rather than a run of text: an edged chip when it filters, a flat badge
* when it doesn't (E28 S3d: edged = pressable). */
tag?: boolean;
onDark?: boolean;
children: ReactNode;
}) {
if (tag)
return onClick ? (
<Chip variant="token" onClick={onClick} title={title}>
{children}
</Chip>
) : (
<Badge onDark={onDark}>{children}</Badge>
);
if (!onClick) return <span className={className}>{children}</span>;
return (
<Pressable
@@ -99,7 +116,7 @@ export function DetailCustomizeMenu({
{open && (
<div
ref={menuRef}
className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
className={`${SURFACE.menu} absolute right-0 top-full z-menu mt-1 w-52 text-sm`}
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
@@ -225,7 +242,7 @@ export function DetailHero({
children: ReactNode;
}) {
return (
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className={`${SURFACE.panel} relative p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5`}>
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
<Pressable
onClick={onPosterClick}
+4 -3
View File
@@ -29,6 +29,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { Chip } from "./ui/Chip";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -186,7 +187,7 @@ export function PlexPersonView({
) : (
<div className="p-4">
{/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6">
<section className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}>
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{d.photo ? (
@@ -445,7 +446,7 @@ export function PlexShowView({
{show.genres.map((g) => (
<Filterable
key={g}
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
tag
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
@@ -501,7 +502,7 @@ export function PlexShowView({
{/* Seasons — in a glassy panel like the movie info-page strips. */}
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
className={`${SURFACE.panel} p-4 sm:p-5 mb-6`}
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
+2 -1
View File
@@ -4,6 +4,7 @@ import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
@@ -17,7 +18,7 @@ import { Pressable } from "./Pressable";
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
* `ContextMenu`. One constant so a context menu can never look like a different component. */
export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]";
export const MENU_SURFACE = `${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`;
// No `disabled:opacity-50` on the row itself: a disabled row may carry a `note`, and opacity on the
// row would dim the one line that explains it. The icon and the label dim instead (`dim`).
const ROW =
+2 -1
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// A floating "back to top" button that fades in once the current page's scroll container is
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
@@ -56,7 +57,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
<Pressable
align="center"
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
+32
View File
@@ -0,0 +1,32 @@
import clsx from "clsx";
import type { ReactNode } from "react";
// A static label (a topic, a keyword, a genre, a status): flat and tonal, with NO edge (owner, E28 S3d).
// Every chip that does something has an edge, so "edged = pressable, flat = just says" holds at a
// glance; a static tag painted like a chip is clicked and does nothing. The tint is the text colour
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's.
export function Badge({
onDark = false,
title,
className,
children,
}: {
onDark?: boolean;
title?: string;
/** Layout only (margin); the paint is the primitive's. */
className?: string;
children: ReactNode;
}) {
return (
<span
title={title}
className={clsx(
"inline-flex h-5 max-w-full shrink-0 items-center gap-1 truncate rounded-full px-2 text-xs whitespace-nowrap [&>svg]:size-3 [&>svg]:shrink-0",
onDark ? "bg-white/10 text-white/90" : "bg-fg/8 text-muted",
className,
)}
>
{children}
</span>
);
}
+3 -85
View File
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode, RefAttributes } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from "./buttonStyles";
// The ONE action-button primitive: a labelled action (Save, Cancel, Subscribe, Delete, "Show all").
// docs/DESIGN.md decides its look (A1 shape, B1 ladder, B3 state layers); this file is where that
@@ -14,77 +15,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
type ButtonVariant =
| "primary" // solid accent — the main affirmative action
| "danger" // solid danger — destructive confirm
| "warn" // solid warn — the action of a warning bar (reload for a new version)
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
| "overlay" // white text on the always-dark player chrome, whatever the theme
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
type ButtonSize =
| "xs" // 24 — inside a table row
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
| "md" // 32 — forms and dialog footers
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
| "none"; // no box of its own — the link variants' default
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
// or pressed button; the text-colour hovers here carry the same guard as a variant.
const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<ButtonVariant, string> = {
primary: "font-medium state-filled",
danger: "font-medium state-danger",
warn: "font-medium state-warn",
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
// is whether the button should advertise the destruction before the pointer arrives.
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
link: `font-semibold text-accent ${ACT}hover:underline`,
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
};
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
// means "floats over something", and a pill's inner button follows its radius).
const SIZES: Record<ButtonSize, string> = {
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
none: "gap-1.5",
};
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE =
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
@@ -110,8 +40,6 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
type NamedSomehow = { label: string } | { "aria-label": string } | { title: string };
export type ButtonProps = ButtonOwnProps & ({ children: ReactNode } | NamedSomehow);
const isLink = (v: ButtonVariant) => v === "link" || v === "link-muted";
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button(
{
@@ -137,11 +65,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
// A link must not inherit the action-button box, so its size default differs. Resolved here rather
// than by asking every call site to pass `size="none"` alongside a link variant — a pairing everyone
// would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (isLink(variant) ? "none" : "md");
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link is excluded on
// purpose — it is a run of text inside a sentence, and a 44px-tall inline link would wreck the
// paragraph it sits in rather than make anything easier to hit.
const touchTarget = !isLink(variant);
const resolvedSize: ButtonSize = size ?? (isLinkVariant(variant) ? "none" : "md");
const leading = loading ? <Loader2 className="animate-spin" /> : icon;
return (
<FocusSafeButton
@@ -156,13 +80,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
aria-pressed={pressed}
title={label}
className={clsx(
BASE,
VARIANTS[variant],
pressed !== undefined && PRESSED,
SIZES[resolvedSize],
resolvedSize !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
touchTarget && "touch-target",
fullWidth && "w-full",
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
className,
)}
{...rest}
+3 -2
View File
@@ -7,6 +7,7 @@ import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { InnerInput } from "./Input";
import { fieldClass } from "./fieldStyles";
import { SURFACE } from "./surfaces";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
@@ -240,7 +241,7 @@ export default function Combobox({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu w-full rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} w-full animate-[popIn_0.16s_ease]`}
>
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
@@ -262,7 +263,7 @@ export default function Combobox({
disabled={o.disabled}
onSelect={() => commit(o.value)}
label={o.label ?? o.value}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+2 -1
View File
@@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign";
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { SURFACE } from "./surfaces";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -323,7 +324,7 @@ export default function DataTable<T>({
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
className={`${SURFACE.card} p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => c.cardPrimary)
+2 -1
View File
@@ -1,6 +1,7 @@
import { createPortal } from "react-dom";
import { Check, RotateCcw, Save } from "lucide-react";
import { Button } from "./Button";
import { SURFACE } from "./surfaces";
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
// admin Configuration). Two visual variants share one state machine + markup:
@@ -38,7 +39,7 @@ export function DraftSaveBar({
const saving = state === "saving";
const floating = variant === "floating";
const wrap = floating
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
? `fixed bottom-4 left-1/2 -translate-x-1/2 z-rail ${SURFACE.panel} flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]`
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
// The floating variant is fixed to the viewport but renders from a page inside the page
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
+3 -2
View File
@@ -3,6 +3,7 @@ import { Check } from "lucide-react";
import { SelectTrigger } from "./SelectTrigger";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { SURFACE } from "./surfaces";
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
@@ -107,7 +108,7 @@ export default function FilterSelect({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
>
{/* Single = one exclusive choice (radios), multi = independent toggles (checkboxes). Both are
menuitem-family roles scoped by a group, so AT announces the checked state — the surface is a
@@ -124,7 +125,7 @@ export default function FilterSelect({
onSelect={() => commit(o.value)}
label={o.label}
data-testid={testId ? `${testId}-${o.value}` : undefined}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+2 -57
View File
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { iconButtonClass, type IconButtonSize, type IconButtonVariant } from "./buttonStyles";
// A button that is only a glyph (docs/DESIGN.md A2): two sizes, r8, and a name it cannot ship without
// (`label` is required: it is the accessible name and the tooltip). On a picture it is round and dark
@@ -10,55 +11,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
// pill field it sits inside — `round`), and nothing else. The glyph
// is sized here, so a row of them never mixes 14 and 16px icons.
type IconButtonVariant =
| "ghost" // the default: quiet glyph, a state layer on hover
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
| "outline" // bordered — an icon action that stands on its own in a form or a header
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
| "danger" // quiet at rest, danger on hover — a row's delete/remove
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
| "glass" // glass card — beside the glass Buttons on a Plex page
| "media" // round, dark translucent — on a thumbnail or a poster
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
type IconButtonSize =
| "sm" // 28 — rows and dense toolbars
| "md" // 32 — on its own
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<IconButtonVariant, string> = {
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
"ghost-accent": "text-accent state-layer",
primary: "state-filled",
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "border border-accent/40 text-accent state-layer",
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "border border-warn/40 text-warn state-layer",
danger: `text-muted state-layer ${ACT}hover:text-danger`,
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
};
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const PRESSED = "aria-pressed:text-accent";
const SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
md: "size-8 [&>svg]:size-4",
lg: "size-12 [&>svg]:size-6",
player: "size-9 [&>svg]:size-5",
};
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** The accessible name and the tooltip. Required: the button renders no text. */
label: string;
@@ -100,14 +52,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
aria-label={label}
aria-pressed={pressed}
title={noTitle ? undefined : label}
className={clsx(
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
VARIANTS[variant],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
SIZES[size],
className,
)}
className={clsx(iconButtonClass({ variant, size, round, pressed }), className)}
{...rest}
>
{loading ? <Loader2 className="animate-spin" /> : children}
+4 -3
View File
@@ -6,8 +6,9 @@ import { formatNumber } from "../../lib/format";
// the pill shows "1M+" and the tooltip carries the precise number.
const OVERFLOW_AT = 1_000_000;
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
// and tonal like a `Badge` — no edge, because it does nothing (E28 S3d: edged = pressable, flat = just
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
@@ -27,7 +28,7 @@ export default function ItemCount({ count }: { count?: number }) {
<span
data-testid="item-count"
title={count == null ? undefined : formatNumber(count)}
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="tabular-nums">{label}</span>
+72
View File
@@ -0,0 +1,72 @@
import clsx from "clsx";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import {
buttonClass,
iconButtonClass,
type ButtonSize,
type ButtonVariant,
type IconButtonSize,
type IconButtonVariant,
} from "./buttonStyles";
// A link that looks like a button (a download, a sign-in that leaves the app). It stays an <a> — the
// browser's own open-in-new-tab, copy-link and download behaviour, and the right role for a screen
// reader — and wears the `Button` / `IconButton` face from buttonStyles, so it can't drift from the
// buttons beside it. A link in a sentence is a plain `<a>` (or `Button variant="link"` for an action).
type Anchor = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "className" | "children">;
export function LinkButton({
variant = "outline",
size = "md",
icon,
fullWidth,
className,
children,
...rest
}: Anchor & {
variant?: ButtonVariant;
size?: ButtonSize;
/** Leading icon (a lucide element; sized here). */
icon?: ReactNode;
fullWidth?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<a
className={clsx(buttonClass({ variant, size, fullWidth, element: "link" }), className)}
{...rest}
>
{icon}
{children}
</a>
);
}
/** The glyph-only form: `label` is the accessible name and the tooltip, as on `IconButton`. */
export function IconLink({
label,
variant = "ghost",
size = "sm",
className,
children,
...rest
}: Anchor & {
label: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
className?: string;
children: ReactNode;
}) {
return (
<a
aria-label={label}
title={label}
className={clsx(iconButtonClass({ variant, size, element: "link" }), className)}
{...rest}
>
{children}
</a>
);
}
+3 -2
View File
@@ -9,6 +9,7 @@ import { trapTab, useDialogChrome } from "./dialogFocus";
import { IconButton } from "./IconButton";
import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder";
import { SURFACE } from "./surfaces";
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
@@ -114,8 +115,8 @@ export default function Modal({
tabIndex={-1}
className={
drawer
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
? `${SURFACE.drawer} relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `${SURFACE.panel} relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
+2 -1
View File
@@ -6,6 +6,7 @@ import { useSortableItem } from "./sortableA11y";
import { DragHandle } from "./DragHandle";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
@@ -55,7 +56,7 @@ export default function PanelGroup({
<div
ref={setNodeRef}
style={style}
className={`glass-card rounded-xl ${
className={`${SURFACE.card} ${
isDragging ? "relative z-base shadow-2xl ring-1 ring-accent" : ""
} ${hidden && editing ? "opacity-50" : ""}`}
>
+5 -2
View File
@@ -37,9 +37,12 @@ domain-free.
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
+3 -2
View File
@@ -5,6 +5,7 @@ import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
@@ -77,7 +78,7 @@ export default function SidePanel({
{...peek.hoverHandlers}
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`glass glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 transition-opacity ${
expanded ? "opacity-0" : ""
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
>
@@ -98,7 +99,7 @@ export default function SidePanel({
{expanded && (
<aside
{...peek.hoverHandlers}
className="glass absolute top-3 bottom-3 left-0 z-panel w-64 rounded-2xl flex flex-col overflow-hidden"
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
>
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
+4 -3
View File
@@ -4,6 +4,7 @@ import { SelectTrigger } from "./SelectTrigger";
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { SURFACE } from "./surfaces";
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
@@ -93,7 +94,7 @@ export default function SortControl({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-56 animate-[popIn_0.16s_ease]`}
>
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
@@ -107,7 +108,7 @@ export default function SortControl({
setOpen(false); // "no sort" is a final choice — nothing left to direct
}}
label={defaultLabel}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
active === null ? "text-fg" : "text-muted"
}`}
>
@@ -126,7 +127,7 @@ export default function SortControl({
checked={on}
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+2 -1
View File
@@ -8,6 +8,7 @@ import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { SURFACE } from "./surfaces";
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
@@ -158,7 +159,7 @@ export default function Toaster({
? undefined
: "status"
}
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} toast-card pointer-events-auto relative overflow-hidden px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]`}
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState, useSyncExternalStore } from "react";
import { PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { hintsEnabled, subscribeHints } from "../../lib/hints";
import { SURFACE } from "./surfaces";
type Side = "top" | "bottom" | "left" | "right";
@@ -50,7 +51,7 @@ export default function Tooltip({
<PopoverSurface
popover={pop}
passthrough
className="glass w-max max-w-60 px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
className={`${SURFACE.tooltip} w-max max-w-60 px-2.5 py-1.5 text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]`}
>
{hint}
</PopoverSurface>
+182
View File
@@ -0,0 +1,182 @@
import clsx from "clsx";
// The paint of the two button primitives (docs/DESIGN.md A1, A2, B1, B3), apart from the components so
// a LINK can wear exactly the same face (`LinkButton`): a download or a sign-in that navigates is an
// <a>, and a hand-painted look-alike beside the real buttons is how the two drifted. The hover guard is
// per element: `enabled:` never matches an <a>, so a link's hovers drop it (it has no disabled state).
const ACT_BUTTON = "enabled:not-aria-disabled:";
const ACT_LINK = "not-aria-disabled:";
export type ButtonVariant =
| "primary" // solid accent — the main affirmative action
| "danger" // solid danger — destructive confirm
| "warn" // solid warn — the action of a warning bar (reload for a new version)
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
| "overlay" // white text on the always-dark player chrome, whatever the theme
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
export type ButtonSize =
| "xs" // 24 — inside a table row
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
| "md" // 32 — forms and dialog footers
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
| "none"; // no box of its own — the link variants' default
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
// or pressed button; the text-colour hovers here carry the same guard as a variant.
const buttonVariants = (ACT: string): Record<ButtonVariant, string> => ({
primary: "font-medium state-filled",
danger: "font-medium state-danger",
warn: "font-medium state-warn",
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
// is whether the button should advertise the destruction before the pointer arrives.
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
link: `font-semibold text-accent ${ACT}hover:underline`,
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
});
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
// means "floats over something", and a pill's inner button follows its radius).
const SIZES: Record<ButtonSize, string> = {
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
none: "gap-1.5",
};
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE =
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
export type IconButtonVariant =
| "ghost" // the default: quiet glyph, a state layer on hover
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
| "outline" // bordered — an icon action that stands on its own in a form or a header
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
| "danger" // quiet at rest, danger on hover — a row's delete/remove
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
| "glass" // glass card — beside the glass Buttons on a Plex page
| "media" // round, dark translucent — on a thumbnail or a poster
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
export type IconButtonSize =
| "sm" // 28 — rows and dense toolbars
| "md" // 32 — on its own
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
const iconVariants = (ACT: string): Record<IconButtonVariant, string> => ({
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
"ghost-accent": "text-accent state-layer",
primary: "state-filled",
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "border border-accent/40 text-accent state-layer",
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "border border-warn/40 text-warn state-layer",
danger: `text-muted state-layer ${ACT}hover:text-danger`,
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
});
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const ICON_PRESSED = "aria-pressed:text-accent";
const ICON_SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
md: "size-8 [&>svg]:size-4",
lg: "size-12 [&>svg]:size-6",
player: "size-9 [&>svg]:size-5",
};
const BUTTON_VARIANTS = { button: buttonVariants(ACT_BUTTON), link: buttonVariants(ACT_LINK) };
const ICON_VARIANTS = { button: iconVariants(ACT_BUTTON), link: iconVariants(ACT_LINK) };
export const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
export function buttonClass({
variant,
size,
round = false,
fullWidth = false,
pressed,
element = "button",
}: {
variant: ButtonVariant;
size: ButtonSize;
round?: boolean;
fullWidth?: boolean;
pressed?: boolean;
element?: "button" | "link";
}) {
return clsx(
BASE,
BUTTON_VARIANTS[element][variant],
pressed !== undefined && PRESSED,
SIZES[size],
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link variant is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
!isLinkVariant(variant) && "touch-target",
fullWidth && "w-full",
);
}
export function iconButtonClass({
variant,
size,
round = false,
pressed,
element = "button",
}: {
variant: IconButtonVariant;
size: IconButtonSize;
round?: boolean;
pressed?: boolean;
element?: "button" | "link";
}) {
return clsx(
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
ICON_VARIANTS[element][variant],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && ICON_PRESSED,
ICON_SIZES[size],
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import Tooltip from "./Tooltip";
import { FocusSafeButton } from "./FocusSafeButton";
import { SURFACE } from "./surfaces";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
@@ -51,7 +52,7 @@ export function Section({
children: ReactNode;
}) {
return (
<div className={card ? "glass rounded-2xl p-4 mb-4" : "mb-6"}>
<div className={card ? `${SURFACE.panel} p-4 mb-4` : "mb-6"}>
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
{title}
</div>
+30
View File
@@ -0,0 +1,30 @@
// B5 (docs/DESIGN.md): one glass class and one radius per surface ROLE, so corners nest and no site
// drifts its own alpha. A call site names the role and adds only its layout (padding, width, flex);
// the radius comes from here, never beside it (no tailwind-merge: two `rounded-*` is a coin flip). A
// raw `glass` / `glass-card` / `glass-menu` class anywhere else is a lint error (`local/surface-role`).
//
// What floats over undimmed content (a menu, a popover, a tooltip, a toast) is `.glass-menu`, the
// near-opaque tier, so it stays readable over any thumbnail; what sits in the page is `.glass-card`;
// a dialog, a drawer, a docked or side panel and a page section is `.glass`.
export const SURFACE = {
/** A dialog, a side panel, a dock, a page section: r16. */
panel: "glass rounded-2xl",
/** A dialog that slides in from the edge: square on a phone, r16 on its open side. */
drawer: "glass rounded-none sm:rounded-l-2xl",
/** A card or a row card in the page (a video, a user, a download): r12. */
card: "glass-card rounded-xl",
/** A menu or a listbox: r12, 4px padding, so its r8 rows nest (12 − 4). */
menu: "glass-menu rounded-xl p-1",
/** Floating content that is not a list of choices (a hover card, a status popover, a toast): r12. */
popover: "glass-menu rounded-xl",
/** A tooltip: r8. */
tooltip: "glass-menu rounded-lg",
/** A control bar floating over the page (the debug recorder's bar): a pill (A2: round floats). */
floating: "glass-menu rounded-full",
/** The pull tab on a side panel's edge: square on the wall side, r12 on the open side. */
edgeTab: "glass rounded-r-xl",
/** A full-width sticky band (the design lab's header): square. */
band: "glass",
} as const;
export type SurfaceRole = keyof typeof SURFACE;
+5 -4
View File
@@ -12,6 +12,7 @@ import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { Input } from "./Input";
import { SURFACE } from "./surfaces";
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
@@ -93,7 +94,7 @@ function FilterFunnel<T>({
popover={pop}
ariaLabel={col.header}
initialFocus={isText ? inputRef : undefined}
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-52 p-2 animate-[popIn_0.16s_ease]`}
>
{f.kind === "text" && (
<Input
@@ -113,7 +114,7 @@ function FilterFunnel<T>({
keepSelectedPaint
onSelect={() => onApply(col.key, "")}
label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
@@ -127,7 +128,7 @@ function FilterFunnel<T>({
keepSelectedPaint
onSelect={() => onApply(col.key, o.value)}
label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
@@ -153,7 +154,7 @@ function FilterFunnel<T>({
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
label={o.label}
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
+4 -1
View File
@@ -5,6 +5,7 @@ import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
@@ -38,7 +39,9 @@ export default function DesignLab() {
return (
<div className="min-h-dvh text-fg" data-testid="design-lab">
<div className="glass sticky top-0 z-chrome flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2.5">
<div
className={`${SURFACE.band} sticky top-0 z-chrome flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2.5`}
>
<strong className="text-sm">Design lab</strong>
<span className="flex gap-1">
{(["dark", "light"] as const).map((m) => (
+3 -5
View File
@@ -1,7 +1,6 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import {
ChevronDown,
Download,
ListPlus,
MoreHorizontal,
@@ -72,10 +71,9 @@ function TouchRow() {
<span className={clsx(pill, "rounded-full bg-accent text-accent-fg")}>Mine</span>
<span className={clsx(pill, "rounded-full text-muted")}>Library</span>
</span>
<span className={clsx(pill, "glass-card gap-1.5 rounded-full border")}>
<span className="text-muted">Sort:</span> Newest
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</span>
<SelectTrigger context="toolbar" open={false} label="Sort">
Newest
</SelectTrigger>
</div>
<div className="flex min-h-11 items-center justify-between rounded-lg bg-card/60 px-3">
<span className="text-sm">Dark mode</span>
+28 -23
View File
@@ -4,7 +4,6 @@ import {
BarChart3,
Bell,
Check,
ChevronDown,
Clapperboard,
Download,
Home,
@@ -25,6 +24,8 @@ import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
import { Chip } from "../components/ui/Chip";
import { SURFACE } from "../components/ui/surfaces";
import { SelectTrigger } from "../components/ui/SelectTrigger";
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
@@ -157,7 +158,7 @@ function Cards() {
return (
<div className="grid grid-cols-3 gap-2 p-3">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
<div key={i} className={`${SURFACE.card} flex flex-col gap-1 p-1.5`}>
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
<span className="truncate text-2xs">Video title number {i + 1}</span>
<span className="text-3xs text-muted">Channel · 2 days ago</span>
@@ -172,10 +173,6 @@ const tab = (on: boolean) =>
"rounded-lg px-2.5 py-1 text-xs",
on ? "bg-accent text-accent-fg" : clsx("text-muted hover:text-fg", LAYER),
);
const pillCtl = clsx(
"glass-card inline-flex items-center gap-1 rounded-full border px-2.5 text-xs",
H.bar,
);
function Tabs() {
return (
@@ -193,17 +190,25 @@ function Band({ collapsed }: { collapsed?: boolean }) {
<div className="flex items-center justify-between gap-2">
<Tabs />
{collapsed && (
<span className={pillCtl}>
<SlidersHorizontal className="h-3 w-3" /> Filters · 3
</span>
<SelectTrigger
context="toolbar"
open={false}
icon={<SlidersHorizontal className="size-3.5 shrink-0 text-muted" />}
>
Filters · 3
</SelectTrigger>
)}
</div>
{!collapsed && (
<div className="flex flex-wrap gap-1.5">
{["State: Unwatched", "Date: Any", "Sort: Newest"].map((f) => (
<span key={f} className={pillCtl}>
{f} <ChevronDown className="h-3 w-3 text-muted" />
</span>
{[
["State", "Unwatched"],
["Date", "Any"],
["Sort", "Newest"],
].map(([k, v]) => (
<SelectTrigger key={k} context="toolbar" open={false} label={k}>
{v}
</SelectTrigger>
))}
</div>
)}
@@ -215,22 +220,22 @@ function Band({ collapsed }: { collapsed?: boolean }) {
function Surfaces() {
return (
<div className="flex flex-col gap-3 rounded-xl bg-linear-to-br from-accent/35 via-card to-bg p-3">
<div className="glass flex flex-col gap-2 rounded-2xl p-3">
<div className={`${SURFACE.panel} flex flex-col gap-2 p-3`}>
<span className="text-sm font-semibold">Dialog · .glass r16</span>
<div className="grid grid-cols-2 gap-2">
<div className="glass-card rounded-xl p-2 text-xs">Card · r12</div>
<div className="glass-card rounded-xl p-2 text-xs">Picker · r12</div>
<div className={`${SURFACE.card} p-2 text-xs`}>Card · r12</div>
<div className={`${SURFACE.card} p-2 text-xs`}>Picker · r12</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="glass-menu flex w-44 flex-col rounded-xl p-1 text-xs">
<div className={`${SURFACE.menu} flex w-44 flex-col text-xs`}>
{["Watch later", "Add to playlist", "Copy link"].map((t) => (
<span key={t} className={clsx("flex h-7 items-center rounded-lg px-2", LAYER)}>
{t}
</span>
))}
</div>
<span className="glass-menu rounded-lg px-2 py-1 text-2xs">Tooltip · .glass-menu r8</span>
<span className={`${SURFACE.tooltip} px-2 py-1 text-2xs`}>Tooltip · .glass-menu r8</span>
</div>
</div>
);
@@ -247,7 +252,7 @@ function WatchPage() {
<div className="grid aspect-video place-items-center rounded-xl border border-border bg-black text-2xs text-white/60">
the browser&apos;s own video controls
</div>
<div className="glass flex flex-wrap items-start gap-2 rounded-2xl p-3">
<div className={`${SURFACE.panel} flex flex-wrap items-start gap-2 p-3`}>
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-semibold">Building a home lab on a budget</div>
<div className="text-xs text-muted">Techno Tim</div>
@@ -333,7 +338,7 @@ function Rail({ lang }: { lang?: "en" | "hu" }) {
<div className="flex flex-col items-start gap-1.5">
<div
ref={ref}
className="glass flex w-15 flex-col items-center gap-0.5 rounded-2xl px-1 py-2.5"
className={`${SURFACE.panel} flex w-15 flex-col items-center gap-0.5 px-1 py-2.5`}
>
{NAV.map((d, i) => (
<RailItem key={d.en} d={d} on={i === 1} lang={lang} />
@@ -361,7 +366,7 @@ function Loading() {
<Sample label="A grid, first load: its skeleton (after ~300ms)">
<div className="grid max-w-md grid-cols-3 gap-2">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="glass-card flex flex-col gap-1.5 rounded-xl p-1.5">
<div key={i} className={`${SURFACE.card} flex flex-col gap-1.5 p-1.5`}>
<div className={clsx("aspect-video rounded-lg", bone)} />
<div className={clsx("h-2 w-4/5", bone)} />
<div className={clsx("h-2 w-1/2", bone)} />
@@ -413,7 +418,7 @@ function Fades() {
<ScrollBody />
</Frame>
<Sample label="2 · Tabs over a scrolling panel">
<div className="glass relative flex h-44 flex-col overflow-hidden rounded-2xl">
<div className={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden`}>
<div className={clsx("relative z-base px-3 pb-2 pt-3", FADE_BELOW_PANEL)}>
<Tabs />
</div>
@@ -423,7 +428,7 @@ function Fades() {
</div>
</Sample>
<Sample label="3 · A dialog's header and footer">
<div className="glass relative flex h-44 flex-col overflow-hidden rounded-2xl">
<div className={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden`}>
<div
className={clsx(
"relative z-base flex items-center gap-2 px-3 py-2 text-sm font-semibold",
+8 -5
View File
@@ -24,6 +24,7 @@ import { Btn } from "./batchA";
import { C1 } from "./batchCActions";
import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens";
import { SURFACE } from "../components/ui/surfaces";
// E28 S2 · Batch C — behaviour on touch and on a narrow window, DECIDED (owner, 2026-09-24): C1–C4 as
// proposed; C5 is the owner's reworked Plex control set (found by the live player pass, E28 S2d: the
@@ -45,7 +46,7 @@ function Content() {
return (
<div className="grid grid-cols-3 gap-2 p-3">
{Array.from({ length: 9 }, (_, i) => (
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
<div key={i} className={`${SURFACE.card} flex flex-col gap-1 p-1.5`}>
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
<span className="truncate text-2xs">Video {i + 1}</span>
</div>
@@ -56,7 +57,7 @@ function Content() {
function FilterPanel() {
return (
<div className="glass flex h-full w-44 flex-col gap-2 rounded-r-2xl p-3 text-xs">
<div className={`${SURFACE.panel} flex h-full w-44 flex-col gap-2 p-3 text-xs`}>
<div className="flex items-center justify-between">
<span className="font-semibold">Filters</span>
<span className="flex gap-0.5">
@@ -163,7 +164,7 @@ function C4() {
title="Dialog conventions"
rule="Modal takes an initialFocus by purpose (APG): a form focuses its first field, a confirm its least destructive button, anything else Close. The header and the footer are fixed, only the body scrolls, with the B9 fade at both edges, so Save stays one tap away with the keyboard up. Shown at a keyboard-up height."
>
<div className="glass relative flex h-52 flex-col overflow-hidden rounded-2xl">
<div className={`${SURFACE.panel} relative flex h-52 flex-col overflow-hidden`}>
<div
className={clsx(
"relative z-base flex items-center justify-between px-3 py-2 text-sm font-semibold",
@@ -270,7 +271,9 @@ function PlayerRow() {
<Maximize className="h-5 w-5" />
</span>
</div>
<div className="glass-menu absolute bottom-16 right-2 flex w-52 flex-col rounded-xl p-1 text-xs text-fg">
<div
className={`${SURFACE.menu} absolute bottom-16 right-2 flex w-52 flex-col text-xs text-fg`}
>
{MORE.map((c) => (
<span key={c.label} className="flex min-h-11 items-center gap-2.5 rounded-lg px-2.5">
<c.icon className={ico} />
@@ -286,7 +289,7 @@ function PlayerRow() {
function MediaCard({ state }: { state: "new" | "started" | "touch" }) {
const pill = "inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium";
return (
<div className="glass-card flex w-44 flex-col gap-1.5 rounded-xl p-1.5">
<div className={`${SURFACE.card} flex w-44 flex-col gap-1.5 p-1.5`}>
<div className="relative aspect-2/3 overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
{state !== "new" && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
+2 -1
View File
@@ -2,6 +2,7 @@ import clsx from "clsx";
import { Bookmark, Check, ListPlus, MoreHorizontal, Play } from "lucide-react";
import { Reference, Sample } from "./labKit";
import { H, LAYER } from "./labTokens";
import { SURFACE } from "../components/ui/surfaces";
// E28 S2 · Batch C, part 1 — action placement: the classification table (object × action → where it
// lives), DECIDED (owner, 2026-09-24: accepted as proposed). It is the source S3 builds each object's
@@ -316,7 +317,7 @@ function Card({ touch }: { touch: boolean }) {
touch ? "h-11 w-11" : H.iconSm,
);
return (
<div className="glass-card group flex w-60 flex-col gap-1.5 rounded-xl p-1.5">
<div className={`${SURFACE.card} group flex w-60 flex-col gap-1.5 p-1.5`}>
<div className="relative aspect-video overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
<span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
<span className="block h-1 w-1/3 bg-accent" />
+2 -1
View File
@@ -1,5 +1,6 @@
import { useSyncExternalStore, type ReactNode } from "react";
import clsx from "clsx";
import { SURFACE } from "../components/ui/surfaces";
// Building blocks of the design lab. Every E28 decision is taken, so the lab is the reference half
// only: each <Reference> is the chosen rendering with the rule it became. A new design round brings
@@ -22,7 +23,7 @@ export function Reference({
children: ReactNode;
}) {
return (
<section id={id} className="glass-card rounded-xl p-4 flex flex-col gap-3 scroll-mt-20">
<section id={id} className={`${SURFACE.card} p-4 flex flex-col gap-3 scroll-mt-20`}>
<header>
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-success">{code} ✓</span>
@@ -0,0 +1,44 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// `local/surface-role` (eslint.config.js): a glass surface is a role from ui/surfaces.ts, which brings
// its radius (docs/DESIGN.md B5; E28 S3d). ~140 sites paired three tiers with five radii; this pins
// that the rule keeping a new one out fires, and where it stands down.
const RULE = "local/surface-role";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a bare glass tier", () => {
it("is refused in a class string, a template fragment and a ui/ composite", async () => {
expect(await hits('export const A = () => <div className="glass rounded-2xl p-4" />;\n')).toBe(
1,
);
expect(await hits('export const B = "glass-menu w-60 rounded-xl p-2";\n')).toBe(1);
expect(await hits("export const C = (x: string) => `glass-card ${x}`;\n")).toBe(1);
expect(
await hits(
'export const D = () => <div className="glass p-3" />;\n',
"src/components/ui/Toaster.tsx",
),
).toBe(1);
});
it("passes as a lone value, through a role, and in the role table itself", async () => {
expect(await hits('export const E = { variant: "glass" };\n')).toBe(0);
expect(
await hits(
'import { SURFACE } from "./ui/surfaces";\nexport const F = () => <div className={`${SURFACE.card} p-3`} />;\n',
),
).toBe(0);
expect(
await hits('export const G = "glass rounded-2xl";\n', "src/components/ui/surfaces.ts"),
).toBe(0);
});
});