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
+76 -67
View File
@@ -26,6 +26,8 @@ import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "./ui/IconButton";
import { Button } from "./ui/Button";
function Actions({
video,
@@ -54,56 +56,49 @@ function Actions({
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
// One button style for the row — AddToPlaylist and DownloadButton take it as a prop, so all six
// stay the same size instead of two of them keeping their own default.
// `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small
// card's text column bottoms out around 160 (its 180px column less the padding), so at its
// narrowest the last button used to hang outside the card. 154 at dense.
const btn = clsx("rounded-md hover:bg-surface text-muted hover:text-fg", dense ? "p-1" : "p-1.5");
// Every button of the row is the IconButton `sm` rung (28), AddToPlaylist and DownloadButton
// included. `dense` halves the gap for the small card, whose text column bottoms out around 160px;
// six 28px buttons wrap there (the flex-wrap below) until the C1 action list (E28 S3e) moves the
// rare ones into the card's "…".
return (
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div className={clsx("flex flex-wrap reveal transition", dense ? "gap-0.5" : "gap-1")}>
<button
<IconButton
onClick={act("watched")}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className={clsx(btn, video.status === "watched" && "text-accent")}
pressed={video.status === "watched"}
label={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
>
{video.status === "watched" ? (
<CheckCheck className="w-4 h-4" />
) : (
<Check className="w-4 h-4" />
)}
</button>
<button
{video.status === "watched" ? <CheckCheck /> : <Check />}
</IconButton>
<IconButton
onClick={toggleSave}
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(btn, video.saved && "fill-current text-accent")}
pressed={!!video.saved}
label={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
>
<Bookmark className="w-4 h-4" />
</button>
<AddToPlaylist videoId={video.id} className={btn} />
<DownloadButton videoId={video.id} title={video.title} className={btn} />
<button
<Bookmark className={clsx(video.saved && "fill-current")} />
</IconButton>
<AddToPlaylist videoId={video.id} />
<DownloadButton videoId={video.id} title={video.title} />
<IconButton
onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(btn, video.status === "hidden" && "text-accent")}
pressed={video.status === "hidden"}
label={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
>
{video.status === "hidden" ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
{video.status === "hidden" ? <Eye /> : <EyeOff />}
</IconButton>
{onResetState && resettable && (
<button
<IconButton
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onResetState(video.id);
}}
title={t("card.resetState")}
className={btn}
label={t("card.resetState")}
>
<RotateCcw className="w-4 h-4" />
</button>
<RotateCcw />
</IconButton>
)}
</div>
);
@@ -285,45 +280,59 @@ function Thumb({
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
{inProgress ? (
<>
<button
onClick={open(null)}
title={t("card.continueTitle")}
aria-label={t("card.continue")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-semibold btn-primary shadow-lg transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)}
>
<Play className="w-4 h-4 fill-current" />
{!small && t("card.continue")}
</button>
<button
onClick={open(0)}
title={t("card.restartTitle")}
aria-label={t("card.restart")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-xs hover:bg-white/25 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)}
>
<RotateCcw className="w-4 h-4" />
{!small && t("card.restart")}
</button>
{small ? (
<IconButton
variant="primary"
size="md"
onMedia
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
) : (
<Button
size="sm"
onMedia
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
)}
{small ? (
<IconButton
variant="media"
size="md"
onClick={open(0)}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
) : (
<Button
variant="media"
size="sm"
onClick={open(0)}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
)}
</>
) : (
<button
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
title={t("card.play")}
aria-label={t("card.play")}
className={clsx(
"inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
small ? "w-9 h-9" : "w-12 h-12",
)}
label={t("card.play")}
>
<Play
className={clsx("fill-current translate-x-px", small ? "w-4 h-4" : "w-5 h-5")}
/>
</button>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</div>
)}