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
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.
+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.
- 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.
+1
View File
@@ -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
+12
View File
@@ -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)
+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(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];
}
+14 -4
View File
@@ -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<UserUiPreferences> | 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));
+52 -6
View File
@@ -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<UserUiTheme>(currentUiPreferences.theme);
const [palette, setPalette] = useState<UserUiPalette>(currentUiPreferences.palette);
const [navigation, setNavigation] = useState<NavigationPreferences | null>(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" />
</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">
<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.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.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
</DescriptionList>
<p className="muted small-note">i18n:govoplan-core.advanced_theme_overrides_follow_up</p>
</FormGrid>
</Card>
</ContentGrid>
@@ -616,20 +651,31 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | 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 (
<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}>
<div key={variant} className="theme-preview" data-preview-theme={variant} data-preview-palette={palette}>
<div className="theme-preview-header">
<span>{themeLabel(variant)}</span>
<i />
+24
View File
@@ -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",
+7 -2
View File
@@ -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 {
+34
View File
@@ -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 {
+2
View File
@@ -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;
};