diff --git a/docs/THEMING.md b/docs/THEMING.md index 4db1a4e..96ce6c1 100644 --- a/docs/THEMING.md +++ b/docs/THEMING.md @@ -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. diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index e4c8866..5e04e61 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -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. diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py index edb0bf5..2f59d5f 100644 --- a/src/govoplan_core/api/v1/schemas.py +++ b/src/govoplan_core/api/v1/schemas.py @@ -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): diff --git a/src/govoplan_core/core/appearance.py b/src/govoplan_core/core/appearance.py index c4fb4c5..9ed352d 100644 --- a/src/govoplan_core/core/appearance.py +++ b/src/govoplan_core/core/appearance.py @@ -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", ] diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 83c5654..605aaea 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -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"], diff --git a/tests/test_appearance.py b/tests/test_appearance.py index 59e441b..2427d91 100644 --- a/tests/test_appearance.py +++ b/tests/test_appearance.py @@ -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"} + } diff --git a/webui/scripts/test-theme-contract.mjs b/webui/scripts/test-theme-contract.mjs index 58606a3..d69886e 100644 --- a/webui/scripts/test-theme-contract.mjs +++ b/webui/scripts/test-theme-contract.mjs @@ -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" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 39f000b..0db2af2 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 | 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 }; } diff --git a/webui/src/components/AppearanceOverridesEditor.tsx b/webui/src/components/AppearanceOverridesEditor.tsx new file mode 100644 index 0000000..6429685 --- /dev/null +++ b/webui/src/components/AppearanceOverridesEditor.tsx @@ -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 = { + 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(); + +const RUNTIME_TOKEN_PROPERTIES: Record = { + 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(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) { + 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 ( +
+
+ + + + + void importDocument(event)} /> +
+ {message && {message}} + {!value &&

i18n:govoplan-core.appearance_override_not_configured_help

} + {value && <> + + + {APPEARANCE_OVERRIDE_TOKENS.map((token) => ( + + updateToken(token, color)} disabled={disabled} /> + + ))} + + +

+ {validationMessage || "i18n:govoplan-core.appearance_override_validation_ok"} +

+ } +
+ ); +} + +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 ( +
+ GovOPlaN + +
+ {STATUS_TOKENS.map((token) => ( + + {TOKEN_LABELS[token]} + + ))} +
+
+ ); +} + +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 { + 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)); +} diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index 95ed81a..31e52e4 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -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(currentUiPreferences.theme); const [palette, setPalette] = useState(currentUiPreferences.palette); + const [appearanceOverrides, setAppearanceOverrides] = useState(currentUiPreferences.appearance_overrides ?? null); const [navigation, setNavigation] = useState(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({
i18n:govoplan-core.accent_color.e49578ed
{paletteLabel(palette ?? auth.user.appearance?.inherited_palette ?? "default")}
i18n:govoplan-core.effective_source
{appearanceSourceLabel(auth.user.appearance?.source)}
i18n:govoplan-core.accessibility
i18n:govoplan-core.palette_contrast_validated
-
i18n:govoplan-core.advanced_theme_overrides
i18n:govoplan-core.not_configured
+
i18n:govoplan-core.advanced_theme_overrides
{appearanceOverrides ? "i18n:govoplan-core.configured" : "i18n:govoplan-core.not_configured"}
i18n:govoplan-core.language.89b86ab0
{languageLabel}
i18n:govoplan-core.enabled.df174a3f
{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}
i18n:govoplan-core.available.7c62a142
{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}
i18n:govoplan-core.density.f9160c22
{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}
-

i18n:govoplan-core.advanced_theme_overrides_follow_up

+ + {auth.user.appearance?.custom_overrides_allowed !== true && +

i18n:govoplan-core.appearance_override_policy_disabled

} @@ -640,6 +655,7 @@ function normalizeUiPreferences(value: Partial | 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 }; } diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index bbc6189..8327722 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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", diff --git a/webui/src/index.ts b/webui/src/index.ts index 9302439..3c08096 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 07829c7..be0352b 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -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); diff --git a/webui/src/types.ts b/webui/src/types.ts index 40e67ca..310a894 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -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; +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 = {