Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

764
shared/appearance.ts Normal file
View file

@ -0,0 +1,764 @@
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),
};
}