diff --git a/docs/WEBUI_BUNDLE_BUDGETS.md b/docs/WEBUI_BUNDLE_BUDGETS.md index 17b7110..a976ee2 100644 --- a/docs/WEBUI_BUNDLE_BUDGETS.md +++ b/docs/WEBUI_BUNDLE_BUDGETS.md @@ -84,3 +84,8 @@ and previews remain in `AppearanceOverridesEditor.tsx` behind the existing lazy Settings route. Importing a runtime helper from a module that also owns editor components can accidentally pull the entire editor into the startup chunk. Public helper exports remain compatible; theme application is still synchronous. +The versioned default color document and its deep-clone helper live in +`appearanceOverrideDefaults.ts`, loaded with that editor. Applying saved overrides +does not load editor defaults or construct a draft. The default values and public +helper names are unchanged; the theme regression checks independent draft clones +as well as synchronous validation, application, and reset. diff --git a/webui/scripts/test-theme-contract.mjs b/webui/scripts/test-theme-contract.mjs index 482eea6..28e4469 100644 --- a/webui/scripts/test-theme-contract.mjs +++ b/webui/scripts/test-theme-contract.mjs @@ -12,6 +12,7 @@ const settings = readFileSync(resolve(webuiRoot, "src/features/settings/Settings 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"); @@ -30,14 +31,24 @@ assert.match(app, /applyAppearanceOverrides/, "the shell must apply validated ov 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(overridesRuntime, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema"); +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 }); -const validOverrides = runtimeExports.cloneDefaultAppearanceOverrides(); +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: { @@ -49,8 +60,10 @@ 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 = runtimeExports.cloneDefaultAppearanceOverrides(); +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"); diff --git a/webui/src/components/AppearanceOverridesEditor.tsx b/webui/src/components/AppearanceOverridesEditor.tsx index 0a26e23..8e4e67c 100644 --- a/webui/src/components/AppearanceOverridesEditor.tsx +++ b/webui/src/components/AppearanceOverridesEditor.tsx @@ -12,8 +12,10 @@ import DismissibleAlert from "./DismissibleAlert"; import FormField from "./FormField"; import SegmentedControl from "./SegmentedControl"; -import { APPEARANCE_OVERRIDE_TOKENS, STATUS_TOKENS, cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides"; -export { APPEARANCE_OVERRIDE_TOKENS, DEFAULT_APPEARANCE_OVERRIDES, applyAppearanceOverrides, cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides"; +import { APPEARANCE_OVERRIDE_TOKENS, STATUS_TOKENS, validateAppearanceOverrides } from "./appearanceOverrides"; +import { cloneDefaultAppearanceOverrides } from "./appearanceOverrideDefaults"; +export { APPEARANCE_OVERRIDE_TOKENS, applyAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides"; +export { DEFAULT_APPEARANCE_OVERRIDES, cloneDefaultAppearanceOverrides } from "./appearanceOverrideDefaults"; const TOKEN_LABELS: Record = { accent: "i18n:govoplan-core.override_accent", diff --git a/webui/src/components/appearanceOverrideDefaults.ts b/webui/src/components/appearanceOverrideDefaults.ts new file mode 100644 index 0000000..319d321 --- /dev/null +++ b/webui/src/components/appearanceOverrideDefaults.ts @@ -0,0 +1,26 @@ +/** Settings-editor defaults; the startup runtime only applies saved documents. */ +import type { AppearanceOverridesDocument } from "../types"; + +export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = { + schema_version: "1", + light: { + accent: "#245f91", accent_foreground: "#ffffff", + surface: "#ffffff", surface_foreground: "#303135", + success: "#d8eee8", success_foreground: "#315f55", + info: "#dce9f3", info_foreground: "#294a61", + warning: "#ffe1a3", warning_foreground: "#593700", + danger: "#f8d1cc", danger_foreground: "#873c35" + }, + dark: { + accent: "#7ea6c5", accent_foreground: "#242424", + surface: "#262724", surface_foreground: "#f1f1f1", + success: "#24473f", success_foreground: "#d8eee8", + info: "#243d4e", info_foreground: "#dce9f3", + warning: "#5a431f", warning_foreground: "#ffe1a3", + danger: "#4f2d2a", danger_foreground: "#f8d1cc" + } +}; + +export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument { + return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument; +} diff --git a/webui/src/components/appearanceOverrides.ts b/webui/src/components/appearanceOverrides.ts index fb35d00..d7b2133 100644 --- a/webui/src/components/appearanceOverrides.ts +++ b/webui/src/components/appearanceOverrides.ts @@ -30,30 +30,6 @@ for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) { for (const property of properties) RUNTIME_PROPERTIES.add(property); } -export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = { - schema_version: "1", - light: { - accent: "#245f91", accent_foreground: "#ffffff", - surface: "#ffffff", surface_foreground: "#303135", - success: "#d8eee8", success_foreground: "#315f55", - info: "#dce9f3", info_foreground: "#294a61", - warning: "#ffe1a3", warning_foreground: "#593700", - danger: "#f8d1cc", danger_foreground: "#873c35" - }, - dark: { - accent: "#7ea6c5", accent_foreground: "#242424", - surface: "#262724", surface_foreground: "#f1f1f1", - success: "#24473f", success_foreground: "#d8eee8", - info: "#243d4e", info_foreground: "#dce9f3", - warning: "#5a431f", warning_foreground: "#ffe1a3", - danger: "#4f2d2a", danger_foreground: "#f8d1cc" - } -}; - -export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument { - return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument; -} - export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument { if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) { throw new Error("i18n:govoplan-core.appearance_override_invalid_schema"); diff --git a/webui/src/index.ts b/webui/src/index.ts index 37e72bf..a82f2b7 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -69,11 +69,10 @@ export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_O export { default as AppearanceOverridesEditor } from "./components/AppearanceOverridesEditor"; export { APPEARANCE_OVERRIDE_TOKENS, - DEFAULT_APPEARANCE_OVERRIDES, applyAppearanceOverrides, - cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./components/appearanceOverrides"; +export { DEFAULT_APPEARANCE_OVERRIDES, cloneDefaultAppearanceOverrides } from "./components/appearanceOverrideDefaults"; export type { ButtonProps } from "./components/Button"; export { default as Card } from "./components/Card"; export type { CardProps } from "./components/Card";