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:`.
61 lines
2.7 KiB
TypeScript
61 lines
2.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { eslint } from "./lintProbe.testkit";
|
|
|
|
// Two lint rules about controls: no native <select> (the canonical "FE control" row in CLAUDE.md;
|
|
// six had drifted in before the rule existed), and no hover gated on `enabled:` alone. A selector
|
|
// that matches nothing looks exactly like a clean codebase, so this pins that each fires.
|
|
|
|
async function refused(source: string): Promise<number> {
|
|
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
|
|
return (result?.messages ?? []).filter((m) => m.message.startsWith("No native <select>")).length;
|
|
}
|
|
|
|
describe("a native select", () => {
|
|
it("is refused; the house controls are not", async () => {
|
|
expect(await refused("export const A = () => <select />;\n")).toBe(1);
|
|
expect(await refused("export const B = () => <SegmentedToggle />;\n")).toBe(0);
|
|
});
|
|
});
|
|
|
|
// `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 () => {
|
|
const lint = async (cls: string) =>
|
|
(
|
|
await eslint.lintText(
|
|
`export const c = "p-1 ${cls}:text-fg";
|
|
`,
|
|
{
|
|
filePath: "src/components/__probe.tsx",
|
|
},
|
|
)
|
|
)[0]?.messages.filter((m) => m.message.startsWith("`enabled:hover:`")).length;
|
|
expect(await lint("enabled:hover")).toBe(1);
|
|
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);
|
|
});
|
|
});
|