perf(webui): keep settings-only appearance defaults out of startup

This commit is contained in:
2026-09-08 01:41:04 +02:00
parent b75ca34295
commit 32c70a4657
6 changed files with 52 additions and 31 deletions
+5
View File
@@ -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 Settings route. Importing a runtime helper from a module that also owns editor
components can accidentally pull the entire editor into the startup chunk. components can accidentally pull the entire editor into the startup chunk.
Public helper exports remain compatible; theme application is still synchronous. 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.
+16 -3
View File
@@ -12,6 +12,7 @@ const settings = readFileSync(resolve(webuiRoot, "src/features/settings/Settings
const paletteControl = readFileSync(resolve(webuiRoot, "src/components/AppearancePaletteControl.tsx"), "utf8"); const paletteControl = readFileSync(resolve(webuiRoot, "src/components/AppearancePaletteControl.tsx"), "utf8");
const overridesEditor = readFileSync(resolve(webuiRoot, "src/components/AppearanceOverridesEditor.tsx"), "utf8"); const overridesEditor = readFileSync(resolve(webuiRoot, "src/components/AppearanceOverridesEditor.tsx"), "utf8");
const overridesRuntime = readFileSync(resolve(webuiRoot, "src/components/appearanceOverrides.ts"), "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, /: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(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.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.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 "\.\/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, /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"); assert.match(overridesRuntime, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation");
const runtimeExports = {}; const runtimeExports = {};
const defaultsExports = {};
vm.runInNewContext(ts.transpileModule(overridesRuntime, { vm.runInNewContext(ts.transpileModule(overridesRuntime, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: runtimeExports }); }).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); assert.equal(runtimeExports.validateAppearanceOverrides(validOverrides), validOverrides);
const colorProperties = new Map(); const colorProperties = new Map();
const root = { style: { 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); assert.equal(colorProperties.get("--danger-text"), validOverrides.dark.danger_foreground);
runtimeExports.applyAppearanceOverrides(root, validOverrides, "light"); runtimeExports.applyAppearanceOverrides(root, validOverrides, "light");
assert.equal(colorProperties.get("--accent"), validOverrides.light.accent); assert.equal(colorProperties.get("--accent"), validOverrides.light.accent);
const invalidOverrides = runtimeExports.cloneDefaultAppearanceOverrides(); const invalidOverrides = defaultsExports.cloneDefaultAppearanceOverrides();
invalidOverrides.light.accent_foreground = invalidOverrides.light.accent; 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)); assert.throws(() => runtimeExports.validateAppearanceOverrides(invalidOverrides));
runtimeExports.applyAppearanceOverrides(root, invalidOverrides, "dark"); runtimeExports.applyAppearanceOverrides(root, invalidOverrides, "dark");
assert.equal(colorProperties.size, 0, "invalid documents clear previous custom tokens and never partially apply"); assert.equal(colorProperties.size, 0, "invalid documents clear previous custom tokens and never partially apply");
@@ -12,8 +12,10 @@ import DismissibleAlert from "./DismissibleAlert";
import FormField from "./FormField"; import FormField from "./FormField";
import SegmentedControl from "./SegmentedControl"; import SegmentedControl from "./SegmentedControl";
import { APPEARANCE_OVERRIDE_TOKENS, STATUS_TOKENS, cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides"; import { APPEARANCE_OVERRIDE_TOKENS, STATUS_TOKENS, validateAppearanceOverrides } from "./appearanceOverrides";
export { APPEARANCE_OVERRIDE_TOKENS, DEFAULT_APPEARANCE_OVERRIDES, applyAppearanceOverrides, cloneDefaultAppearanceOverrides, 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<AppearanceOverrideToken, string> = { const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
accent: "i18n:govoplan-core.override_accent", accent: "i18n:govoplan-core.override_accent",
@@ -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;
}
@@ -30,30 +30,6 @@ for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
for (const property of properties) RUNTIME_PROPERTIES.add(property); 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 { export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) { if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema"); throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
+1 -2
View File
@@ -69,11 +69,10 @@ export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_O
export { default as AppearanceOverridesEditor } from "./components/AppearanceOverridesEditor"; export { default as AppearanceOverridesEditor } from "./components/AppearanceOverridesEditor";
export { export {
APPEARANCE_OVERRIDE_TOKENS, APPEARANCE_OVERRIDE_TOKENS,
DEFAULT_APPEARANCE_OVERRIDES,
applyAppearanceOverrides, applyAppearanceOverrides,
cloneDefaultAppearanceOverrides,
validateAppearanceOverrides validateAppearanceOverrides
} from "./components/appearanceOverrides"; } from "./components/appearanceOverrides";
export { DEFAULT_APPEARANCE_OVERRIDES, cloneDefaultAppearanceOverrides } from "./components/appearanceOverrideDefaults";
export type { ButtonProps } from "./components/Button"; export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card"; export { default as Card } from "./components/Card";
export type { CardProps } from "./components/Card"; export type { CardProps } from "./components/Card";