feat: add validated appearance palettes

This commit is contained in:
2026-08-20 06:26:52 +02:00
parent fd90b60430
commit 6643c8fc1e
11 changed files with 196 additions and 17 deletions
+18 -4
View File
@@ -4,6 +4,9 @@ GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
`system` follows `prefers-color-scheme` live; it is not resolved permanently at `system` follows `prefers-color-scheme` live; it is not resolved permanently at
save time. Core applies the resolved mode through `data-theme` on the document save time. Core applies the resolved mode through `data-theme` on the document
root and exposes the selected preference through `data-theme-preference`. root and exposes the selected preference through `data-theme-preference`.
Each user may also choose a validated `default`, `civic_blue`, `forest`, or
`plum` accent palette. Core applies it through `data-palette`; every module
inherits the result through semantic tokens without module-specific CSS.
## Ownership ## Ownership
@@ -12,17 +15,28 @@ root and exposes the selected preference through `data-theme-preference`.
- 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. Tenant and system policy may provide a - User preference selects the mode and palette. Invalid stored palette values
future default, but must not silently replace an explicit user choice. fail safely to `default`; the profile API accepts only the supported preset
identifiers. Tenant and system policy may provide a future default, but must
not silently replace an explicit user choice.
- 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
The current slice is intentionally user-level. The Settings preview shows the
chosen accent in every applicable light/dark preview before Save, and Reset
palette returns the draft to the GovOPlaN default before persistence. Presets
are checked for WCAG AA contrast in the theme contract. Arbitrary token
overrides, tenant/system defaults, branding import/export, and policy locks are
not inferred from this preference and require their own governed follow-up.
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 the root behavior and representative `npm run test:theme-contract` verifies root mode/palette behavior, preset
contrast, 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.
+5 -1
View File
@@ -165,13 +165,17 @@ Decision: the WebUI shell exposes a small, stable appearance contract based on
shared CSS tokens and persisted user preference selection. shared CSS tokens and persisted user preference selection.
- Core applies `system`, `light`, and `dark` preferences at the document root. - Core applies `system`, `light`, and `dark` preferences at the document root.
- Core applies validated user accent presets through `data-palette`; palette
values change semantic tokens globally and never require module CSS changes.
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`, - Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic `--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
status colors, radii, shadows, and disabled-control colors. status colors, radii, shadows, and disabled-control colors.
- 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. Tenant defaults and policy - Appearance controls live in user settings first. The user preference wins
over future tenant and system defaults unless a separately documented policy
lock is introduced. Tenant defaults and policy
enforcement can be added later without changing the token contract. enforcement can be added later without changing the token contract.
- 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.
+1
View File
@@ -110,6 +110,7 @@ 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"
navigation: NavigationPreferencesPayload | None = None navigation: NavigationPreferencesPayload | None = None
+12
View File
@@ -6464,6 +6464,7 @@ class ApiSmokeTests(unittest.TestCase):
"reduce_motion": True, "reduce_motion": True,
"sticky_section_sidebars": False, "sticky_section_sidebars": False,
"theme": "dark", "theme": "dark",
"palette": "civic_blue",
"navigation": { "navigation": {
"contract_version": "1", "contract_version": "1",
"order": ["files.navigation.files", "mail.navigation.mail"], "order": ["files.navigation.files", "mail.navigation.mail"],
@@ -6483,6 +6484,7 @@ class ApiSmokeTests(unittest.TestCase):
"reduce_motion": True, "reduce_motion": True,
"sticky_section_sidebars": False, "sticky_section_sidebars": False,
"theme": "dark", "theme": "dark",
"palette": "civic_blue",
"navigation": { "navigation": {
"contract_version": "1", "contract_version": "1",
"order": ["files.navigation.files", "mail.navigation.mail"], "order": ["files.navigation.files", "mail.navigation.mail"],
@@ -6507,11 +6509,21 @@ class ApiSmokeTests(unittest.TestCase):
}, },
) )
self.assertEqual(rejected_lock.status_code, 422, rejected_lock.text) self.assertEqual(rejected_lock.status_code, 422, rejected_lock.text)
rejected_palette = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"palette": "low_contrast_custom"}},
)
self.assertEqual(rejected_palette.status_code, 422, rejected_palette.text)
refreshed = self.client.get("/api/v1/auth/me", headers=headers) refreshed = self.client.get("/api/v1/auth/me", headers=headers)
self.assertEqual(refreshed.status_code, 200, refreshed.text) self.assertEqual(refreshed.status_code, 200, refreshed.text)
self.assertEqual(refreshed.json()["user"]["display_name"], "Global Account Name") self.assertEqual(refreshed.json()["user"]["display_name"], "Global Account Name")
self.assertEqual(refreshed.json()["user"]["tenant_display_name"], "Tenant Alias") self.assertEqual(refreshed.json()["user"]["tenant_display_name"], "Tenant Alias")
self.assertEqual(refreshed.json()["user"]["ui_preferences"]["theme"], "dark") self.assertEqual(refreshed.json()["user"]["ui_preferences"]["theme"], "dark")
self.assertEqual(
refreshed.json()["user"]["ui_preferences"]["palette"],
"civic_blue",
)
self.assertFalse(refreshed.json()["user"]["ui_preferences"]["show_inline_help_hints"]) self.assertFalse(refreshed.json()["user"]["ui_preferences"]["show_inline_help_hints"])
roles = self.client.get("/api/v1/admin/system/roles", headers=headers) roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
+27
View File
@@ -12,9 +12,24 @@ assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are req
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");
assert.match(app, /matchMedia\?\.\("\(prefers-color-scheme: dark\)"\)/, "system theme must follow OS changes"); assert.match(app, /matchMedia\?\.\("\(prefers-color-scheme: dark\)"\)/, "system theme must follow OS changes");
assert.match(app, /dataset\.themePreference/, "the selected preference must remain inspectable"); assert.match(app, /dataset\.themePreference/, "the selected preference must remain inspectable");
assert.match(app, /dataset\.palette/, "the selected palette must be applied at the document root");
for (const theme of ["system", "light", "dark"]) { 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"]) {
assert.match(settings, new RegExp(`value:\\s*"${palette}"`), `Settings must expose ${palette}`);
}
const paletteContrasts = [
{ selector: "civic_blue", accent: "#245f91", foreground: "#ffffff" },
{ selector: "forest", accent: "#276749", foreground: "#ffffff" },
{ selector: "plum", accent: "#704b78", foreground: "#ffffff" }
];
for (const palette of paletteContrasts) {
assert.match(tokens, new RegExp(`data-palette="${palette.selector}"[\\s\\S]*?--accent:\\s*${palette.accent}`, "i"));
assert.ok(contrastRatio(palette.accent, palette.foreground) >= 4.5, `${palette.selector} accent contrast must meet WCAG AA`);
}
assert.ok(contrastRatio("#ef6b3a", "#242424") >= 4.5, "default accent badge contrast must meet WCAG AA");
const representativeModules = [ const representativeModules = [
"govoplan-campaign/webui/src/styles/campaign-workspace.css", "govoplan-campaign/webui/src/styles/campaign-workspace.css",
@@ -28,3 +43,15 @@ for (const relativePath of representativeModules) {
} }
console.log("Theme contract passed for core and representative module surfaces."); console.log("Theme contract passed for core and representative module surfaces.");
function contrastRatio(first, second) {
const left = relativeLuminance(first);
const right = relativeLuminance(second);
return (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05);
}
function relativeLuminance(color) {
const channels = color.slice(1).match(/../g).map((value) => parseInt(value, 16) / 255);
const linear = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
+14 -4
View File
@@ -3,7 +3,7 @@ import { lazy, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform"; import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client"; import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences, ViewsRuntimeUiCapability } from "./types"; import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
import AppShell from "./layout/AppShell"; import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
@@ -35,7 +35,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
show_inline_help_hints: true, show_inline_help_hints: true,
reduce_motion: false, reduce_motion: false,
sticky_section_sidebars: true, sticky_section_sidebars: true,
theme: "system" theme: "system",
palette: "default"
}; };
export default function App() { export default function App() {
@@ -389,6 +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);
const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null; const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
const applyTheme = () => { const applyTheme = () => {
@@ -413,7 +415,8 @@ export default function App() {
auth?.user.ui_preferences?.show_inline_help_hints, auth?.user.ui_preferences?.show_inline_help_hints,
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
]); ]);
useEffect(() => { useEffect(() => {
@@ -716,10 +719,17 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints), show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints),
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)
}; };
} }
function normalizeUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette {
return value === "civic_blue" || value === "forest" || value === "plum"
? value
: "default";
}
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));
+52 -6
View File
@@ -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, UserUiPreferences, UserUiTheme } from "../../types"; import type { 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";
@@ -35,7 +35,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
show_inline_help_hints: true, show_inline_help_hints: true,
reduce_motion: false, reduce_motion: false,
sticky_section_sidebars: true, sticky_section_sidebars: true,
theme: "system" theme: "system",
palette: "default"
}; };
const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
@@ -44,6 +45,13 @@ 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
@@ -155,6 +163,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 [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("");
@@ -171,6 +180,7 @@ export default function SettingsPage({
reduceMotion !== currentUiPreferences.reduce_motion || reduceMotion !== currentUiPreferences.reduce_motion ||
stickySections !== currentUiPreferences.sticky_section_sidebars || stickySections !== currentUiPreferences.sticky_section_sidebars ||
theme !== currentUiPreferences.theme || theme !== currentUiPreferences.theme ||
palette !== currentUiPreferences.palette ||
JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null); JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null);
useUnsavedDraftGuard({ useUnsavedDraftGuard({
@@ -225,6 +235,7 @@ export default function SettingsPage({
setReduceMotion(currentUiPreferences.reduce_motion); setReduceMotion(currentUiPreferences.reduce_motion);
setStickySections(currentUiPreferences.sticky_section_sidebars); setStickySections(currentUiPreferences.sticky_section_sidebars);
setTheme(currentUiPreferences.theme); setTheme(currentUiPreferences.theme);
setPalette(currentUiPreferences.palette);
setNavigation(currentUiPreferences.navigation ?? null); setNavigation(currentUiPreferences.navigation ?? null);
}, [ }, [
currentUiPreferences.compact_tables, currentUiPreferences.compact_tables,
@@ -232,6 +243,7 @@ export default function SettingsPage({
currentUiPreferences.reduce_motion, currentUiPreferences.reduce_motion,
currentUiPreferences.sticky_section_sidebars, currentUiPreferences.sticky_section_sidebars,
currentUiPreferences.theme, currentUiPreferences.theme,
currentUiPreferences.palette,
currentUiPreferences.navigation currentUiPreferences.navigation
]); ]);
@@ -271,6 +283,7 @@ export default function SettingsPage({
setReduceMotion(currentUiPreferences.reduce_motion); setReduceMotion(currentUiPreferences.reduce_motion);
setStickySections(currentUiPreferences.sticky_section_sidebars); setStickySections(currentUiPreferences.sticky_section_sidebars);
setTheme(currentUiPreferences.theme); setTheme(currentUiPreferences.theme);
setPalette(currentUiPreferences.palette);
setNavigation(currentUiPreferences.navigation ?? null); setNavigation(currentUiPreferences.navigation ?? null);
} }
@@ -281,6 +294,7 @@ export default function SettingsPage({
reduce_motion: reduceMotion, reduce_motion: reduceMotion,
sticky_section_sidebars: stickySections, sticky_section_sidebars: stickySections,
theme, theme,
palette,
navigation navigation
}; };
} }
@@ -473,15 +487,36 @@ export default function SettingsPage({
width="fill" width="fill"
ariaLabel="i18n:govoplan-core.theme.a797e309" /> ariaLabel="i18n:govoplan-core.theme.a797e309" />
</FormField> </FormField>
<ThemePreview theme={theme} /> <FormField
label="i18n:govoplan-core.color_palette"
help="i18n:govoplan-core.color_palette_help"
>
<select
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>
<div className="button-row compact-actions">
<Button onClick={() => setPalette("default")} disabled={palette === "default"}>
i18n:govoplan-core.reset_palette
</Button>
</div>
<ThemePreview theme={theme} palette={palette} />
<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>i18n:govoplan-core.default_brand_accent.606ae693</dd></div> <div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>{paletteLabel(palette)}</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.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>
</FormGrid> </FormGrid>
</Card> </Card>
</ContentGrid> </ContentGrid>
@@ -616,20 +651,31 @@ 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),
navigation: value?.navigation ?? null navigation: value?.navigation ?? null
}; };
} }
function normalizeUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette {
return UI_PALETTE_OPTIONS.some((item) => item.value === value)
? value as UserUiPalette
: "default";
}
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 ThemePreview({ theme }: {theme: UserUiTheme;}) { function paletteLabel(value: UserUiPalette): string {
return UI_PALETTE_OPTIONS.find((item) => item.value === value)?.label ?? UI_PALETTE_OPTIONS[0].label;
}
function ThemePreview({ theme, palette }: {theme: UserUiTheme;palette: UserUiPalette;}) {
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme]; const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme];
return ( return (
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309"> <div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309">
{variants.map((variant) => {variants.map((variant) =>
<div key={variant} className="theme-preview" data-preview-theme={variant}> <div key={variant} className="theme-preview" data-preview-theme={variant} data-preview-palette={palette}>
<div className="theme-preview-header"> <div className="theme-preview-header">
<span>{themeLabel(variant)}</span> <span>{themeLabel(variant)}</span>
<i /> <i />
+24
View File
@@ -2,6 +2,18 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-core.color_palette": "Color palette",
"i18n:govoplan-core.color_palette_help": "Choose a validated accent palette. It applies to every module through shared semantic tokens.",
"i18n:govoplan-core.palette_default": "GovOPlaN default",
"i18n:govoplan-core.palette_civic_blue": "Civic blue",
"i18n:govoplan-core.palette_forest": "Forest",
"i18n:govoplan-core.palette_plum": "Plum",
"i18n:govoplan-core.reset_palette": "Reset palette",
"i18n:govoplan-core.accessibility": "Accessibility",
"i18n:govoplan-core.palette_contrast_validated": "Validated preset contrast",
"i18n:govoplan-core.advanced_theme_overrides": "Advanced custom overrides",
"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.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",
@@ -689,6 +701,18 @@ 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.color_palette": "Farbpalette",
"i18n:govoplan-core.color_palette_help": "Wählen Sie eine geprüfte Akzentpalette. Sie gilt über gemeinsame semantische Tokens für alle Module.",
"i18n:govoplan-core.palette_default": "GovOPlaN-Standard",
"i18n:govoplan-core.palette_civic_blue": "Kommunalblau",
"i18n:govoplan-core.palette_forest": "Waldgrün",
"i18n:govoplan-core.palette_plum": "Pflaume",
"i18n:govoplan-core.reset_palette": "Palette zurücksetzen",
"i18n:govoplan-core.accessibility": "Barrierefreiheit",
"i18n:govoplan-core.palette_contrast_validated": "Geprüfter Kontrast der Vorgabe",
"i18n:govoplan-core.advanced_theme_overrides": "Erweiterte benutzerdefinierte Anpassungen",
"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.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",
+7 -2
View File
@@ -3231,6 +3231,7 @@
--preview-line: var(--theme-preview-light-line); --preview-line: var(--theme-preview-light-line);
--preview-text: var(--theme-preview-light-text); --preview-text: var(--theme-preview-light-text);
--preview-muted: var(--theme-preview-light-muted); --preview-muted: var(--theme-preview-light-muted);
--preview-accent: var(--theme-preview-accent-default);
display: grid; display: grid;
gap: 0; gap: 0;
min-height: 112px; min-height: 112px;
@@ -3240,6 +3241,10 @@
background: var(--preview-bg); background: var(--preview-bg);
} }
.theme-preview[data-preview-palette="civic_blue"] { --preview-accent: var(--theme-preview-accent-civic-blue); }
.theme-preview[data-preview-palette="forest"] { --preview-accent: var(--theme-preview-accent-forest); }
.theme-preview[data-preview-palette="plum"] { --preview-accent: var(--theme-preview-accent-plum); }
.theme-preview[data-preview-theme="dark"] { .theme-preview[data-preview-theme="dark"] {
--preview-bg: var(--theme-preview-dark-bg); --preview-bg: var(--theme-preview-dark-bg);
--preview-bar: var(--theme-preview-dark-bar); --preview-bar: var(--theme-preview-dark-bar);
@@ -3266,8 +3271,8 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: var(--radius-round); border-radius: var(--radius-round);
background: var(--accent); background: var(--preview-accent);
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 3px color-mix(in srgb, var(--preview-accent) 35%, transparent);
} }
.theme-preview-body { .theme-preview-body {
+34
View File
@@ -242,6 +242,10 @@
--theme-preview-dark-line: #454740; --theme-preview-dark-line: #454740;
--theme-preview-dark-text: #f6f4ed; --theme-preview-dark-text: #f6f4ed;
--theme-preview-dark-muted: #aaa79d; --theme-preview-dark-muted: #aaa79d;
--theme-preview-accent-default: #ef6b3a;
--theme-preview-accent-civic-blue: #245f91;
--theme-preview-accent-forest: #276749;
--theme-preview-accent-plum: #704b78;
--calendar-event-bg-default: #edf8f5; --calendar-event-bg-default: #edf8f5;
--calendar-event-border-default: #b9d7d0; --calendar-event-border-default: #b9d7d0;
@@ -429,6 +433,36 @@
--campaign-panel-bg: rgba(38, 39, 36, .94); --campaign-panel-bg: rgba(38, 39, 36, .94);
} }
:root[data-palette="civic_blue"] {
--accent: #245f91;
--accent-rgb: 36, 95, 145;
--accent-soft: rgba(36, 95, 145, .35);
--accent-hover-bg: rgba(36, 95, 145, .14);
--accent-auth-glow: rgba(36, 95, 145, .16);
--accent-ring-soft: 0 0 0 2px rgba(36, 95, 145, .20);
--badge-accent-text: #ffffff;
}
:root[data-palette="forest"] {
--accent: #276749;
--accent-rgb: 39, 103, 73;
--accent-soft: rgba(39, 103, 73, .35);
--accent-hover-bg: rgba(39, 103, 73, .14);
--accent-auth-glow: rgba(39, 103, 73, .16);
--accent-ring-soft: 0 0 0 2px rgba(39, 103, 73, .20);
--badge-accent-text: #ffffff;
}
:root[data-palette="plum"] {
--accent: #704b78;
--accent-rgb: 112, 75, 120;
--accent-soft: rgba(112, 75, 120, .35);
--accent-hover-bg: rgba(112, 75, 120, .14);
--accent-auth-glow: rgba(112, 75, 120, .16);
--accent-ring-soft: 0 0 0 2px rgba(112, 75, 120, .20);
--badge-accent-text: #ffffff;
}
.ui-reduce-motion *, .ui-reduce-motion *,
.ui-reduce-motion *::before, .ui-reduce-motion *::before,
.ui-reduce-motion *::after { .ui-reduce-motion *::after {
+2
View File
@@ -41,6 +41,7 @@ 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 UserUiPreferences = { export type UserUiPreferences = {
compact_tables: boolean; compact_tables: boolean;
@@ -48,6 +49,7 @@ export type UserUiPreferences = {
reduce_motion: boolean; reduce_motion: boolean;
sticky_section_sidebars: boolean; sticky_section_sidebars: boolean;
theme: UserUiTheme; theme: UserUiTheme;
palette: UserUiPalette;
navigation?: NavigationPreferences | null; navigation?: NavigationPreferences | null;
}; };