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
@@ -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 = <K extends string>(r: Record<K, true>) => Object.keys(r) as K[];
const BV = keys<ButtonVariant>({
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<ButtonSize>({ xs: true, sm: true, md: true, lg: true, none: true });
const IV = keys<IconButtonVariant>({
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<IconButtonSize>({ sm: true, md: true, lg: true, player: true });
const CV = keys<ChipVariant>({ filter: true, token: true, tag: true, suggest: true, ghost: true });
const CS = keys<ChipSize>({ 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([]);
});
});