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
+19 -6
View File
@@ -22,22 +22,35 @@ inherits the result through semantic tokens without module-specific CSS.
child scope; otherwise a tenant lock suppresses a personal override. The
authenticated profile reports the effective palette, source, inherited
palette, and lock state.
- Tenant branding is a separate policy surface and must preserve contrast and
status semantics in both modes.
- Advanced personal overrides are a separately governed surface. The system
must opt in, a tenant may inherit or block that decision, and palette locks
always suppress overrides. Changing either policy requires
`admin:policies:write` in addition to the owning settings permission.
## Palette safety and scope
The Settings preview shows the chosen or inherited accent in every applicable
light/dark preview before Save. Presets are checked for WCAG AA contrast in the
theme contract. Arbitrary token overrides and branding import/export are not
inferred from this preference and require their own governed follow-up.
theme contract. When policy permits, the shared advanced editor can atomically
override accent, surface, and semantic status pairs for both modes. Every
foreground/background pair must meet WCAG AA contrast, and success,
information, warning, and danger colors must remain distinct. Invalid stored
documents fail closed and are not partially applied.
Import and export use the exact versioned JSON schema `schema_version: "1"`.
Both `light` and `dark` must contain every supported token exactly once as a
six-digit hex value. Import changes only the local draft; Save persists the
whole document. Removing overrides returns to palette and policy inheritance.
The system default is disabled so upgrades do not unexpectedly admit arbitrary
branding. Tenant `null` means inherit, `false` blocks, and `true` is accepted
only while the system permits overrides.
Do not introduce fixed foreground/background colors in a module merely to make
one mode look correct. Add or reuse a semantic Core token, then define both
light and dark values. Bitmap content and externally authored HTML are exempt,
but their surrounding controls must still use the shared tokens.
`npm run test:theme-contract` verifies root mode/palette behavior, preset
contrast, and representative
`npm run test:theme-contract` verifies root mode/palette behavior, preset and
custom-override validation/application, and representative
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
production WebUI build.
+5 -4
View File
@@ -173,10 +173,11 @@ shared CSS tokens and persisted user preference selection.
- Modules must style new UI with these tokens and shared controls. Module-local
CSS may tune layout and spacing, but it must not introduce a separate
appearance system.
- Appearance controls live in user settings first. The user preference wins
over future tenant and system defaults unless a separately documented policy
lock is introduced. Tenant defaults and policy
enforcement can be added later without changing the token contract.
- Appearance controls live in user settings. A personal palette wins over
unlocked tenant and system defaults; system and tenant locks take precedence.
Advanced personal token overrides additionally require system opt-in and may
be narrowed by tenant policy. Their versioned import/export document is
validated and applied all-or-nothing in both light and dark modes.
- Visual preview in settings is illustrative; it must reflect token families,
not become a second theme implementation.
+36 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.core.appearance import normalize_appearance_overrides
class AuditLogItemResponse(BaseModel):
@@ -102,6 +104,36 @@ class NavigationPreferencesPayload(BaseModel):
locked: list[str] = Field(default_factory=list, max_length=256)
class AppearanceModeOverrides(BaseModel):
model_config = ConfigDict(extra="forbid")
accent: str
accent_foreground: str
surface: str
surface_foreground: str
success: str
success_foreground: str
info: str
info_foreground: str
warning: str
warning_foreground: str
danger: str
danger_foreground: str
class AppearanceOverridesDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal["1"] = "1"
light: AppearanceModeOverrides
dark: AppearanceModeOverrides
@model_validator(mode="after")
def validate_accessibility(self) -> "AppearanceOverridesDocument":
normalize_appearance_overrides(self.model_dump(mode="json"))
return self
class UserUiPreferences(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -111,6 +143,7 @@ class UserUiPreferences(BaseModel):
sticky_section_sidebars: bool = True
theme: Literal["system", "light", "dark"] = "system"
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
appearance_overrides: AppearanceOverridesDocument | None = None
navigation: NavigationPreferencesPayload | None = None
@@ -121,6 +154,8 @@ class EffectiveAppearanceInfo(BaseModel):
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
custom_overrides: AppearanceOverridesDocument | None = None
custom_overrides_allowed: bool = False
class UserInfo(BaseModel):
+114
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any, Literal, Mapping
@@ -9,6 +10,14 @@ AppearanceSource = Literal["user", "tenant", "system", "tenant_lock", "system_lo
APPEARANCE_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum")
APPEARANCE_SETTINGS_KEY = "appearance"
APPEARANCE_OVERRIDE_SCHEMA_VERSION = "1"
APPEARANCE_OVERRIDE_TOKENS: tuple[str, ...] = (
"accent", "accent_foreground", "surface", "surface_foreground",
"success", "success_foreground", "info", "info_foreground",
"warning", "warning_foreground", "danger", "danger_foreground",
)
_STATUS_TOKENS = ("success", "info", "warning", "danger")
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
@dataclass(frozen=True, slots=True)
@@ -19,6 +28,8 @@ class EffectiveAppearance:
system_default_palette: AppearancePalette
tenant_default_palette: AppearancePalette | None
inherited_palette: AppearancePalette
custom_overrides: dict[str, object] | None = None
custom_overrides_allowed: bool = False
def as_dict(self) -> dict[str, object]:
return {
@@ -28,6 +39,8 @@ class EffectiveAppearance:
"system_default_palette": self.system_default_palette,
"tenant_default_palette": self.tenant_default_palette,
"inherited_palette": self.inherited_palette,
"custom_overrides": self.custom_overrides,
"custom_overrides_allowed": self.custom_overrides_allowed,
}
@@ -43,6 +56,91 @@ def appearance_settings(settings: Mapping[str, Any] | None) -> tuple[AppearanceP
return normalize_appearance_palette(raw.get("default_palette")), raw.get("palette_locked") is True
def appearance_custom_overrides_policy(settings: Mapping[str, Any] | None) -> bool | None:
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
if not isinstance(raw, Mapping) or "allow_custom_overrides" not in raw:
return None
return raw.get("allow_custom_overrides") is True
def update_appearance_custom_overrides_policy(
settings: Mapping[str, Any] | None,
*,
allowed: bool | None,
) -> dict[str, Any]:
updated = dict(settings or {})
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
if allowed is None:
appearance.pop("allow_custom_overrides", None)
else:
appearance["allow_custom_overrides"] = allowed
if appearance:
updated[APPEARANCE_SETTINGS_KEY] = appearance
else:
updated.pop(APPEARANCE_SETTINGS_KEY, None)
return updated
def normalize_appearance_overrides(value: object) -> dict[str, object] | None:
"""Validate and canonicalize the versioned, all-or-nothing color contract."""
if value is None:
return None
if not isinstance(value, Mapping):
raise ValueError("Appearance overrides must be an object.")
if set(value) != {"schema_version", "light", "dark"}:
raise ValueError("Appearance overrides must contain only schema_version, light, and dark.")
if str(value.get("schema_version")) != APPEARANCE_OVERRIDE_SCHEMA_VERSION:
raise ValueError("Unsupported appearance override schema version.")
normalized: dict[str, object] = {"schema_version": APPEARANCE_OVERRIDE_SCHEMA_VERSION}
for mode in ("light", "dark"):
raw_mode = value.get(mode)
if not isinstance(raw_mode, Mapping) or set(raw_mode) != set(APPEARANCE_OVERRIDE_TOKENS):
raise ValueError(f"Appearance override mode {mode} must define every supported token exactly once.")
colors: dict[str, str] = {}
for token in APPEARANCE_OVERRIDE_TOKENS:
color = str(raw_mode.get(token) or "").strip().lower()
if not _HEX_COLOR.fullmatch(color):
raise ValueError(f"Appearance override {mode}.{token} must be a six-digit hexadecimal color.")
colors[token] = color
_validate_mode_accessibility(mode, colors)
normalized[mode] = colors
return normalized
def _validate_mode_accessibility(mode: str, colors: Mapping[str, str]) -> None:
pairs = (
("accent", "accent_foreground"), ("surface", "surface_foreground"),
("success", "success_foreground"), ("info", "info_foreground"),
("warning", "warning_foreground"), ("danger", "danger_foreground"),
)
for background, foreground in pairs:
if _contrast_ratio(colors[background], colors[foreground]) < 4.5:
raise ValueError(f"Appearance override {mode}.{foreground} must have WCAG AA contrast against {mode}.{background}.")
status_colors = [colors[token] for token in _STATUS_TOKENS]
for index, first in enumerate(status_colors):
for second in status_colors[index + 1:]:
if _rgb_distance(first, second) < 12:
raise ValueError(f"Appearance override status colors in {mode} must remain visibly distinct.")
def _relative_luminance(color: str) -> float:
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
linear = [channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 for channel in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _contrast_ratio(first: str, second: str) -> float:
high, low = sorted((_relative_luminance(first), _relative_luminance(second)), reverse=True)
return (high + 0.05) / (low + 0.05)
def _rgb_distance(first: str, second: str) -> float:
first_channels = [int(first[index:index + 2], 16) for index in (1, 3, 5)]
second_channels = [int(second[index:index + 2], 16) for index in (1, 3, 5)]
return sum((left - right) ** 2 for left, right in zip(first_channels, second_channels, strict=True)) ** 0.5
def update_appearance_settings(
settings: Mapping[str, Any] | None,
*,
@@ -81,6 +179,15 @@ def resolve_effective_appearance(
inherited_palette = tenant_palette or system_palette
raw_ui = user_settings.get("ui") if isinstance(user_settings, Mapping) else None
user_palette = normalize_appearance_palette(raw_ui.get("palette")) if isinstance(raw_ui, Mapping) else None
system_custom_policy = appearance_custom_overrides_policy(system_settings) is True
tenant_custom_policy = appearance_custom_overrides_policy(tenant_settings)
custom_overrides_allowed = system_custom_policy and tenant_custom_policy is not False and not system_locked and not tenant_locked
try:
custom_overrides = normalize_appearance_overrides(raw_ui.get("appearance_overrides")) if isinstance(raw_ui, Mapping) else None
except ValueError:
custom_overrides = None
if not custom_overrides_allowed:
custom_overrides = None
if system_locked:
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
@@ -93,17 +200,24 @@ def resolve_effective_appearance(
system_palette,
tenant_palette,
inherited_palette,
custom_overrides,
custom_overrides_allowed,
)
__all__ = [
"APPEARANCE_PALETTES",
"APPEARANCE_SETTINGS_KEY",
"APPEARANCE_OVERRIDE_SCHEMA_VERSION",
"APPEARANCE_OVERRIDE_TOKENS",
"AppearancePalette",
"AppearanceSource",
"EffectiveAppearance",
"appearance_settings",
"appearance_custom_overrides_policy",
"normalize_appearance_overrides",
"normalize_appearance_palette",
"resolve_effective_appearance",
"update_appearance_settings",
"update_appearance_custom_overrides_policy",
]
+88
View File
@@ -6556,6 +6556,93 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(final_profile.json()["user"]["appearance"]["palette"], "civic_blue")
self.assertEqual(final_profile.json()["user"]["appearance"]["source"], "system_lock")
def test_governed_custom_appearance_overrides_are_atomic_and_removable(self) -> None:
headers, _ = self._login()
document = {
"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",
},
}
initially_denied = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": document}},
)
self.assertEqual(initially_denied.status_code, 422, initially_denied.text)
system = self.client.get("/api/v1/admin/system/settings", headers=headers).json()
enabled = self.client.patch(
"/api/v1/admin/system/settings",
headers=headers,
json={
"default_locale": system["default_locale"],
"allow_tenant_custom_groups": system["allow_tenant_custom_groups"],
"allow_tenant_custom_roles": system["allow_tenant_custom_roles"],
"allow_tenant_api_keys": system["allow_tenant_api_keys"],
"appearance_custom_overrides_allowed": True,
},
)
self.assertEqual(enabled.status_code, 200, enabled.text)
self.assertTrue(enabled.json()["appearance_custom_overrides_allowed"])
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json()
self.assertIsNone(tenant["appearance_custom_overrides_allowed"])
self.assertTrue(tenant["effective_appearance_custom_overrides_allowed"])
saved = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": document}},
)
self.assertEqual(saved.status_code, 200, saved.text)
self.assertEqual(saved.json()["user"]["appearance"]["custom_overrides"], document)
invalid = {
**document,
"light": {**document["light"], "accent_foreground": document["light"]["accent"]},
}
rejected = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": invalid}},
)
self.assertEqual(rejected.status_code, 422, rejected.text)
unchanged = self.client.get("/api/v1/auth/profile", headers=headers).json()
self.assertEqual(unchanged["user"]["appearance"]["custom_overrides"], document)
blocked = self.client.patch(
"/api/v1/admin/tenant/settings",
headers=headers,
json={
"default_locale": tenant["default_locale"],
"appearance_custom_overrides_allowed": False,
},
)
self.assertEqual(blocked.status_code, 200, blocked.text)
self.assertFalse(blocked.json()["effective_appearance_custom_overrides_allowed"])
inactive = self.client.get("/api/v1/auth/profile", headers=headers).json()
self.assertIsNone(inactive["user"]["appearance"]["custom_overrides"])
removed = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": None}},
)
self.assertEqual(removed.status_code, 200, removed.text)
self.assertIsNone(removed.json()["user"]["ui_preferences"]["appearance_overrides"])
def test_profile_refresh_and_system_role_protection_model(self) -> None:
headers, _ = self._login()
profile = self.client.patch(
@@ -6591,6 +6678,7 @@ class ApiSmokeTests(unittest.TestCase):
"sticky_section_sidebars": False,
"theme": "dark",
"palette": "civic_blue",
"appearance_overrides": None,
"navigation": {
"contract_version": "1",
"order": ["files.navigation.files", "mail.navigation.mail"],
+74 -1
View File
@@ -1,6 +1,35 @@
from __future__ import annotations
from govoplan_core.core.appearance import resolve_effective_appearance, update_appearance_settings
import pytest
from govoplan_core.core.appearance import (
normalize_appearance_overrides,
resolve_effective_appearance,
update_appearance_custom_overrides_policy,
update_appearance_settings,
)
def _overrides() -> dict[str, object]:
return {
"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",
},
}
def test_appearance_precedence_and_inheritance() -> None:
@@ -32,6 +61,8 @@ def test_system_lock_wins_and_invalid_values_fail_safe() -> None:
"system_default_palette": "civic_blue",
"tenant_default_palette": "forest",
"inherited_palette": "civic_blue",
"custom_overrides": None,
"custom_overrides_allowed": False,
}
fallback = resolve_effective_appearance(
system_settings={"appearance": {"default_palette": "unsafe"}},
@@ -52,3 +83,45 @@ def test_appearance_settings_reset_without_touching_neighbors() -> None:
assert update_appearance_settings(configured, default_palette=None, palette_locked=False) == {
"neighbor": {"kept": True}
}
def test_custom_overrides_require_system_and_tenant_policy_and_validate_both_modes() -> None:
document = _overrides()
normalized = normalize_appearance_overrides(document)
assert normalized == document
decision = resolve_effective_appearance(
system_settings={"appearance": {"allow_custom_overrides": True}},
tenant_settings={"appearance": {"allow_custom_overrides": True}},
user_settings={"ui": {"appearance_overrides": document}},
)
assert decision.custom_overrides_allowed is True
assert decision.custom_overrides == document
blocked = resolve_effective_appearance(
system_settings={"appearance": {"allow_custom_overrides": True}},
tenant_settings={"appearance": {"allow_custom_overrides": False}},
user_settings={"ui": {"appearance_overrides": document}},
)
assert blocked.custom_overrides_allowed is False
assert blocked.custom_overrides is None
invalid = _overrides()
invalid["dark"]["danger"] = invalid["dark"]["warning"] # type: ignore[index]
with pytest.raises(ValueError, match="visibly distinct"):
normalize_appearance_overrides(invalid)
low_contrast = _overrides()
low_contrast["light"]["accent_foreground"] = "#245f91" # type: ignore[index]
with pytest.raises(ValueError, match="WCAG AA"):
normalize_appearance_overrides(low_contrast)
def test_custom_override_policy_update_preserves_neighboring_appearance_settings() -> None:
configured = update_appearance_custom_overrides_policy(
{"appearance": {"default_palette": "forest"}},
allowed=True,
)
assert configured == {"appearance": {"default_palette": "forest", "allow_custom_overrides": True}}
assert update_appearance_custom_overrides_policy(configured, allowed=None) == {
"appearance": {"default_palette": "forest"}
}
+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 = {