import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import vm from "node:vm"; import ts from "typescript"; const webuiRoot = resolve(import.meta.dirname, ".."); const repositoryRoot = resolve(webuiRoot, "..", ".."); const tokens = readFileSync(resolve(webuiRoot, "src/styles/tokens.css"), "utf8"); const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8"); const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.tsx"), "utf8"); const paletteControl = readFileSync(resolve(webuiRoot, "src/components/AppearancePaletteControl.tsx"), "utf8"); const overridesEditor = readFileSync(resolve(webuiRoot, "src/components/AppearanceOverridesEditor.tsx"), "utf8"); const overridesRuntime = readFileSync(resolve(webuiRoot, "src/components/appearanceOverrides.ts"), "utf8"); const overridesDefaults = readFileSync(resolve(webuiRoot, "src/components/appearanceOverrideDefaults.ts"), "utf8"); assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are required"); assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme"); assert.match(app, /matchMedia\?\.\("\(prefers-color-scheme: dark\)"\)/, "system theme must follow OS changes"); assert.match(app, /dataset\.themePreference/, "the selected preference must remain inspectable"); assert.match(app, /dataset\.palette/, "the selected palette must be applied at the document root"); for (const theme of ["system", "light", "dark"]) { assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`); } for (const palette of ["default", "civic_blue", "forest", "plum"]) { assert.match(paletteControl, new RegExp(`value:\\s*"${palette}"`), `Shared appearance control must expose ${palette}`); } assert.match(settings, /AppearancePaletteSelect/, "personal settings must use the shared palette control"); assert.match(settings, /AppearanceOverridesEditor/, "personal settings must use the shared override editor"); assert.match(app, /applyAppearanceOverrides/, "the shell must apply validated overrides centrally"); assert.match(app, /import \{ applyAppearanceOverrides \} from "\.\/components\/appearanceOverrides"/, "startup applies themes synchronously without importing settings editor controls"); assert.doesNotMatch(overridesRuntime, /import .*from ["']react["']|\.tsx|ColorPickerField|ContentGrid/, "the startup theme runtime must stay independent of editor UI"); assert.match(overridesEditor, /from "\.\/appearanceOverrides"/, "editor and runtime share the same validation implementation"); assert.match(overridesEditor, /from "\.\/appearanceOverrideDefaults"/, "settings owns the unchanged default document"); assert.doesNotMatch(overridesRuntime, /appearanceOverrideDefaults|DEFAULT_APPEARANCE_OVERRIDES|cloneDefaultAppearanceOverrides/, "startup must not import settings-only default documents"); assert.match(overridesDefaults, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema"); assert.match(overridesRuntime, /contrastRatio[\s\S]*?<\s*4\.5/, "custom pairs must enforce WCAG AA contrast"); assert.match(overridesRuntime, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation"); const runtimeExports = {}; const defaultsExports = {}; vm.runInNewContext(ts.transpileModule(overridesRuntime, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: runtimeExports }); vm.runInNewContext(ts.transpileModule(overridesDefaults, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: defaultsExports }); const validOverrides = defaultsExports.cloneDefaultAppearanceOverrides(); assert.deepEqual(validOverrides, defaultsExports.DEFAULT_APPEARANCE_OVERRIDES); assert.notEqual(validOverrides, defaultsExports.DEFAULT_APPEARANCE_OVERRIDES); assert.notEqual(validOverrides.light, defaultsExports.DEFAULT_APPEARANCE_OVERRIDES.light); assert.notEqual(validOverrides.dark, defaultsExports.DEFAULT_APPEARANCE_OVERRIDES.dark); assert.equal(runtimeExports.validateAppearanceOverrides(validOverrides), validOverrides); const colorProperties = new Map(); const root = { style: { setProperty: (property, value) => colorProperties.set(property, value), removeProperty: (property) => colorProperties.delete(property) } }; runtimeExports.applyAppearanceOverrides(root, validOverrides, "dark"); assert.equal(colorProperties.get("--accent"), validOverrides.dark.accent); assert.equal(colorProperties.get("--danger-text"), validOverrides.dark.danger_foreground); runtimeExports.applyAppearanceOverrides(root, validOverrides, "light"); assert.equal(colorProperties.get("--accent"), validOverrides.light.accent); const invalidOverrides = defaultsExports.cloneDefaultAppearanceOverrides(); invalidOverrides.light.accent_foreground = invalidOverrides.light.accent; assert.equal(defaultsExports.DEFAULT_APPEARANCE_OVERRIDES.light.accent_foreground, "#ffffff", "draft edits must not mutate shared defaults"); assert.equal(validOverrides.light.accent_foreground, "#ffffff", "independent override drafts must not share nested modes"); assert.throws(() => runtimeExports.validateAppearanceOverrides(invalidOverrides)); runtimeExports.applyAppearanceOverrides(root, invalidOverrides, "dark"); assert.equal(colorProperties.size, 0, "invalid documents clear previous custom tokens and never partially apply"); runtimeExports.applyAppearanceOverrides(root, validOverrides, "light"); runtimeExports.applyAppearanceOverrides(root, null, "light"); assert.equal(colorProperties.size, 0, "reset restores inherited CSS values synchronously"); for (const token of ["accent", "surface", "success", "info", "warning", "danger"]) { assert.match(overridesRuntime, new RegExp(`"--${token}`), `runtime overrides must map ${token} into shared tokens`); } for (const relativePath of [ "govoplan-admin/webui/src/features/admin/SystemSettingsPanel.tsx", "govoplan-tenancy/webui/src/features/admin/TenantSettingsPanel.tsx" ]) { const source = readFileSync(resolve(repositoryRoot, relativePath), "utf8"); assert.match(source, /AppearancePaletteSelect/, `${relativePath} must use the shared palette control`); assert.match(source, /AppearancePalettePreview/, `${relativePath} must use the shared palette preview`); } const paletteContrasts = [ { selector: "civic_blue", accent: "#245f91", foreground: "#ffffff" }, { selector: "forest", accent: "#276749", foreground: "#ffffff" }, { selector: "plum", accent: "#704b78", foreground: "#ffffff" } ]; for (const palette of paletteContrasts) { assert.match(tokens, new RegExp(`data-palette="${palette.selector}"[\\s\\S]*?--accent:\\s*${palette.accent}`, "i")); assert.ok(contrastRatio(palette.accent, palette.foreground) >= 4.5, `${palette.selector} accent contrast must meet WCAG AA`); } assert.ok(contrastRatio("#ef6b3a", "#242424") >= 4.5, "default accent badge contrast must meet WCAG AA"); const representativeModules = [ "govoplan-campaign/webui/src/styles/campaign-workspace.css", "govoplan-calendar/webui/src/styles/calendar.css", "govoplan-files/webui/src/styles/file-manager.css", "govoplan-mail/webui/src/styles/mail-profiles.css", ]; for (const relativePath of representativeModules) { const css = readFileSync(resolve(repositoryRoot, relativePath), "utf8"); assert.match(css, /var\(--/, `${relativePath} must consume shared theme tokens`); } console.log("Theme contract passed for core and representative module surfaces."); function contrastRatio(first, second) { const left = relativeLuminance(first); const right = relativeLuminance(second); return (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05); } function relativeLuminance(color) { const channels = color.slice(1).match(/../g).map((value) => parseInt(value, 16) / 255); const linear = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; }