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
+29 -34
View File
@@ -26,7 +26,8 @@ import { formatNumber, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -200,21 +201,12 @@ function JobRow({
}`}
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<FocusSafeButton
onClick={save}
disabled={saving || !valid}
className="text-success hover:opacity-80 disabled:opacity-50"
aria-label={t("common.save")}
>
<Check className="w-3.5 h-3.5" />
</FocusSafeButton>
<button
onClick={close}
className="text-muted hover:text-fg"
aria-label={t("common.cancel")}
>
<X className="w-3.5 h-3.5" />
</button>
<IconButton onClick={save} disabled={saving || !valid} label={t("common.save")}>
<Check className="text-success" />
</IconButton>
<IconButton onClick={close} label={t("common.cancel")}>
<X />
</IconButton>
{!valid && (
<span id={rangeId} role="alert" className="text-2xs text-danger">
{t("scheduler.range", { min: 1, max: 1440 })}
@@ -223,14 +215,14 @@ function JobRow({
</span>
) : (
<Tooltip hint={t("scheduler.editInterval")}>
<button
<Pressable
ref={triggerRef}
onClick={() => setDraft(String(job.interval_minutes))}
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
>
· {t("scheduler.everyMin", { count: job.interval_minutes })}
<Pencil className="w-3 h-3 reveal transition" />
</button>
</Pressable>
</Tooltip>
)}
</div>
@@ -261,14 +253,14 @@ function JobRow({
)}
</div>
<Tooltip hint={t("scheduler.runNow")}>
<FocusSafeButton
<IconButton
onClick={onRun}
disabled={job.running || runDisabled}
aria-label={t("scheduler.runNow")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition disabled:opacity-30 disabled:hover:text-muted disabled:hover:bg-transparent"
label={t("scheduler.runNow")}
noTitle
>
<Play className="w-4 h-4" />
</FocusSafeButton>
<Play />
</IconButton>
</Tooltip>
</div>
);
@@ -516,17 +508,20 @@ export default function Scheduler() {
</div>
<div className="flex-1" />
<Tooltip hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={<Play />}
onClick={() => runAllMut.mutate()}
disabled={runAllMut.isPending || data.paused}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Play className="w-4 h-4" />
{t("scheduler.runAll")}
</FocusSafeButton>
</Button>
</Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
onClick={async () => {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
@@ -537,21 +532,21 @@ export default function Scheduler() {
if (ok) purgeMut.mutate();
}}
disabled={purgeMut.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Trash2 className="w-4 h-4" />
{t("scheduler.purgeDiscovery")}
</FocusSafeButton>
</Button>
</Tooltip>
<Tooltip hint={t("scheduler.pauseHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={data.paused ? <Play /> : <Pause />}
onClick={() => pauseResume.mutate(data.paused)}
disabled={pauseResume.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
{data.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
</FocusSafeButton>
</Button>
</Tooltip>
</div>