The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier. A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
197 lines
11 KiB
TypeScript
197 lines
11 KiB
TypeScript
import clsx from "clsx";
|
||
|
||
// The paint of the two button primitives (docs/DESIGN.md A1, A2, B1, B3), apart from the components so
|
||
// a LINK can wear exactly the same face (`LinkButton`): a download or a sign-in that navigates is an
|
||
// <a>, and a hand-painted look-alike beside the real buttons is how the two drifted. Every hover carries
|
||
// the `act:` variant (index.css), which stands down on a disabled or busy control and fits a link too.
|
||
|
||
export 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
|
||
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
|
||
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
|
||
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
|
||
| "overlay" // white text on the always-dark player chrome, whatever the theme
|
||
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
|
||
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
|
||
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
|
||
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
|
||
|
||
export type ButtonSize =
|
||
| "xs" // 24 — inside a table row
|
||
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
|
||
| "md" // 32 — forms and dialog footers
|
||
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
|
||
| "none"; // no box of its own — the link variants' default
|
||
|
||
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
|
||
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
|
||
// or pressed button; the text-colour hovers here carry the same guard as a variant.
|
||
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
||
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
|
||
// is whether the button should advertise the destruction before the pointer arrives.
|
||
"outline-danger":
|
||
"font-medium border border-border text-muted state-layer act:hover:text-danger act:hover:border-danger/40",
|
||
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
|
||
ghost: "font-medium text-muted state-layer act:hover:text-fg",
|
||
glass: "font-medium glass-card glass-hover act:hover:text-accent",
|
||
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
||
overlay: "font-medium text-white/80 act:hover:text-white act:hover:bg-white/15",
|
||
// On a picture a layer is lost in the picture's own light and dark: the hover is the accent edge
|
||
// (B3's edged-pill rule), a 2px ring over a darker body (owner pick B).
|
||
media:
|
||
"font-medium bg-black/60 text-white act:hover:bg-black/80 act:hover:ring-2 act:hover:ring-accent",
|
||
"danger-soft":
|
||
"font-medium border border-danger/60 text-danger state-layer act:hover:border-danger",
|
||
link: "font-semibold text-accent act:hover:underline",
|
||
"link-muted": "font-medium text-muted act:hover:text-fg",
|
||
};
|
||
|
||
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
|
||
// accent"); the layer utilities leave it alone, and its colours are marked important (`!`) because a
|
||
// variant's hover colour (`act:hover:text-fg`, the overlay's `act:hover:bg-white/15`) is the more
|
||
// specific selector — without it the accent turned grey, or white, under the pointer.
|
||
const PRESSED =
|
||
"aria-pressed:bg-accent! aria-pressed:text-accent-fg! aria-pressed:border-accent! aria-pressed:no-underline!";
|
||
// A mode cycle that is not off (`lit`: auto-advance Next, loop One) looks pressed but is no toggle —
|
||
// its name says the mode, and aria-pressed would announce a two-state switch whose name changes on
|
||
// every press. The same look as PRESSED, under the pointer too: the layers stand down on `[data-lit]`.
|
||
const LIT =
|
||
"data-lit:bg-accent! data-lit:text-accent-fg! data-lit:border-accent! data-lit:no-underline!";
|
||
|
||
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
|
||
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
|
||
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
|
||
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
|
||
// means "floats over something", and a pill's inner button follows its radius).
|
||
const SIZES: Record<ButtonSize, string> = {
|
||
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
|
||
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
|
||
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
|
||
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
|
||
// No box of its own, but still a 24 target on a mouse (B1).
|
||
none: "gap-1.5 min-h-6",
|
||
};
|
||
|
||
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
|
||
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
|
||
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
|
||
const BASE =
|
||
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
|
||
|
||
export 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
|
||
|
||
export 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)
|
||
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
|
||
|
||
const ICON_VARIANTS: Record<IconButtonVariant, string> = {
|
||
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).
|
||
media: "bg-black/60 text-white act:hover:bg-black/80 act:hover:ring-2 act:hover:ring-accent",
|
||
overlay: "text-white/80 act:hover:text-white act:hover:bg-white/15",
|
||
};
|
||
|
||
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
|
||
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action. Important
|
||
// for the same reason as PRESSED: the variant's hover colour would take the tint off under the pointer.
|
||
const ICON_PRESSED = "aria-pressed:text-chosen!";
|
||
const ICON_LIT = "data-lit:text-chosen!";
|
||
|
||
const ICON_SIZES: Record<IconButtonSize, string> = {
|
||
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 const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
|
||
|
||
export function buttonClass({
|
||
variant,
|
||
size,
|
||
round = false,
|
||
fullWidth = false,
|
||
pressed,
|
||
lit,
|
||
}: {
|
||
variant: ButtonVariant;
|
||
size: ButtonSize;
|
||
round?: boolean;
|
||
fullWidth?: boolean;
|
||
pressed?: boolean;
|
||
lit?: boolean;
|
||
}) {
|
||
return clsx(
|
||
BASE,
|
||
BUTTON_VARIANTS[variant],
|
||
pressed !== undefined && PRESSED,
|
||
lit !== undefined && LIT,
|
||
SIZES[size],
|
||
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
||
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites — on every variant.
|
||
// The link variants were once excluded as "a run of text inside a sentence", but none is: every
|
||
// one stands on its own ("See all", "Forgot your passphrase?", a notification's action), and the
|
||
// an audit measured them at 16–20px on a tablet. Text inside a sentence is a plain <a>.
|
||
"touch-target",
|
||
fullWidth && "w-full",
|
||
);
|
||
}
|
||
|
||
export function iconButtonClass({
|
||
variant,
|
||
size,
|
||
round = false,
|
||
pressed,
|
||
lit,
|
||
}: {
|
||
variant: IconButtonVariant;
|
||
size: IconButtonSize;
|
||
round?: boolean;
|
||
pressed?: boolean;
|
||
lit?: boolean;
|
||
}) {
|
||
return clsx(
|
||
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-chosen disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||
ICON_VARIANTS[variant],
|
||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||
pressed !== undefined && ICON_PRESSED,
|
||
lit !== undefined && ICON_LIT,
|
||
ICON_SIZES[size],
|
||
);
|
||
}
|