diff --git a/CLAUDE.md b/CLAUDE.md
index 53e6a54f..2c5da3bb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
-| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) | a hand-rolled `
)}
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx
index 1d3c1669..11de7b53 100644
--- a/frontend/src/components/ChatDock.tsx
+++ b/frontend/src/components/ChatDock.tsx
@@ -16,6 +16,8 @@ import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
// 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
@@ -82,9 +84,9 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
/>
)}
diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx
index 12dd501f..40959122 100644
--- a/frontend/src/components/LanguageSwitcher.tsx
+++ b/frontend/src/components/LanguageSwitcher.tsx
@@ -3,6 +3,7 @@ import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
+import { Pressable } from "./ui/Pressable";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
@@ -42,15 +43,16 @@ export default function LanguageSwitcher({
return (
<>
-
@@ -63,7 +65,7 @@ export default function LanguageSwitcher({
) : (
{current.code}
)}
-
+
-
@@ -55,7 +56,7 @@ export default function LogLine({
{/* A phone has no room for a fourth column: the message takes a line of its own under the
code, full width — sharing the code's line left it a few characters. */}
{msg}
-
+
{open &&
{(["hu", "en"] as const).map((st) => (
- patchPrefs({ clockDateStyle: st })}
className={`flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st
@@ -263,7 +268,7 @@ export function PlexPlayerSettings(props: {
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
-
+
))}
)}
diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx
index e8fee825..dad4da0c 100644
--- a/frontend/src/components/PlexPlaylistAdd.tsx
+++ b/frontend/src/components/PlexPlaylistAdd.tsx
@@ -8,7 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { Pressable } from "./ui/Pressable";
// 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.
@@ -146,11 +146,13 @@ export default function PlexPlaylistAdd({
const all = n >= size;
const partial = n > 0 && n < size;
return (
- toggle(p)}
disabled={busy}
- className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${
+ className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm disabled:opacity-50 ${
all ? "text-accent" : ""
}`}
>
@@ -175,7 +177,7 @@ export default function PlexPlaylistAdd({
{t("plex.playlistAdd.count", { count: p.item_count })}
)}
-
+
);
})
)}
diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx
index 1195527c..5f757fef 100644
--- a/frontend/src/components/PlexSearchBox.tsx
+++ b/frontend/src/components/PlexSearchBox.tsx
@@ -9,6 +9,7 @@ import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
+import { Pressable } from "./ui/Pressable";
// 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
@@ -144,17 +145,16 @@ export default function PlexSearchBox() {
) : null
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index aa016893..57932d75 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -4,7 +4,7 @@ import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
+import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { type PanelLayout } from "../lib/panelLayout";
@@ -20,6 +20,7 @@ import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext";
import { useMe } from "../lib/useMe";
+import { DismissibleChip } from "./ui/Chip";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -498,16 +499,13 @@ function CollectionPicker({
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
const { t } = useTranslation();
return (
-
- {label}
-
-
-
-
+
+ {label}
+
);
}
diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx
index 0cadd95f..24e33974 100644
--- a/frontend/src/components/PlexStatsOverlay.tsx
+++ b/frontend/src/components/PlexStatsOverlay.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
+import { IconButton } from "./ui/IconButton";
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
reader announce the changing values continuously and drown out real player announcements.
diff --git a/frontend/src/components/PosterTile.tsx b/frontend/src/components/PosterTile.tsx
index 64e3b75a..a2298e5c 100644
--- a/frontend/src/components/PosterTile.tsx
+++ b/frontend/src/components/PosterTile.tsx
@@ -1,6 +1,7 @@
import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
+import { Pressable } from "./ui/Pressable";
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
@@ -26,12 +27,11 @@ export default function PosterTile({
const posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title));
return (
-
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
diff --git a/frontend/src/components/SavedViewsPopover.tsx b/frontend/src/components/SavedViewsPopover.tsx
index e3a576fd..72c15954 100644
--- a/frontend/src/components/SavedViewsPopover.tsx
+++ b/frontend/src/components/SavedViewsPopover.tsx
@@ -4,6 +4,8 @@ import { Bookmark, Check, Settings2 } from "lucide-react";
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";
// 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
@@ -26,15 +28,26 @@ export default function SavedViewsPopover({
const label = ariaLabel ?? t("views.saved");
return (
<>
-
- {trigger}
-
+ {/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */}
+ {className ? (
+
+ {trigger}
+
+ ) : (
+
+ {trigger}
+
+ )}
{value && (
- onChange("")}
- title={t("header.clearSearch")}
- aria-label={t("header.clearSearch")}
- className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
- >
-
-
+ onChange("")} label={t("header.clearSearch")}>
+
+
)}
{onGo && (
-
{goIcon}
{goLabel ?? t("header.go")}
-
+
)}
{below}
diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx
index a02bcfee..b1dc5f13 100644
--- a/frontend/src/components/SetupWizard.tsx
+++ b/frontend/src/components/SetupWizard.tsx
@@ -6,7 +6,6 @@ import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
// 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
@@ -280,27 +279,23 @@ export default function SetupWizard() {
)}
diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx
index c66fa8d4..765fef0d 100644
--- a/frontend/src/components/TagManager.tsx
+++ b/frontend/src/components/TagManager.tsx
@@ -12,6 +12,8 @@ import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
// 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
@@ -67,14 +69,9 @@ function TagRow({
>
{t("tagManager.channels", { count: tag.channel_count })}
-
-
-
+
+
+
{channels.map((ch) => (
- onPickChannel(ch.title)}
- className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
+ className="w-full text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg truncate"
>
{ch.title}
-
+
))}
diff --git a/frontend/src/components/UpNextCard.tsx b/frontend/src/components/UpNextCard.tsx
index e2901bcf..720285fd 100644
--- a/frontend/src/components/UpNextCard.tsx
+++ b/frontend/src/components/UpNextCard.tsx
@@ -5,6 +5,8 @@ import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
+import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
@@ -84,10 +86,7 @@ export function UpNextCard({
{heading || "…"}
{sub &&
{sub}
}
-
+
-
+
{t("plex.player.upNext.cancel")}
-
+
-
-
-
+
+
);
}
diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx
index 1b6a762e..91fe61c9 100644
--- a/frontend/src/components/UploadDialog.tsx
+++ b/frontend/src/components/UploadDialog.tsx
@@ -14,7 +14,7 @@ import { navigateTo } from "../lib/nav";
import { formatBytes } from "../lib/format";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { inputCls } from "./ui/form";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { Pressable } from "./ui/Pressable";
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
@@ -102,8 +102,8 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
{t("downloads.upload.hint")}
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
- {
e.preventDefault();
@@ -158,7 +158,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
>
)}
-
+
+
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
@@ -559,7 +555,7 @@ function VideoCard({
budget). ROW_COL.actions already counts its width. */}
{actions}
-
+
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
diff --git a/frontend/src/components/VideoInfoCard.tsx b/frontend/src/components/VideoInfoCard.tsx
index 21004e08..1433609d 100644
--- a/frontend/src/components/VideoInfoCard.tsx
+++ b/frontend/src/components/VideoInfoCard.tsx
@@ -9,6 +9,7 @@ import { linkify } from "../lib/linkify";
import Avatar from "./ui/Avatar";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
+import { IconButton } from "./ui/IconButton";
// 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
@@ -31,11 +32,14 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({
video,
+ onMedia = false,
className,
}: {
video: Video;
- /** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
- * live here so each host can match its surface (overlay vs inline). */
+ /** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
+ onMedia?: boolean;
+ /** Trigger placement + hover-reveal. The reveal/touch-suppression rules live with the host, which
+ * knows its surface (overlay vs inline); the skin is the IconButton's. */
className?: string;
}) {
const { t, i18n } = useTranslation();
@@ -90,8 +94,8 @@ export default function VideoInfoCard({
return (
<>
- (like the play buttons), so a click must
@@ -100,12 +104,11 @@ export default function VideoInfoCard({
e.preventDefault();
e.stopPropagation();
}}
- aria-label={t("card.info")}
- title={t("card.info")}
+ label={t("card.info")}
className={className}
>
-
-
+
+
{
id: T;
@@ -59,7 +60,7 @@ export default function ViewSwitcher({
return (
<>
- ({
-
+
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
- }
onClick={toggleRepeat}
- className="pill inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium"
+ className="font-medium"
>
- {T.repeat}
-
+ {T.repeat}
+
{meta.allow_download && (
{
const on = selected === y.year;
return (
- pick(on ? undefined : y.year)}
- className={filterChipClass}
- >
+ pick(on ? undefined : y.year)}>
{y.year}
{y.count > 0 && }
-
+
);
})}
{years.length > quick.length && (
<>
- {t("sidebar.year.more")}
-
+
(
- setSortByCount(v)}
- className={`text-2xs rounded-md border px-2 py-0.5 transition ${
- sortByCount === v
- ? "border-accent bg-accent/15 text-accent"
- : "border-border text-muted hover:border-accent/50 hover:text-fg"
- }`}
>
{t("sidebar.year." + key)}
-
+
))}
diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx
index 13c0d799..7faef334 100644
--- a/frontend/src/components/channelCells.tsx
+++ b/frontend/src/components/channelCells.tsx
@@ -4,7 +4,7 @@ import type { ManagedChannel } from "../lib/api";
import { formatNumber } from "../lib/format";
import Tooltip from "./ui/Tooltip";
import { isFullySynced } from "./channelSync";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { IconButton } from "./ui/IconButton";
// The Channels-manager cells that are components in their own right (they read i18n), split out of
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
@@ -67,39 +67,35 @@ export function ActionsCell({
return (
{job.source_url && }
@@ -306,12 +300,10 @@ export function DownloadRow({
)}
- {/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
- IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
- them on the title's line; the text column shrinks instead. */}
-
- {children}
-
+ {/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one
+ IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column
+ shrinks instead. */}
+
{children}
);
}
diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/components/playerModalControls.tsx
index 7e8c5b15..b2933370 100644
--- a/frontend/src/components/playerModalControls.tsx
+++ b/frontend/src/components/playerModalControls.tsx
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { AutoMode, LoopMode } from "../lib/playerModes";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { Button } from "./ui/Button";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
@@ -35,76 +35,69 @@ export function PlayerTransportControls({
}) {
const { t } = useTranslation();
const docked = variant === "docked";
- const navBtn = docked
- ? "inline-flex items-center gap-1 text-white/80 enabled:not-aria-disabled:hover:text-white disabled:opacity-30 transition"
- : "inline-flex items-center gap-1.5 text-muted enabled:not-aria-disabled:hover:text-fg disabled:opacity-40 transition";
+ // The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
+ const chrome = docked ? "overlay" : "ghost";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
- const toggle = (on: boolean) =>
- docked
- ? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
- on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
- }`
- : `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
- on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
- }`;
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
- }
onClick={onPrev}
disabled={index === 0}
+ label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
- className={navBtn}
>
- {t("player.previous")}
-
+
{index + 1} / {queueLength}
- {t("player.next")}
-
-
+
+
{/* Persistent playback settings — cycle on click, saved to your account. */}
- : autoMode === "random" ? :
+ }
onClick={onCycleAuto}
+ label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
title={t("player.autoAdvance.hint")}
- className={toggle(autoMode !== "off")}
>
- {autoMode === "prev" ? (
-
- ) : autoMode === "random" ? (
-
- ) : (
-
- )}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
-
-
+ : }
onClick={onCycleLoop}
+ label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
title={t("player.loop.hint")}
- className={toggle(loopMode !== "off")}
>
- {loopMode === "one" ? (
-
- ) : (
-
- )}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
-
+
>
);
}
diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx
index f3017786..654f49be 100644
--- a/frontend/src/components/playlistColumns.tsx
+++ b/frontend/src/components/playlistColumns.tsx
@@ -3,6 +3,8 @@ import type { TFunction } from "i18next";
import type { Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import type { Column } from "../lib/tableColumns";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
@@ -56,7 +58,7 @@ export function playlistColumns({
cardPrimary: true,
render: (v) => (
- onPlay(v)}
aria-label={`${t("card.play")} — ${v.title}`}
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group"
@@ -67,15 +69,15 @@ export function playlistColumns({
-
-
+ onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open=""
- className="min-w-0 flex-1 text-left"
+ className="min-w-0 flex-1"
>
{v.title}
-
+
),
},
@@ -129,14 +131,14 @@ export function playlistColumns({
width: "2.5rem",
nowrap: true,
render: (v) => (
- onRemove(v)}
+ label={t("playlists.removeItemOf", { title: v.title })}
title={t("playlists.removeItem")}
- aria-label={t("playlists.removeItemOf", { title: v.title })}
- className="text-muted hover:text-fg p-1"
>
-
-
+
+
),
});
}
diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx
index c8c006e9..e42d5dcd 100644
--- a/frontend/src/components/plexBrowseLists.tsx
+++ b/frontend/src/components/plexBrowseLists.tsx
@@ -12,7 +12,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { Button } from "./ui/Button";
+import { IconButton } from "./ui/IconButton";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -109,13 +110,9 @@ function NewCollectionPanel({
@@ -190,13 +188,9 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{isAdmin && !creating && (
- setCreating(true)}
- className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
- >
-
+ } onClick={() => setCreating(true)}>
{t("plex.collection.newCollection")}
-
+
)}
{creating && (
diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx
index b081ac26..2afa1cc9 100644
--- a/frontend/src/components/plexPlayerControls.tsx
+++ b/frontend/src/components/plexPlayerControls.tsx
@@ -1,7 +1,7 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react";
-import { FocusSafeButton } from "./ui/FocusSafeButton";
+import { IconButton } from "./ui/IconButton";
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
@@ -82,13 +82,14 @@ export function Slider({
{suffix ?? ""}
{onReset && (
-
-
-
+
+
)}
@@ -146,14 +147,17 @@ export function Ctrl({
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return (
-
{children}
-
+
diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx
index c3ccd0dd..92826a98 100644
--- a/frontend/src/components/plexViews.tsx
+++ b/frontend/src/components/plexViews.tsx
@@ -27,6 +27,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
+import { Chip } from "./ui/Chip";
+import { Pressable } from "./ui/Pressable";
// 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,
@@ -222,12 +224,9 @@ export function PlexPersonView({
)}
{left && (
- page(-1)}
- aria-label={t("common.scrollLeft")}
+ label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
-
-
+
+
)}
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
@@ -140,14 +140,15 @@ export function Carousel({
{children}
diff --git a/frontend/src/components/ui/Chip.dom.test.tsx b/frontend/src/components/ui/Chip.dom.test.tsx
new file mode 100644
index 00000000..fc036f02
--- /dev/null
+++ b/frontend/src/components/ui/Chip.dom.test.tsx
@@ -0,0 +1,86 @@
+// @vitest-environment jsdom
+//
+// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
+// filter, an applied narrowing, a tag and a suggestion, where five files had five.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { Chip, DismissibleChip } from "./Chip";
+import { chipClass } from "./chipStyles";
+
+afterEach(cleanup);
+
+describe("Chip", () => {
+ it("is a pill on the 24px floor that never submits a form, and a toggle announces its state", () => {
+ const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
+ render(
+ ,
+ );
+ const chip = screen.getByRole("button", { name: "2024" });
+ expect(chip.className).toContain("rounded-full");
+ expect(chip.className).toContain("h-6");
+ expect(chip.className).toContain("touch-target");
+ expect(chip.getAttribute("aria-pressed")).toBe("false");
+ fireEvent.click(chip);
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("a removing chip is named by the removal and shows the ×", () => {
+ const onClick = vi.fn();
+ render(
+
+ Music
+ ,
+ );
+ const chip = screen.getByRole("button", { name: "Remove Music" });
+ expect(chip.getAttribute("title")).toBe("Remove Music");
+ expect(chip.className).toContain("h-7");
+ expect(chip.querySelector("svg")).not.toBeNull();
+ fireEvent.click(chip);
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("the measuring mirror lays out exactly like the chip it measures", () => {
+ render(
+
+ tag
+ ,
+ );
+ const cls = screen.getByRole("button", { name: "tag" }).className;
+ for (const c of chipClass("tag", "xs").split(" ")) expect(cls).toContain(c);
+ });
+});
+
+describe("DismissibleChip", () => {
+ it("is two sibling controls at most, never a button in a button", () => {
+ const onAccept = vi.fn();
+ const onDismiss = vi.fn();
+ const { container } = render(
+
+ Music
+ ,
+ );
+ expect(container.querySelector("button button")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Add the tag Music" }));
+ fireEvent.click(screen.getByRole("button", { name: "Not now" }));
+ expect(onAccept).toHaveBeenCalledOnce();
+ expect(onDismiss).toHaveBeenCalledOnce();
+ });
+
+ it("with no body action the label is text, and only the × is a control", () => {
+ render(
+ {}} dismissLabel="Remove Year: 2024">
+ Year: 2024
+ ,
+ );
+ expect(screen.getAllByRole("button")).toHaveLength(1);
+ expect(screen.getByText("Year: 2024").closest("button")).toBeNull();
+ });
+});
diff --git a/frontend/src/components/ui/Chip.tsx b/frontend/src/components/ui/Chip.tsx
new file mode 100644
index 00000000..9a2e70e6
--- /dev/null
+++ b/frontend/src/components/ui/Chip.tsx
@@ -0,0 +1,125 @@
+import { forwardRef } from "react";
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import clsx from "clsx";
+import { X } from "lucide-react";
+import { FocusSafeButton } from "./FocusSafeButton";
+import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles";
+
+// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that
+// is a filter, an applied narrowing, a tag, or a suggestion. It was hand-painted in five files with
+// five geometries; this is the one. `FilterChipGroup` renders a single-select row of these.
+//
+// filter — a selectable option (a year, a tag filter, a content type). Toggles with `pressed`: the
+// `.pill` recipe in index.css paints the solid accent when on. Also the "+N" overflow and
+// "More…" triggers, which light up while their popover is open (aria-expanded).
+// token — an applied narrowing, tinted accent (the `.chip` recipe): it exists because it is on.
+// tag — a user's tag, the solid accent (`.chip-solid`). Dense: it sits in table rows.
+// suggest — dashed accent: a suggestion not yet taken.
+// ghost — dashed neutral: the "add a tag" trigger, quiet in every table row until reached for.
+//
+// Nothing goes under 24px (B1, WCAG 2.5.8) and a coarse pointer grows every chip to 44 (A3).
+
+type ChipProps = ButtonHTMLAttributes & {
+ variant?: ChipVariant;
+ size?: ChipSize;
+ /** A filter chip that toggles: aria-pressed, painted solid when on. */
+ pressed?: boolean;
+ /** Leading glyph (a lucide element). */
+ icon?: ReactNode;
+ /** The whole chip removes what it stands for: a trailing × and the removal as its name. */
+ removeLabel?: string;
+};
+
+/** A chip that is itself the control (a toggle, a removal, an accept, a "+N" trigger). */
+export const Chip = forwardRef(function Chip(
+ {
+ variant = "filter",
+ size = "sm",
+ pressed,
+ icon,
+ removeLabel,
+ type,
+ className,
+ children,
+ ...rest
+ },
+ ref,
+) {
+ return (
+
+ {icon}
+ {children}
+ {removeLabel && (
+
+ )}
+
+ );
+});
+
+/** A chip whose label is not the control but which carries its own trailing × (a captured filter in
+ * a saved view, a suggestion with a dismiss). Two sibling controls at most, never a button in a
+ * button: `onClick` makes the body one (an accept), `onDismiss` the ×. */
+export function DismissibleChip({
+ variant = "token",
+ size = "sm",
+ icon,
+ children,
+ onClick,
+ bodyLabel,
+ bodyTitle,
+ onDismiss,
+ dismissLabel,
+ className,
+}: {
+ variant?: Exclude;
+ size?: ChipSize;
+ icon?: ReactNode;
+ children: ReactNode;
+ onClick?: () => void;
+ bodyLabel?: string;
+ bodyTitle?: string;
+ onDismiss: () => void;
+ dismissLabel: string;
+ className?: string;
+}) {
+ const body = (
+ <>
+ {icon}
+ {children}
+ >
+ );
+ return (
+
+ {onClick ? (
+
+ {body}
+
+ ) : (
+ {body}
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx
index b661d342..200cc6e1 100644
--- a/frontend/src/components/ui/CommandPalette.tsx
+++ b/frontend/src/components/ui/CommandPalette.tsx
@@ -4,6 +4,7 @@ import { Search } from "lucide-react";
import Modal from "./Modal";
import { closeTopOverlayThenRun } from "../../lib/history";
import { pageStep, pageTarget } from "../../lib/listPaging";
+import { Pressable } from "./Pressable";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
@@ -243,14 +244,13 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
{g.label}
{g.items.map((item) => (
- void; groups:
if (e.movementX === 0 && e.movementY === 0) return;
setPicked(item.id);
}}
- className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
+ className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm ${
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
{item.icon && {item.icon}}
{item.label}
{item.hint && {item.hint}}
-
+
))}
))}
diff --git a/frontend/src/components/ui/FilterChipGroup.dom.test.tsx b/frontend/src/components/ui/FilterChipGroup.dom.test.tsx
index 388872b6..0a20d674 100644
--- a/frontend/src/components/ui/FilterChipGroup.dom.test.tsx
+++ b/frontend/src/components/ui/FilterChipGroup.dom.test.tsx
@@ -4,7 +4,7 @@
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
-import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
+import FilterChipGroup from "./FilterChipGroup";
const OPTS = [
{ value: "all", label: "All", testId: "chip-all" },
@@ -56,8 +56,12 @@ describe("FilterChipGroup", () => {
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
});
- it("exports the canonical chip class for multi-select siblings to reuse", () => {
- expect(filterChipClass).toContain("rounded-full");
- expect(filterChipClass).toContain("pill");
+ it("renders its options as Chips: the pill, on the 24px rung", () => {
+ render();
+ const chip = screen.getByTestId("chip-all");
+ expect(chip.className).toContain("rounded-full");
+ expect(chip.className).toContain("pill");
+ expect(chip.className).toContain("h-6");
+ expect(chip.getAttribute("type")).toBe("button");
});
});
diff --git a/frontend/src/components/ui/FilterChipGroup.tsx b/frontend/src/components/ui/FilterChipGroup.tsx
index 99bc79d9..4c107422 100644
--- a/frontend/src/components/ui/FilterChipGroup.tsx
+++ b/frontend/src/components/ui/FilterChipGroup.tsx
@@ -1,13 +1,6 @@
import { type ReactNode } from "react";
import CountBadge from "./CountBadge";
-
-// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
-// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
-// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
-// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
-// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
-export const filterChipClass =
- "pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
+import { Chip } from "./Chip";
export interface FilterChipOption {
value: T;
@@ -19,7 +12,7 @@ export interface FilterChipOption {
}
// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
-// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
+// render `Chip`s with `pressed` instead — same look, different (independent) semantics.
export default function FilterChipGroup({
options,
value,
@@ -39,18 +32,17 @@ export default function FilterChipGroup({
{options.map((o) => {
const on = value === o.value;
return (
- onChange(o.value)}
title={o.title}
- aria-pressed={on}
- className={filterChipClass}
+ pressed={on}
+ icon={o.icon}
>
- {o.icon}
{o.label}
{o.count != null && o.count > 0 && }
-
+
);
})}
diff --git a/frontend/src/components/ui/IconButton.tsx b/frontend/src/components/ui/IconButton.tsx
index cc76174d..5b670bd9 100644
--- a/frontend/src/components/ui/IconButton.tsx
+++ b/frontend/src/components/ui/IconButton.tsx
@@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton";
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
@@ -24,17 +26,20 @@ type IconButtonVariant =
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)
+ | "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 = {
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).
@@ -50,6 +55,7 @@ const SIZES: Record = {
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, "children"> {
@@ -94,7 +100,7 @@ export const IconButton = forwardRef(functio
aria-pressed={pressed}
title={noTitle ? undefined : label}
className={clsx(
- "inline-grid shrink-0 place-items-center transition touch-target disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
+ "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],
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
diff --git a/frontend/src/components/ui/Pager.tsx b/frontend/src/components/ui/Pager.tsx
index 2623ad1c..3cdfab19 100644
--- a/frontend/src/components/ui/Pager.tsx
+++ b/frontend/src/components/ui/Pager.tsx
@@ -2,8 +2,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
-import { FocusSafeButton } from "./FocusSafeButton";
import SegmentedToggle from "./SegmentedToggle";
+import { Button } from "./Button";
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
@@ -66,14 +66,15 @@ export default function Pager({
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md
index 5097d2e8..0be8fb54 100644
--- a/frontend/src/components/ui/README.md
+++ b/frontend/src/components/ui/README.md
@@ -39,7 +39,7 @@ domain-free.
| 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`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
+| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `` outside this folder is a lint error), `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` |
@@ -51,9 +51,9 @@ are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Devic
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
-- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` and `IconButton`'s sizes are the
- rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll
- padding on a primitive.
+- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
+ are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
+ and player rungs). Pick a rung; do not hand-roll padding on a primitive.
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
diff --git a/frontend/src/components/ui/SidePanel.tsx b/frontend/src/components/ui/SidePanel.tsx
index 86fa02d6..e38e195f 100644
--- a/frontend/src/components/ui/SidePanel.tsx
+++ b/frontend/src/components/ui/SidePanel.tsx
@@ -4,6 +4,7 @@ import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { IconButton } from "./IconButton";
+import { Pressable } from "./Pressable";
// 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
@@ -72,7 +73,7 @@ export default function SidePanel({
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
-
{title}
-
+
{expanded && (
diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx
index 263cbbf3..daf5d5a1 100644
--- a/frontend/src/components/ui/Toaster.tsx
+++ b/frontend/src/components/ui/Toaster.tsx
@@ -6,6 +6,8 @@ import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels";
+import { Button } from "./Button";
+import { IconButton } from "./IconButton";
// 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 at the
@@ -170,24 +172,25 @@ export default function Toaster({
)}
{toast.action && (
- {
toast.action!.onClick();
dismiss(toast.id);
}}
- className="mt-1 text-accent text-sm font-semibold hover:underline"
+ className="mt-1 text-sm"
>
{toast.action.label}
-
+
)}
- dismiss(toast.id)}
- className="shrink-0 text-muted hover:text-fg"
- title={t("notifications.dismiss")}
+ label={t("notifications.dismiss")}
+ className="-m-1.5"
>
-
-
+
+
{toast.duration && (
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
diff --git a/frontend/src/components/ui/UndoToolbar.tsx b/frontend/src/components/ui/UndoToolbar.tsx
index 3579dc42..4c5ec3d8 100644
--- a/frontend/src/components/ui/UndoToolbar.tsx
+++ b/frontend/src/components/ui/UndoToolbar.tsx
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redo2, Undo2 } from "lucide-react";
import { dispatch, useKeyScope } from "../../lib/keymap";
-import { FocusSafeButton } from "./FocusSafeButton";
+import { IconButton } from "./IconButton";
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
@@ -44,28 +44,14 @@ export default function UndoToolbar({
return () => window.removeEventListener("keydown", onKey);
}, [canUndo, canRedo, onUndo, onRedo]);
- const btn =
- "p-1.5 rounded-lg border border-border text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:border-accent disabled:opacity-30 transition";
return (
-
-
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/frontend/src/components/ui/chipStyles.ts b/frontend/src/components/ui/chipStyles.ts
new file mode 100644
index 00000000..8a1d56a7
--- /dev/null
+++ b/frontend/src/components/ui/chipStyles.ts
@@ -0,0 +1,37 @@
+import clsx from "clsx";
+
+// The chip family's geometry and paint, apart from the component (`Chip.tsx`) so the hidden measuring
+// mirror of an overflowing chip lane can lay out exactly like the chips it measures.
+
+export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
+export type ChipSize =
+ | "md" // 28, text-sm — the applied-filter row above the feed
+ | "sm" // 24, text-xs — a filter row
+ | "xs"; // 24, text-2xs, tight — a tag in a table row
+
+const ACT = "enabled:not-aria-disabled:";
+const PAINT: Record = {
+ // `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
+ // fill, so the open-popover state is the only one spelled out.
+ filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent",
+ token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`,
+ // B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim.
+ tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`,
+ suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`,
+ ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`,
+};
+
+const SIZES: Record = {
+ md: "h-7 px-2.5 text-sm gap-1",
+ sm: "h-6 px-2.5 text-xs gap-1.5",
+ xs: "h-6 px-1.5 text-2xs gap-1",
+};
+
+const BASE =
+ "inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
+
+/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
+ * chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
+export function chipClass(variant: ChipVariant = "filter", size: ChipSize = "sm") {
+ return clsx(BASE, PAINT[variant], SIZES[size]);
+}
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx
index 11b6aff2..694ffd7b 100644
--- a/frontend/src/components/ui/form.tsx
+++ b/frontend/src/components/ui/form.tsx
@@ -14,9 +14,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
export const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
-/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
-export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
-
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({
diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx
index f0542f97..69feeea0 100644
--- a/frontend/src/components/ui/tableHeader.tsx
+++ b/frontend/src/components/ui/tableHeader.tsx
@@ -8,6 +8,9 @@ import { type SortState } from "../../lib/columnSort";
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
import { alignClass } from "./tableAlign";
import { useStickyTop } from "./stickyTop";
+import { Button } from "./Button";
+import { IconButton } from "./IconButton";
+import { Pressable } from "./Pressable";
// 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
@@ -75,17 +78,16 @@ function FilterFunnel({
return (
<>
-
-
-
+
+
({
)}
{filterOn && (
- onApply(col.key, f.kind === "multi" ? [] : "")}
- className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
+ className="mt-1.5 w-full text-xs"
>
{t("datatable.clear")}
-
+
)}
>
@@ -229,7 +232,7 @@ export function ColumnHead({
focusable no-op button, which put unlabeled dead stops in the tab order —
two of them per playlist table, whose grip and actions headers are empty. */}
{col.sortable ? (
- onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
>
@@ -241,7 +244,7 @@ export function ColumnHead({
) : (
))}
-
+
) : (
{col.header}
)}
diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx
index c28b1186..ccab4cd8 100644
--- a/frontend/src/design-lab/DesignLab.tsx
+++ b/frontend/src/design-lab/DesignLab.tsx
@@ -1,9 +1,9 @@
import { useLayoutEffect, useState } from "react";
-import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
+import { Chip } from "../components/ui/Chip";
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE , so it owns the theme attributes on itself, through the same data-theme /
@@ -23,12 +23,6 @@ function applyLook(l: Look) {
el.style.setProperty("--font-scale", String(l.fontScale));
}
-const chip = (on: boolean) =>
- clsx(
- "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition",
- on ? "border-accent bg-accent text-accent-fg" : "border-border text-muted hover:text-fg",
- );
-
export default function DesignLab() {
const [look, setLook] = useState(START);
useLayoutEffect(() => {
@@ -47,36 +41,22 @@ export default function DesignLab() {
Design lab
{(["dark", "light"] as const).map((m) => (
- set({ mode: m })}
- >
+ set({ mode: m })}>
{m}
-
+
))}
{SCHEMES.map((s) => (
- set({ scheme: s.id })}
- >
+ set({ scheme: s.id })}>
{s.name}
-
+
))}
- set({ backdrop: !look.backdrop })}
- >
+ set({ backdrop: !look.backdrop })}>
backdrop
-
+