diff --git a/packages/ui-web/src/lib/utils.test.ts b/packages/ui-web/src/lib/utils.test.ts new file mode 100644 index 0000000..4452e9a --- /dev/null +++ b/packages/ui-web/src/lib/utils.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buttonVariants } from "../components/ui/button.js"; +import { cn } from "./utils.js"; + +describe("cn", () => { + it("keeps a text color next to a ui text size", () => { + expect(cn("text-primary-foreground", "text-ui-label")).toBe( + "text-primary-foreground text-ui-label", + ); + expect(cn("text-ui-body-sm", "text-muted-foreground")).toBe( + "text-ui-body-sm text-muted-foreground", + ); + }); + + it("merges two text sizes to the last one", () => { + expect(cn("text-ui-body-sm", "text-ui-label")).toBe("text-ui-label"); + expect(cn("text-sm", "text-ui-caption")).toBe("text-ui-caption"); + expect(cn("text-ui-caption", "text-lg")).toBe("text-lg"); + }); + + it("merges two text colors to the last one", () => { + expect(cn("text-ui-label text-primary-foreground", "text-destructive")).toBe( + "text-ui-label text-destructive", + ); + }); + + it("merges ui control sizes with other sizes", () => { + expect(cn("h-control-sm", "h-8")).toBe("h-8"); + expect(cn("h-auto", "h-control-md")).toBe("h-control-md"); + expect(cn("[&_svg]:size-icon-md", "[&_svg]:size-icon-lg")).toBe("[&_svg]:size-icon-lg"); + }); + + it("keeps small buttons readable", () => { + for (const size of ["xs", "sm", "default", "lg"] as const) { + const classes = cn(buttonVariants({ variant: "default", size })).split(" "); + expect(classes, size).toContain("text-primary-foreground"); + expect(classes.filter((name) => name.startsWith("text-ui-")).length, size).toBe(1); + } + expect(cn(buttonVariants({ variant: "destructive", size: "sm" })).split(" ")).toEqual( + expect.arrayContaining(["text-destructive", "text-ui-label"]), + ); + }); +}); + +/** One merge check per `@theme` namespace: [earlier classes, later classes, expected]. */ +const NAMESPACE_PROBES: Record [string, string, string]> = { + color: (name) => ["text-ui-body text-foreground", `text-${name}`, `text-ui-body text-${name}`], + radius: (name) => ["rounded-sm", `rounded-${name}`, `rounded-${name}`], + spacing: (name) => [ + "h-4 p-2 gap-2", + `h-${name} p-${name} gap-${name}`, + `h-${name} p-${name} gap-${name}`, + ], + text: (name) => ["text-foreground text-sm", `text-${name}`, `text-foreground text-${name}`], +}; + +describe("cn knows every @theme key in styles.css", () => { + const css = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); + const theme = css.match(/@theme inline \{([\s\S]*?)\n\}/)?.[1] ?? ""; + const keys = [...theme.matchAll(/^\s*--([a-z0-9-]+):/gm)] + .map((match) => match[1] ?? "") + .filter((key) => !key.includes("--")); + + it("finds the theme keys", () => { + expect(keys).toContain("text-ui-label"); + expect(keys).toContain("spacing-control-sm"); + }); + + for (const key of keys) { + it(`--${key}`, () => { + const namespace = Object.keys(NAMESPACE_PROBES).find((ns) => key.startsWith(`${ns}-`)); + expect(namespace, `add a probe for the @theme namespace of --${key}`).toBeDefined(); + const [earlier, later, expected] = NAMESPACE_PROBES[namespace ?? ""]!( + key.slice(`${namespace}-`.length), + ); + expect(cn(earlier, later)).toBe(expected); + }); + } +}); diff --git a/packages/ui-web/src/lib/utils.ts b/packages/ui-web/src/lib/utils.ts index 365058c..7cdcf9e 100644 --- a/packages/ui-web/src/lib/utils.ts +++ b/packages/ui-web/src/lib/utils.ts @@ -1,5 +1,40 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * The scales `styles.css` adds under `@theme`. tailwind-merge only knows Tailwind's + * defaults, so without them it reads `text-ui-label` as a text color (dropping the + * real color) and never merges `h-control-sm` with `h-8`. `utils.test.ts` fails + * when `@theme` gains a key that is missing here. + */ +const twMerge = extendTailwindMerge({ + extend: { + theme: { + text: [ + "ui-caption", + "ui-label", + "ui-body-sm", + "ui-body", + "ui-reading", + "ui-title-sm", + "ui-title", + ], + spacing: [ + "control-xs", + "control-sm", + "control-md", + "control-lg", + "icon-sm", + "icon-md", + "icon-lg", + "rk-shell", + "rk-sidebar-x", + "rk-sidebar-y", + ], + }, + }, +}); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));