feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)

Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.

Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
This commit is contained in:
2026-09-24 05:25:55 +02:00
parent 7d480e2ad0
commit 86f467bd65
42 changed files with 1304 additions and 1036 deletions
+32 -30
View File
@@ -50,8 +50,8 @@ import FilterSelect from "./ui/FilterSelect";
import ItemCount from "./ui/ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import SegmentedToggle from "./ui/SegmentedToggle";
import { IconButton } from "./ui/IconButton";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -436,7 +436,10 @@ export default function Feed({
<div className="p-4">
<div className="pb-3 mb-1 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<button
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft />}
onClick={() => {
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance.
@@ -444,11 +447,9 @@ export default function Feed({
onExitYtSearch();
dropFeedCaches();
}}
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
>
<ArrowLeft className="w-4 h-4" />
{t("feed.yt.back")}
</button>
</Button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
@@ -484,7 +485,10 @@ export default function Feed({
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
<Info className="w-3.5 h-3.5 shrink-0" />
<span>{t("feed.yt.ephemeralNote")}</span>
<button
<Button
variant="outline-danger"
size="xs"
icon={<Trash2 />}
onClick={() => {
const ids = ytItems.map((v) => v.id);
api
@@ -500,11 +504,9 @@ export default function Feed({
dropFeedCaches();
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
>
<Trash2 className="w-3 h-3" />
{t("feed.yt.clearNow")}
</button>
</Button>
</div>
)}
</div>
@@ -530,17 +532,17 @@ export default function Feed({
/>
{ytQuery.hasNextPage && (
<div className="text-center pt-4">
<FocusSafeButton
<Button
variant="outline"
onClick={() => ytQuery.fetchNextPage()}
disabled={ytQuery.isFetchingNextPage}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
loading={ytQuery.isFetchingNextPage}
>
{ytQuery.isFetchingNextPage
? t("feed.loadingMore")
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
</FocusSafeButton>
</Button>
</div>
)}
</>
@@ -594,12 +596,13 @@ export default function Feed({
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<button
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
</button>
</Button>
</>
)}
</div>
@@ -635,14 +638,13 @@ export default function Feed({
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
<IconButton
variant="outline"
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
label={t("sidebar.reshuffle")}
>
<RefreshCw className="w-4 h-4" />
</button>
<RefreshCw />
</IconButton>
) : undefined
}
/>
@@ -757,23 +759,23 @@ export default function Feed({
{filters.channelIds.length > 0 && filters.scope === "my" && (
<div className="mt-4">
<p className="text-sm">{t("feed.channelScopeHint")}</p>
<button
<Button
className="mt-3"
icon={<Library />}
onClick={() => setFilters({ ...filters, scope: "all" })}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Library className="w-4 h-4" />
{t("feed.viewInLibrary")}
</button>
</Button>
</div>
)}
{filters.q.trim() && !isDemo && (
<button
<Button
className="mt-4"
icon={<Youtube />}
onClick={() => onYtSearch(filters.q.trim())}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Youtube className="w-4 h-4" />
{t("feed.yt.searchFor", { query: filters.q.trim() })}
</button>
</Button>
)}
</div>
) : (