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 -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>
+13 -47
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);
}}
onFocus={() => setOpen(true)}
// Close on blur, but after a tick so a click on a dropdown row still registers.
onBlur={() => setTimeout(() => setOpen(false), 150)}
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>
<UserPicker
users={list}
loading={recipients.isLoading}
onPick={(u) => {
if (!share.isPending) share.mutate(u.email);
}}
label={t("downloads.share.userSection")}
placeholder={t("downloads.share.pickPlaceholder")}
/>
{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" : ""}`}
>
+10 -7
View File
@@ -36,13 +36,16 @@ 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` |
| 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` |
| 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`, `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` |
## The size contract (E10 S1c)
+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"
}`}
>