feat: govern accessible appearance overrides
This commit is contained in:
+19
-6
@@ -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
|
child scope; otherwise a tenant lock suppresses a personal override. The
|
||||||
authenticated profile reports the effective palette, source, inherited
|
authenticated profile reports the effective palette, source, inherited
|
||||||
palette, and lock state.
|
palette, and lock state.
|
||||||
- Tenant branding is a separate policy surface and must preserve contrast and
|
- Advanced personal overrides are a separately governed surface. The system
|
||||||
status semantics in both modes.
|
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
|
## Palette safety and scope
|
||||||
|
|
||||||
The Settings preview shows the chosen or inherited accent in every applicable
|
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
|
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
|
theme contract. When policy permits, the shared advanced editor can atomically
|
||||||
inferred from this preference and require their own governed follow-up.
|
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
|
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
|
||||||
light and dark values. Bitmap content and externally authored HTML are exempt,
|
light and dark values. Bitmap content and externally authored HTML are exempt,
|
||||||
but their surrounding controls must still use the shared tokens.
|
but their surrounding controls must still use the shared tokens.
|
||||||
|
|
||||||
`npm run test:theme-contract` verifies root mode/palette behavior, preset
|
`npm run test:theme-contract` verifies root mode/palette behavior, preset and
|
||||||
contrast, and representative
|
custom-override validation/application, and representative
|
||||||
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||||
production WebUI build.
|
production WebUI build.
|
||||||
|
|||||||
@@ -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
|
- 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
|
CSS may tune layout and spacing, but it must not introduce a separate
|
||||||
appearance system.
|
appearance system.
|
||||||
- Appearance controls live in user settings first. The user preference wins
|
- Appearance controls live in user settings. A personal palette wins over
|
||||||
over future tenant and system defaults unless a separately documented policy
|
unlocked tenant and system defaults; system and tenant locks take precedence.
|
||||||
lock is introduced. Tenant defaults and policy
|
Advanced personal token overrides additionally require system opt-in and may
|
||||||
enforcement can be added later without changing the token contract.
|
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,
|
- Visual preview in settings is illustrative; it must reflect token families,
|
||||||
not become a second theme implementation.
|
not become a second theme implementation.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
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):
|
class AuditLogItemResponse(BaseModel):
|
||||||
@@ -102,6 +104,36 @@ class NavigationPreferencesPayload(BaseModel):
|
|||||||
locked: list[str] = Field(default_factory=list, max_length=256)
|
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):
|
class UserUiPreferences(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
@@ -111,6 +143,7 @@ class UserUiPreferences(BaseModel):
|
|||||||
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"] | None = None
|
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||||
|
appearance_overrides: AppearanceOverridesDocument | None = None
|
||||||
navigation: NavigationPreferencesPayload | None = None
|
navigation: NavigationPreferencesPayload | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +154,8 @@ class EffectiveAppearanceInfo(BaseModel):
|
|||||||
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||||
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
|
custom_overrides: AppearanceOverridesDocument | None = None
|
||||||
|
custom_overrides_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserInfo(BaseModel):
|
class UserInfo(BaseModel):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import re
|
||||||
from typing import Any, Literal, Mapping
|
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_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum")
|
||||||
APPEARANCE_SETTINGS_KEY = "appearance"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -19,6 +28,8 @@ class EffectiveAppearance:
|
|||||||
system_default_palette: AppearancePalette
|
system_default_palette: AppearancePalette
|
||||||
tenant_default_palette: AppearancePalette | None
|
tenant_default_palette: AppearancePalette | None
|
||||||
inherited_palette: AppearancePalette
|
inherited_palette: AppearancePalette
|
||||||
|
custom_overrides: dict[str, object] | None = None
|
||||||
|
custom_overrides_allowed: bool = False
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, object]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
@@ -28,6 +39,8 @@ class EffectiveAppearance:
|
|||||||
"system_default_palette": self.system_default_palette,
|
"system_default_palette": self.system_default_palette,
|
||||||
"tenant_default_palette": self.tenant_default_palette,
|
"tenant_default_palette": self.tenant_default_palette,
|
||||||
"inherited_palette": self.inherited_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
|
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(
|
def update_appearance_settings(
|
||||||
settings: Mapping[str, Any] | None,
|
settings: Mapping[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
@@ -81,6 +179,15 @@ def resolve_effective_appearance(
|
|||||||
inherited_palette = tenant_palette or system_palette
|
inherited_palette = tenant_palette or system_palette
|
||||||
raw_ui = user_settings.get("ui") if isinstance(user_settings, Mapping) else None
|
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
|
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:
|
if system_locked:
|
||||||
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
|
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
|
||||||
@@ -93,17 +200,24 @@ def resolve_effective_appearance(
|
|||||||
system_palette,
|
system_palette,
|
||||||
tenant_palette,
|
tenant_palette,
|
||||||
inherited_palette,
|
inherited_palette,
|
||||||
|
custom_overrides,
|
||||||
|
custom_overrides_allowed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"APPEARANCE_PALETTES",
|
"APPEARANCE_PALETTES",
|
||||||
"APPEARANCE_SETTINGS_KEY",
|
"APPEARANCE_SETTINGS_KEY",
|
||||||
|
"APPEARANCE_OVERRIDE_SCHEMA_VERSION",
|
||||||
|
"APPEARANCE_OVERRIDE_TOKENS",
|
||||||
"AppearancePalette",
|
"AppearancePalette",
|
||||||
"AppearanceSource",
|
"AppearanceSource",
|
||||||
"EffectiveAppearance",
|
"EffectiveAppearance",
|
||||||
"appearance_settings",
|
"appearance_settings",
|
||||||
|
"appearance_custom_overrides_policy",
|
||||||
|
"normalize_appearance_overrides",
|
||||||
"normalize_appearance_palette",
|
"normalize_appearance_palette",
|
||||||
"resolve_effective_appearance",
|
"resolve_effective_appearance",
|
||||||
"update_appearance_settings",
|
"update_appearance_settings",
|
||||||
|
"update_appearance_custom_overrides_policy",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"]["palette"], "civic_blue")
|
||||||
self.assertEqual(final_profile.json()["user"]["appearance"]["source"], "system_lock")
|
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:
|
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(
|
||||||
@@ -6591,6 +6678,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"sticky_section_sidebars": False,
|
"sticky_section_sidebars": False,
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"palette": "civic_blue",
|
"palette": "civic_blue",
|
||||||
|
"appearance_overrides": None,
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"contract_version": "1",
|
"contract_version": "1",
|
||||||
"order": ["files.navigation.files", "mail.navigation.mail"],
|
"order": ["files.navigation.files", "mail.navigation.mail"],
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
from __future__ import annotations
|
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:
|
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",
|
"system_default_palette": "civic_blue",
|
||||||
"tenant_default_palette": "forest",
|
"tenant_default_palette": "forest",
|
||||||
"inherited_palette": "civic_blue",
|
"inherited_palette": "civic_blue",
|
||||||
|
"custom_overrides": None,
|
||||||
|
"custom_overrides_allowed": False,
|
||||||
}
|
}
|
||||||
fallback = resolve_effective_appearance(
|
fallback = resolve_effective_appearance(
|
||||||
system_settings={"appearance": {"default_palette": "unsafe"}},
|
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) == {
|
assert update_appearance_settings(configured, default_palette=None, palette_locked=False) == {
|
||||||
"neighbor": {"kept": True}
|
"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"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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");
|
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, /: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");
|
||||||
@@ -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(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, /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 [
|
for (const relativePath of [
|
||||||
"govoplan-admin/webui/src/features/admin/SystemSettingsPanel.tsx",
|
"govoplan-admin/webui/src/features/admin/SystemSettingsPanel.tsx",
|
||||||
"govoplan-tenancy/webui/src/features/admin/TenantSettingsPanel.tsx"
|
"govoplan-tenancy/webui/src/features/admin/TenantSettingsPanel.tsx"
|
||||||
|
|||||||
+8
-3
@@ -26,6 +26,7 @@ import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
|||||||
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
|
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
|
||||||
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
||||||
import { hasAnyScope } from "./utils/permissions";
|
import { hasAnyScope } from "./utils/permissions";
|
||||||
|
import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor";
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||||
@@ -36,7 +37,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
|||||||
reduce_motion: false,
|
reduce_motion: false,
|
||||||
sticky_section_sidebars: true,
|
sticky_section_sidebars: true,
|
||||||
theme: "system",
|
theme: "system",
|
||||||
palette: null
|
palette: null,
|
||||||
|
appearance_overrides: null
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -399,6 +401,7 @@ export default function App() {
|
|||||||
preferences.theme;
|
preferences.theme;
|
||||||
root.dataset.theme = resolvedTheme;
|
root.dataset.theme = resolvedTheme;
|
||||||
root.dataset.themePreference = preferences.theme;
|
root.dataset.themePreference = preferences.theme;
|
||||||
|
applyAppearanceOverrides(root, auth?.user.appearance?.custom_overrides ?? null, resolvedTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
applyTheme();
|
applyTheme();
|
||||||
@@ -417,7 +420,8 @@ export default function App() {
|
|||||||
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
|
auth?.user.appearance?.palette,
|
||||||
|
auth?.user.appearance?.custom_overrides
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -721,7 +725,8 @@ 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: normalizeOptionalUiPalette(value?.palette)
|
palette: normalizeOptionalUiPalette(value?.palette),
|
||||||
|
appearance_overrides: value?.appearance_overrides ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { useRef, useState, type CSSProperties, type ChangeEvent } from "react";
|
||||||
|
import type {
|
||||||
|
AppearanceModeOverrides,
|
||||||
|
AppearanceOverridesDocument,
|
||||||
|
AppearanceOverrideToken,
|
||||||
|
UserUiTheme
|
||||||
|
} from "../types";
|
||||||
|
import Button from "./Button";
|
||||||
|
import ColorPickerField from "./ColorPickerField";
|
||||||
|
import ContentGrid from "./ContentGrid";
|
||||||
|
import DismissibleAlert from "./DismissibleAlert";
|
||||||
|
import FormField from "./FormField";
|
||||||
|
import SegmentedControl from "./SegmentedControl";
|
||||||
|
|
||||||
|
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
|
||||||
|
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||||
|
"success", "success_foreground", "info", "info_foreground",
|
||||||
|
"warning", "warning_foreground", "danger", "danger_foreground"
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
|
||||||
|
accent: "i18n:govoplan-core.override_accent",
|
||||||
|
accent_foreground: "i18n:govoplan-core.override_accent_foreground",
|
||||||
|
surface: "i18n:govoplan-core.override_surface",
|
||||||
|
surface_foreground: "i18n:govoplan-core.override_surface_foreground",
|
||||||
|
success: "i18n:govoplan-core.override_success",
|
||||||
|
success_foreground: "i18n:govoplan-core.override_success_foreground",
|
||||||
|
info: "i18n:govoplan-core.override_info",
|
||||||
|
info_foreground: "i18n:govoplan-core.override_info_foreground",
|
||||||
|
warning: "i18n:govoplan-core.override_warning",
|
||||||
|
warning_foreground: "i18n:govoplan-core.override_warning_foreground",
|
||||||
|
danger: "i18n:govoplan-core.override_danger",
|
||||||
|
danger_foreground: "i18n:govoplan-core.override_danger_foreground"
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
|
||||||
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
const RUNTIME_PROPERTIES = new Set<string>();
|
||||||
|
|
||||||
|
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
|
||||||
|
accent: ["--accent", "--action-primary-bg"],
|
||||||
|
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
|
||||||
|
surface: ["--surface", "--panel-soft"],
|
||||||
|
surface_foreground: ["--text", "--text-strong"],
|
||||||
|
success: ["--success-bg", "--success-soft"],
|
||||||
|
success_foreground: ["--success-text", "--success-text-strong"],
|
||||||
|
info: ["--info-bg", "--info-soft"],
|
||||||
|
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
|
||||||
|
warning: ["--warning-bg", "--warning-soft"],
|
||||||
|
warning_foreground: ["--warning-text", "--warning-text-strong"],
|
||||||
|
danger: ["--danger-bg", "--danger-soft"],
|
||||||
|
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
|
||||||
|
for (const property of properties) RUNTIME_PROPERTIES.add(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = {
|
||||||
|
schema_version: "1",
|
||||||
|
light: {
|
||||||
|
accent: "#245f91", accent_foreground: "#ffffff",
|
||||||
|
surface: "#ffffff", surface_foreground: "#303135",
|
||||||
|
success: "#d8eee8", success_foreground: "#315f55",
|
||||||
|
info: "#dce9f3", info_foreground: "#294a61",
|
||||||
|
warning: "#ffe1a3", warning_foreground: "#593700",
|
||||||
|
danger: "#f8d1cc", danger_foreground: "#873c35"
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
accent: "#7ea6c5", accent_foreground: "#242424",
|
||||||
|
surface: "#262724", surface_foreground: "#f1f1f1",
|
||||||
|
success: "#24473f", success_foreground: "#d8eee8",
|
||||||
|
info: "#243d4e", info_foreground: "#dce9f3",
|
||||||
|
warning: "#5a431f", warning_foreground: "#ffe1a3",
|
||||||
|
danger: "#4f2d2a", danger_foreground: "#f8d1cc"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
|
||||||
|
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
|
||||||
|
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||||
|
}
|
||||||
|
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
|
||||||
|
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||||
|
}
|
||||||
|
const document = value as unknown as AppearanceOverridesDocument;
|
||||||
|
for (const modeName of ["light", "dark"] as const) {
|
||||||
|
const mode = document[modeName];
|
||||||
|
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
|
||||||
|
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
|
||||||
|
}
|
||||||
|
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||||
|
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
|
||||||
|
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [background, foreground] of [
|
||||||
|
["accent", "accent_foreground"], ["surface", "surface_foreground"],
|
||||||
|
["success", "success_foreground"], ["info", "info_foreground"],
|
||||||
|
["warning", "warning_foreground"], ["danger", "danger_foreground"]
|
||||||
|
] as const) {
|
||||||
|
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
|
||||||
|
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
|
||||||
|
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
|
||||||
|
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
|
||||||
|
throw new Error("i18n:govoplan-core.appearance_override_status_error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAppearanceOverrides(
|
||||||
|
root: HTMLElement,
|
||||||
|
document: AppearanceOverridesDocument | null,
|
||||||
|
theme: "light" | "dark"
|
||||||
|
) {
|
||||||
|
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
|
||||||
|
if (!document) return;
|
||||||
|
let validated: AppearanceOverridesDocument;
|
||||||
|
try {
|
||||||
|
validated = validateAppearanceOverrides(document);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const colors = validated[theme];
|
||||||
|
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||||
|
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
|
||||||
|
root.style.setProperty(property, colors[token]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppearanceOverridesEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
theme = "system",
|
||||||
|
disabled = false
|
||||||
|
}: {
|
||||||
|
value: AppearanceOverridesDocument | null;
|
||||||
|
onChange: (value: AppearanceOverridesDocument | null) => void;
|
||||||
|
theme?: UserUiTheme;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<"light" | "dark">(theme === "dark" ? "dark" : "light");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
const validationMessage = appearanceOverridesValidationMessage(value);
|
||||||
|
|
||||||
|
function updateToken(token: AppearanceOverrideToken, color: string) {
|
||||||
|
const document = value ?? cloneDefaultAppearanceOverrides();
|
||||||
|
onChange({
|
||||||
|
...document,
|
||||||
|
[mode]: { ...document[mode], [token]: color }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportDocument() {
|
||||||
|
if (!value) return;
|
||||||
|
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = window.document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = "govoplan-appearance-overrides-v1.json";
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importDocument(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const document = validateAppearanceOverrides(JSON.parse(await file.text()));
|
||||||
|
onChange(document);
|
||||||
|
setMessage("i18n:govoplan-core.appearance_override_imported");
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="appearance-overrides-editor" aria-label="i18n:govoplan-core.advanced_theme_overrides">
|
||||||
|
<div className="appearance-overrides-actions">
|
||||||
|
<Button onClick={() => onChange(cloneDefaultAppearanceOverrides())} disabled={disabled}>
|
||||||
|
{value ? "i18n:govoplan-core.restore_safe_defaults" : "i18n:govoplan-core.configure_overrides"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => fileInput.current?.click()} disabled={disabled}>i18n:govoplan-core.import_overrides</Button>
|
||||||
|
<Button onClick={exportDocument} disabled={!value}>i18n:govoplan-core.export_overrides</Button>
|
||||||
|
<Button onClick={() => onChange(null)} disabled={!value}>i18n:govoplan-core.remove_overrides</Button>
|
||||||
|
<input ref={fileInput} type="file" accept="application/json,.json" hidden onChange={(event) => void importDocument(event)} />
|
||||||
|
</div>
|
||||||
|
{message && <DismissibleAlert tone={message.endsWith("imported") ? "success" : "warning"} resetKey={message}>{message}</DismissibleAlert>}
|
||||||
|
{!value && <p className="muted small-note">i18n:govoplan-core.appearance_override_not_configured_help</p>}
|
||||||
|
{value && <>
|
||||||
|
<SegmentedControl
|
||||||
|
options={[
|
||||||
|
{ id: "light" as const, label: "i18n:govoplan-core.light_theme.7878f1fa" },
|
||||||
|
{ id: "dark" as const, label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||||
|
]}
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
ariaLabel="i18n:govoplan-core.appearance_override_mode"
|
||||||
|
/>
|
||||||
|
<ContentGrid columns={3} collapseAt="workspace" className="appearance-overrides-fields">
|
||||||
|
{APPEARANCE_OVERRIDE_TOKENS.map((token) => (
|
||||||
|
<FormField key={token} label={TOKEN_LABELS[token]}>
|
||||||
|
<ColorPickerField value={value[mode][token]} onChange={(color) => updateToken(token, color)} disabled={disabled} />
|
||||||
|
</FormField>
|
||||||
|
))}
|
||||||
|
</ContentGrid>
|
||||||
|
<AppearanceOverridesPreview mode={mode} colors={value[mode]} />
|
||||||
|
<p className={validationMessage ? "form-error" : "muted small-note"}>
|
||||||
|
{validationMessage || "i18n:govoplan-core.appearance_override_validation_ok"}
|
||||||
|
</p>
|
||||||
|
</>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceOverridesPreview({ mode, colors }: { mode: "light" | "dark"; colors: AppearanceModeOverrides }) {
|
||||||
|
const style = {
|
||||||
|
"--appearance-preview-accent": colors.accent,
|
||||||
|
"--appearance-preview-accent-foreground": colors.accent_foreground,
|
||||||
|
"--appearance-preview-surface": colors.surface,
|
||||||
|
"--appearance-preview-surface-foreground": colors.surface_foreground
|
||||||
|
} as CSSProperties;
|
||||||
|
return (
|
||||||
|
<div className="appearance-overrides-preview" data-mode={mode} style={style}>
|
||||||
|
<strong>GovOPlaN</strong>
|
||||||
|
<button type="button">i18n:govoplan-core.preview_action</button>
|
||||||
|
<div className="appearance-overrides-statuses">
|
||||||
|
{STATUS_TOKENS.map((token) => (
|
||||||
|
<span key={token} style={{ backgroundColor: colors[token], color: colors[`${token}_foreground` as AppearanceOverrideToken] }}>
|
||||||
|
{TOKEN_LABELS[token]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appearanceOverridesValidationMessage(value: AppearanceOverridesDocument | null): string {
|
||||||
|
if (!value) return "";
|
||||||
|
try {
|
||||||
|
validateAppearanceOverrides(value);
|
||||||
|
return "";
|
||||||
|
} catch (error) {
|
||||||
|
return error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExactKeys(value: object, keys: readonly string[]): boolean {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeLuminance(color: string): number {
|
||||||
|
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
|
||||||
|
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastRatio(first: string, second: string): number {
|
||||||
|
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
|
||||||
|
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbDistance(first: string, second: string): number {
|
||||||
|
return Math.sqrt([1, 3, 5].reduce((total, index) => {
|
||||||
|
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
|
||||||
|
return total + delta * delta;
|
||||||
|
}, 0));
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import DescriptionList from "../../components/DescriptionList";
|
|||||||
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
|
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router";
|
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 Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PasswordField from "../../components/PasswordField";
|
import PasswordField from "../../components/PasswordField";
|
||||||
@@ -28,6 +28,7 @@ import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManage
|
|||||||
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";
|
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;
|
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
|||||||
reduce_motion: false,
|
reduce_motion: false,
|
||||||
sticky_section_sidebars: true,
|
sticky_section_sidebars: true,
|
||||||
theme: "system",
|
theme: "system",
|
||||||
palette: null
|
palette: null,
|
||||||
|
appearance_overrides: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
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 [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 | null>(currentUiPreferences.palette);
|
const [palette, setPalette] = useState<UserUiPalette | null>(currentUiPreferences.palette);
|
||||||
|
const [appearanceOverrides, setAppearanceOverrides] = useState<AppearanceOverridesDocument | null>(currentUiPreferences.appearance_overrides ?? null);
|
||||||
const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null);
|
const [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("");
|
||||||
@@ -175,6 +178,7 @@ export default function SettingsPage({
|
|||||||
stickySections !== currentUiPreferences.sticky_section_sidebars ||
|
stickySections !== currentUiPreferences.sticky_section_sidebars ||
|
||||||
theme !== currentUiPreferences.theme ||
|
theme !== currentUiPreferences.theme ||
|
||||||
palette !== currentUiPreferences.palette ||
|
palette !== currentUiPreferences.palette ||
|
||||||
|
JSON.stringify(appearanceOverrides) !== JSON.stringify(currentUiPreferences.appearance_overrides ?? null) ||
|
||||||
JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null);
|
JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null);
|
||||||
|
|
||||||
useUnsavedDraftGuard({
|
useUnsavedDraftGuard({
|
||||||
@@ -230,6 +234,7 @@ export default function SettingsPage({
|
|||||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||||
setTheme(currentUiPreferences.theme);
|
setTheme(currentUiPreferences.theme);
|
||||||
setPalette(currentUiPreferences.palette);
|
setPalette(currentUiPreferences.palette);
|
||||||
|
setAppearanceOverrides(currentUiPreferences.appearance_overrides ?? null);
|
||||||
setNavigation(currentUiPreferences.navigation ?? null);
|
setNavigation(currentUiPreferences.navigation ?? null);
|
||||||
}, [
|
}, [
|
||||||
currentUiPreferences.compact_tables,
|
currentUiPreferences.compact_tables,
|
||||||
@@ -238,6 +243,7 @@ export default function SettingsPage({
|
|||||||
currentUiPreferences.sticky_section_sidebars,
|
currentUiPreferences.sticky_section_sidebars,
|
||||||
currentUiPreferences.theme,
|
currentUiPreferences.theme,
|
||||||
currentUiPreferences.palette,
|
currentUiPreferences.palette,
|
||||||
|
currentUiPreferences.appearance_overrides,
|
||||||
currentUiPreferences.navigation
|
currentUiPreferences.navigation
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -278,6 +284,7 @@ export default function SettingsPage({
|
|||||||
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
setStickySections(currentUiPreferences.sticky_section_sidebars);
|
||||||
setTheme(currentUiPreferences.theme);
|
setTheme(currentUiPreferences.theme);
|
||||||
setPalette(currentUiPreferences.palette);
|
setPalette(currentUiPreferences.palette);
|
||||||
|
setAppearanceOverrides(currentUiPreferences.appearance_overrides ?? null);
|
||||||
setNavigation(currentUiPreferences.navigation ?? null);
|
setNavigation(currentUiPreferences.navigation ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +296,7 @@ export default function SettingsPage({
|
|||||||
sticky_section_sidebars: stickySections,
|
sticky_section_sidebars: stickySections,
|
||||||
theme,
|
theme,
|
||||||
palette,
|
palette,
|
||||||
|
appearance_overrides: appearanceOverrides,
|
||||||
navigation
|
navigation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -498,13 +506,20 @@ export default function SettingsPage({
|
|||||||
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>{paletteLabel(palette ?? auth.user.appearance?.inherited_palette ?? "default")}</dd></div>
|
<div><dt>i18n:govoplan-core.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.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>{appearanceOverrides ? "i18n:govoplan-core.configured" : "i18n:govoplan-core.not_configured"}</dd></div>
|
||||||
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
|
<div><dt>i18n:govoplan-core.language.89b86ab0</dt><dd>{languageLabel}</dd></div>
|
||||||
<div><dt>i18n:govoplan-core.enabled.df174a3f</dt><dd>{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
<div><dt>i18n:govoplan-core.enabled.df174a3f</dt><dd>{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||||
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
|
||||||
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
|
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
|
||||||
</DescriptionList>
|
</DescriptionList>
|
||||||
<p className="muted small-note">i18n:govoplan-core.advanced_theme_overrides_follow_up</p>
|
<AppearanceOverridesEditor
|
||||||
|
value={appearanceOverrides}
|
||||||
|
onChange={setAppearanceOverrides}
|
||||||
|
theme={theme}
|
||||||
|
disabled={auth.user.appearance?.custom_overrides_allowed !== true}
|
||||||
|
/>
|
||||||
|
{auth.user.appearance?.custom_overrides_allowed !== true &&
|
||||||
|
<p className="muted small-note">i18n:govoplan-core.appearance_override_policy_disabled</p>}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</Card>
|
</Card>
|
||||||
</ContentGrid>
|
</ContentGrid>
|
||||||
@@ -640,6 +655,7 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
|
|||||||
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
|
||||||
theme,
|
theme,
|
||||||
palette: normalizeOptionalUiPalette(value?.palette),
|
palette: normalizeOptionalUiPalette(value?.palette),
|
||||||
|
appearance_overrides: value?.appearance_overrides ?? null,
|
||||||
navigation: value?.navigation ?? null
|
navigation: value?.navigation ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,35 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.advanced_theme_overrides": "Advanced custom overrides",
|
"i18n:govoplan-core.advanced_theme_overrides": "Advanced custom overrides",
|
||||||
"i18n:govoplan-core.not_configured": "Not configured",
|
"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.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": "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.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",
|
"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.advanced_theme_overrides": "Erweiterte benutzerdefinierte Anpassungen",
|
||||||
"i18n:govoplan-core.not_configured": "Nicht konfiguriert",
|
"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.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": "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.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",
|
"i18n:govoplan-core.inbox_folder": "Posteingang",
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ export { default as AdminSelectionList } from "./components/admin/AdminSelection
|
|||||||
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 { 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 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";
|
||||||
|
|||||||
@@ -3224,6 +3224,63 @@
|
|||||||
gap: 10px;
|
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 {
|
.theme-preview {
|
||||||
--preview-bg: var(--theme-preview-light-bg);
|
--preview-bg: var(--theme-preview-light-bg);
|
||||||
--preview-bar: var(--theme-preview-light-bar);
|
--preview-bar: var(--theme-preview-light-bar);
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ export type AuthUser = {
|
|||||||
|
|
||||||
export type UserUiTheme = "system" | "light" | "dark";
|
export type UserUiTheme = "system" | "light" | "dark";
|
||||||
export type UserUiPalette = "default" | "civic_blue" | "forest" | "plum";
|
export type UserUiPalette = "default" | "civic_blue" | "forest" | "plum";
|
||||||
|
export type AppearanceOverrideToken =
|
||||||
|
| "accent" | "accent_foreground" | "surface" | "surface_foreground"
|
||||||
|
| "success" | "success_foreground" | "info" | "info_foreground"
|
||||||
|
| "warning" | "warning_foreground" | "danger" | "danger_foreground";
|
||||||
|
export type AppearanceModeOverrides = Record<AppearanceOverrideToken, string>;
|
||||||
|
export type AppearanceOverridesDocument = {
|
||||||
|
schema_version: "1";
|
||||||
|
light: AppearanceModeOverrides;
|
||||||
|
dark: AppearanceModeOverrides;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserUiPreferences = {
|
export type UserUiPreferences = {
|
||||||
compact_tables: boolean;
|
compact_tables: boolean;
|
||||||
@@ -51,6 +61,7 @@ export type UserUiPreferences = {
|
|||||||
sticky_section_sidebars: boolean;
|
sticky_section_sidebars: boolean;
|
||||||
theme: UserUiTheme;
|
theme: UserUiTheme;
|
||||||
palette: UserUiPalette | null;
|
palette: UserUiPalette | null;
|
||||||
|
appearance_overrides?: AppearanceOverridesDocument | null;
|
||||||
navigation?: NavigationPreferences | null;
|
navigation?: NavigationPreferences | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,6 +72,8 @@ export type EffectiveAppearance = {
|
|||||||
system_default_palette: UserUiPalette;
|
system_default_palette: UserUiPalette;
|
||||||
tenant_default_palette?: UserUiPalette | null;
|
tenant_default_palette?: UserUiPalette | null;
|
||||||
inherited_palette: UserUiPalette;
|
inherited_palette: UserUiPalette;
|
||||||
|
custom_overrides?: AppearanceOverridesDocument | null;
|
||||||
|
custom_overrides_allowed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthTenant = {
|
export type AuthTenant = {
|
||||||
|
|||||||
Reference in New Issue
Block a user