feat: govern effective appearance defaults

This commit is contained in:
2026-08-20 07:03:28 +02:00
parent 6643c8fc1e
commit 8f642bd618
13 changed files with 429 additions and 61 deletions
+9 -4
View File
@@ -36,7 +36,7 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
reduce_motion: false,
sticky_section_sidebars: true,
theme: "system",
palette: "default"
palette: null
};
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-reduce-motion", preferences.reduce_motion);
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 applyTheme = () => {
@@ -416,7 +416,8 @@ export default function App() {
auth?.user.ui_preferences?.reduce_motion,
auth?.user.ui_preferences?.sticky_section_sidebars,
auth?.user.ui_preferences?.theme,
auth?.user.ui_preferences?.palette
auth?.user.ui_preferences?.palette,
auth?.user.appearance?.palette
]);
useEffect(() => {
@@ -720,7 +721,7 @@ 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)
palette: normalizeOptionalUiPalette(value?.palette)
};
}
@@ -730,6 +731,10 @@ function normalizeUiPalette(value: UserUiPalette | string | null | undefined): U
: "default";
}
function normalizeOptionalUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette | null {
return value == null ? null : normalizeUiPalette(value);
}
function mergeWebModules(localModules: PlatformWebModule[], remoteModules: PlatformWebModule[]): PlatformWebModule[] {
if (remoteModules.length === 0) return localModules;
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;
}
+24 -42
View File
@@ -27,6 +27,7 @@ import { usePlatformLanguage } from "../../i18n/LanguageContext";
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
import DocumentationHelpLink from "../../components/help/DocumentationHelpLink";
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;
@@ -36,7 +37,7 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
reduce_motion: false,
sticky_section_sidebars: true,
theme: "system",
palette: "default"
palette: null
};
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" }
];
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
@@ -163,7 +157,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 [palette, setPalette] = useState<UserUiPalette | null>(currentUiPreferences.palette);
const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null);
const [uiBusy, setUiBusy] = useState(false);
const [uiResult, setUiResult] = useState("");
@@ -491,24 +485,18 @@ export default function SettingsPage({
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>
<AppearancePaletteSelect value={palette} onChange={setPalette} allowInherit disabled={auth.user.appearance?.locked === true} />
</FormField>
<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
</Button>
</div>
<ThemePreview theme={theme} palette={palette} />
<AppearancePalettePreview theme={theme} palette={palette ?? auth.user.appearance?.inherited_palette ?? "default"} />
<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>{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.advanced_theme_overrides</dt><dd>i18n:govoplan-core.not_configured</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),
sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars),
theme,
palette: normalizeUiPalette(value?.palette),
palette: normalizeOptionalUiPalette(value?.palette),
navigation: value?.navigation ?? null
};
}
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
: "default";
}
function normalizeOptionalUiPalette(value: UserUiPalette | string | null | undefined): UserUiPalette | null {
return value == null ? null : normalizeUiPalette(value);
}
function themeLabel(value: UserUiTheme): string {
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
}
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;}) {
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>{themeLabel(variant)}</span>
<i />
</div>
<div className="theme-preview-body">
<strong>GovOPlaN</strong>
<span />
<span />
</div>
</div>
)}
</div>);
function appearanceSourceLabel(value: string | null | undefined): string {
const labels: Record<string, string> = {
user: "i18n:govoplan-core.appearance_source_user",
tenant: "i18n:govoplan-core.appearance_source_tenant",
system: "i18n:govoplan-core.appearance_source_system",
tenant_lock: "i18n:govoplan-core.appearance_source_tenant_lock",
system_lock: "i18n:govoplan-core.appearance_source_system_lock"
};
return labels[value ?? "system"] ?? labels.system;
}
+14
View File
@@ -2,6 +2,13 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = {
"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_help": "Choose a validated accent palette. It applies to every module through shared semantic tokens.",
"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."
},
"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_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",
+1
View File
@@ -61,6 +61,7 @@ export type { AdminPageLayoutProps } from "./components/admin/AdminPageLayout";
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
export { default as Button } from "./components/Button";
export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "./components/AppearancePaletteControl";
export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card";
export type { CardProps } from "./components/Card";
+11 -1
View File
@@ -38,6 +38,7 @@ export type AuthUser = {
preferred_language?: string | null;
enabled_language_codes?: string[];
ui_preferences?: UserUiPreferences;
appearance?: EffectiveAppearance;
};
export type UserUiTheme = "system" | "light" | "dark";
@@ -49,10 +50,19 @@ export type UserUiPreferences = {
reduce_motion: boolean;
sticky_section_sidebars: boolean;
theme: UserUiTheme;
palette: UserUiPalette;
palette: UserUiPalette | 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 = {
id: string;
slug: string;