feat: govern accessible appearance overrides

This commit is contained in:
2026-08-20 10:50:55 +02:00
parent 0fae09ba3c
commit f11c675d11
14 changed files with 796 additions and 19 deletions
+9
View File
@@ -8,6 +8,7 @@ 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");
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");
@@ -21,6 +22,14 @@ 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(overridesEditor, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema");
assert.match(overridesEditor, /contrastRatio[\s\S]*?<\s*4\.5/, "custom pairs must enforce WCAG AA contrast");
assert.match(overridesEditor, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation");
for (const token of ["accent", "surface", "success", "info", "warning", "danger"]) {
assert.match(overridesEditor, 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"
+8 -3
View File
@@ -26,6 +26,7 @@ import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
import { hasAnyScope } from "./utils/permissions";
import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
@@ -36,7 +37,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
reduce_motion: false,
sticky_section_sidebars: true,
theme: "system",
palette: null
palette: null,
appearance_overrides: null
};
export default function App() {
@@ -399,6 +401,7 @@ export default function App() {
preferences.theme;
root.dataset.theme = resolvedTheme;
root.dataset.themePreference = preferences.theme;
applyAppearanceOverrides(root, auth?.user.appearance?.custom_overrides ?? null, resolvedTheme);
};
applyTheme();
@@ -417,7 +420,8 @@ export default function App() {
auth?.user.ui_preferences?.sticky_section_sidebars,
auth?.user.ui_preferences?.theme,
auth?.user.ui_preferences?.palette,
auth?.user.appearance?.palette
auth?.user.appearance?.palette,
auth?.user.appearance?.custom_overrides
]);
useEffect(() => {
@@ -721,7 +725,8 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion),
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
theme,
palette: normalizeOptionalUiPalette(value?.palette)
palette: normalizeOptionalUiPalette(value?.palette),
appearance_overrides: value?.appearance_overrides ?? null
};
}
@@ -0,0 +1,287 @@
import { useRef, useState, type CSSProperties, type ChangeEvent } from "react";
import type {
AppearanceModeOverrides,
AppearanceOverridesDocument,
AppearanceOverrideToken,
UserUiTheme
} from "../types";
import Button from "./Button";
import ColorPickerField from "./ColorPickerField";
import ContentGrid from "./ContentGrid";
import DismissibleAlert from "./DismissibleAlert";
import FormField from "./FormField";
import SegmentedControl from "./SegmentedControl";
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
"accent", "accent_foreground", "surface", "surface_foreground",
"success", "success_foreground", "info", "info_foreground",
"warning", "warning_foreground", "danger", "danger_foreground"
];
const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
accent: "i18n:govoplan-core.override_accent",
accent_foreground: "i18n:govoplan-core.override_accent_foreground",
surface: "i18n:govoplan-core.override_surface",
surface_foreground: "i18n:govoplan-core.override_surface_foreground",
success: "i18n:govoplan-core.override_success",
success_foreground: "i18n:govoplan-core.override_success_foreground",
info: "i18n:govoplan-core.override_info",
info_foreground: "i18n:govoplan-core.override_info_foreground",
warning: "i18n:govoplan-core.override_warning",
warning_foreground: "i18n:govoplan-core.override_warning_foreground",
danger: "i18n:govoplan-core.override_danger",
danger_foreground: "i18n:govoplan-core.override_danger_foreground"
};
const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const RUNTIME_PROPERTIES = new Set<string>();
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
accent: ["--accent", "--action-primary-bg"],
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
surface: ["--surface", "--panel-soft"],
surface_foreground: ["--text", "--text-strong"],
success: ["--success-bg", "--success-soft"],
success_foreground: ["--success-text", "--success-text-strong"],
info: ["--info-bg", "--info-soft"],
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
warning: ["--warning-bg", "--warning-soft"],
warning_foreground: ["--warning-text", "--warning-text-strong"],
danger: ["--danger-bg", "--danger-soft"],
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
};
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");
}
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
}
const document = value as unknown as AppearanceOverridesDocument;
for (const modeName of ["light", "dark"] as const) {
const mode = document[modeName];
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
}
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
}
}
for (const [background, foreground] of [
["accent", "accent_foreground"], ["surface", "surface_foreground"],
["success", "success_foreground"], ["info", "info_foreground"],
["warning", "warning_foreground"], ["danger", "danger_foreground"]
] as const) {
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
}
}
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
throw new Error("i18n:govoplan-core.appearance_override_status_error");
}
}
}
}
return document;
}
export function applyAppearanceOverrides(
root: HTMLElement,
document: AppearanceOverridesDocument | null,
theme: "light" | "dark"
) {
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
if (!document) return;
let validated: AppearanceOverridesDocument;
try {
validated = validateAppearanceOverrides(document);
} catch {
return;
}
const colors = validated[theme];
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
root.style.setProperty(property, colors[token]);
}
}
}
export default function AppearanceOverridesEditor({
value,
onChange,
theme = "system",
disabled = false
}: {
value: AppearanceOverridesDocument | null;
onChange: (value: AppearanceOverridesDocument | null) => void;
theme?: UserUiTheme;
disabled?: boolean;
}) {
const [mode, setMode] = useState<"light" | "dark">(theme === "dark" ? "dark" : "light");
const [message, setMessage] = useState("");
const fileInput = useRef<HTMLInputElement | null>(null);
const validationMessage = appearanceOverridesValidationMessage(value);
function updateToken(token: AppearanceOverrideToken, color: string) {
const document = value ?? cloneDefaultAppearanceOverrides();
onChange({
...document,
[mode]: { ...document[mode], [token]: color }
});
}
function exportDocument() {
if (!value) return;
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = "govoplan-appearance-overrides-v1.json";
anchor.click();
URL.revokeObjectURL(url);
}
async function importDocument(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const document = validateAppearanceOverrides(JSON.parse(await file.text()));
onChange(document);
setMessage("i18n:govoplan-core.appearance_override_imported");
} catch (error) {
setMessage(error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema");
}
}
return (
<section className="appearance-overrides-editor" aria-label="i18n:govoplan-core.advanced_theme_overrides">
<div className="appearance-overrides-actions">
<Button onClick={() => onChange(cloneDefaultAppearanceOverrides())} disabled={disabled}>
{value ? "i18n:govoplan-core.restore_safe_defaults" : "i18n:govoplan-core.configure_overrides"}
</Button>
<Button onClick={() => fileInput.current?.click()} disabled={disabled}>i18n:govoplan-core.import_overrides</Button>
<Button onClick={exportDocument} disabled={!value}>i18n:govoplan-core.export_overrides</Button>
<Button onClick={() => onChange(null)} disabled={!value}>i18n:govoplan-core.remove_overrides</Button>
<input ref={fileInput} type="file" accept="application/json,.json" hidden onChange={(event) => void importDocument(event)} />
</div>
{message && <DismissibleAlert tone={message.endsWith("imported") ? "success" : "warning"} resetKey={message}>{message}</DismissibleAlert>}
{!value && <p className="muted small-note">i18n:govoplan-core.appearance_override_not_configured_help</p>}
{value && <>
<SegmentedControl
options={[
{ id: "light" as const, label: "i18n:govoplan-core.light_theme.7878f1fa" },
{ id: "dark" as const, label: "i18n:govoplan-core.dark_theme.164a90d9" }
]}
value={mode}
onChange={setMode}
ariaLabel="i18n:govoplan-core.appearance_override_mode"
/>
<ContentGrid columns={3} collapseAt="workspace" className="appearance-overrides-fields">
{APPEARANCE_OVERRIDE_TOKENS.map((token) => (
<FormField key={token} label={TOKEN_LABELS[token]}>
<ColorPickerField value={value[mode][token]} onChange={(color) => updateToken(token, color)} disabled={disabled} />
</FormField>
))}
</ContentGrid>
<AppearanceOverridesPreview mode={mode} colors={value[mode]} />
<p className={validationMessage ? "form-error" : "muted small-note"}>
{validationMessage || "i18n:govoplan-core.appearance_override_validation_ok"}
</p>
</>}
</section>
);
}
function AppearanceOverridesPreview({ mode, colors }: { mode: "light" | "dark"; colors: AppearanceModeOverrides }) {
const style = {
"--appearance-preview-accent": colors.accent,
"--appearance-preview-accent-foreground": colors.accent_foreground,
"--appearance-preview-surface": colors.surface,
"--appearance-preview-surface-foreground": colors.surface_foreground
} as CSSProperties;
return (
<div className="appearance-overrides-preview" data-mode={mode} style={style}>
<strong>GovOPlaN</strong>
<button type="button">i18n:govoplan-core.preview_action</button>
<div className="appearance-overrides-statuses">
{STATUS_TOKENS.map((token) => (
<span key={token} style={{ backgroundColor: colors[token], color: colors[`${token}_foreground` as AppearanceOverrideToken] }}>
{TOKEN_LABELS[token]}
</span>
))}
</div>
</div>
);
}
function appearanceOverridesValidationMessage(value: AppearanceOverridesDocument | null): string {
if (!value) return "";
try {
validateAppearanceOverrides(value);
return "";
} catch (error) {
return error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema";
}
}
function hasExactKeys(value: object, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function relativeLuminance(color: string): number {
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
function contrastRatio(first: string, second: string): number {
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
}
function rgbDistance(first: string, second: string): number {
return Math.sqrt([1, 3, 5].reduce((total, index) => {
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
return total + delta * delta;
}, 0));
}
+20 -4
View File
@@ -2,7 +2,7 @@ import DescriptionList from "../../components/DescriptionList";
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPalette, UserUiPreferences, UserUiTheme } from "../../types";
import type { AppearanceOverridesDocument, ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPalette, UserUiPreferences, UserUiTheme } from "../../types";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
@@ -28,6 +28,7 @@ import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManage
import DocumentationHelpLink from "../../components/help/DocumentationHelpLink";
import WorkspaceLayout from "../../components/WorkspaceLayout";
import { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "../../components/AppearancePaletteControl";
import AppearanceOverridesEditor from "../../components/AppearanceOverridesEditor";
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
@@ -37,7 +38,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
reduce_motion: false,
sticky_section_sidebars: true,
theme: "system",
palette: null
palette: null,
appearance_overrides: null
};
const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
@@ -158,6 +160,7 @@ export default function SettingsPage({
const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars);
const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme);
const [palette, setPalette] = useState<UserUiPalette | null>(currentUiPreferences.palette);
const [appearanceOverrides, setAppearanceOverrides] = useState<AppearanceOverridesDocument | null>(currentUiPreferences.appearance_overrides ?? null);
const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null);
const [uiBusy, setUiBusy] = useState(false);
const [uiResult, setUiResult] = useState("");
@@ -175,6 +178,7 @@ export default function SettingsPage({
stickySections !== currentUiPreferences.sticky_section_sidebars ||
theme !== currentUiPreferences.theme ||
palette !== currentUiPreferences.palette ||
JSON.stringify(appearanceOverrides) !== JSON.stringify(currentUiPreferences.appearance_overrides ?? null) ||
JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null);
useUnsavedDraftGuard({
@@ -230,6 +234,7 @@ export default function SettingsPage({
setStickySections(currentUiPreferences.sticky_section_sidebars);
setTheme(currentUiPreferences.theme);
setPalette(currentUiPreferences.palette);
setAppearanceOverrides(currentUiPreferences.appearance_overrides ?? null);
setNavigation(currentUiPreferences.navigation ?? null);
}, [
currentUiPreferences.compact_tables,
@@ -238,6 +243,7 @@ export default function SettingsPage({
currentUiPreferences.sticky_section_sidebars,
currentUiPreferences.theme,
currentUiPreferences.palette,
currentUiPreferences.appearance_overrides,
currentUiPreferences.navigation
]);
@@ -278,6 +284,7 @@ export default function SettingsPage({
setStickySections(currentUiPreferences.sticky_section_sidebars);
setTheme(currentUiPreferences.theme);
setPalette(currentUiPreferences.palette);
setAppearanceOverrides(currentUiPreferences.appearance_overrides ?? null);
setNavigation(currentUiPreferences.navigation ?? null);
}
@@ -289,6 +296,7 @@ export default function SettingsPage({
sticky_section_sidebars: stickySections,
theme,
palette,
appearance_overrides: appearanceOverrides,
navigation
};
}
@@ -498,13 +506,20 @@ export default function SettingsPage({
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>{paletteLabel(palette ?? auth.user.appearance?.inherited_palette ?? "default")}</dd></div>
<div><dt>i18n:govoplan-core.effective_source</dt><dd>{appearanceSourceLabel(auth.user.appearance?.source)}</dd></div>
<div><dt>i18n:govoplan-core.accessibility</dt><dd>i18n:govoplan-core.palette_contrast_validated</dd></div>
<div><dt>i18n:govoplan-core.advanced_theme_overrides</dt><dd>i18n:govoplan-core.not_configured</dd></div>
<div><dt>i18n:govoplan-core.advanced_theme_overrides</dt><dd>{appearanceOverrides ? "i18n:govoplan-core.configured" : "i18n:govoplan-core.not_configured"}</dd></div>
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
<div><dt>i18n:govoplan-core.enabled.df174a3f</dt><dd>{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
</DescriptionList>
<p className="muted small-note">i18n:govoplan-core.advanced_theme_overrides_follow_up</p>
<AppearanceOverridesEditor
value={appearanceOverrides}
onChange={setAppearanceOverrides}
theme={theme}
disabled={auth.user.appearance?.custom_overrides_allowed !== true}
/>
{auth.user.appearance?.custom_overrides_allowed !== true &&
<p className="muted small-note">i18n:govoplan-core.appearance_override_policy_disabled</p>}
</FormGrid>
</Card>
</ContentGrid>
@@ -640,6 +655,7 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
theme,
palette: normalizeOptionalUiPalette(value?.palette),
appearance_overrides: value?.appearance_overrides ?? null,
navigation: value?.navigation ?? null
};
}
+58
View File
@@ -21,6 +21,35 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.advanced_theme_overrides": "Advanced custom overrides",
"i18n:govoplan-core.not_configured": "Not configured",
"i18n:govoplan-core.advanced_theme_overrides_follow_up": "Arbitrary token overrides require separate tenant branding policy and accessibility safeguards.",
"i18n:govoplan-core.configured": "Configured",
"i18n:govoplan-core.configure_overrides": "Configure safe defaults",
"i18n:govoplan-core.restore_safe_defaults": "Restore safe defaults",
"i18n:govoplan-core.import_overrides": "Import JSON",
"i18n:govoplan-core.export_overrides": "Export JSON",
"i18n:govoplan-core.remove_overrides": "Remove overrides",
"i18n:govoplan-core.appearance_override_not_configured_help": "No personal token overrides are stored. Preset and policy inheritance remain active.",
"i18n:govoplan-core.appearance_override_policy_disabled": "The governing appearance policy currently blocks creating or editing personal overrides. Existing overrides may still be exported or removed.",
"i18n:govoplan-core.appearance_override_mode": "Color mode",
"i18n:govoplan-core.appearance_override_imported": "The validated appearance document was imported into this draft.",
"i18n:govoplan-core.appearance_override_invalid_schema": "Use the supported version 1 appearance document with exactly light and dark modes.",
"i18n:govoplan-core.appearance_override_all_tokens_required": "Every supported token must be present exactly once in both modes.",
"i18n:govoplan-core.appearance_override_hex_required": "Every token must use a six-digit hexadecimal color.",
"i18n:govoplan-core.appearance_override_contrast_error": "Each foreground must meet WCAG AA contrast against its paired color.",
"i18n:govoplan-core.appearance_override_status_error": "Success, information, warning, and danger colors must remain visibly distinct.",
"i18n:govoplan-core.appearance_override_validation_ok": "Both modes pass contrast and semantic status differentiation checks. Saving applies the document atomically.",
"i18n:govoplan-core.override_accent": "Accent",
"i18n:govoplan-core.override_accent_foreground": "Accent foreground",
"i18n:govoplan-core.override_surface": "Surface",
"i18n:govoplan-core.override_surface_foreground": "Surface foreground",
"i18n:govoplan-core.override_success": "Success",
"i18n:govoplan-core.override_success_foreground": "Success foreground",
"i18n:govoplan-core.override_info": "Information",
"i18n:govoplan-core.override_info_foreground": "Information foreground",
"i18n:govoplan-core.override_warning": "Warning",
"i18n:govoplan-core.override_warning_foreground": "Warning foreground",
"i18n:govoplan-core.override_danger": "Danger",
"i18n:govoplan-core.override_danger_foreground": "Danger foreground",
"i18n:govoplan-core.preview_action": "Primary action",
"i18n:govoplan-core.standard_folder_mappings": "Standard folder mappings",
"i18n:govoplan-core.standard_folder_mappings_help": "Map each standard mailbox role to a folder exposed by this IMAP account. Leave a field empty to use automatic detection.",
"i18n:govoplan-core.inbox_folder": "Inbox folder",
@@ -727,6 +756,35 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.advanced_theme_overrides": "Erweiterte benutzerdefinierte Anpassungen",
"i18n:govoplan-core.not_configured": "Nicht konfiguriert",
"i18n:govoplan-core.advanced_theme_overrides_follow_up": "Beliebige Token-Anpassungen erfordern eine getrennte Mandanten-Branding-Richtlinie und Barrierefreiheitsprüfungen.",
"i18n:govoplan-core.configured": "Konfiguriert",
"i18n:govoplan-core.configure_overrides": "Sichere Standardwerte einrichten",
"i18n:govoplan-core.restore_safe_defaults": "Sichere Standardwerte wiederherstellen",
"i18n:govoplan-core.import_overrides": "JSON importieren",
"i18n:govoplan-core.export_overrides": "JSON exportieren",
"i18n:govoplan-core.remove_overrides": "Anpassungen entfernen",
"i18n:govoplan-core.appearance_override_not_configured_help": "Es sind keine persönlichen Token-Anpassungen gespeichert. Vorgaben und Richtlinienvererbung bleiben aktiv.",
"i18n:govoplan-core.appearance_override_policy_disabled": "Die geltende Darstellungsrichtlinie sperrt derzeit das Anlegen oder Bearbeiten persönlicher Anpassungen. Bestehende Anpassungen können weiterhin exportiert oder entfernt werden.",
"i18n:govoplan-core.appearance_override_mode": "Farbmodus",
"i18n:govoplan-core.appearance_override_imported": "Das geprüfte Darstellungsdokument wurde in diesen Entwurf importiert.",
"i18n:govoplan-core.appearance_override_invalid_schema": "Verwenden Sie das unterstützte Darstellungsdokument der Version 1 mit genau einem hellen und einem dunklen Modus.",
"i18n:govoplan-core.appearance_override_all_tokens_required": "Jedes unterstützte Token muss in beiden Modi genau einmal vorhanden sein.",
"i18n:govoplan-core.appearance_override_hex_required": "Jedes Token muss eine sechsstellige hexadezimale Farbe verwenden.",
"i18n:govoplan-core.appearance_override_contrast_error": "Jede Vordergrundfarbe muss gegenüber der zugehörigen Farbe den WCAG-AA-Kontrast erfüllen.",
"i18n:govoplan-core.appearance_override_status_error": "Erfolg, Information, Warnung und Gefahr müssen visuell unterscheidbar bleiben.",
"i18n:govoplan-core.appearance_override_validation_ok": "Beide Modi erfüllen die Prüfungen für Kontrast und semantische Statusunterscheidung. Beim Speichern wird das Dokument atomar angewendet.",
"i18n:govoplan-core.override_accent": "Akzent",
"i18n:govoplan-core.override_accent_foreground": "Akzent-Vordergrund",
"i18n:govoplan-core.override_surface": "Oberfläche",
"i18n:govoplan-core.override_surface_foreground": "Oberflächen-Vordergrund",
"i18n:govoplan-core.override_success": "Erfolg",
"i18n:govoplan-core.override_success_foreground": "Erfolg-Vordergrund",
"i18n:govoplan-core.override_info": "Information",
"i18n:govoplan-core.override_info_foreground": "Information-Vordergrund",
"i18n:govoplan-core.override_warning": "Warnung",
"i18n:govoplan-core.override_warning_foreground": "Warnungs-Vordergrund",
"i18n:govoplan-core.override_danger": "Gefahr",
"i18n:govoplan-core.override_danger_foreground": "Gefahren-Vordergrund",
"i18n:govoplan-core.preview_action": "Primäraktion",
"i18n:govoplan-core.standard_folder_mappings": "Zuordnung der Standardordner",
"i18n:govoplan-core.standard_folder_mappings_help": "Ordnen Sie jede Standardfunktion einem Ordner dieses IMAP-Kontos zu. Lassen Sie ein Feld leer, um die automatische Erkennung zu verwenden.",
"i18n:govoplan-core.inbox_folder": "Posteingang",
+8
View File
@@ -62,6 +62,14 @@ export { default as AdminSelectionList } from "./components/admin/AdminSelection
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
export { default as Button } from "./components/Button";
export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "./components/AppearancePaletteControl";
export {
default as AppearanceOverridesEditor,
APPEARANCE_OVERRIDE_TOKENS,
DEFAULT_APPEARANCE_OVERRIDES,
applyAppearanceOverrides,
cloneDefaultAppearanceOverrides,
validateAppearanceOverrides
} from "./components/AppearanceOverridesEditor";
export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card";
export type { CardProps } from "./components/Card";
+57
View File
@@ -3224,6 +3224,63 @@
gap: 10px;
}
.appearance-overrides-editor {
display: grid;
gap: 12px;
padding-top: 12px;
border-top: var(--border-line);
}
.appearance-overrides-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.appearance-overrides-fields {
padding: 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted);
}
.appearance-overrides-preview {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
min-height: 82px;
padding: 14px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--appearance-preview-surface);
color: var(--appearance-preview-surface-foreground);
}
.appearance-overrides-preview > button {
border: 0;
border-radius: var(--radius-sm);
background: var(--appearance-preview-accent);
color: var(--appearance-preview-accent-foreground);
padding: 7px 12px;
font: inherit;
font-weight: 700;
}
.appearance-overrides-statuses {
display: flex;
flex: 1 1 100%;
flex-wrap: wrap;
gap: 6px;
}
.appearance-overrides-statuses > span {
border-radius: var(--radius-pill);
padding: 4px 9px;
font-size: 11px;
font-weight: 700;
}
.theme-preview {
--preview-bg: var(--theme-preview-light-bg);
--preview-bar: var(--theme-preview-light-bar);
+13
View File
@@ -43,6 +43,16 @@ export type AuthUser = {
export type UserUiTheme = "system" | "light" | "dark";
export type UserUiPalette = "default" | "civic_blue" | "forest" | "plum";
export type AppearanceOverrideToken =
| "accent" | "accent_foreground" | "surface" | "surface_foreground"
| "success" | "success_foreground" | "info" | "info_foreground"
| "warning" | "warning_foreground" | "danger" | "danger_foreground";
export type AppearanceModeOverrides = Record<AppearanceOverrideToken, string>;
export type AppearanceOverridesDocument = {
schema_version: "1";
light: AppearanceModeOverrides;
dark: AppearanceModeOverrides;
};
export type UserUiPreferences = {
compact_tables: boolean;
@@ -51,6 +61,7 @@ export type UserUiPreferences = {
sticky_section_sidebars: boolean;
theme: UserUiTheme;
palette: UserUiPalette | null;
appearance_overrides?: AppearanceOverridesDocument | null;
navigation?: NavigationPreferences | null;
};
@@ -61,6 +72,8 @@ export type EffectiveAppearance = {
system_default_palette: UserUiPalette;
tenant_default_palette?: UserUiPalette | null;
inherited_palette: UserUiPalette;
custom_overrides?: AppearanceOverridesDocument | null;
custom_overrides_allowed?: boolean;
};
export type AuthTenant = {