/** * Regression guard for the theme-token format contract (fix for "Appearance * theme not working"). * * The 19 core theme tokens in globals.css MUST be stored as raw oklch * channel triples (`--primary: 0.554 0.143 249.3`), because * tailwind.config.ts references them as `oklch(var(--token) / * )`. Storing a full color (`oklch(0.554 0.143 249.3)`) * produces the invalid nested `oklch(oklch(…))` which the browser resolves * to TRANSPARENT — silently blanking every themed bg/text/border utility and * making dark mode look identical to light mode. This test pins both halves * of the contract so the regression cannot reappear. * * NOTE: the suite default is jsdom, but this test reads files via node:fs / * node:path — run it under the node environment so those modules are not * externalized (externalization made `resolve` undefined and the whole * suite no-op before any token was checked). */ // @vitest-environment node import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; const CORE_TOKENS = [ "background", "foreground", "card", "card-foreground", "popover", "popover-foreground", "primary", "primary-foreground", "secondary", "secondary-foreground", "muted", "muted-foreground", "accent", "accent-foreground", "destructive", "destructive-foreground", "border", "input", "ring", ] as const; /** "0.985 0.002 247.839" — three space-separated numbers, no color function. */ const RAW_TRIPLE = /^(\d+(?:\.\d+)?\s+){2}\d+(?:\.\d+)?$/; function blockBody(css: string, selector: string): string { const i = css.indexOf(selector); if (i === -1) throw new Error(`${selector} not found in globals.css`); const open = css.indexOf("{", i); const close = css.indexOf("}", open); return css.slice(open + 1, close); } function tokenValues(body: string): Record { const out: Record = {}; for (const name of CORE_TOKENS) { const m = body.match(new RegExp(`--${name}:\\s*([^;]+);`)); const value = m?.[1]; if (!value) throw new Error(`--${name} missing in block`); out[name] = value.trim(); } return out; } describe("theme tokens (globals.css + tailwind.config.ts)", () => { const css = readFileSync(resolve(__dirname, "../globals.css"), "utf8"); const config = readFileSync( resolve(__dirname, "../../../tailwind.config.ts"), "utf8", ); const light = tokenValues(blockBody(css, ":root")); const dark = tokenValues(blockBody(css, ".dark")); it("stores every core token as a raw oklch channel triple in both themes", () => { for (const [name, value] of Object.entries({ ...light, ...dark })) { expect(value, `--${name} must be raw channels, got: ${value}`).toMatch(RAW_TRIPLE); expect( value.startsWith("oklch("), `--${name} must NOT be a full oklch() color`, ).toBe(false); } }); it("defines the same 19 core tokens in light and dark", () => { expect(Object.keys(light).sort()).toEqual(Object.keys(dark).sort()); expect(CORE_TOKENS).toHaveLength(19); }); it("wraps the tokens in the tailwind config as oklch(var(--x) / )", () => { // Spot-check the utilities most affected by the original bug. for (const name of ["background", "foreground", "border", "ring", "primary", "muted", "card"]) { expect( config, `tailwind.config must wrap --${name}`, ).toContain(`oklch(var(--${name}) / )`); } // And none of them may be referenced as a bare var (which breaks /NN modifiers). expect(config).not.toMatch(/"(?:var\(--(background|foreground|border|ring|primary)\))"/); }); });