import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createEkklesiaThemeController, DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, DESIGN_TOKEN_MOTION_ATTRIBUTE, EKKLESIA_APPEARANCES, EKKLESIA_MOTION_PREFERENCES, } from "@ekklesia/design-tokens"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { accountDisplayName, accountInitials } from "../src/browser/AccountAvatar.js"; import { APPEARANCE_CACHE_KEY, createLocalAppearanceCache, createMemoryAppearanceCache, parseAppearanceCache, serializeAppearanceChoice, } from "../src/browser/appearance-cache.js"; import { createAppearancePreference } from "../src/browser/appearance-preference.js"; /** * The browser half of account appearance (ADR 0062): the pre-paint cache, the pre-paint script, * and the one handle that puts a choice into effect through the ADR 0060 theme controller. */ const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); function memoryStorage(initial: Record = {}) { const values = new Map(Object.entries(initial)); return { getItem: vi.fn((key: string) => values.get(key) ?? null), removeItem: vi.fn((key: string) => { values.delete(key); }), setItem: vi.fn((key: string, value: string) => { values.set(key, value); }), values, }; } function throwingStorage() { const fail = () => { throw new Error("SecurityError: storage is disabled"); }; return { getItem: vi.fn(fail), removeItem: vi.fn(fail), setItem: vi.fn(fail) }; } function recordingTarget() { const attributes = new Map(); return { attributes, setAttribute: (name: string, value: string) => { attributes.set(name, value); }, }; } describe("the appearance cache", () => { it("accepts every combination of the design tokens' own values, and round-trips each", () => { for (const appearance of EKKLESIA_APPEARANCES) { for (const motion of EKKLESIA_MOTION_PREFERENCES) { const serialized = serializeAppearanceChoice({ appearance, motion }); expect(parseAppearanceCache(serialized)).toEqual({ appearance, motion }); } } }); it("refuses anything else, so a stale or tampered value is no cache at all", () => { for (const value of [ null, undefined, "", "dark", "dark reduce extra", "Dark reduce", "dark reduce", "sepia reduce", "dark none", '{"appearance":"dark","motion":"reduce"}', 42, ]) { expect(parseAppearanceCache(value), String(value)).toBeUndefined(); } }); it("holds two words under one namespaced key, and nothing about the account", () => { const storage = memoryStorage(); const cache = createLocalAppearanceCache(storage); cache.write({ appearance: "dark", motion: "reduce" }); expect([...storage.values.entries()]).toEqual([[APPEARANCE_CACHE_KEY, "dark reduce"]]); expect(APPEARANCE_CACHE_KEY).toBe("ekklesia.dashboard.appearance"); expect(cache.read()).toEqual({ appearance: "dark", motion: "reduce" }); cache.clear(); expect(storage.values.size).toBe(0); expect(cache.read()).toBeUndefined(); }); it("never breaks when storage is missing or throws", () => { for (const storage of [undefined, throwingStorage()]) { const cache = createLocalAppearanceCache(storage); expect(() => cache.write({ appearance: "dark", motion: "allow" })).not.toThrow(); expect(cache.read()).toBeUndefined(); expect(() => cache.clear()).not.toThrow(); } }); }); describe("the appearance preference", () => { function build(initial?: { appearance: "system" | "light" | "dark"; motion: "system" | "reduce" | "allow"; }) { const root = recordingTarget(); const meta = recordingTarget(); const cache = createMemoryAppearanceCache(initial); const theme = createEkklesiaThemeController({ appearance: initial?.appearance ?? "system", motion: initial?.motion ?? "system", target: root, }); const preference = createAppearancePreference({ cache, colorSchemeMeta: meta, theme }); return { cache, meta, preference, root, theme }; } it("drives the ADR 0060 theme controller: the root's two attributes are the whole effect", () => { const { preference, root, theme } = build(); preference.apply({ appearance: "dark", motion: "reduce" }); expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce"); expect(theme.colorScheme()).toBe("dark"); expect(theme.reducedMotion()).toBe(true); }); it("remembers what it applied for the next first paint, and narrows the colour scheme with it", () => { const { cache, meta, preference } = build(); preference.apply({ appearance: "light", motion: "allow" }); expect(cache.read()).toEqual({ appearance: "light", motion: "allow" }); expect(meta.attributes.get("content")).toBe("light"); preference.apply({ appearance: "system", motion: "allow" }); expect(meta.attributes.get("content")).toBe("light dark"); }); it("gives a stable snapshot between changes, and notifies only on a real change", () => { const { preference } = build(); const listener = vi.fn(); preference.subscribe(listener); const before = preference.current(); expect(preference.current()).toBe(before); preference.apply({ appearance: "system", motion: "system" }); expect(listener).not.toHaveBeenCalled(); expect(preference.current()).toBe(before); preference.apply({ appearance: "dark", motion: "system" }); expect(listener).toHaveBeenCalledOnce(); expect(preference.current()).toEqual({ appearance: "dark", motion: "system" }); }); it("follows another tab through the cache, without writing it back", () => { const { cache, preference } = build({ appearance: "light", motion: "system" }); const write = vi.spyOn(cache, "write"); const listener = vi.fn(); preference.subscribe(listener); cache.write({ appearance: "dark", motion: "reduce" }); write.mockClear(); preference.syncFromCache(); expect(preference.current()).toEqual({ appearance: "dark", motion: "reduce" }); expect(listener).toHaveBeenCalledOnce(); expect(write).not.toHaveBeenCalled(); }); it("follows another tab's sign-out back to the system", () => { const { cache, preference } = build({ appearance: "dark", motion: "reduce" }); cache.clear(); preference.syncFromCache(); expect(preference.current()).toEqual({ appearance: "system", motion: "system" }); }); it("forgets on sign-out: the system again, and no cached choice for the next person", () => { const { cache, meta, preference, root } = build({ appearance: "dark", motion: "reduce" }); preference.forget(); expect(cache.read()).toBeUndefined(); expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system"); expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system"); expect(meta.attributes.get("content")).toBe("light dark"); }); }); describe("the pre-paint appearance script", () => { let meta: HTMLMetaElement; beforeEach(() => { vi.resetModules(); window.localStorage.clear(); document.documentElement.removeAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE); document.documentElement.removeAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE); meta = document.createElement("meta"); meta.setAttribute("name", "color-scheme"); meta.setAttribute("content", "light dark"); document.head.append(meta); }); afterEach(() => { meta.remove(); window.localStorage.clear(); vi.restoreAllMocks(); }); async function runBootScript(): Promise { await import("../src/browser/appearance-boot.js"); } it("puts a cached explicit choice on the root and narrows the colour scheme, before anything renders", async () => { window.localStorage.setItem(APPEARANCE_CACHE_KEY, "dark reduce"); await runBootScript(); expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce"); expect(meta.getAttribute("content")).toBe("dark"); }); it("leaves the colour scheme following the system for a cached System choice", async () => { window.localStorage.setItem(APPEARANCE_CACHE_KEY, "system allow"); await runBootScript(); expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system"); expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow"); expect(meta.getAttribute("content")).toBe("light dark"); }); it("does nothing without a valid cache", async () => { window.localStorage.setItem(APPEARANCE_CACHE_KEY, "neon reduce"); await runBootScript(); expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false); expect(meta.getAttribute("content")).toBe("light dark"); }); it("does nothing, and throws nothing, where storage is blocked", async () => { vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("SecurityError"); }); await expect(runBootScript()).resolves.toBeUndefined(); expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false); }); it("names exactly the token stylesheet's attributes, and reads storage without ever writing it", async () => { const source = await readFile( path.join(packageDirectory, "src", "browser", "appearance-boot.ts"), "utf8", ); expect(source).toContain(`"${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}"`); expect(source).toContain(`"${DESIGN_TOKEN_MOTION_ATTRIBUTE}"`); expect(source).not.toMatch(/setItem|removeItem|fetch|cookie/u); // Types only from the token package, so the render-blocking script stays tiny. const cacheSource = await readFile( path.join(packageDirectory, "src", "browser", "appearance-cache.ts"), "utf8", ); expect(cacheSource).toMatch(/^import type \{[^}]*\} from "@ekklesia\/design-tokens";$/mu); }); }); describe("account initials", () => { it.each([ [{ email: "ada@example.test", name: "Ada Lovelace" }, "AL"], [{ email: "ada@example.test", name: "Ada King Lovelace" }, "AL"], [{ email: "ada@example.test", name: " ada lovelace " }, "AL"], [{ email: "cher@example.test", name: "Cher" }, "C"], [{ email: "zoe@example.test", name: "Émile Zola" }, "ÉZ"], [{ email: "blank@example.test", name: " " }, "B"], ])("draws %o as %s", (account, initials) => { expect(accountInitials(account)).toBe(initials); }); it("shows the email as the name when the name is blank", () => { expect(accountDisplayName({ email: "blank@example.test", name: " " })).toBe( "blank@example.test", ); expect(accountDisplayName({ email: "ada@example.test", name: "Ada" })).toBe("Ada"); }); });