diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 297ba0c4..02d1f841 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -372,6 +372,11 @@ const PALETTE = const PALETTE_MESSAGE = "a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500."; +// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`). +const SPLIT_CLASS = "/^[a-z][\\w-]*:[\\w\\[!-]/"; +const SPLIT_CLASS_MESSAGE = + "a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant)."; + export default tseslint.config( { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { @@ -451,12 +456,19 @@ export default tseslint.config( { selector: "Literal[value=/(^|\\s)enabled:hover:/]", message: - "`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.", + "`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).", }, { selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]", message: - "`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.", + "`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).", + }, + // Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an + // interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every + // hover colour of the button and chip primitives was dead that way (E28 audit). + { + selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`, + message: SPLIT_CLASS_MESSAGE, }, // The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a // longer one or a user's own list a FilterSelect. Six native selects had drifted in; the diff --git a/frontend/src/components/ui/LinkButton.tsx b/frontend/src/components/ui/LinkButton.tsx index 00b31946..99824f61 100644 --- a/frontend/src/components/ui/LinkButton.tsx +++ b/frontend/src/components/ui/LinkButton.tsx @@ -34,10 +34,7 @@ export function LinkButton({ children: ReactNode; }) { return ( - + {icon} {children} @@ -63,7 +60,7 @@ export function IconLink({ {children} diff --git a/frontend/src/components/ui/buttonStyles.ts b/frontend/src/components/ui/buttonStyles.ts index 28681a38..2a662209 100644 --- a/frontend/src/components/ui/buttonStyles.ts +++ b/frontend/src/components/ui/buttonStyles.ts @@ -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 -// , 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 , 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:"; +// , 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 => ({ +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: "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 => ({ - ghost: `text-muted state-layer ${ACT}hover:text-fg`, +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: "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 = { 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], diff --git a/frontend/src/components/ui/chipStyles.ts b/frontend/src/components/ui/chipStyles.ts index 8a1d56a7..cae3df12 100644 --- a/frontend/src/components/ui/chipStyles.ts +++ b/frontend/src/components/ui/chipStyles.ts @@ -9,16 +9,17 @@ export type ChipSize = | "sm" // 24, text-xs — a filter row | "xs"; // 24, text-2xs, tight — a tag in a table row -const ACT = "enabled:not-aria-disabled:"; const PAINT: Record = { // `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed // fill, so the open-popover state is the only one spelled out. filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent", - token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`, + token: "chip act:hover:border-accent act:hover:bg-accent/20 aria-expanded:border-accent", // B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim. - tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`, - suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`, - ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`, + tag: "chip-solid font-medium act:hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]", + suggest: + "border border-dashed border-accent/50 text-accent/90 act:hover:text-accent act:hover:border-accent", + ghost: + "border border-dashed border-border text-muted act:hover:text-accent act:hover:border-accent", }; const SIZES: Record = { diff --git a/frontend/src/components/ui/tailwindScan.contract.test.ts b/frontend/src/components/ui/tailwindScan.contract.test.ts new file mode 100644 index 00000000..2331d085 --- /dev/null +++ b/frontend/src/components/ui/tailwindScan.contract.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { + type ButtonSize, + type ButtonVariant, + buttonClass, + type IconButtonSize, + type IconButtonVariant, + iconButtonClass, +} from "./buttonStyles"; +import { type ChipSize, type ChipVariant, chipClass } from "./chipStyles"; + +// Tailwind generates CSS only for the class names it FINDS in the source, token by token. A class +// assembled at runtime (`${ACT}hover:text-fg`) is never found whole, so it ships with no rule at all: +// every hover colour of Button, IconButton and Chip was dead that way, found in the E28 audit by +// reading the stylesheet (0 rules for `not-aria-disabled`). So: every class a primitive can emit has +// to stand in its source file whole, between the delimiters the scanner splits on. +const tokens = (file: string) => + new Set(readFileSync(new URL(file, import.meta.url), "utf8").split(/[\s"'`{}$]+/)); + +// Records, so a new variant or size that is not listed here fails to compile. +const keys = (r: Record) => Object.keys(r) as K[]; +const BV = keys({ + primary: true, danger: true, warn: true, outline: true, "outline-accent": true, + "outline-danger": true, "outline-warn": true, ghost: true, glass: true, overlay: true, + media: true, "danger-soft": true, link: true, "link-muted": true, +}); // prettier-ignore +const BS = keys({ xs: true, sm: true, md: true, lg: true, none: true }); +const IV = keys({ + ghost: true, "ghost-accent": true, primary: true, outline: true, "outline-accent": true, + "outline-danger": true, "outline-warn": true, danger: true, "danger-soft": true, glass: true, + media: true, overlay: true, +}); // prettier-ignore +const IS = keys({ sm: true, md: true, lg: true, player: true }); +const CV = keys({ filter: true, token: true, tag: true, suggest: true, ghost: true }); +const CS = keys({ md: true, sm: true, xs: true }); + +const missing = (emitted: string[], file: string) => { + const found = tokens(file); + return [...new Set(emitted.flatMap((c) => c.split(/\s+/)).filter(Boolean))].filter( + (c) => !found.has(c), + ); +}; + +describe("every class a primitive emits is visible to the Tailwind scanner", () => { + it("Button and IconButton", () => { + const emitted: string[] = []; + for (const pressed of [undefined, true]) { + for (const variant of BV) + for (const size of BS) emitted.push(buttonClass({ variant, size, pressed, round: true })); + for (const variant of IV) + for (const size of IS) emitted.push(iconButtonClass({ variant, size, pressed })); + } + expect(missing(emitted, "./buttonStyles.ts")).toEqual([]); + }); + + it("Chip", () => { + const emitted = CV.flatMap((v) => CS.map((s) => chipClass(v, s))); + expect(missing(emitted, "./chipStyles.ts")).toEqual([]); + }); +}); diff --git a/frontend/src/index.css b/frontend/src/index.css index 0b8a820d..76f93eba 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -75,6 +75,13 @@ /* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design lab both read, so a decided value is never restated as an arbitrary class. ===== */ +/* B3: `act:` — a hover or press that answers only a control that can act: not a disabled one, not a busy + one (FocusSafeButton's aria-disabled). `:disabled` never matches an , so one variant serves a + button and a link alike. A literal variant, never a prefix glued on at runtime: Tailwind emits CSS + only for a class it finds WHOLE in the source, and the `${ACT}hover:…` the primitives used to build + shipped with no rule at all (E28 audit; ui/tailwindScan.contract.test.ts). */ +@custom-variant act (&:not(:disabled, [aria-disabled="true"])); + /* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act: not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose solid accent is its state and must not turn grey under the pointer. */ diff --git a/frontend/src/lib/controlRules.contract.test.ts b/frontend/src/lib/controlRules.contract.test.ts index 8cec87da..6e22bf6a 100644 --- a/frontend/src/lib/controlRules.contract.test.ts +++ b/frontend/src/lib/controlRules.contract.test.ts @@ -18,7 +18,7 @@ describe("a native select", () => { }); }); -// `enabled:hover:` is blind to a focused FocusSafeButton (aria-disabled, not the native attribute), +// `enabled:hover:` is blind to a focused FocusSafeButton (aria-disabled, not the native attribute; `act:` is not), // which then lit up on hover as if a click would work. describe("a hover gated on `enabled:` alone", () => { it("is refused; the aria-aware gate is not", async () => { @@ -33,6 +33,28 @@ describe("a hover gated on `enabled:` alone", () => { ) )[0]?.messages.filter((m) => m.message.startsWith("`enabled:hover:`")).length; expect(await lint("enabled:hover")).toBe(1); - expect(await lint("enabled:not-aria-disabled:hover")).toBe(0); + expect(await lint("act:hover")).toBe(0); + }); +}); + +// A variant glued onto an interpolated prefix is never seen whole by Tailwind's scanner, so it ships +// with no CSS (every button and chip hover colour died that way). A plain interpolated value is fine. +describe("a class split by an interpolation", () => { + const split = async (src: string) => + (await eslint.lintText(src, { filePath: "src/components/__probe.tsx" }))[0]?.messages.filter( + (m) => m.message.startsWith("a class split by an interpolation"), + ).length; + const act = "enabled:"; // assembled, as the offender was + it("is refused after an expression", async () => { + expect(await split("const A = 'x';\nexport const b = `${A}" + "hover:text-fg`;\n")).toBe(1); + expect(await split("const A = 'x';\nexport const c = `p-1 ${A}" + act + "hover:x`;\n")).toBe(1); + }); + it("leaves a whole class, a leading variant and a plain value alone", async () => { + expect(await split("const n = 1;\nexport const d = `hover:text-fg w-${n} ${n}px`;\n")).toBe(0); + expect( + await split( + "const u = 'h';\nexport const e = `${u}://x:${n()}`;\nfunction n() { return 1; }\n", + ), + ).toBe(0); }); });