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 // , 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 = { 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", media: "font-medium bg-black/60 text-white act:hover:bg-black/75", "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, so it stays solid under the pointer too. const PRESSED = "aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed: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 = { 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 = { 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/75", 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. const ICON_PRESSED = "aria-pressed:text-accent"; const ICON_SIZES: Record = { 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, }: { variant: ButtonVariant; size: ButtonSize; round?: boolean; fullWidth?: boolean; pressed?: boolean; }) { return clsx( BASE, BUTTON_VARIANTS[variant], pressed !== undefined && PRESSED, 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 . "touch-target", fullWidth && "w-full", ); } export function iconButtonClass({ variant, size, round = false, pressed, }: { variant: IconButtonVariant; size: IconButtonSize; round?: boolean; pressed?: boolean; }) { return clsx( "inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent 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, ICON_SIZES[size], ); }