feat: govern accessible appearance overrides
This commit is contained in:
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_core.core.appearance import normalize_appearance_overrides
|
||||
|
||||
|
||||
class AuditLogItemResponse(BaseModel):
|
||||
@@ -102,6 +104,36 @@ class NavigationPreferencesPayload(BaseModel):
|
||||
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||
|
||||
|
||||
class AppearanceModeOverrides(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
accent: str
|
||||
accent_foreground: str
|
||||
surface: str
|
||||
surface_foreground: str
|
||||
success: str
|
||||
success_foreground: str
|
||||
info: str
|
||||
info_foreground: str
|
||||
warning: str
|
||||
warning_foreground: str
|
||||
danger: str
|
||||
danger_foreground: str
|
||||
|
||||
|
||||
class AppearanceOverridesDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: Literal["1"] = "1"
|
||||
light: AppearanceModeOverrides
|
||||
dark: AppearanceModeOverrides
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_accessibility(self) -> "AppearanceOverridesDocument":
|
||||
normalize_appearance_overrides(self.model_dump(mode="json"))
|
||||
return self
|
||||
|
||||
|
||||
class UserUiPreferences(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -111,6 +143,7 @@ class UserUiPreferences(BaseModel):
|
||||
sticky_section_sidebars: bool = True
|
||||
theme: Literal["system", "light", "dark"] = "system"
|
||||
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||
appearance_overrides: AppearanceOverridesDocument | None = None
|
||||
navigation: NavigationPreferencesPayload | None = None
|
||||
|
||||
|
||||
@@ -121,6 +154,8 @@ class EffectiveAppearanceInfo(BaseModel):
|
||||
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||
custom_overrides: AppearanceOverridesDocument | None = None
|
||||
custom_overrides_allowed: bool = False
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, Literal, Mapping
|
||||
|
||||
|
||||
@@ -9,6 +10,14 @@ AppearanceSource = Literal["user", "tenant", "system", "tenant_lock", "system_lo
|
||||
|
||||
APPEARANCE_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum")
|
||||
APPEARANCE_SETTINGS_KEY = "appearance"
|
||||
APPEARANCE_OVERRIDE_SCHEMA_VERSION = "1"
|
||||
APPEARANCE_OVERRIDE_TOKENS: tuple[str, ...] = (
|
||||
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||
"success", "success_foreground", "info", "info_foreground",
|
||||
"warning", "warning_foreground", "danger", "danger_foreground",
|
||||
)
|
||||
_STATUS_TOKENS = ("success", "info", "warning", "danger")
|
||||
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -19,6 +28,8 @@ class EffectiveAppearance:
|
||||
system_default_palette: AppearancePalette
|
||||
tenant_default_palette: AppearancePalette | None
|
||||
inherited_palette: AppearancePalette
|
||||
custom_overrides: dict[str, object] | None = None
|
||||
custom_overrides_allowed: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -28,6 +39,8 @@ class EffectiveAppearance:
|
||||
"system_default_palette": self.system_default_palette,
|
||||
"tenant_default_palette": self.tenant_default_palette,
|
||||
"inherited_palette": self.inherited_palette,
|
||||
"custom_overrides": self.custom_overrides,
|
||||
"custom_overrides_allowed": self.custom_overrides_allowed,
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +56,91 @@ def appearance_settings(settings: Mapping[str, Any] | None) -> tuple[AppearanceP
|
||||
return normalize_appearance_palette(raw.get("default_palette")), raw.get("palette_locked") is True
|
||||
|
||||
|
||||
def appearance_custom_overrides_policy(settings: Mapping[str, Any] | None) -> bool | None:
|
||||
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
|
||||
if not isinstance(raw, Mapping) or "allow_custom_overrides" not in raw:
|
||||
return None
|
||||
return raw.get("allow_custom_overrides") is True
|
||||
|
||||
|
||||
def update_appearance_custom_overrides_policy(
|
||||
settings: Mapping[str, Any] | None,
|
||||
*,
|
||||
allowed: bool | None,
|
||||
) -> dict[str, Any]:
|
||||
updated = dict(settings or {})
|
||||
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
|
||||
if allowed is None:
|
||||
appearance.pop("allow_custom_overrides", None)
|
||||
else:
|
||||
appearance["allow_custom_overrides"] = allowed
|
||||
if appearance:
|
||||
updated[APPEARANCE_SETTINGS_KEY] = appearance
|
||||
else:
|
||||
updated.pop(APPEARANCE_SETTINGS_KEY, None)
|
||||
return updated
|
||||
|
||||
|
||||
def normalize_appearance_overrides(value: object) -> dict[str, object] | None:
|
||||
"""Validate and canonicalize the versioned, all-or-nothing color contract."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("Appearance overrides must be an object.")
|
||||
if set(value) != {"schema_version", "light", "dark"}:
|
||||
raise ValueError("Appearance overrides must contain only schema_version, light, and dark.")
|
||||
if str(value.get("schema_version")) != APPEARANCE_OVERRIDE_SCHEMA_VERSION:
|
||||
raise ValueError("Unsupported appearance override schema version.")
|
||||
normalized: dict[str, object] = {"schema_version": APPEARANCE_OVERRIDE_SCHEMA_VERSION}
|
||||
for mode in ("light", "dark"):
|
||||
raw_mode = value.get(mode)
|
||||
if not isinstance(raw_mode, Mapping) or set(raw_mode) != set(APPEARANCE_OVERRIDE_TOKENS):
|
||||
raise ValueError(f"Appearance override mode {mode} must define every supported token exactly once.")
|
||||
colors: dict[str, str] = {}
|
||||
for token in APPEARANCE_OVERRIDE_TOKENS:
|
||||
color = str(raw_mode.get(token) or "").strip().lower()
|
||||
if not _HEX_COLOR.fullmatch(color):
|
||||
raise ValueError(f"Appearance override {mode}.{token} must be a six-digit hexadecimal color.")
|
||||
colors[token] = color
|
||||
_validate_mode_accessibility(mode, colors)
|
||||
normalized[mode] = colors
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_mode_accessibility(mode: str, colors: Mapping[str, str]) -> None:
|
||||
pairs = (
|
||||
("accent", "accent_foreground"), ("surface", "surface_foreground"),
|
||||
("success", "success_foreground"), ("info", "info_foreground"),
|
||||
("warning", "warning_foreground"), ("danger", "danger_foreground"),
|
||||
)
|
||||
for background, foreground in pairs:
|
||||
if _contrast_ratio(colors[background], colors[foreground]) < 4.5:
|
||||
raise ValueError(f"Appearance override {mode}.{foreground} must have WCAG AA contrast against {mode}.{background}.")
|
||||
status_colors = [colors[token] for token in _STATUS_TOKENS]
|
||||
for index, first in enumerate(status_colors):
|
||||
for second in status_colors[index + 1:]:
|
||||
if _rgb_distance(first, second) < 12:
|
||||
raise ValueError(f"Appearance override status colors in {mode} must remain visibly distinct.")
|
||||
|
||||
|
||||
def _relative_luminance(color: str) -> float:
|
||||
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
|
||||
linear = [channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 for channel in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
|
||||
def _contrast_ratio(first: str, second: str) -> float:
|
||||
high, low = sorted((_relative_luminance(first), _relative_luminance(second)), reverse=True)
|
||||
return (high + 0.05) / (low + 0.05)
|
||||
|
||||
|
||||
def _rgb_distance(first: str, second: str) -> float:
|
||||
first_channels = [int(first[index:index + 2], 16) for index in (1, 3, 5)]
|
||||
second_channels = [int(second[index:index + 2], 16) for index in (1, 3, 5)]
|
||||
return sum((left - right) ** 2 for left, right in zip(first_channels, second_channels, strict=True)) ** 0.5
|
||||
|
||||
|
||||
def update_appearance_settings(
|
||||
settings: Mapping[str, Any] | None,
|
||||
*,
|
||||
@@ -81,6 +179,15 @@ def resolve_effective_appearance(
|
||||
inherited_palette = tenant_palette or system_palette
|
||||
raw_ui = user_settings.get("ui") if isinstance(user_settings, Mapping) else None
|
||||
user_palette = normalize_appearance_palette(raw_ui.get("palette")) if isinstance(raw_ui, Mapping) else None
|
||||
system_custom_policy = appearance_custom_overrides_policy(system_settings) is True
|
||||
tenant_custom_policy = appearance_custom_overrides_policy(tenant_settings)
|
||||
custom_overrides_allowed = system_custom_policy and tenant_custom_policy is not False and not system_locked and not tenant_locked
|
||||
try:
|
||||
custom_overrides = normalize_appearance_overrides(raw_ui.get("appearance_overrides")) if isinstance(raw_ui, Mapping) else None
|
||||
except ValueError:
|
||||
custom_overrides = None
|
||||
if not custom_overrides_allowed:
|
||||
custom_overrides = None
|
||||
|
||||
if system_locked:
|
||||
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
|
||||
@@ -93,17 +200,24 @@ def resolve_effective_appearance(
|
||||
system_palette,
|
||||
tenant_palette,
|
||||
inherited_palette,
|
||||
custom_overrides,
|
||||
custom_overrides_allowed,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"APPEARANCE_PALETTES",
|
||||
"APPEARANCE_SETTINGS_KEY",
|
||||
"APPEARANCE_OVERRIDE_SCHEMA_VERSION",
|
||||
"APPEARANCE_OVERRIDE_TOKENS",
|
||||
"AppearancePalette",
|
||||
"AppearanceSource",
|
||||
"EffectiveAppearance",
|
||||
"appearance_settings",
|
||||
"appearance_custom_overrides_policy",
|
||||
"normalize_appearance_overrides",
|
||||
"normalize_appearance_palette",
|
||||
"resolve_effective_appearance",
|
||||
"update_appearance_settings",
|
||||
"update_appearance_custom_overrides_policy",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user