import assert from "node:assert/strict"; import { readFileSync, readdirSync } from "node:fs"; import { extname, join } from "node:path"; import test from "node:test"; const root = new URL("../", import.meta.url); const css = readFileSync(new URL("src/app/globals.css", root), "utf8"); /** * Tailwind v4 builds its utility namespace only from `@theme`. A `:root` custom * property is invisible to the compiler, so `text-ink` compiles to nothing at * all while still looking correct in the JSX. Text assertions on class names * cannot catch that, so this file cross-references the two sides instead. */ function themeDeclarations() { const block = css.match(/@theme[^{]*\{([\s\S]*?)\n\}/); assert.ok(block, "globals.css must declare a @theme block"); return new Map( [...block[1].matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)].map(([, name, value]) => [name, value.trim()]), ); } function rootDeclarations() { const entries = new Map(); for (const [, body] of css.matchAll(/^:root\s*\{([\s\S]*?)\n\}/gm)) { for (const [, name, value] of body.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) { if (!entries.has(name)) entries.set(name, value.trim()); } } return entries; } function sourceFiles(path: string): string[] { const directory = new URL(path, root); return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const child = join(directory.pathname, entry.name); if (entry.isDirectory()) return sourceFiles(`${path}/${entry.name}`); return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : []; }); } // Utility prefixes that resolve their argument through the `--color-*` namespace. const colorPrefixes = [ "accent", "bg", "border", "border-b", "border-e", "border-l", "border-r", "border-s", "border-t", "border-x", "border-y", "caret", "decoration", "divide", "fill", "from", "inset-shadow", "outline", "placeholder", "ring", "ring-offset", "shadow", "stroke", "text", "text-shadow", "to", "via", ]; /** Every `--color-*` token that `src/` uses as a Tailwind utility, with its call sites. */ function colorTokensUsedAsUtilities(palette: Map) { const used = new Map>(); for (const file of sourceFiles("src")) { const label = file.slice(file.indexOf("/src/") + 1); readFileSync(file, "utf8").split("\n").forEach((line, index) => { for (const [candidate] of line.matchAll(/[a-z][A-Za-z0-9:\/.-]*/g)) { // Strip variants (`hover:`), the important marker and any opacity modifier. const utility = candidate.split(":").pop()!.replace(/^!/, "").split("/")[0]; for (const prefix of colorPrefixes) { if (!utility.startsWith(`${prefix}-`)) continue; const token = `--color-${utility.slice(prefix.length + 1)}`; if (!palette.has(token)) continue; if (!used.has(token)) used.set(token, new Set()); used.get(token)!.add(`${label}:${index + 1} (${utility})`); } } }); } return used; } test("every color token used as a Tailwind utility is exposed through @theme", () => { const theme = themeDeclarations(); const palette = rootDeclarations(); const dead: string[] = []; for (const [token, callSites] of colorTokensUsedAsUtilities(palette)) { if (theme.has(token)) continue; dead.push(`${token} -> ${[...callSites].sort().join(", ")}`); } assert.deepEqual( dead, [], `these utilities compile to no CSS because the token is missing from @theme:\n${dead.join("\n")}`, ); }); test("the whole :root colour palette is reachable from the utility namespace", () => { const theme = themeDeclarations(); const missing = [...rootDeclarations().keys()] .filter((token) => token.startsWith("--color-") && !theme.has(token)) .sort(); assert.deepEqual(missing, [], `add these to @theme so utilities generate: ${missing.join(", ")}`); }); test("@theme keeps the shadcn aliases pointing at the same palette tokens", () => { const theme = themeDeclarations(); const aliases: Record = { "--color-background": "var(--color-canvas)", "--color-foreground": "var(--color-ink)", "--color-primary": "var(--color-action)", "--color-primary-foreground": "var(--color-on-dark)", "--color-secondary": "var(--color-canvas-muted)", "--color-secondary-foreground": "var(--color-ink)", "--color-muted": "var(--color-canvas-muted)", "--color-muted-foreground": "var(--color-ink-secondary)", "--color-destructive": "var(--color-danger)", }; for (const [name, value] of Object.entries(aliases)) { assert.equal(theme.get(name), value, `@theme must keep ${name} as ${value}`); } }); test("@theme resolves palette aliases inline so the :root values stay authoritative", () => { // `inline` makes a utility emit the declared value rather than a reference to // the theme variable, which is what lets `--color-background` forward to // `--color-canvas` instead of shadowing it. assert.match(css, /@theme inline \{/); // Tailwind emits `@theme` into `@layer theme`, and unlayered declarations beat // layered ones, so the literal values below must stay outside any layer. assert.ok(css.indexOf("@theme inline {") < css.indexOf(":root {"), "@theme must precede the :root palette"); assert.match(css, /^:root \{\n color-scheme: light;\n --color-action: #85432f;/m); }); test("@theme literals do not drift from the :root token they duplicate", () => { const palette = rootDeclarations(); for (const [name, value] of themeDeclarations()) { if (!name.startsWith("--color-") || value.startsWith("var(")) continue; const token = palette.get(name); if (token === undefined) continue; assert.equal(value, token, `@theme ${name} must match the :root token ${token}`); } });