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:
@@ -6,6 +6,8 @@ import { Carousel } from "./ui/Carousel";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||
@@ -179,28 +181,26 @@ export function PlexPosterCard({
|
||||
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
|
||||
{isPlayable && (
|
||||
<div className="reveal absolute right-1.5 top-1.5 z-10 flex gap-1">
|
||||
<button
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInfo();
|
||||
}}
|
||||
title={t("plex.info.title")}
|
||||
aria-label={t("plex.info.title")}
|
||||
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
||||
label={t("plex.info.title")}
|
||||
>
|
||||
<Info className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
<Info />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched(card);
|
||||
}}
|
||||
title={watchedLabel}
|
||||
aria-label={watchedLabel}
|
||||
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
||||
label={watchedLabel}
|
||||
>
|
||||
<WatchedIcon watched={card.status === "watched"} />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -276,31 +276,31 @@ export function SeasonCard({
|
||||
</div>
|
||||
{/* Quick watched toggle (whole season), on hover. */}
|
||||
{onToggleWatched && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched();
|
||||
}}
|
||||
title={watchedLabel}
|
||||
aria-label={watchedLabel}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
label={watchedLabel}
|
||||
className="absolute top-1.5 right-1.5 reveal"
|
||||
>
|
||||
<WatchedIcon watched={watched} />
|
||||
</button>
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Add whole season to a playlist, on hover. */}
|
||||
{onAdd && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd();
|
||||
}}
|
||||
title={t("plex.playlist.addSeason")}
|
||||
aria-label={t("plex.playlist.addSeason")}
|
||||
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
label={t("plex.playlist.addSeason")}
|
||||
className="absolute bottom-1.5 right-1.5 reveal"
|
||||
>
|
||||
<ListPlus className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
)}
|
||||
{watched && (
|
||||
<span
|
||||
@@ -383,8 +383,9 @@ export function CastStrip({
|
||||
</>
|
||||
);
|
||||
return clickable ? (
|
||||
<button
|
||||
<Pressable
|
||||
key={i}
|
||||
align="center"
|
||||
{...navItemAttrs(i)}
|
||||
onClick={() =>
|
||||
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||
@@ -394,10 +395,10 @@ export function CastStrip({
|
||||
? t("plex.info.viewPerson", { name: c.name })
|
||||
: t("plex.info.filterActor", { name: c.name })
|
||||
}
|
||||
className="group/cast w-20 shrink-0 text-center"
|
||||
className="group/cast w-20 shrink-0"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<div key={i} className="w-20 shrink-0 text-center">
|
||||
{inner}
|
||||
@@ -428,12 +429,12 @@ export function RelatedStrip({
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
|
||||
{related.map((r, i) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={r.id}
|
||||
{...navItemAttrs(i)}
|
||||
{...menuProps?.(r)}
|
||||
onClick={() => onOpen(r.id)}
|
||||
className="group w-32.5 shrink-0 text-left"
|
||||
className="group w-32.5 shrink-0"
|
||||
>
|
||||
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
|
||||
<img
|
||||
@@ -451,7 +452,7 @@ export function RelatedStrip({
|
||||
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
|
||||
{r.title}
|
||||
</div>
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
@@ -506,17 +507,17 @@ export function EpisodeCard({
|
||||
</div>
|
||||
{/* Quick watched toggle (single episode), on hover. */}
|
||||
{onToggleWatched && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched();
|
||||
}}
|
||||
title={watchedLabel}
|
||||
aria-label={watchedLabel}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
label={watchedLabel}
|
||||
className="absolute top-1.5 right-1.5 reveal"
|
||||
>
|
||||
<WatchedIcon watched={ep.status === "watched"} />
|
||||
</button>
|
||||
</IconButton>
|
||||
)}
|
||||
{ep.status === "watched" && (
|
||||
<span
|
||||
@@ -553,14 +554,9 @@ export function EpisodeCard({
|
||||
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||
</div>
|
||||
{onAdd && (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
title={t("plex.playlist.addEpisode")}
|
||||
aria-label={t("plex.playlist.addEpisode")}
|
||||
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton onClick={onAdd} label={t("plex.playlist.addEpisode")}>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user