feat: govern effective appearance defaults
This commit is contained in:
+11
-10
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user