feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)

This commit is contained in:
2026-09-25 00:34:35 +02:00
parent db10bfbbca
commit c47c193ad1
89 changed files with 818 additions and 478 deletions
+3 -85
View File
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode, RefAttributes } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from "./buttonStyles";
// The ONE action-button primitive: a labelled action (Save, Cancel, Subscribe, Delete, "Show all").
// docs/DESIGN.md decides its look (A1 shape, B1 ladder, B3 state layers); this file is where that
@@ -14,77 +15,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
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"
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 ACT = "enabled:not-aria-disabled:";
const 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`,
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<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",
none: "gap-1.5",
};
// 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";
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
@@ -110,8 +40,6 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
type NamedSomehow = { label: string } | { "aria-label": string } | { title: string };
export type ButtonProps = ButtonOwnProps & ({ children: ReactNode } | NamedSomehow);
const isLink = (v: ButtonVariant) => v === "link" || v === "link-muted";
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button(
{
@@ -137,11 +65,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
// A link must not inherit the action-button box, so its size default differs. Resolved here rather
// than by asking every call site to pass `size="none"` alongside a link variant — a pairing everyone
// would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (isLink(variant) ? "none" : "md");
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link is excluded on
// purpose — it is a run of text inside a sentence, and a 44px-tall inline link would wreck the
// paragraph it sits in rather than make anything easier to hit.
const touchTarget = !isLink(variant);
const resolvedSize: ButtonSize = size ?? (isLinkVariant(variant) ? "none" : "md");
const leading = loading ? <Loader2 className="animate-spin" /> : icon;
return (
<FocusSafeButton
@@ -156,13 +80,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
aria-pressed={pressed}
title={label}
className={clsx(
BASE,
VARIANTS[variant],
pressed !== undefined && PRESSED,
SIZES[resolvedSize],
resolvedSize !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
touchTarget && "touch-target",
fullWidth && "w-full",
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
className,
)}
{...rest}