From 6643c8fc1ec2ea3b698f9af4829a0052c58d1de2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 06:26:52 +0200 Subject: [PATCH] feat: add validated appearance palettes --- docs/THEMING.md | 22 ++++++-- docs/UI_UX_DECISION_LEDGER.md | 6 +- src/govoplan_core/api/v1/schemas.py | 1 + tests/test_api_smoke.py | 12 ++++ webui/scripts/test-theme-contract.mjs | 27 +++++++++ webui/src/App.tsx | 18 ++++-- webui/src/features/settings/SettingsPage.tsx | 58 ++++++++++++++++++-- webui/src/i18n/generatedTranslations.ts | 24 ++++++++ webui/src/styles/components.css | 9 ++- webui/src/styles/tokens.css | 34 ++++++++++++ webui/src/types.ts | 2 + 11 files changed, 196 insertions(+), 17 deletions(-) diff --git a/docs/THEMING.md b/docs/THEMING.md index b21ee77..f843b51 100644 --- a/docs/THEMING.md +++ b/docs/THEMING.md @@ -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 save time. Core applies the resolved mode through `data-theme` on the document 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 @@ -12,17 +15,28 @@ root and exposes the selected preference through `data-theme-preference`. - Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and the status token families. They may define domain aliases whose values resolve to shared tokens. -- User preference selects the mode. Tenant and system policy may provide a - future default, but must not silently replace an explicit user choice. +- User preference selects the mode and palette. Invalid stored palette values + 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 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 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, 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 production WebUI build. - diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index 925073e..7f0cd62 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -165,13 +165,17 @@ Decision: the WebUI shell exposes a small, stable appearance contract based on shared CSS tokens and persisted user preference selection. - 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`, `--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic status colors, radii, shadows, and disabled-control colors. - 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 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. - Visual preview in settings is illustrative; it must reflect token families, not become a second theme implementation. diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py index 2b2128b..3cbcdb3 100644 --- a/src/govoplan_core/api/v1/schemas.py +++ b/src/govoplan_core/api/v1/schemas.py @@ -110,6 +110,7 @@ class UserUiPreferences(BaseModel): reduce_motion: bool = False sticky_section_sidebars: bool = True theme: Literal["system", "light", "dark"] = "system" + palette: Literal["default", "civic_blue", "forest", "plum"] = "default" navigation: NavigationPreferencesPayload | None = None diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 02f9232..f00945b 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -6464,6 +6464,7 @@ class ApiSmokeTests(unittest.TestCase): "reduce_motion": True, "sticky_section_sidebars": False, "theme": "dark", + "palette": "civic_blue", "navigation": { "contract_version": "1", "order": ["files.navigation.files", "mail.navigation.mail"], @@ -6483,6 +6484,7 @@ class ApiSmokeTests(unittest.TestCase): "reduce_motion": True, "sticky_section_sidebars": False, "theme": "dark", + "palette": "civic_blue", "navigation": { "contract_version": "1", "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) + 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) self.assertEqual(refreshed.status_code, 200, refreshed.text) 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"]["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"]) roles = self.client.get("/api/v1/admin/system/roles", headers=headers) diff --git a/webui/scripts/test-theme-contract.mjs b/webui/scripts/test-theme-contract.mjs index 6e7e0c9..f5c9d72 100644 --- a/webui/scripts/test-theme-contract.mjs +++ b/webui/scripts/test-theme-contract.mjs @@ -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(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\.palette/, "the selected palette must be applied at the document root"); for (const theme of ["system", "light", "dark"]) { 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 = [ "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."); + +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]; +} diff --git a/webui/src/App.tsx b/webui/src/App.tsx index f7e116c..22d96bd 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -3,7 +3,7 @@ import { lazy, useEffect, useMemo, useState } from "react"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth"; import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform"; 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 PublicLandingPage from "./features/auth/PublicLandingPage"; import LoginModal from "./features/auth/LoginModal"; @@ -35,7 +35,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = { show_inline_help_hints: true, reduce_motion: false, sticky_section_sidebars: true, - theme: "system" + theme: "system", + palette: "default" }; 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-reduce-motion", preferences.reduce_motion); 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 applyTheme = () => { @@ -413,7 +415,8 @@ export default function App() { auth?.user.ui_preferences?.show_inline_help_hints, auth?.user.ui_preferences?.reduce_motion, auth?.user.ui_preferences?.sticky_section_sidebars, - auth?.user.ui_preferences?.theme + auth?.user.ui_preferences?.theme, + auth?.user.ui_preferences?.palette ]); useEffect(() => { @@ -716,10 +719,17 @@ function normalizeUiPreferences(value: Partial | null | undef 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), 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[] { if (remoteModules.length === 0) return localModules; const seen = new Set(localModules.map((module) => module.id)); diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index c9993ab..ee2c111 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -2,7 +2,7 @@ import DescriptionList from "../../components/DescriptionList"; import ContentGrid, { FormGrid } from "../../components/ContentGrid"; import { useEffect, useMemo, useState } from "react"; 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 FormField from "../../components/FormField"; import PasswordField from "../../components/PasswordField"; @@ -35,7 +35,8 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = { show_inline_help_hints: true, reduce_motion: false, sticky_section_sidebars: true, - theme: "system" + theme: "system", + palette: "default" }; 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" } ]; +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 = { contextId: "core.settings", documentationType: "user" as const @@ -155,6 +163,7 @@ export default function SettingsPage({ const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion); const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars); const [theme, setTheme] = useState(currentUiPreferences.theme); + const [palette, setPalette] = useState(currentUiPreferences.palette); const [navigation, setNavigation] = useState(currentUiPreferences.navigation ?? null); const [uiBusy, setUiBusy] = useState(false); const [uiResult, setUiResult] = useState(""); @@ -171,6 +180,7 @@ export default function SettingsPage({ reduceMotion !== currentUiPreferences.reduce_motion || stickySections !== currentUiPreferences.sticky_section_sidebars || theme !== currentUiPreferences.theme || + palette !== currentUiPreferences.palette || JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null); useUnsavedDraftGuard({ @@ -225,6 +235,7 @@ export default function SettingsPage({ setReduceMotion(currentUiPreferences.reduce_motion); setStickySections(currentUiPreferences.sticky_section_sidebars); setTheme(currentUiPreferences.theme); + setPalette(currentUiPreferences.palette); setNavigation(currentUiPreferences.navigation ?? null); }, [ currentUiPreferences.compact_tables, @@ -232,6 +243,7 @@ export default function SettingsPage({ currentUiPreferences.reduce_motion, currentUiPreferences.sticky_section_sidebars, currentUiPreferences.theme, + currentUiPreferences.palette, currentUiPreferences.navigation ]); @@ -271,6 +283,7 @@ export default function SettingsPage({ setReduceMotion(currentUiPreferences.reduce_motion); setStickySections(currentUiPreferences.sticky_section_sidebars); setTheme(currentUiPreferences.theme); + setPalette(currentUiPreferences.palette); setNavigation(currentUiPreferences.navigation ?? null); } @@ -281,6 +294,7 @@ export default function SettingsPage({ reduce_motion: reduceMotion, sticky_section_sidebars: stickySections, theme, + palette, navigation }; } @@ -473,15 +487,36 @@ export default function SettingsPage({ width="fill" ariaLabel="i18n:govoplan-core.theme.a797e309" /> - + + + +
+ +
+
i18n:govoplan-core.theme.a797e309
{themeLabel(theme)}
-
i18n:govoplan-core.accent_color.e49578ed
i18n:govoplan-core.default_brand_accent.606ae693
+
i18n:govoplan-core.accent_color.e49578ed
{paletteLabel(palette)}
+
i18n:govoplan-core.accessibility
i18n:govoplan-core.palette_contrast_validated
+
i18n:govoplan-core.advanced_theme_overrides
i18n:govoplan-core.not_configured
i18n:govoplan-core.language.89b86ab0
{languageLabel}
i18n:govoplan-core.enabled.df174a3f
{enabledLanguages.map((item) => item.code.toUpperCase()).join(", ")}
i18n:govoplan-core.available.7c62a142
{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}
i18n:govoplan-core.density.f9160c22
{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}
+

i18n:govoplan-core.advanced_theme_overrides_follow_up

@@ -616,20 +651,31 @@ function normalizeUiPreferences(value: Partial | null | undef 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), theme, + palette: normalizeUiPalette(value?.palette), 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 { 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]; return (
{variants.map((variant) => -
+
{themeLabel(variant)} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 60e7514..94cda6d 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,18 @@ import type { PlatformTranslations } from "../types"; export const generatedTranslations: PlatformTranslations = { "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_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", @@ -689,6 +701,18 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid." }, "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_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", diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 7387ded..07829c7 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -3231,6 +3231,7 @@ --preview-line: var(--theme-preview-light-line); --preview-text: var(--theme-preview-light-text); --preview-muted: var(--theme-preview-light-muted); + --preview-accent: var(--theme-preview-accent-default); display: grid; gap: 0; min-height: 112px; @@ -3240,6 +3241,10 @@ 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"] { --preview-bg: var(--theme-preview-dark-bg); --preview-bar: var(--theme-preview-dark-bar); @@ -3266,8 +3271,8 @@ width: 18px; height: 18px; border-radius: var(--radius-round); - background: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); + background: var(--preview-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--preview-accent) 35%, transparent); } .theme-preview-body { diff --git a/webui/src/styles/tokens.css b/webui/src/styles/tokens.css index be18917..96151f2 100644 --- a/webui/src/styles/tokens.css +++ b/webui/src/styles/tokens.css @@ -242,6 +242,10 @@ --theme-preview-dark-line: #454740; --theme-preview-dark-text: #f6f4ed; --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-border-default: #b9d7d0; @@ -429,6 +433,36 @@ --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 *::before, .ui-reduce-motion *::after { diff --git a/webui/src/types.ts b/webui/src/types.ts index 3e0466c..8331622 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -41,6 +41,7 @@ export type AuthUser = { }; export type UserUiTheme = "system" | "light" | "dark"; +export type UserUiPalette = "default" | "civic_blue" | "forest" | "plum"; export type UserUiPreferences = { compact_tables: boolean; @@ -48,6 +49,7 @@ export type UserUiPreferences = { reduce_motion: boolean; sticky_section_sidebars: boolean; theme: UserUiTheme; + palette: UserUiPalette; navigation?: NavigationPreferences | null; };