import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { type ColorTokens, cssVariableName, darkTokens, lightTokens, normalizeAppearancePreference, persistAppearancePreference, renderTokensCss, resolveAppearance, resolveAppearancePreference, scaleCssVariableName, scaleTokens, tokensForAppearance, UI_APPEARANCE_STORAGE_KEY, } from "./index.js"; describe("appearance preference", () => { it("defaults unknown values to system", () => { expect(normalizeAppearancePreference(null)).toBe("system"); expect(normalizeAppearancePreference("nope")).toBe("system"); expect(normalizeAppearancePreference("light")).toBe("light"); }); it("resolves system from the platform scheme", () => { expect(resolveAppearance("system", "light")).toBe("light"); expect(resolveAppearance("system", "dark")).toBe("dark"); expect(resolveAppearance("dark", "light")).toBe("dark"); }); it("reads and writes storage", () => { const store = new Map(); const storage = { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { store.set(key, value); }, }; expect(resolveAppearancePreference({ storage })).toBe("system"); persistAppearancePreference("light", storage); expect(store.get(UI_APPEARANCE_STORAGE_KEY)).toBe("light"); expect(resolveAppearancePreference({ storage })).toBe("light"); }); it("returns distinct light and dark token sets", () => { expect(tokensForAppearance("dark")).toBe(darkTokens); expect(tokensForAppearance("light")).toBe(lightTokens); for (const key of Object.keys(darkTokens) as (keyof ColorTokens)[]) { if (key === "destructiveForeground") continue; expect(darkTokens[key], key).not.toBe(lightTokens[key]); } }); it("keeps user message surfaces muted, not cream invert", () => { const dark = tokensForAppearance("dark"); const light = tokensForAppearance("light"); expect(dark.secondary).not.toBe(dark.primary); expect(light.secondary).not.toBe(light.primary); expect(dark.secondaryForeground).not.toBe(dark.primaryForeground); }); it("separates user bubbles from bot bubbles and the sidebar from the app", () => { const dark = tokensForAppearance("dark"); const light = tokensForAppearance("light"); expect(dark.chatUser).not.toBe(dark.muted); expect(light.chatUser).not.toBe(light.muted); expect(dark.sidebar).not.toBe(dark.background); expect(light.sidebar).not.toBe(light.background); }); it("tolerates a throwing localStorage getter", () => { const desc = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { configurable: true, get() { throw new Error("blocked"); }, }); try { expect(resolveAppearancePreference()).toBe("system"); persistAppearancePreference("light"); expect(resolveAppearancePreference()).toBe("system"); } finally { if (desc) Object.defineProperty(globalThis, "localStorage", desc); else delete (globalThis as { localStorage?: Storage }).localStorage; } }); }); describe("tokens.css", () => { it("derives kebab-case variable names", () => { expect(cssVariableName("background")).toBe("--background"); expect(cssVariableName("mutedForeground")).toBe("--muted-foreground"); expect(cssVariableName("sidebarAccentForeground")).toBe("--sidebar-accent-foreground"); }); it("is generated from the TS palette", () => { const onDisk = readFileSync(fileURLToPath(new URL("./tokens.css", import.meta.url)), "utf8"); expect(onDisk).toBe(renderTokensCss()); }); it("scopes light and dark under data-theme", () => { const css = renderTokensCss(); expect(css).toContain('[data-theme="dark"] {\n color-scheme: dark;'); expect(css).toContain('[data-theme="light"] {\n color-scheme: light;'); expect(css).toContain(`--background: ${lightTokens.background.toLowerCase()};`); expect(css).toContain(`--background: ${darkTokens.background.toLowerCase()};`); }); it("emits shared UI scale tokens in both themes", () => { const css = renderTokensCss(); expect(scaleCssVariableName("fontSizeBody")).toBe("--font-size-body"); expect(css).toContain(`--font-size-body: ${scaleTokens.fontSizeBody};`); expect(css).toContain(`--control-height-lg: ${scaleTokens.controlHeightLg};`); expect(css).toContain(`--icon-md: ${scaleTokens.iconMd};`); }); });