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 ` + ); diff --git a/frontend/src/components/ExcludedChannelList.tsx b/frontend/src/components/ExcludedChannelList.tsx index 811bfd2e..31588d94 100644 --- a/frontend/src/components/ExcludedChannelList.tsx +++ b/frontend/src/components/ExcludedChannelList.tsx @@ -2,6 +2,7 @@ import { type ReactNode } from "react"; import ChannelLink from "./ChannelLink"; import NavGrid from "./ui/NavGrid"; import { navItemAttrs } from "../lib/listNav"; +import { Button } from "./ui/Button"; // 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 @@ -58,15 +59,9 @@ export default function ExcludedChannelList({ onView={onView ? () => onView(c) : undefined} /> - + ))} diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index cb2ce71d..3c11edd6 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -42,6 +42,7 @@ import { PlexPosterCard } from "./plexCards"; import { useNavigationActions } from "./navigationContext"; import { usePlexActions } from "./plexContext"; import { navItemAttrs } from "../lib/listNav"; +import { Pressable } from "./ui/Pressable"; // The Home surface (E24) — "Mit nézzek most?". // @@ -244,8 +245,7 @@ function ShelfTile({ ? t("home.shelf.unwatchedCount", { count: count ?? 0 }) : t("home.shelf.itemCount", { count: count ?? 0 }); return ( - + ); } @@ -307,8 +307,7 @@ function Hero({ {...pinMenu.triggerProps(card)} className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]" > - +

{kicker}

diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index 3683c045..7b21ff9d 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -172,9 +172,9 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa {configured ? t("messages.unlock") : t("messages.setUp")} {configured && ( - + )} {!configured && !compact &&

{t("messages.setupWarn")}

}
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 ( <> - + - + {open &&
{children}
} ); diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index ca4b44d0..c08d9366 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -19,6 +19,7 @@ import { inputCls } from "./ui/form"; import ChatThread from "./ChatThread"; import NavGrid from "./ui/NavGrid"; import { navItemAttrs } from "../lib/listNav"; +import { Pressable } from "./ui/Pressable"; type View = | "list" @@ -158,10 +159,10 @@ function ConversationList({ {...navItemAttrs(i, c.partner.name)} className="group flex items-center gap-1 rounded-xl hover:bg-card transition" > - + {!isSystem && ready && ( - + + )} ); @@ -253,10 +254,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac ) : (
{users.map((u) => ( - + ))}
)} @@ -317,14 +319,13 @@ function PageThread({ )}
{!isSystem && ready && ( - + + )}
diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index 62cb3183..b642c494 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -11,6 +11,7 @@ import Toaster from "./ui/Toaster"; import { useOverlayOrder } from "./ui/overlayOrder"; import { useLayer } from "../lib/layerStack"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; +import { IconButton } from "./ui/IconButton"; // 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 @@ -172,13 +173,14 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () 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)]" > - + + {step === "read" && ( <> @@ -196,12 +198,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()

- + @@ -254,21 +253,21 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () )}

{importSubs.isError && ( - + {t("onboarding.write.retryImport")} + )}
- + + )} diff --git a/frontend/src/components/PaletteLauncher.tsx b/frontend/src/components/PaletteLauncher.tsx index b1f1a206..cc10af0f 100644 --- a/frontend/src/components/PaletteLauncher.tsx +++ b/frontend/src/components/PaletteLauncher.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { Command } from "lucide-react"; import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap"; +import { Pressable } from "./ui/Pressable"; // 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 @@ -30,15 +31,14 @@ export function PaletteKeycap() { const { t } = useTranslation(); const cap = useCap(); return ( - + ); } @@ -47,8 +47,7 @@ export function PaletteLauncher() { const { t } = useTranslation(); const cap = useCap(); return ( - + ); } diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 18428c9c..0cba1667 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react"; +import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react"; import { closestCenter, DndContext, @@ -23,6 +23,7 @@ import UndoToolbar from "./ui/UndoToolbar"; import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; import { DragHandle } from "./ui/DragHandle"; import { Pressable } from "./ui/Pressable"; +import { DismissibleChip } from "./ui/Chip"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name @@ -259,20 +260,14 @@ export default function PersonalViewsModal() { ) : (
{captured.map((f) => ( - {f.label} - - + ))}
)} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index ce6497ed..61ec5b8e 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -46,7 +46,7 @@ import { formatViews, relativeTime, } from "../lib/format"; -import { renderDescription } from "../lib/descriptionLinks"; +import { renderDescription } from "./descriptionLinks"; import { fullscreenElement } from "../lib/fullscreen"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; @@ -748,9 +748,9 @@ export default function PlayerModal({ href={`https://www.youtube.com/watch?v=${currentVideoId}`} target="_blank" rel="noreferrer" - className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition" + className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium state-filled touch-target" > - + {t("player.openOnYoutube")}
diff --git a/frontend/src/components/PlaylistLimitsHint.tsx b/frontend/src/components/PlaylistLimitsHint.tsx index 64ca4cfb..b5c96ba2 100644 --- a/frontend/src/components/PlaylistLimitsHint.tsx +++ b/frontend/src/components/PlaylistLimitsHint.tsx @@ -4,6 +4,7 @@ import { Info } from "lucide-react"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import { Button } from "./ui/Button"; // 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). @@ -29,14 +30,9 @@ export default function PlaylistLimitsHint() { >
) : ( - + ), )} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 72ba9f61..c32ab588 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -43,6 +43,9 @@ import { useContextMenu } from "./ui/useContextMenu"; import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; import { dispatch, useKeyScope } from "../lib/keymap"; import NavGrid from "./ui/NavGrid"; +import { Chip } from "./ui/Chip"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -553,10 +556,10 @@ export default function PlexBrowse() { className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5" > {/* The photo + name open the actor's filmography page; the ✕ removes the filter. */} - - + + ))} @@ -604,12 +606,9 @@ export default function PlexBrowse() { // query — say so and offer a one-click escape, instead of a bare "No matches".
{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })} - +
) : (

{dq ? t("plex.noMatches") : t("plex.empty")}

diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 3271f9e0..b20950e4 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -9,7 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Pressable } from "./ui/Pressable"; // 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 @@ -191,10 +191,12 @@ export default function PlexCollectionEditor({ key={c.id} className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm" > - toggle(c.id)} disabled={!!busy} - className={`inline-flex min-w-0 flex-1 items-center gap-2 text-left disabled:opacity-50 ${ + className={`inline-flex min-w-0 flex-1 items-center gap-2 disabled:opacity-50 ${ inIt ? "text-accent" : "" }`} > @@ -209,14 +211,15 @@ export default function PlexCollectionEditor({ {t("plex.collEditor.count", { count: c.child_count ?? 0 })} - - + ); }) @@ -243,13 +246,14 @@ export default function PlexCollectionEditor({ {t("plex.collEditor.count", { count: c.child_count ?? 0 })} - markEditable(c.id)} disabled={!!busy} - className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50" > {t("plex.collEditor.makeEditable")} - + ))} diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index ecf0ca8c..634129b2 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -21,7 +21,8 @@ import PlexLibraryPicker from "./PlexLibraryPicker"; import { toggleWatchState } from "../lib/plexWatch"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; // 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, @@ -312,12 +313,14 @@ export default function PlexCollectionView({ > {d.summary}

- + )} @@ -412,15 +415,15 @@ export default function PlexCollectionView({ menuProps={pinMenu.triggerProps} /> {canEdit && ( - doRemove(c)} disabled={busy} - title={t("plex.collection.removeItem")} - aria-label={t("plex.collection.removeItem")} - className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50" + label={t("plex.collection.removeItem")} + className="absolute right-1.5 top-1.5 z-base" > - - + + )} ))} @@ -475,13 +478,9 @@ function AddItemPanel({ aria-label={t("plex.collection.addSearch")} className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - + + + { const inColl = existing.has(c.id) || added.has(c.id); return ( - : } onClick={() => add(c)} disabled={inColl} - className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50" > - {inColl ? : } {inColl ? t("plex.collection.added") : t("plex.collection.add")} - + ); }} /> diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 00a87b4d..90ceeed1 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -14,6 +14,7 @@ import { PlexPinMenu } from "./ComfortPinMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; +import { IconButton } from "./ui/IconButton"; // The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE // /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you @@ -213,15 +214,13 @@ export default function PlexDiscover({ ariaLabel={t("plex.discover.surprise")} cards={surprise} action={ - + + } {...railProps} /> diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 2d513091..f869c1d9 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -82,6 +82,9 @@ import { anyBlockingLayerAbove, escapeHandled, } from "../lib/layerStack"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -1231,13 +1234,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { }`} >
- + +
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title} @@ -1270,9 +1275,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {/* Skip intro / credits (with the auto-skip countdown fill, if armed) */} {activeMarker && ( - + )} {/* Bottom controls */} @@ -1476,14 +1482,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */} {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
- + + {tracksOpen && (
{t("plex.player.audio")} {detail.audio_streams.map((a) => ( - + ))} )} {textSubs.length > 0 && ( <> {t("plex.player.subtitles")} - + {textSubs.map((s) => ( - + ))} )} @@ -1543,14 +1552,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */} {detail && (
- + + {menuOpen && (
{settingsTabs.map((tab) => ( - + ))}
@@ -253,8 +256,10 @@ export function PlexPlayerSettings(props: { {prefs.clockDate && (
{(["hu", "en"] as const).map((st) => ( - + ))}
)} 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() {
{t("plex.loading")}
)} {suggestions.map((c, i) => ( - + ))}
) : 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 ( -
- + ); } diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index f1645337..90fb3077 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -10,6 +10,7 @@ import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import SegmentedToggle from "./ui/SegmentedToggle"; import FilterSelect from "./ui/FilterSelect"; +import { IconButton } from "./ui/IconButton"; const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; @@ -129,20 +130,16 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card" > {p.name} - - + +
))}
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 ( <> - + {/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */} + {className ? ( + + {trigger} + + ) : ( + + {trigger} + + )} {value && ( - + 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() { )}
- } onClick={() => go(-1)} disabled={stepIdx === 0 || busy} - className="glass-card glass-hover inline-flex items-center gap-1 px-3 py-2 rounded-xl text-sm disabled:opacity-40 transition" > - {t("setup.nav.back")} - - +
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 299b49ff..5b471306 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -3,7 +3,6 @@ import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; -import clsx from "clsx"; import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; @@ -14,7 +13,7 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../l import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Pressable } from "./ui/Pressable"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; // --- share with a registered user (ACL) ----------------------------------------------------- @@ -72,15 +71,16 @@ function UserShare({ job }: { job: DownloadJob }) { {open && filtered.length > 0 && (
{filtered.map((u) => ( - share.mutate(u.email)} disabled={share.isPending} - className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col" + className="w-full px-3 py-2 text-sm flex flex-col" > {u.display_name || u.email} {u.display_name && {u.email}} - + ))}
)} @@ -227,15 +227,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } )} className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - savePass.mutate(newPass.trim())} - disabled={savePass.isPending || !newPass.trim()} - className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition" + loading={savePass.isPending} + disabled={!newPass.trim()} > {t("downloads.share.savePassword")} - + {link.has_password && ( - { if ( await confirm({ @@ -248,10 +251,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } savePass.mutate(""); }} disabled={savePass.isPending} - className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition" > {t("downloads.share.removePassword")} - + )} )} @@ -336,20 +338,17 @@ function LinkShare({ job }: { job: DownloadJob }) {
{t("downloads.share.expiry")}: {EXPIRY_DAYS.map((d) => ( - + ))}
- - syncSubs.mutate()} - disabled={syncSubs.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" - > - - {t("stats.sync.syncSubscriptions")} - - - - deepAll.mutate()} - disabled={deepAll.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2" - > - - {t("stats.sync.backfillEverything")} - - +
+ + + + + + +
)} @@ -227,15 +230,16 @@ function SystemStats() { {s && (
- +
)} @@ -321,9 +325,10 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { const isSystem = row.user_id === null; return (
- + {/* Proportion bar */}
0 || notFull > 0) && ( - toggle.mutate()} - disabled={toggle.isPending} - title={data.paused ? t("header.sync.resume") : t("header.sync.pause")} - className="p-1 rounded-md hover:bg-card hover:text-fg transition" + loading={toggle.isPending} + label={data.paused ? t("header.sync.resume") : t("header.sync.pause")} > - {data.paused ? : } - + {data.paused ? : } + ); // Chip: the headline count rides in the header; the state, the history link and pause live in a @@ -117,7 +118,7 @@ export default function SyncStatus({ )} ${t("header.sync.total")}`; return (
- + 0 && (
- + {!showMain && pauseBtn}
)} diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx index c9ba083e..f65a9b3c 100644 --- a/frontend/src/components/TagFilterChips.tsx +++ b/frontend/src/components/TagFilterChips.tsx @@ -2,7 +2,8 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { fitChipsWithPill } from "../lib/chipOverflow"; import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; -import { filterChipClass } from "./ui/FilterChipGroup"; +import { Chip } from "./ui/Chip"; +import { chipClass } from "./ui/chipStyles"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; @@ -20,16 +21,11 @@ export interface FilterChip { onToggle: () => void; } -function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) { +function TagChip({ chip }: { chip: FilterChip }) { return ( - + ); } @@ -103,28 +99,22 @@ export default function TagFilterChips({ }} className="inline-flex" > - {c.label} + {c.label} ))} - + +{chips.length}
{chips.slice(0, visible).map((c) => ( - + ))} {hidden > 0 && ( - + )}
@@ -134,7 +124,7 @@ export default function TagFilterChips({ 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]" > {chips.slice(visible).map((c) => ( - + ))}
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) => ( - + ))}
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}
}
- - +
- + + ); } 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 ( <> - + + { 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. */} - + {T.repeat} + {meta.allow_download && (
{ const on = selected === y.year; return ( - + ); })} {years.length > quick.length && ( <> - + ( - + ))}
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 (
- - - + + - + {c.hidden ? : } + {canWrite && ( - + + )}
diff --git a/frontend/src/lib/descriptionLinks.tsx b/frontend/src/components/descriptionLinks.tsx similarity index 97% rename from frontend/src/lib/descriptionLinks.tsx rename to frontend/src/components/descriptionLinks.tsx index 7ef0b2a2..4ed6c032 100644 --- a/frontend/src/lib/descriptionLinks.tsx +++ b/frontend/src/components/descriptionLinks.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { TFunction } from "i18next"; +import { Pressable } from "./ui/Pressable"; // Turn a video description into clickable nodes: // - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player @@ -78,7 +79,7 @@ export function renderDescription( if (yt) { const sameVideo = yt.id === opts.currentId; out.push( - , + , ); } else { out.push( @@ -133,13 +134,13 @@ export function renderDescription( // alternative — the timestamp group — is the one that fired. const ts = m[3]!; out.push( - , + , ); } last = m.index + m[0].length; diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 180ef597..a19c3452 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -11,6 +11,8 @@ import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import { StatusPill } from "./downloadRow"; import { jobTitle } from "./downloadTitle"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // 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). @@ -165,18 +167,19 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } {open && filtered.length > 0 && (
{filtered.map((u) => ( - + ))}
)} @@ -225,13 +228,12 @@ export function AdminSystem() { > {u.email} {formatBytes(u.footprint_bytes)} - + + ))} {s.per_user.length === 0 && ( diff --git a/frontend/src/components/downloadModals.tsx b/frontend/src/components/downloadModals.tsx index e12ebdae..fc43b4ad 100644 --- a/frontend/src/components/downloadModals.tsx +++ b/frontend/src/components/downloadModals.tsx @@ -95,12 +95,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () ))} - +

{t("downloads.edit.hint")}

@@ -148,12 +145,14 @@ export function ErrorModal({

{t(failCopyKeys(job, "body"))}

{job.error && (
- + {showRaw && (
                 {job.error}
diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx
index 9de3413d..17008407 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -18,6 +18,8 @@ import {
 import { navItemAttrs } from "../lib/listNav";
 import { failCopyKeys } from "../lib/downloadFailure";
 import { jobTitle } from "./downloadTitle";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
 
 // 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,
@@ -136,17 +138,14 @@ export function IconBtn({
   children: ReactNode;
 }) {
   return (
-    
+    
   );
 }
 
@@ -182,14 +181,9 @@ function SourceRef({ url }: { url: string }) {
       >
         {displayUrl(url)}
       
-      
+      
+        
+      
     
); } @@ -267,14 +261,14 @@ export function DownloadRow({ ) : null} {job.status === "error" ? ( - + ) : null}
{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. */} - - + ); } 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) => (
- - +
), }, @@ -129,14 +131,14 @@ export function playlistColumns({ width: "2.5rem", nowrap: true, render: (v) => ( - + + ), }); } 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({

{t("plex.collection.newCollection")}

- + + +
( - } onClick={() => create(c)} - disabled={busy} - className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50" + loading={busy} > - {t("plex.collection.createWith")} - + )} />
@@ -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 && ( - + )} {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({

)}
- + {d.biography && ( @@ -428,7 +427,7 @@ export function PlexShowView({ if (!badge) return null; const { score } = badge; return ( - + ); })()} @@ -645,12 +644,9 @@ export function PlexSeasonView({ /> } > - +

{se.title}

{t("plex.series.episodeCount", { count: se.episode_count })} diff --git a/frontend/src/components/ui/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx index 797a54e6..e092f36b 100644 --- a/frontend/src/components/ui/ActionMenu.tsx +++ b/frontend/src/components/ui/ActionMenu.tsx @@ -2,6 +2,8 @@ import { Fragment, useState, type ReactNode } from "react"; import { ChevronRight, MoreHorizontal } from "lucide-react"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks"; +import { IconButton } from "./IconButton"; +import { Pressable } from "./Pressable"; // 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 @@ -209,19 +211,28 @@ export default function ActionMenu({ const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" }); return ( <> - + {/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */} + {className ? ( + + {trigger ?? + ) : ( + + {trigger ?? + )} setOpen(false)} /> diff --git a/frontend/src/components/ui/BackToTop.tsx b/frontend/src/components/ui/BackToTop.tsx index 51399a3a..f4a2bc0e 100644 --- a/frontend/src/components/ui/BackToTop.tsx +++ b/frontend/src/components/ui/BackToTop.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArrowUp } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { Pressable } from "./Pressable"; // 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 @@ -50,7 +51,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr // commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"]. // The accessible name comes from a visually-hidden child instead, which those selectors can't match. return createPortal( - , + , document.body, ); } diff --git a/frontend/src/components/ui/Banner.tsx b/frontend/src/components/ui/Banner.tsx index faf180c9..b39e3275 100644 --- a/frontend/src/components/ui/Banner.tsx +++ b/frontend/src/components/ui/Banner.tsx @@ -2,20 +2,21 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { LucideIcon } from "lucide-react"; import { X } from "lucide-react"; +import { Button } from "./Button"; import { IconButton } from "./IconButton"; type BannerTone = "accent" | "warning"; -const TONES: Record = { +const TONES: Record = { accent: { bar: "bg-accent/15 border-accent/30", icon: "text-accent", - action: "btn-primary", + action: "primary", }, warning: { bar: "bg-warn/15 border-warn/30", icon: "text-warn", - action: "btn-warn", + action: "warn", }, }; @@ -54,12 +55,14 @@ export default function Banner({ {children} {action && ( - + )} {onDismiss && ( - + )} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 4ea21558..72cd7dfb 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton"; 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 @@ -43,6 +44,7 @@ const ACT = "enabled:not-aria-disabled:"; const VARIANTS: Record = { 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 diff --git a/frontend/src/components/ui/Carousel.tsx b/frontend/src/components/ui/Carousel.tsx index ea858cb3..c6295e31 100644 --- a/frontend/src/components/ui/Carousel.tsx +++ b/frontend/src/components/ui/Carousel.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import clsx from "clsx"; import { useScrollEdges } from "../../lib/useScrollEdges"; import { useListNav } from "../../lib/listNav"; +import { Button } from "./Button"; +import { IconButton } from "./IconButton"; // Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing // the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future @@ -95,10 +97,10 @@ export function Carousel({ // Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover` // removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap. + // Round, dark translucent: they sit over the pictures (A2). const arrowBtn = - "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + - "bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " + - "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75"; + "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " + + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100"; return (

@@ -106,27 +108,25 @@ export function Carousel({

{title}

{onSeeAll && ( - + )}
)}
{left && ( - + + )} {/* 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}
{right && ( - + + )}
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( +
+ 2024 +
, + ); + 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) => ( - + ))} ))} 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 ( - + ); })} 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({
{totalPages > 1 && (
- } onClick={() => goToPage(Math.max(0, safePage - 1))} disabled={safePage === 0} - className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition" > - {t("datatable.pager.prev")} - + {t("datatable.pager.pageLabel")} {t("datatable.pager.ofTotal", { total: totalPages })} - goToPage(Math.min(totalPages - 1, safePage + 1))} disabled={safePage >= totalPages - 1} - className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition" > {t("datatable.pager.next")} - +
)} {total > 0 && ( diff --git a/frontend/src/components/ui/PanelGroup.tsx b/frontend/src/components/ui/PanelGroup.tsx index 487a06de..8b42a007 100644 --- a/frontend/src/components/ui/PanelGroup.tsx +++ b/frontend/src/components/ui/PanelGroup.tsx @@ -1,8 +1,11 @@ import { type ReactNode, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react"; +import { ChevronDown, Eye, EyeOff, Pencil } from "lucide-react"; import { CSS } from "@dnd-kit/utilities"; import { useSortableItem } from "./sortableA11y"; +import { DragHandle } from "./DragHandle"; +import { IconButton } from "./IconButton"; +import { Pressable } from "./Pressable"; // 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 @@ -46,7 +49,7 @@ export default function PanelGroup({ const titleIsTrigger = !editing && !collapsible && !!onTitleClick; const titleCls = - "flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide"; + "flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide"; return (
{editing && ( - + label={t("sidebar.dragToReorder")} + className="-ml-1" + /> )} {collapsible && !editing ? ( - + ) : titleIsTrigger ? ( - + ) : ( {title} )} {editing ? ( - + {hidden ? : } + ) : ( collapsible && ( - + ) )}
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 ` + {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.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 && ( - + )} @@ -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 ? ( - + ) : ( {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) => ( - + ))} {SCHEMES.map((s) => ( - + ))} - +