feat: govern effective appearance defaults

This commit is contained in:
2026-08-20 07:03:28 +02:00
parent 6643c8fc1e
commit 8f642bd618
13 changed files with 429 additions and 61 deletions
+11 -10
View File
@@ -15,21 +15,22 @@ inherits the result through semantic tokens without module-specific CSS.
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and - Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
the status token families. They may define domain aliases whose values resolve the status token families. They may define domain aliases whose values resolve
to shared tokens. to shared tokens.
- User preference selects the mode and palette. Invalid stored palette values - Palette defaults form a provenance chain: system, tenant, then an explicit
fail safely to `default`; the profile API accepts only the supported preset user choice. Invalid stored values are ignored. Reset means inheritance and
identifiers. Tenant and system policy may provide a future default, but must does not copy the current parent value into the child scope.
not silently replace an explicit user choice. - 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 - Tenant branding is a separate policy surface and must preserve contrast and
status semantics in both modes. status semantics in both modes.
## Palette safety and scope ## Palette safety and scope
The current slice is intentionally user-level. The Settings preview shows the The Settings preview shows the chosen or inherited accent in every applicable
chosen accent in every applicable light/dark preview before Save, and Reset light/dark preview before Save. Presets are checked for WCAG AA contrast in the
palette returns the draft to the GovOPlaN default before persistence. Presets theme contract. Arbitrary token overrides and branding import/export are not
are checked for WCAG AA contrast in the theme contract. Arbitrary token inferred from this preference and require their own governed follow-up.
overrides, tenant/system defaults, branding import/export, and policy locks 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 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 one mode look correct. Add or reuse a semantic Core token, then define both
+2 -2
View File
@@ -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. | | 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. | | 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. | | 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 ## 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. | | 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. | | 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. | | 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 ## Review Checklist
+11 -1
View File
@@ -110,10 +110,19 @@ class UserUiPreferences(BaseModel):
reduce_motion: bool = False reduce_motion: bool = False
sticky_section_sidebars: bool = True sticky_section_sidebars: bool = True
theme: Literal["system", "light", "dark"] = "system" 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 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): class UserInfo(BaseModel):
id: str id: str
account_id: str account_id: str
@@ -127,6 +136,7 @@ class UserInfo(BaseModel):
preferred_language: str | None = None preferred_language: str | None = None
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences) ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
appearance: EffectiveAppearanceInfo = Field(default_factory=EffectiveAppearanceInfo)
class AuthSessionUserInfo(BaseModel): class AuthSessionUserInfo(BaseModel):
+109
View File
@@ -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",
]
+106
View File
@@ -6450,6 +6450,112 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(raw_test.status_code, 403, raw_test.text) self.assertEqual(raw_test.status_code, 403, raw_test.text)
self.assertIn("manage_credentials", raw_test.json()["detail"]) 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: def test_profile_refresh_and_system_role_protection_model(self) -> None:
headers, _ = self._login() headers, _ = self._login()
profile = self.client.patch( profile = self.client.patch(
+54
View File
@@ -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}
}
+11 -1
View File
@@ -7,6 +7,7 @@ const repositoryRoot = resolve(webuiRoot, "..", "..");
const tokens = readFileSync(resolve(webuiRoot, "src/styles/tokens.css"), "utf8"); const tokens = readFileSync(resolve(webuiRoot, "src/styles/tokens.css"), "utf8");
const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8"); const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8");
const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.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, /:root\[data-theme="dark"\]/, "dark token overrides are required");
assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme"); assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme");
@@ -17,7 +18,16 @@ for (const theme of ["system", "light", "dark"]) {
assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`); assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`);
} }
for (const palette of ["default", "civic_blue", "forest", "plum"]) { 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 = [ const paletteContrasts = [
+9 -4
View File
@@ -36,7 +36,7 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
reduce_motion: false, reduce_motion: false,
sticky_section_sidebars: true, sticky_section_sidebars: true,
theme: "system", theme: "system",
palette: "default" palette: null
}; };
export default function App() { 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-hide-help-hints", !preferences.show_inline_help_hints);
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion); root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars); 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 systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
const applyTheme = () => { const applyTheme = () => {
@@ -416,7 +416,8 @@ export default function App() {
auth?.user.ui_preferences?.reduce_motion, auth?.user.ui_preferences?.reduce_motion,
auth?.user.ui_preferences?.sticky_section_sidebars, auth?.user.ui_preferences?.sticky_section_sidebars,
auth?.user.ui_preferences?.theme, auth?.user.ui_preferences?.theme,
auth?.user.ui_preferences?.palette auth?.user.ui_preferences?.palette,
auth?.user.appearance?.palette
]); ]);
useEffect(() => { useEffect(() => {
@@ -720,7 +721,7 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion), 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), sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
theme, theme,
palette: normalizeUiPalette(value?.palette) palette: normalizeOptionalUiPalette(value?.palette)
}; };
} }
@@ -730,6 +731,10 @@ function normalizeUiPalette(value: UserUiPalette | string | null | undefined): U
: "default"; : "default";
} }
function normalizeOptionalUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette | null {
return value == null ? null : normalizeUiPalette(value);
}
function mergeWebModules(localModules: PlatformWebModule[], remoteModules: PlatformWebModule[]): PlatformWebModule[] { function mergeWebModules(localModules: PlatformWebModule[], remoteModules: PlatformWebModule[]): PlatformWebModule[] {
if (remoteModules.length === 0) return localModules; if (remoteModules.length === 0) return localModules;
const seen = new Set(localModules.map((module) => module.id)); const seen = new Set(localModules.map((module) => module.id));
@@ -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 (
<select
value={value ?? (allowInherit ? "inherit" : "default")}
disabled={disabled}
onChange={(event) => onChange(event.target.value === "inherit" ? null : event.target.value as UserUiPalette)}
>
{allowInherit && <option value="inherit">{inheritLabel}</option>}
{APPEARANCE_PALETTE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
);
}
export function AppearancePalettePreview({
theme = "system",
palette
}: {
theme?: UserUiTheme;
palette: UserUiPalette;
}) {
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme];
return (
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309">
{variants.map((variant) => (
<div key={variant} className="theme-preview" data-preview-theme={variant} data-preview-palette={palette}>
<div className="theme-preview-header">
<span>{variant === "light" ? "i18n:govoplan-core.light_theme.7878f1fa" : "i18n:govoplan-core.dark_theme.164a90d9"}</span>
<i />
</div>
<div className="theme-preview-body">
<strong>GovOPlaN</strong>
<span />
<span />
</div>
</div>
))}
</div>
);
}
export function appearancePaletteLabel(value: UserUiPalette): string {
return APPEARANCE_PALETTE_OPTIONS.find((item) => item.value === value)?.label ?? APPEARANCE_PALETTE_OPTIONS[0].label;
}
+24 -42
View File
@@ -27,6 +27,7 @@ import { usePlatformLanguage } from "../../i18n/LanguageContext";
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager"; import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
import DocumentationHelpLink from "../../components/help/DocumentationHelpLink"; import DocumentationHelpLink from "../../components/help/DocumentationHelpLink";
import WorkspaceLayout from "../../components/WorkspaceLayout"; 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; type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
@@ -36,7 +37,7 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
reduce_motion: false, reduce_motion: false,
sticky_section_sidebars: true, sticky_section_sidebars: true,
theme: "system", theme: "system",
palette: "default" palette: null
}; };
const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [ 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" } { 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 = { const SETTINGS_DOCUMENTATION = {
contextId: "core.settings", contextId: "core.settings",
documentationType: "user" as const documentationType: "user" as const
@@ -163,7 +157,7 @@ export default function SettingsPage({
const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion); const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion);
const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars); const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars);
const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme); const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme);
const [palette, setPalette] = useState<UserUiPalette>(currentUiPreferences.palette); const [palette, setPalette] = useState<UserUiPalette | null>(currentUiPreferences.palette);
const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null); const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null);
const [uiBusy, setUiBusy] = useState(false); const [uiBusy, setUiBusy] = useState(false);
const [uiResult, setUiResult] = useState(""); const [uiResult, setUiResult] = useState("");
@@ -491,24 +485,18 @@ export default function SettingsPage({
label="i18n:govoplan-core.color_palette" label="i18n:govoplan-core.color_palette"
help="i18n:govoplan-core.color_palette_help" help="i18n:govoplan-core.color_palette_help"
> >
<select <AppearancePaletteSelect value={palette} onChange={setPalette} allowInherit disabled={auth.user.appearance?.locked === true} />
value={palette}
onChange={(event) => setPalette(event.target.value as UserUiPalette)}
>
{UI_PALETTE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
</FormField> </FormField>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => setPalette("default")} disabled={palette === "default"}> <Button onClick={() => setPalette(null)} disabled={palette === null || auth.user.appearance?.locked === true}>
i18n:govoplan-core.reset_palette i18n:govoplan-core.reset_palette
</Button> </Button>
</div> </div>
<ThemePreview theme={theme} palette={palette} /> <AppearancePalettePreview theme={theme} palette={palette ?? auth.user.appearance?.inherited_palette ?? "default"} />
<DescriptionList variant="inline" density="compact"> <DescriptionList variant="inline" density="compact">
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div> <div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>{paletteLabel(palette)}</dd></div> <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.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>i18n:govoplan-core.not_configured</dd></div>
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div> <div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
@@ -651,42 +639,36 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion), 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), sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
theme, theme,
palette: normalizeUiPalette(value?.palette), palette: normalizeOptionalUiPalette(value?.palette),
navigation: value?.navigation ?? null navigation: value?.navigation ?? null
}; };
} }
function normalizeUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette { 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 ? value as UserUiPalette
: "default"; : "default";
} }
function normalizeOptionalUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette | null {
return value == null ? null : normalizeUiPalette(value);
}
function themeLabel(value: UserUiTheme): string { function themeLabel(value: UserUiTheme): string {
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label; return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
} }
function paletteLabel(value: UserUiPalette): string { 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;}) { function appearanceSourceLabel(value: string | null | undefined): string {
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme]; const labels: Record<string, string> = {
return ( user: "i18n:govoplan-core.appearance_source_user",
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309"> tenant: "i18n:govoplan-core.appearance_source_tenant",
{variants.map((variant) => system: "i18n:govoplan-core.appearance_source_system",
<div key={variant} className="theme-preview" data-preview-theme={variant} data-preview-palette={palette}> tenant_lock: "i18n:govoplan-core.appearance_source_tenant_lock",
<div className="theme-preview-header"> system_lock: "i18n:govoplan-core.appearance_source_system_lock"
<span>{themeLabel(variant)}</span> };
<i /> return labels[value ?? "system"] ?? labels.system;
</div>
<div className="theme-preview-body">
<strong>GovOPlaN</strong>
<span />
<span />
</div>
</div>
)}
</div>);
} }
+14
View File
@@ -2,6 +2,13 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "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": "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.color_palette_help": "Choose a validated accent palette. It applies to every module through shared semantic tokens.",
"i18n:govoplan-core.palette_default": "GovOPlaN default", "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." "i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
}, },
"de": { "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": "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.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", "i18n:govoplan-core.palette_default": "GovOPlaN-Standard",
+1
View File
@@ -61,6 +61,7 @@ export type { AdminPageLayoutProps } from "./components/admin/AdminPageLayout";
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList"; export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils"; export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
export { default as Button } from "./components/Button"; export { default as Button } from "./components/Button";
export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "./components/AppearancePaletteControl";
export type { ButtonProps } from "./components/Button"; export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card"; export { default as Card } from "./components/Card";
export type { CardProps } from "./components/Card"; export type { CardProps } from "./components/Card";
+11 -1
View File
@@ -38,6 +38,7 @@ export type AuthUser = {
preferred_language?: string | null; preferred_language?: string | null;
enabled_language_codes?: string[]; enabled_language_codes?: string[];
ui_preferences?: UserUiPreferences; ui_preferences?: UserUiPreferences;
appearance?: EffectiveAppearance;
}; };
export type UserUiTheme = "system" | "light" | "dark"; export type UserUiTheme = "system" | "light" | "dark";
@@ -49,10 +50,19 @@ export type UserUiPreferences = {
reduce_motion: boolean; reduce_motion: boolean;
sticky_section_sidebars: boolean; sticky_section_sidebars: boolean;
theme: UserUiTheme; theme: UserUiTheme;
palette: UserUiPalette; palette: UserUiPalette | null;
navigation?: NavigationPreferences | 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 = { export type AuthTenant = {
id: string; id: string;
slug: string; slug: string;