fix(design): the button and chip hover colours were never generated — an act: variant instead of a runtime prefix (E28)

buttonStyles/chipStyles glued the hover guard on at runtime (`${ACT}hover:text-fg`). Tailwind emits CSS
only for a class it finds whole in the source, so all 22 guarded hovers shipped with no rule: no text or
edge colour on hover for ghost/outline/danger buttons, no white layer on the player's control row, no
underline on a link button, no accent hover on token/suggest/ghost chips. The stylesheet had 0 rules
for `not-aria-disabled`.

- `@custom-variant act` (index.css): the same guard as the state layers; `:disabled` never matches an
  <a>, so one variant serves buttons and links and the per-element variant maps go.
- ui/tailwindScan.contract.test.ts: every class the primitives can emit stands whole in their source.
- a lint rule refuses a variant right after an interpolation; the `enabled:hover:` rule now points at
  `act:hover:`.
This commit is contained in:
2026-09-25 14:13:51 +02:00
parent ff80cc96fd
commit cc8e0648db
7 changed files with 144 additions and 48 deletions
+28 -34
View File
@@ -2,10 +2,8 @@ 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. The hover guard is
// per element: `enabled:` never matches an <a>, so a link's hovers drop it (it has no disabled state).
const ACT_BUTTON = "enabled:not-aria-disabled:";
const ACT_LINK = "not-aria-disabled:";
// <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
@@ -33,25 +31,27 @@ export type ButtonSize =
// 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 buttonVariants = (ACT: string): Record<ButtonVariant, string> => ({
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: "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-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`,
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`,
});
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.
@@ -98,22 +98,23 @@ export type IconButtonSize =
| "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 iconVariants = (ACT: string): Record<IconButtonVariant, string> => ({
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
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: "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-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`,
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`,
});
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.
@@ -126,9 +127,6 @@ const ICON_SIZES: Record<IconButtonSize, string> = {
player: "size-9 [&>svg]:size-5",
};
const BUTTON_VARIANTS = { button: buttonVariants(ACT_BUTTON), link: buttonVariants(ACT_LINK) };
const ICON_VARIANTS = { button: iconVariants(ACT_BUTTON), link: iconVariants(ACT_LINK) };
export const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
export function buttonClass({
@@ -137,18 +135,16 @@ export function buttonClass({
round = false,
fullWidth = false,
pressed,
element = "button",
}: {
variant: ButtonVariant;
size: ButtonSize;
round?: boolean;
fullWidth?: boolean;
pressed?: boolean;
element?: "button" | "link";
}) {
return clsx(
BASE,
BUTTON_VARIANTS[element][variant],
BUTTON_VARIANTS[variant],
pressed !== undefined && PRESSED,
SIZES[size],
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
@@ -166,17 +162,15 @@ export function iconButtonClass({
size,
round = false,
pressed,
element = "button",
}: {
variant: IconButtonVariant;
size: IconButtonSize;
round?: boolean;
pressed?: boolean;
element?: "button" | "link";
}) {
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[element][variant],
ICON_VARIANTS[variant],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && ICON_PRESSED,
ICON_SIZES[size],