diff --git a/docs/THEMING.md b/docs/THEMING.md index f843b51..4db1a4e 100644 --- a/docs/THEMING.md +++ b/docs/THEMING.md @@ -15,21 +15,22 @@ inherits the result through semantic tokens without module-specific CSS. - Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and the status token families. They may define domain aliases whose values resolve to shared tokens. -- User preference selects the mode and palette. Invalid stored palette values - fail safely to `default`; the profile API accepts only the supported preset - identifiers. Tenant and system policy may provide a future default, but must - not silently replace an explicit user choice. +- Palette defaults form a provenance chain: system, tenant, then an explicit + user choice. Invalid stored values are ignored. Reset means inheritance and + does not copy the current parent value into the child scope. +- A policy lock is separate from the default. A system lock wins over every + 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. ## Palette safety and scope -The current slice is intentionally user-level. The Settings preview shows the -chosen accent in every applicable light/dark preview before Save, and Reset -palette returns the draft to the GovOPlaN default before persistence. Presets -are checked for WCAG AA contrast in the theme contract. Arbitrary token -overrides, tenant/system defaults, branding import/export, and policy locks are -not inferred from this preference and require their own governed follow-up. +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. 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 diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index 7f0cd62..e4c8866 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -327,7 +327,7 @@ converted or reviewed. | Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. | | Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. | | API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. | -| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, and explicit busy/clean disabled-action reasons. | Keep bounded; new contributed sections must satisfy the checklist. | +| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, explicit busy/clean disabled-action reasons, and an effective appearance source. Palette selection and light/dark preview are shared with system and tenant administration. | Keep bounded; new contributed sections must satisfy the checklist. | ## Impact Index @@ -343,7 +343,7 @@ converted or reviewed. | Automation/workflow commands | Hidden side effects would undermine accountability. | Action/effect preview, system-actor display, command record, retry/quarantine/manual states, and audit links. | | Postbox and encrypted communication | Retraction and access can be misunderstood. | Honest key-fetch/decryption state, expiry limits, recipient/device access provenance, and delivery evidence. | | API keys | Security-sensitive creation and scope selection. | Scoped creation wizard, least-privilege suggestions, clear expiry/owner explanation. | -| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback and no double-click navigation traps. | +| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback, explicit inherit/reset semantics, effective-source provenance, and no double-click navigation traps. | ## Review Checklist diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py index 3cbcdb3..edb0bf5 100644 --- a/src/govoplan_core/api/v1/schemas.py +++ b/src/govoplan_core/api/v1/schemas.py @@ -110,10 +110,19 @@ class UserUiPreferences(BaseModel): reduce_motion: bool = False sticky_section_sidebars: bool = True theme: Literal["system", "light", "dark"] = "system" - palette: Literal["default", "civic_blue", "forest", "plum"] = "default" + palette: Literal["default", "civic_blue", "forest", "plum"] | None = None navigation: NavigationPreferencesPayload | None = None +class EffectiveAppearanceInfo(BaseModel): + palette: Literal["default", "civic_blue", "forest", "plum"] = "default" + source: Literal["user", "tenant", "system", "tenant_lock", "system_lock"] = "system" + locked: bool = False + 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" + + class UserInfo(BaseModel): id: str account_id: str @@ -127,6 +136,7 @@ class UserInfo(BaseModel): preferred_language: str | None = None enabled_language_codes: list[str] = Field(default_factory=list) ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences) + appearance: EffectiveAppearanceInfo = Field(default_factory=EffectiveAppearanceInfo) class AuthSessionUserInfo(BaseModel): diff --git a/src/govoplan_core/core/appearance.py b/src/govoplan_core/core/appearance.py new file mode 100644 index 0000000..c4fb4c5 --- /dev/null +++ b/src/govoplan_core/core/appearance.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Mapping + + +AppearancePalette = Literal["default", "civic_blue", "forest", "plum"] +AppearanceSource = Literal["user", "tenant", "system", "tenant_lock", "system_lock"] + +APPEARANCE_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum") +APPEARANCE_SETTINGS_KEY = "appearance" + + +@dataclass(frozen=True, slots=True) +class EffectiveAppearance: + palette: AppearancePalette + source: AppearanceSource + locked: bool + system_default_palette: AppearancePalette + tenant_default_palette: AppearancePalette | None + inherited_palette: AppearancePalette + + def as_dict(self) -> dict[str, object]: + return { + "palette": self.palette, + "source": self.source, + "locked": self.locked, + "system_default_palette": self.system_default_palette, + "tenant_default_palette": self.tenant_default_palette, + "inherited_palette": self.inherited_palette, + } + + +def normalize_appearance_palette(value: object, *, fallback: AppearancePalette | None = None) -> AppearancePalette | None: + normalized = str(value or "").strip().lower() + return normalized if normalized in APPEARANCE_PALETTES else fallback # type: ignore[return-value] + + +def appearance_settings(settings: Mapping[str, Any] | None) -> tuple[AppearancePalette | None, bool]: + raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None + if not isinstance(raw, Mapping): + return None, False + return normalize_appearance_palette(raw.get("default_palette")), raw.get("palette_locked") is True + + +def update_appearance_settings( + settings: Mapping[str, Any] | None, + *, + default_palette: AppearancePalette | None, + palette_locked: bool, +) -> 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 default_palette is None: + appearance.pop("default_palette", None) + else: + normalized = normalize_appearance_palette(default_palette) + if normalized is None: + raise ValueError("Unsupported appearance palette.") + appearance["default_palette"] = normalized + if palette_locked: + appearance["palette_locked"] = True + else: + appearance.pop("palette_locked", None) + if appearance: + updated[APPEARANCE_SETTINGS_KEY] = appearance + else: + updated.pop(APPEARANCE_SETTINGS_KEY, None) + return updated + + +def resolve_effective_appearance( + *, + system_settings: Mapping[str, Any] | None, + tenant_settings: Mapping[str, Any] | None, + user_settings: Mapping[str, Any] | None, +) -> EffectiveAppearance: + system_palette, system_locked = appearance_settings(system_settings) + system_palette = system_palette or "default" + tenant_palette, tenant_locked = appearance_settings(tenant_settings) + 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 + + if system_locked: + return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette) + if tenant_locked: + return EffectiveAppearance(inherited_palette, "tenant_lock", True, system_palette, tenant_palette, inherited_palette) + return EffectiveAppearance( + user_palette or inherited_palette, + "user" if user_palette else "tenant" if tenant_palette else "system", + False, + system_palette, + tenant_palette, + inherited_palette, + ) + + +__all__ = [ + "APPEARANCE_PALETTES", + "APPEARANCE_SETTINGS_KEY", + "AppearancePalette", + "AppearanceSource", + "EffectiveAppearance", + "appearance_settings", + "normalize_appearance_palette", + "resolve_effective_appearance", + "update_appearance_settings", +] diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index f00945b..83c5654 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -6450,6 +6450,112 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(raw_test.status_code, 403, raw_test.text) self.assertIn("manage_credentials", raw_test.json()["detail"]) + def test_appearance_defaults_precedence_and_policy_lock(self) -> None: + headers, _ = self._login() + system = self.client.get("/api/v1/admin/system/settings", headers=headers) + self.assertEqual(system.status_code, 200, system.text) + system_payload = system.json() + + system_saved = self.client.patch( + "/api/v1/admin/system/settings", + headers=headers, + json={ + "default_locale": system_payload["default_locale"], + "allow_tenant_custom_groups": system_payload["allow_tenant_custom_groups"], + "allow_tenant_custom_roles": system_payload["allow_tenant_custom_roles"], + "allow_tenant_api_keys": system_payload["allow_tenant_api_keys"], + "appearance_palette": "civic_blue", + "appearance_palette_locked": False, + }, + ) + self.assertEqual(system_saved.status_code, 200, system_saved.text) + + tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers) + self.assertEqual(tenant.status_code, 200, tenant.text) + tenant_payload = tenant.json() + tenant_saved = self.client.patch( + "/api/v1/admin/tenant/settings", + headers=headers, + json={ + "default_locale": tenant_payload["default_locale"], + "appearance_palette": "forest", + "appearance_palette_locked": False, + }, + ) + self.assertEqual(tenant_saved.status_code, 200, tenant_saved.text) + self.assertEqual(tenant_saved.json()["effective_appearance_palette"], "forest") + + inherited = self.client.patch( + "/api/v1/auth/profile", + headers=headers, + json={"ui_preferences": {"palette": None}}, + ) + self.assertEqual(inherited.status_code, 200, inherited.text) + self.assertEqual(inherited.json()["user"]["appearance"]["palette"], "forest") + self.assertEqual(inherited.json()["user"]["appearance"]["source"], "tenant") + + explicit = self.client.patch( + "/api/v1/auth/profile", + headers=headers, + json={"ui_preferences": {"palette": "plum"}}, + ) + self.assertEqual(explicit.status_code, 200, explicit.text) + self.assertEqual(explicit.json()["user"]["appearance"]["source"], "user") + + tenant_locked = self.client.patch( + "/api/v1/admin/tenant/settings", + headers=headers, + json={ + "default_locale": tenant_payload["default_locale"], + "appearance_palette": "forest", + "appearance_palette_locked": True, + }, + ) + self.assertEqual(tenant_locked.status_code, 200, tenant_locked.text) + locked_profile = self.client.get("/api/v1/auth/profile", headers=headers) + self.assertEqual(locked_profile.json()["user"]["appearance"]["source"], "tenant_lock") + self.assertTrue(locked_profile.json()["user"]["appearance"]["locked"]) + unchanged_palette = self.client.patch( + "/api/v1/auth/profile", + headers=headers, + json={"ui_preferences": {"palette": "plum", "theme": "dark"}}, + ) + self.assertEqual(unchanged_palette.status_code, 200, unchanged_palette.text) + self.assertEqual(unchanged_palette.json()["user"]["ui_preferences"]["theme"], "dark") + denied = self.client.patch( + "/api/v1/auth/profile", + headers=headers, + json={"ui_preferences": {"palette": "default"}}, + ) + self.assertEqual(denied.status_code, 422, denied.text) + + system_locked = self.client.patch( + "/api/v1/admin/system/settings", + headers=headers, + json={ + "default_locale": system_payload["default_locale"], + "allow_tenant_custom_groups": system_payload["allow_tenant_custom_groups"], + "allow_tenant_custom_roles": system_payload["allow_tenant_custom_roles"], + "allow_tenant_api_keys": system_payload["allow_tenant_api_keys"], + "appearance_palette": "civic_blue", + "appearance_palette_locked": True, + }, + ) + self.assertEqual(system_locked.status_code, 200, system_locked.text) + blocked_tenant_override = self.client.patch( + "/api/v1/admin/tenant/settings", + headers=headers, + json={ + "default_locale": tenant_payload["default_locale"], + "appearance_palette": "plum", + "appearance_palette_locked": False, + }, + ) + self.assertEqual(blocked_tenant_override.status_code, 422, blocked_tenant_override.text) + final_profile = self.client.get("/api/v1/auth/profile", headers=headers) + self.assertEqual(final_profile.json()["user"]["appearance"]["palette"], "civic_blue") + self.assertEqual(final_profile.json()["user"]["appearance"]["source"], "system_lock") + def test_profile_refresh_and_system_role_protection_model(self) -> None: headers, _ = self._login() profile = self.client.patch( diff --git a/tests/test_appearance.py b/tests/test_appearance.py new file mode 100644 index 0000000..59e441b --- /dev/null +++ b/tests/test_appearance.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from govoplan_core.core.appearance import resolve_effective_appearance, update_appearance_settings + + +def test_appearance_precedence_and_inheritance() -> None: + decision = resolve_effective_appearance( + system_settings={"appearance": {"default_palette": "civic_blue"}}, + tenant_settings={"appearance": {"default_palette": "forest"}}, + user_settings={"ui": {"palette": "plum"}}, + ) + assert (decision.palette, decision.source, decision.locked) == ("plum", "user", False) + + inherited = resolve_effective_appearance( + system_settings={"appearance": {"default_palette": "civic_blue"}}, + tenant_settings={"appearance": {"default_palette": "forest"}}, + user_settings={"ui": {"palette": None}}, + ) + assert (inherited.palette, inherited.source) == ("forest", "tenant") + + +def test_system_lock_wins_and_invalid_values_fail_safe() -> None: + decision = resolve_effective_appearance( + system_settings={"appearance": {"default_palette": "civic_blue", "palette_locked": True}}, + tenant_settings={"appearance": {"default_palette": "forest", "palette_locked": True}}, + user_settings={"ui": {"palette": "plum"}}, + ) + assert decision.as_dict() == { + "palette": "civic_blue", + "source": "system_lock", + "locked": True, + "system_default_palette": "civic_blue", + "tenant_default_palette": "forest", + "inherited_palette": "civic_blue", + } + fallback = resolve_effective_appearance( + system_settings={"appearance": {"default_palette": "unsafe"}}, + tenant_settings={}, + user_settings={"ui": {"palette": "unknown"}}, + ) + assert (fallback.palette, fallback.source) == ("default", "system") + + +def test_appearance_settings_reset_without_touching_neighbors() -> None: + configured = update_appearance_settings( + {"neighbor": {"kept": True}}, + default_palette="forest", + palette_locked=True, + ) + assert configured["neighbor"] == {"kept": True} + assert configured["appearance"] == {"default_palette": "forest", "palette_locked": True} + assert update_appearance_settings(configured, default_palette=None, palette_locked=False) == { + "neighbor": {"kept": True} + } diff --git a/webui/scripts/test-theme-contract.mjs b/webui/scripts/test-theme-contract.mjs index f5c9d72..58606a3 100644 --- a/webui/scripts/test-theme-contract.mjs +++ b/webui/scripts/test-theme-contract.mjs @@ -7,6 +7,7 @@ const repositoryRoot = resolve(webuiRoot, "..", ".."); 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"); 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"); @@ -17,7 +18,16 @@ for (const theme of ["system", "light", "dark"]) { assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`); } for (const palette of ["default", "civic_blue", "forest", "plum"]) { - assert.match(settings, new RegExp(`value:\\s*"${palette}"`), `Settings must expose ${palette}`); + 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"); +for (const relativePath of [ + "govoplan-admin/webui/src/features/admin/SystemSettingsPanel.tsx", + "govoplan-tenancy/webui/src/features/admin/TenantSettingsPanel.tsx" +]) { + const source = readFileSync(resolve(repositoryRoot, relativePath), "utf8"); + assert.match(source, /AppearancePaletteSelect/, `${relativePath} must use the shared palette control`); + assert.match(source, /AppearancePalettePreview/, `${relativePath} must use the shared palette preview`); } const paletteContrasts = [ diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 22d96bd..39f000b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -36,7 +36,7 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = { reduce_motion: false, sticky_section_sidebars: true, theme: "system", - palette: "default" + palette: null }; export default function App() { @@ -390,7 +390,7 @@ export default function App() { root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints); root.classList.toggle("ui-reduce-motion", preferences.reduce_motion); root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars); - root.dataset.palette = normalizeUiPalette(preferences.palette); + root.dataset.palette = normalizeUiPalette(auth?.user.appearance?.palette ?? preferences.palette); const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null; const applyTheme = () => { @@ -416,7 +416,8 @@ export default function App() { auth?.user.ui_preferences?.reduce_motion, auth?.user.ui_preferences?.sticky_section_sidebars, auth?.user.ui_preferences?.theme, - auth?.user.ui_preferences?.palette + auth?.user.ui_preferences?.palette, + auth?.user.appearance?.palette ]); useEffect(() => { @@ -720,7 +721,7 @@ 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: normalizeUiPalette(value?.palette) + palette: normalizeOptionalUiPalette(value?.palette) }; } @@ -730,6 +731,10 @@ function normalizeUiPalette(value: UserUiPalette | string | null | undefined): U : "default"; } +function normalizeOptionalUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette | null { + return value == null ? null : normalizeUiPalette(value); +} + function mergeWebModules(localModules: PlatformWebModule[], remoteModules: PlatformWebModule[]): PlatformWebModule[] { if (remoteModules.length === 0) return localModules; const seen = new Set(localModules.map((module) => module.id)); diff --git a/webui/src/components/AppearancePaletteControl.tsx b/webui/src/components/AppearancePaletteControl.tsx new file mode 100644 index 0000000..1b66df0 --- /dev/null +++ b/webui/src/components/AppearancePaletteControl.tsx @@ -0,0 +1,66 @@ +import type { UserUiPalette, UserUiTheme } from "../types"; + +export const APPEARANCE_PALETTE_OPTIONS: ReadonlyArray<{ value: UserUiPalette; label: string }> = [ + { value: "default", label: "i18n:govoplan-core.palette_default" }, + { value: "civic_blue", label: "i18n:govoplan-core.palette_civic_blue" }, + { value: "forest", label: "i18n:govoplan-core.palette_forest" }, + { value: "plum", label: "i18n:govoplan-core.palette_plum" } +]; + +export function AppearancePaletteSelect({ + value, + onChange, + allowInherit = false, + inheritLabel = "i18n:govoplan-core.inherit_governed_palette", + disabled = false +}: { + value: UserUiPalette | null; + onChange: (value: UserUiPalette | null) => void; + allowInherit?: boolean; + inheritLabel?: string; + disabled?: boolean; +}) { + return ( + + ); +} + +export function AppearancePalettePreview({ + theme = "system", + palette +}: { + theme?: UserUiTheme; + palette: UserUiPalette; +}) { + const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme]; + return ( +
+ {variants.map((variant) => ( +
+
+ {variant === "light" ? "i18n:govoplan-core.light_theme.7878f1fa" : "i18n:govoplan-core.dark_theme.164a90d9"} + +
+
+ GovOPlaN + + +
+
+ ))} +
+ ); +} + +export function appearancePaletteLabel(value: UserUiPalette): string { + return APPEARANCE_PALETTE_OPTIONS.find((item) => item.value === value)?.label ?? APPEARANCE_PALETTE_OPTIONS[0].label; +} diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index ee2c111..95ed81a 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -27,6 +27,7 @@ import { usePlatformLanguage } from "../../i18n/LanguageContext"; import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager"; import DocumentationHelpLink from "../../components/help/DocumentationHelpLink"; import WorkspaceLayout from "../../components/WorkspaceLayout"; +import { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "../../components/AppearancePaletteControl"; type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string; @@ -36,7 +37,7 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = { reduce_motion: false, sticky_section_sidebars: true, theme: "system", - palette: "default" + palette: null }; const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [ @@ -45,13 +46,6 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [ { value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" } ]; -const UI_PALETTE_OPTIONS: Array<{ value: UserUiPalette; label: string }> = [ - { value: "default", label: "i18n:govoplan-core.palette_default" }, - { value: "civic_blue", label: "i18n:govoplan-core.palette_civic_blue" }, - { value: "forest", label: "i18n:govoplan-core.palette_forest" }, - { value: "plum", label: "i18n:govoplan-core.palette_plum" } -]; - const SETTINGS_DOCUMENTATION = { contextId: "core.settings", documentationType: "user" as const @@ -163,7 +157,7 @@ export default function SettingsPage({ const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion); const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars); const [theme, setTheme] = useState(currentUiPreferences.theme); - const [palette, setPalette] = useState(currentUiPreferences.palette); + const [palette, setPalette] = useState(currentUiPreferences.palette); const [navigation, setNavigation] = useState(currentUiPreferences.navigation ?? null); const [uiBusy, setUiBusy] = useState(false); const [uiResult, setUiResult] = useState(""); @@ -491,24 +485,18 @@ export default function SettingsPage({ label="i18n:govoplan-core.color_palette" help="i18n:govoplan-core.color_palette_help" > - +
-
- +
i18n:govoplan-core.theme.a797e309
{themeLabel(theme)}
-
i18n:govoplan-core.accent_color.e49578ed
{paletteLabel(palette)}
+
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.language.89b86ab0
{languageLabel}
@@ -651,42 +639,36 @@ 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: normalizeUiPalette(value?.palette), + palette: normalizeOptionalUiPalette(value?.palette), navigation: value?.navigation ?? null }; } function normalizeUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette { - return UI_PALETTE_OPTIONS.some((item) => item.value === value) + return APPEARANCE_PALETTE_OPTIONS.some((item) => item.value === value) ? value as UserUiPalette : "default"; } +function normalizeOptionalUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette | null { + return value == null ? null : normalizeUiPalette(value); +} + function themeLabel(value: UserUiTheme): string { return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label; } function paletteLabel(value: UserUiPalette): string { - return UI_PALETTE_OPTIONS.find((item) => item.value === value)?.label ?? UI_PALETTE_OPTIONS[0].label; + return appearancePaletteLabel(value); } -function ThemePreview({ theme, palette }: {theme: UserUiTheme;palette: UserUiPalette;}) { - const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme]; - return ( -
- {variants.map((variant) => -
-
- {themeLabel(variant)} - -
-
- GovOPlaN - - -
-
- )} -
); - +function appearanceSourceLabel(value: string | null | undefined): string { + const labels: Record = { + user: "i18n:govoplan-core.appearance_source_user", + tenant: "i18n:govoplan-core.appearance_source_tenant", + system: "i18n:govoplan-core.appearance_source_system", + tenant_lock: "i18n:govoplan-core.appearance_source_tenant_lock", + system_lock: "i18n:govoplan-core.appearance_source_system_lock" + }; + return labels[value ?? "system"] ?? labels.system; } diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 94cda6d..bbc6189 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,13 @@ import type { PlatformTranslations } from "../types"; export const generatedTranslations: PlatformTranslations = { "en": { + "i18n:govoplan-core.inherit_governed_palette": "Inherit governed default", + "i18n:govoplan-core.effective_source": "Effective source", + "i18n:govoplan-core.appearance_source_user": "Personal preference", + "i18n:govoplan-core.appearance_source_tenant": "Tenant default", + "i18n:govoplan-core.appearance_source_system": "System default", + "i18n:govoplan-core.appearance_source_tenant_lock": "Tenant policy lock", + "i18n:govoplan-core.appearance_source_system_lock": "System policy lock", "i18n:govoplan-core.color_palette": "Color palette", "i18n:govoplan-core.color_palette_help": "Choose a validated accent palette. It applies to every module through shared semantic tokens.", "i18n:govoplan-core.palette_default": "GovOPlaN default", @@ -701,6 +708,13 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid." }, "de": { + "i18n:govoplan-core.inherit_governed_palette": "Verwalteten Standard übernehmen", + "i18n:govoplan-core.effective_source": "Wirksame Quelle", + "i18n:govoplan-core.appearance_source_user": "Persönliche Einstellung", + "i18n:govoplan-core.appearance_source_tenant": "Mandantenstandard", + "i18n:govoplan-core.appearance_source_system": "Systemstandard", + "i18n:govoplan-core.appearance_source_tenant_lock": "Mandantenrichtlinie", + "i18n:govoplan-core.appearance_source_system_lock": "Systemrichtlinie", "i18n:govoplan-core.color_palette": "Farbpalette", "i18n:govoplan-core.color_palette_help": "Wählen Sie eine geprüfte Akzentpalette. Sie gilt über gemeinsame semantische Tokens für alle Module.", "i18n:govoplan-core.palette_default": "GovOPlaN-Standard", diff --git a/webui/src/index.ts b/webui/src/index.ts index bae1f17..9302439 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -61,6 +61,7 @@ export type { AdminPageLayoutProps } from "./components/admin/AdminPageLayout"; export { default as AdminSelectionList } from "./components/admin/AdminSelectionList"; 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 type { ButtonProps } from "./components/Button"; export { default as Card } from "./components/Card"; export type { CardProps } from "./components/Card"; diff --git a/webui/src/types.ts b/webui/src/types.ts index 8331622..40e67ca 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -38,6 +38,7 @@ export type AuthUser = { preferred_language?: string | null; enabled_language_codes?: string[]; ui_preferences?: UserUiPreferences; + appearance?: EffectiveAppearance; }; export type UserUiTheme = "system" | "light" | "dark"; @@ -49,10 +50,19 @@ export type UserUiPreferences = { reduce_motion: boolean; sticky_section_sidebars: boolean; theme: UserUiTheme; - palette: UserUiPalette; + palette: UserUiPalette | null; navigation?: NavigationPreferences | null; }; +export type EffectiveAppearance = { + palette: UserUiPalette; + source: "user" | "tenant" | "system" | "tenant_lock" | "system_lock"; + locked: boolean; + system_default_palette: UserUiPalette; + tenant_default_palette?: UserUiPalette | null; + inherited_palette: UserUiPalette; +}; + export type AuthTenant = { id: string; slug: string;