portail-associations/shared/appearance.ts

764 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const appearanceThemeModes = ["light", "dark", "system"] as const;
export const appearanceFontFamilies = ["marianne", "system", "rounded", "serif"] as const;
export const appearanceCardStyles = ["soft", "outlined", "elevated"] as const;
export const appearanceTableStyles = ["comfortable", "compact", "striped"] as const;
export const appearanceDisplayDensities = ["comfortable", "compact", "detailed"] as const;
export const appearanceSidebarStyles = ["soft", "contrast", "floating"] as const;
export const appearanceButtonStyles = ["rounded", "pill", "square"] as const;
export const appearanceInputStyles = ["outline", "soft", "filled"] as const;
export const appearanceBackgroundModes = ["cover", "repeat", "fixed"] as const;
export const appearanceBackgroundPresentationModes = ["full", "edge_frame"] as const;
export const appearanceDecorationScopes = [
"all",
"home",
"public",
"public_directory",
"public_map",
"dashboard",
"dashboard_home",
"dashboard_profile",
"dashboard_documents",
"dashboard_requests",
"dashboard_new_request",
"dashboard_reservation_salle",
"dashboard_material",
"dashboard_directory",
"admin",
"admin_pilotage",
"admin_logistics",
"admin_directory",
"admin_compliance",
] as const;
export const appearanceDecorationLayers = ["background", "card"] as const;
export const appearanceCardDecorationAnchors = ["top_left", "top_right", "bottom_left", "bottom_right"] as const;
export const appearanceBackgroundPositions = [
"center",
"top",
"bottom",
"left",
"right",
"top_left",
"top_right",
"bottom_left",
"bottom_right",
] as const;
export const appearanceBackgroundTargets = [
"home",
"public",
"public_directory",
"public_map",
"dashboard",
"dashboard_home",
"dashboard_profile",
"dashboard_documents",
"dashboard_requests",
"dashboard_new_request",
"dashboard_reservation_salle",
"dashboard_material",
"dashboard_directory",
"admin",
"admin_pilotage",
"admin_logistics",
"admin_directory",
"admin_compliance",
] as const;
export const appearanceAssetFields = ["logoUrl", "faviconUrl", "heroImageUrl", "backgroundImageUrl"] as const;
export type AppearanceThemeMode = (typeof appearanceThemeModes)[number];
export type AppearanceFontFamily = (typeof appearanceFontFamilies)[number];
export type AppearanceCardStyle = (typeof appearanceCardStyles)[number];
export type AppearanceTableStyle = (typeof appearanceTableStyles)[number];
export type AppearanceDisplayDensity = (typeof appearanceDisplayDensities)[number];
export type AppearanceSidebarStyle = (typeof appearanceSidebarStyles)[number];
export type AppearanceButtonStyle = (typeof appearanceButtonStyles)[number];
export type AppearanceInputStyle = (typeof appearanceInputStyles)[number];
export type AppearanceBackgroundMode = (typeof appearanceBackgroundModes)[number];
export type AppearanceBackgroundPresentationMode = (typeof appearanceBackgroundPresentationModes)[number];
export type AppearanceDecorationScope = (typeof appearanceDecorationScopes)[number];
export type AppearanceDecorationLayer = (typeof appearanceDecorationLayers)[number];
export type AppearanceCardDecorationAnchor = (typeof appearanceCardDecorationAnchors)[number];
export type AppearanceBackgroundPosition = (typeof appearanceBackgroundPositions)[number];
export type AppearanceBackgroundTarget = (typeof appearanceBackgroundTargets)[number];
export type AppearanceAssetField = (typeof appearanceAssetFields)[number];
export interface PortalAppearanceDecoration {
id: string;
imageUrl: string;
scope: AppearanceDecorationScope;
layer: AppearanceDecorationLayer;
widthPercent: number;
topPercent: number;
leftPercent: number;
opacity: number;
blurRadius: number;
cardAnchor: AppearanceCardDecorationAnchor;
cardInsetPercent: number;
}
export interface PortalAppearance {
themeMode: AppearanceThemeMode;
activeSkinId: string;
portalTitle: string;
portalTagline: string;
primaryColor: string;
secondaryColor: string;
accentColor: string;
buttonColor: string;
alertColor: string;
fontFamily: AppearanceFontFamily;
headingFontFamily: AppearanceFontFamily;
headingScalePercent: number;
bodyScalePercent: number;
logoUrl: string;
faviconUrl: string;
heroImageUrl: string;
backgroundImageUrl: string;
backgroundEnabled: boolean;
backgroundMode: AppearanceBackgroundMode;
backgroundPresentationMode: AppearanceBackgroundPresentationMode;
backgroundOverlayOpacity: number;
backgroundBlurRadius: number;
backgroundTargets: AppearanceBackgroundTarget[];
backgroundTargetImageUrls: Partial<Record<AppearanceBackgroundTarget, string>>;
backgroundTargetOverlayOpacities: Partial<Record<AppearanceBackgroundTarget, number>>;
backgroundTargetBlurRadii: Partial<Record<AppearanceBackgroundTarget, number>>;
backgroundTargetPositions: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPosition>>;
backgroundTargetPresentationModes: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPresentationMode>>;
heroImagePositionX: number;
heroImagePositionY: number;
heroOverlayOpacity: number;
heroTitlePositionX: number;
heroTitlePositionY: number;
heroTitleWidthPercent: number;
heroTaglinePositionX: number;
heroTaglinePositionY: number;
heroTaglineWidthPercent: number;
decorativeElements: PortalAppearanceDecoration[];
cardStyle: AppearanceCardStyle;
tableStyle: AppearanceTableStyle;
displayDensity: AppearanceDisplayDensity;
sidebarStyle: AppearanceSidebarStyle;
buttonStyle: AppearanceButtonStyle;
inputStyle: AppearanceInputStyle;
}
export interface AppearanceSkinPreset {
id: string;
label: string;
description: string;
category: string;
usage: string[];
value: Partial<PortalAppearance>;
}
export const APPEARANCE_SETTING_KEY = "appearance";
export const defaultPortalAppearance: PortalAppearance = {
themeMode: "light",
activeSkinId: "guyane-institutionnelle",
portalTitle: "Portail Associations 973",
portalTagline: "Démarches, annuaire et services du tissu associatif guyanais",
primaryColor: "#2e7d32",
secondaryColor: "#1565c0",
accentColor: "#f9a825",
buttonColor: "#1565c0",
alertColor: "#dc2626",
fontFamily: "marianne",
headingFontFamily: "marianne",
headingScalePercent: 100,
bodyScalePercent: 100,
logoUrl: "",
faviconUrl: "",
heroImageUrl: "",
backgroundImageUrl: "",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 72,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {},
backgroundTargetBlurRadii: {},
backgroundTargetPositions: {},
backgroundTargetPresentationModes: {},
heroImagePositionX: 50,
heroImagePositionY: 50,
heroOverlayOpacity: 74,
heroTitlePositionX: 0,
heroTitlePositionY: 0,
heroTitleWidthPercent: 50,
heroTaglinePositionX: 0,
heroTaglinePositionY: 0,
heroTaglineWidthPercent: 80,
decorativeElements: [],
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "comfortable",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
};
export const appearanceSkinPresets: AppearanceSkinPreset[] = [
{
id: "guyane-institutionnelle",
label: "Guyane institutionnelle",
description: "Palette ancrée dans le territoire, pensée pour les collectivités, services publics et fédérations.",
category: "Institutionnel",
usage: ["collectivités", "mairies", "fédérations", "services publics"],
value: {
activeSkinId: "guyane-institutionnelle",
portalTitle: "Portail Associations 973",
portalTagline: "Lespace territorial des démarches associatives en Guyane",
primaryColor: "#2e7d32",
secondaryColor: "#1565c0",
accentColor: "#f9a825",
buttonColor: "#1565c0",
alertColor: "#c62828",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "comfortable",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 72,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "centre-spatial-guyanais",
label: "Centre spatial guyanais",
description: "Ambiance technologique et institutionnelle, inspirée du spatial, de linnovation et des grands projets.",
category: "Spatial",
usage: ["innovation", "recherche", "éducation", "projets scientifiques"],
value: {
activeSkinId: "centre-spatial-guyanais",
portalTitle: "Portail Associations 973",
portalTagline: "Innovation, projets et coopérations du territoire guyanais",
primaryColor: "#0b3d91",
secondaryColor: "#1f5fbf",
accentColor: "#f6b317",
buttonColor: "#155eef",
alertColor: "#d92d20",
fontFamily: "system",
headingFontFamily: "rounded",
cardStyle: "elevated",
tableStyle: "striped",
displayDensity: "compact",
sidebarStyle: "contrast",
buttonStyle: "pill",
inputStyle: "soft",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 68,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "nature-amazonienne",
label: "Nature amazonienne",
description: "Univers plus organique, centré sur la biodiversité, lenvironnement et les initiatives de terrain.",
category: "Territoire",
usage: ["environnement", "tourisme", "biodiversité", "éducation populaire"],
value: {
activeSkinId: "nature-amazonienne",
portalTitle: "Portail Associations 973",
portalTagline: "Les initiatives guyanaises entre nature, territoire et engagement",
primaryColor: "#1b5e20",
secondaryColor: "#4caf50",
accentColor: "#0277bd",
buttonColor: "#2e7d32",
alertColor: "#d84315",
fontFamily: "serif",
headingFontFamily: "serif",
cardStyle: "outlined",
tableStyle: "comfortable",
displayDensity: "detailed",
sidebarStyle: "floating",
buttonStyle: "rounded",
inputStyle: "filled",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 70,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "republicain",
label: "Républicain",
description: "Lecture claire, signal institutionnel fort, pour les espaces administratifs et les usages plus officiels.",
category: "Administration",
usage: ["préfecture", "administration", "services publics", "collectivités"],
value: {
activeSkinId: "republicain",
portalTitle: "Portail Associations 973",
portalTagline: "Un portail administratif lisible, fiable et immédiatement identifiable",
primaryColor: "#0055a4",
secondaryColor: "#ffffff",
accentColor: "#ef4135",
buttonColor: "#0055a4",
alertColor: "#b42318",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "outlined",
tableStyle: "compact",
displayDensity: "compact",
sidebarStyle: "contrast",
buttonStyle: "square",
inputStyle: "outline",
backgroundEnabled: false,
backgroundMode: "cover",
backgroundPresentationMode: "full",
backgroundOverlayOpacity: 78,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
themeMode: "light",
},
},
{
id: "institution-immersif",
label: "Institution immersif",
description: "Preset prêt à lemploi pour un fond premium lisible, avec centre neutre et bords illustrés renforcés.",
category: "Immersif",
usage: ["dashboard", "portail institutionnel", "parcours connectés", "habillage premium"],
value: {
activeSkinId: "institution-immersif",
portalTitle: "Portail Associations 973",
portalTagline: "Un portail territorial clair, immersif et immédiatement lisible",
primaryColor: "#2e7d32",
secondaryColor: "#1565c0",
accentColor: "#f9a825",
buttonColor: "#1565c0",
alertColor: "#c62828",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "compact",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
backgroundEnabled: true,
backgroundMode: "cover",
backgroundPresentationMode: "edge_frame",
backgroundOverlayOpacity: 62,
backgroundBlurRadius: 0,
backgroundTargets: ["home", "public", "dashboard", "admin"],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {
home: 58,
public: 64,
dashboard: 60,
admin: 68,
},
backgroundTargetPositions: {
home: "center",
public: "center",
dashboard: "right",
admin: "right",
},
backgroundTargetPresentationModes: {
home: "edge_frame",
public: "edge_frame",
dashboard: "edge_frame",
admin: "edge_frame",
},
themeMode: "light",
},
},
{
id: "parcours-guide-immersif",
label: "Parcours guidé immersif",
description: "Met en avant les parcours, les formulaires et les cartes avec un habillage plus narratif et très lisible.",
category: "Immersif",
usage: ["nouvelle demande", "réservation", "matériel", "parcours guidés"],
value: {
activeSkinId: "parcours-guide-immersif",
portalTitle: "Portail Associations 973",
portalTagline: "Des démarches plus claires, étape par étape",
primaryColor: "#2f9e44",
secondaryColor: "#1d4ed8",
accentColor: "#f6c453",
buttonColor: "#2563eb",
alertColor: "#c2410c",
fontFamily: "marianne",
headingFontFamily: "rounded",
cardStyle: "soft",
tableStyle: "comfortable",
displayDensity: "compact",
sidebarStyle: "floating",
buttonStyle: "pill",
inputStyle: "soft",
backgroundEnabled: true,
backgroundMode: "cover",
backgroundPresentationMode: "edge_frame",
backgroundOverlayOpacity: 56,
backgroundBlurRadius: 1,
backgroundTargets: [
"dashboard",
"dashboard_home",
"dashboard_new_request",
"dashboard_reservation_salle",
"dashboard_material",
],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {
dashboard: 58,
dashboard_home: 54,
dashboard_new_request: 52,
dashboard_reservation_salle: 56,
dashboard_material: 56,
},
backgroundTargetPositions: {
dashboard: "right",
dashboard_home: "right",
dashboard_new_request: "right",
dashboard_reservation_salle: "bottom_right",
dashboard_material: "bottom_left",
},
backgroundTargetPresentationModes: {
dashboard: "edge_frame",
dashboard_home: "edge_frame",
dashboard_new_request: "edge_frame",
dashboard_reservation_salle: "edge_frame",
dashboard_material: "edge_frame",
},
themeMode: "light",
},
},
{
id: "pilotage-territorial",
label: "Pilotage territorial",
description: "Preset plus sobre et plus dense, pensé pour le bordereau, la logistique et les référentiels admin.",
category: "Immersif",
usage: ["bordereau", "logistique", "référentiels", "pilotage admin"],
value: {
activeSkinId: "pilotage-territorial",
portalTitle: "Portail Associations 973",
portalTagline: "Référentiels, logistique et pilotage du territoire",
primaryColor: "#256f5c",
secondaryColor: "#0f4c81",
accentColor: "#d97706",
buttonColor: "#0f4c81",
alertColor: "#b91c1c",
fontFamily: "marianne",
headingFontFamily: "marianne",
cardStyle: "outlined",
tableStyle: "compact",
displayDensity: "compact",
sidebarStyle: "soft",
buttonStyle: "rounded",
inputStyle: "outline",
backgroundEnabled: true,
backgroundMode: "cover",
backgroundPresentationMode: "edge_frame",
backgroundOverlayOpacity: 68,
backgroundBlurRadius: 0,
backgroundTargets: ["admin", "admin_pilotage", "admin_logistics", "admin_directory", "admin_compliance"],
backgroundTargetImageUrls: {},
backgroundTargetOverlayOpacities: {
admin: 68,
admin_pilotage: 70,
admin_logistics: 66,
admin_directory: 64,
admin_compliance: 72,
},
backgroundTargetPositions: {
admin: "right",
admin_pilotage: "center",
admin_logistics: "bottom_right",
admin_directory: "left",
admin_compliance: "top_right",
},
backgroundTargetPresentationModes: {
admin: "edge_frame",
admin_pilotage: "edge_frame",
admin_logistics: "edge_frame",
admin_directory: "edge_frame",
admin_compliance: "edge_frame",
},
themeMode: "light",
},
},
];
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
function normalizeHexColor(value: string | undefined, fallback: string) {
const trimmed = value?.trim();
if (!trimmed || !HEX_COLOR_REGEX.test(trimmed)) {
return fallback;
}
if (trimmed.length === 4) {
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`.toLowerCase();
}
return trimmed.toLowerCase();
}
function normalizeUrl(value: string | undefined) {
return value?.trim() || "";
}
function normalizeText(value: string | undefined, fallback: string, maxLength: number) {
const trimmed = value?.trim();
if (!trimmed) {
return fallback;
}
return trimmed.slice(0, maxLength);
}
function normalizePercent(value: number | undefined, fallback: number, min = 0, max = 100) {
if (typeof value !== "number" || Number.isNaN(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}
function normalizeOption<T extends readonly string[]>(value: string | undefined, allowed: T, fallback: T[number]) {
if (value && allowed.includes(value)) {
return value as T[number];
}
return fallback;
}
function normalizeOptionsArray<T extends readonly string[]>(value: string[] | undefined, allowed: T, fallback: readonly T[number][]) {
if (!Array.isArray(value)) {
return [...fallback] as T[number][];
}
const filtered = value.filter((entry): entry is T[number] => allowed.includes(entry as T[number]));
return filtered.length ? Array.from(new Set(filtered)) : [...fallback] as T[number][];
}
function normalizeTargetImageUrls(
value: Partial<Record<string, string>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, string>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const normalized = normalizeUrl(value[target]);
if (normalized) {
next[target] = normalized;
}
}
return next;
}
function normalizeTargetOverlayOpacities(
value: Partial<Record<string, number>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, number>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (typeof raw === "number" && !Number.isNaN(raw)) {
next[target] = Math.min(100, Math.max(0, Math.round(raw)));
}
}
return next;
}
function normalizeTargetBlurRadii(
value: Partial<Record<string, number>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, number>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (typeof raw === "number" && !Number.isNaN(raw)) {
next[target] = Math.min(24, Math.max(0, Math.round(raw)));
}
}
return next;
}
function normalizeTargetPositions(
value: Partial<Record<string, string>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPosition>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (raw && appearanceBackgroundPositions.includes(raw as AppearanceBackgroundPosition)) {
next[target] = raw as AppearanceBackgroundPosition;
}
}
return next;
}
function normalizeTargetPresentationModes(
value: Partial<Record<string, string>> | undefined,
allowed: readonly AppearanceBackgroundTarget[],
) {
const next: Partial<Record<AppearanceBackgroundTarget, AppearanceBackgroundPresentationMode>> = {};
if (!value || typeof value !== "object") {
return next;
}
for (const target of allowed) {
const raw = value[target];
if (raw && appearanceBackgroundPresentationModes.includes(raw as AppearanceBackgroundPresentationMode)) {
next[target] = raw as AppearanceBackgroundPresentationMode;
}
}
return next;
}
function normalizeDecorativeElements(value: unknown): PortalAppearanceDecoration[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry, index) => {
const raw = entry && typeof entry === "object" ? entry as Partial<PortalAppearanceDecoration> : {};
const imageUrl = normalizeUrl(raw.imageUrl);
if (!imageUrl) {
return null;
}
return {
id: normalizeText(raw.id, `decor-${index + 1}`, 80),
imageUrl,
scope: normalizeOption(raw.scope, appearanceDecorationScopes, "all"),
layer: normalizeOption(raw.layer, appearanceDecorationLayers, "background"),
widthPercent: normalizePercent(raw.widthPercent, 18, 6, 38),
topPercent: normalizePercent(raw.topPercent, 20, 0, 100),
leftPercent: normalizePercent(raw.leftPercent, 10, 0, 100),
opacity: normalizePercent(raw.opacity, 12, 4, 28),
blurRadius: normalizePercent(raw.blurRadius, 2, 0, 12),
cardAnchor: normalizeOption(raw.cardAnchor, appearanceCardDecorationAnchors, "top_right"),
cardInsetPercent: normalizePercent(raw.cardInsetPercent, 6, 0, 20),
} satisfies PortalAppearanceDecoration;
})
.filter((entry): entry is PortalAppearanceDecoration => Boolean(entry))
.slice(0, 6);
}
export function getAppearanceSkinPreset(presetId: string | undefined) {
return appearanceSkinPresets.find((preset) => preset.id === presetId) || null;
}
export function sanitizePortalAppearance(value?: Partial<PortalAppearance> | null): PortalAppearance {
const preset = getAppearanceSkinPreset(value?.activeSkinId) || getAppearanceSkinPreset(defaultPortalAppearance.activeSkinId);
const presetValue = preset?.value || {};
const base: PortalAppearance = {
...defaultPortalAppearance,
...presetValue,
};
const normalizedBackgroundImageUrl = normalizeUrl(value?.backgroundImageUrl);
const normalizedBackgroundTargetImageUrls = normalizeTargetImageUrls(value?.backgroundTargetImageUrls, appearanceBackgroundTargets);
const normalizedBackgroundTargetOverlayOpacities = normalizeTargetOverlayOpacities(
value?.backgroundTargetOverlayOpacities,
appearanceBackgroundTargets
);
const normalizedBackgroundTargetBlurRadii = normalizeTargetBlurRadii(
value?.backgroundTargetBlurRadii,
appearanceBackgroundTargets
);
const normalizedBackgroundTargetPositions = normalizeTargetPositions(
value?.backgroundTargetPositions,
appearanceBackgroundTargets
);
const normalizedBackgroundTargetPresentationModes = normalizeTargetPresentationModes(
value?.backgroundTargetPresentationModes,
appearanceBackgroundTargets
);
const normalizedDecorativeElements = normalizeDecorativeElements(value?.decorativeElements);
const hasAnyBackgroundAsset =
Boolean(normalizedBackgroundImageUrl) || Object.values(normalizedBackgroundTargetImageUrls).some(Boolean);
return {
themeMode: normalizeOption(value?.themeMode, appearanceThemeModes, base.themeMode),
activeSkinId: normalizeText(value?.activeSkinId, base.activeSkinId, 80),
portalTitle: normalizeText(value?.portalTitle, base.portalTitle, 120),
portalTagline: normalizeText(value?.portalTagline, base.portalTagline, 220),
primaryColor: normalizeHexColor(value?.primaryColor, base.primaryColor),
secondaryColor: normalizeHexColor(value?.secondaryColor, base.secondaryColor),
accentColor: normalizeHexColor(value?.accentColor, base.accentColor),
buttonColor: normalizeHexColor(value?.buttonColor, base.buttonColor),
alertColor: normalizeHexColor(value?.alertColor, base.alertColor),
fontFamily: normalizeOption(value?.fontFamily, appearanceFontFamilies, base.fontFamily),
headingFontFamily: normalizeOption(value?.headingFontFamily, appearanceFontFamilies, base.headingFontFamily),
headingScalePercent: normalizePercent(value?.headingScalePercent, base.headingScalePercent, 85, 130),
bodyScalePercent: normalizePercent(value?.bodyScalePercent, base.bodyScalePercent, 90, 115),
logoUrl: normalizeUrl(value?.logoUrl),
faviconUrl: normalizeUrl(value?.faviconUrl),
heroImageUrl: normalizeUrl(value?.heroImageUrl),
backgroundImageUrl: normalizedBackgroundImageUrl,
backgroundEnabled: Boolean(value?.backgroundEnabled) && hasAnyBackgroundAsset,
backgroundMode: normalizeOption(value?.backgroundMode, appearanceBackgroundModes, base.backgroundMode),
backgroundPresentationMode: normalizeOption(
value?.backgroundPresentationMode,
appearanceBackgroundPresentationModes,
base.backgroundPresentationMode
),
backgroundOverlayOpacity: normalizePercent(value?.backgroundOverlayOpacity, base.backgroundOverlayOpacity, 0, 100),
backgroundBlurRadius: normalizePercent(value?.backgroundBlurRadius, base.backgroundBlurRadius, 0, 24),
backgroundTargets: normalizeOptionsArray(value?.backgroundTargets, appearanceBackgroundTargets, base.backgroundTargets),
backgroundTargetImageUrls: normalizedBackgroundTargetImageUrls,
backgroundTargetOverlayOpacities: normalizedBackgroundTargetOverlayOpacities,
backgroundTargetBlurRadii: normalizedBackgroundTargetBlurRadii,
backgroundTargetPositions: normalizedBackgroundTargetPositions,
backgroundTargetPresentationModes: normalizedBackgroundTargetPresentationModes,
heroImagePositionX: normalizePercent(value?.heroImagePositionX, base.heroImagePositionX),
heroImagePositionY: normalizePercent(value?.heroImagePositionY, base.heroImagePositionY),
heroOverlayOpacity: normalizePercent(value?.heroOverlayOpacity, base.heroOverlayOpacity),
heroTitlePositionX: normalizePercent(value?.heroTitlePositionX, base.heroTitlePositionX, -50, 50),
heroTitlePositionY: normalizePercent(value?.heroTitlePositionY, base.heroTitlePositionY, -50, 50),
heroTitleWidthPercent: normalizePercent(value?.heroTitleWidthPercent, base.heroTitleWidthPercent, 30, 100),
heroTaglinePositionX: normalizePercent(value?.heroTaglinePositionX, base.heroTaglinePositionX, -50, 50),
heroTaglinePositionY: normalizePercent(value?.heroTaglinePositionY, base.heroTaglinePositionY, -50, 50),
heroTaglineWidthPercent: normalizePercent(value?.heroTaglineWidthPercent, base.heroTaglineWidthPercent, 30, 100),
decorativeElements: normalizedDecorativeElements,
cardStyle: normalizeOption(value?.cardStyle, appearanceCardStyles, base.cardStyle),
tableStyle: normalizeOption(value?.tableStyle, appearanceTableStyles, base.tableStyle),
displayDensity: normalizeOption(value?.displayDensity, appearanceDisplayDensities, base.displayDensity),
sidebarStyle: normalizeOption(value?.sidebarStyle, appearanceSidebarStyles, base.sidebarStyle),
buttonStyle: normalizeOption(value?.buttonStyle, appearanceButtonStyles, base.buttonStyle),
inputStyle: normalizeOption(value?.inputStyle, appearanceInputStyles, base.inputStyle),
};
}