Lumi/src/services/themes.js
2026-06-16 08:30:39 +02:00

751 lines
22 KiB
JavaScript

const crypto = require("crypto");
const { db } = require("./db");
const { getSetting, setSetting } = require("./settings");
const DEFAULT_THEME_ID = "builtin:lumi-default";
const THEME_SYSTEM_VERSION = 1;
const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const FONT_STACKS = Object.freeze({
lumi: {
label: "Lumi Sans",
stack: '"Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
system: {
label: "System UI",
stack: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
rounded: {
label: "Rounded",
stack: 'ui-rounded, "Nunito Sans", "Aptos", Inter, ui-sans-serif, system-ui, sans-serif'
},
editorial: {
label: "Editorial",
stack: 'Georgia, "Times New Roman", ui-serif, serif'
},
mono: {
label: "Mono",
stack: '"Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace'
}
});
const TYPOGRAPHY_FIELDS = ["bodyFont", "displayFont", "monoFont", "baseSize", "headingScale", "controlScale"];
const MODE_COLOR_FIELDS = [
"bg1",
"bg2",
"bg3",
"text",
"muted",
"accent",
"accentAlt",
"success",
"warning",
"danger",
"info",
"surface",
"surface2",
"surface3",
"border",
"link",
"buttonBg",
"buttonText",
"buttonHover",
"inputBg",
"inputBorder",
"inputText",
"focusRing"
];
const ROLE_COLOR_FIELDS = ["public", "mod", "admin"];
const DEFAULT_MODE_LIGHT = {
bg1: "#dff4f2",
bg2: "#f5f7f8",
bg3: "#fff1dc",
text: "#182026",
muted: "#5a6872",
accent: "#176b75",
accentAlt: "#e58b2b",
success: "#23845b",
warning: "#a96612",
danger: "#bd4d4d",
info: "#3479a8",
surface: "#ffffff",
surface2: "#f4f7f8",
surface3: "#edf2f3",
border: "#d8e0e3",
link: "#0d6470",
buttonBg: "#176b75",
buttonText: "#ffffff",
buttonHover: "#0f5660",
inputBg: "#ffffff",
inputBorder: "#c8d3d7",
inputText: "#182026",
focusRing: "#2f98a5"
};
const DEFAULT_MODE_DARK = {
bg1: "#102c31",
bg2: "#11171b",
bg3: "#261e18",
text: "#f2f6f7",
muted: "#aebbc1",
accent: "#63c4cf",
accentAlt: "#f0b45f",
success: "#59c894",
warning: "#e4b35d",
danger: "#ef7b78",
info: "#74b9e6",
surface: "#1a2227",
surface2: "#202b31",
surface3: "#27343b",
border: "#35434a",
link: "#7bd3dc",
buttonBg: "#4dafba",
buttonText: "#08191c",
buttonHover: "#68c8d2",
inputBg: "#151d21",
inputBorder: "#42535b",
inputText: "#f2f6f7",
focusRing: "#7bd3dc"
};
const DEFAULT_THEME_VALUES = {
light: DEFAULT_MODE_LIGHT,
dark: DEFAULT_MODE_DARK,
role: {
public: "#ffffff",
mod: "#23845b",
admin: "#bd4d6d"
},
metrics: {
radius: 14,
shadowStrength: 0.14,
spacingScale: 1
},
typography: {
bodyFont: "lumi",
displayFont: "system",
monoFont: "mono",
baseSize: 16,
headingScale: 1,
controlScale: 1,
bodyFontStack: FONT_STACKS.lumi.stack,
displayFontStack: FONT_STACKS.system.stack,
monoFontStack: FONT_STACKS.mono.stack
}
};
function mergeMode(base, override = {}) {
return Object.fromEntries(
MODE_COLOR_FIELDS.map((field) => [field, override[field] || base[field]])
);
}
function createBuiltin(id, name, description, overrides = {}) {
return Object.freeze({
id: `builtin:${id}`,
name,
description,
builtin: true,
readOnly: true,
baseThemeId: null,
light: mergeMode(DEFAULT_MODE_LIGHT, overrides.light),
dark: mergeMode(DEFAULT_MODE_DARK, overrides.dark),
role: { ...DEFAULT_THEME_VALUES.role, ...(overrides.role || {}) },
metrics: { ...DEFAULT_THEME_VALUES.metrics, ...(overrides.metrics || {}) },
typography: normalizeTypography(overrides.typography, DEFAULT_THEME_VALUES.typography)
});
}
const BUILTIN_THEMES = [
createBuiltin(
"lumi-default",
"Lumi Default",
"Balanced teal and warm accents with automatic light and dark modes."
),
createBuiltin("lumi-dark", "Lumi Dark", "A deep, low-glare theme for dark workspaces.", {
light: {
bg1: "#18242a",
bg2: "#11171b",
bg3: "#241d19",
text: "#f3f6f7",
muted: "#b4c0c5",
surface: "#1c252a",
surface2: "#222d33",
surface3: "#29363d",
border: "#3b4a52",
inputBg: "#141c20",
inputBorder: "#465860",
inputText: "#f3f6f7",
accent: "#67c6d0",
link: "#7bd3dc",
buttonBg: "#51b4bf",
buttonText: "#08191c",
buttonHover: "#71d0da",
focusRing: "#7bd3dc"
}
}),
createBuiltin("lumi-light", "Lumi Light", "A crisp, bright theme with restrained shadows.", {
dark: DEFAULT_MODE_LIGHT,
metrics: { shadowStrength: 0.08 }
}),
createBuiltin("high-contrast", "High Contrast", "Maximum clarity with strong focus and status colors.", {
light: {
bg1: "#ffffff",
bg2: "#ffffff",
bg3: "#f2f2f2",
text: "#000000",
muted: "#303030",
accent: "#004f5a",
accentAlt: "#8a4300",
success: "#006b3c",
warning: "#7a4700",
danger: "#a00000",
info: "#004b88",
surface: "#ffffff",
surface2: "#f5f5f5",
surface3: "#e8e8e8",
border: "#555555",
link: "#003f99",
buttonBg: "#003f49",
buttonText: "#ffffff",
buttonHover: "#002c33",
inputBg: "#ffffff",
inputBorder: "#333333",
inputText: "#000000",
focusRing: "#005fcc"
},
dark: {
bg1: "#000000",
bg2: "#000000",
bg3: "#101010",
text: "#ffffff",
muted: "#d6d6d6",
accent: "#67e8f9",
accentAlt: "#ffd166",
success: "#65e6a3",
warning: "#ffd166",
danger: "#ff8c8c",
info: "#8fd3ff",
surface: "#080808",
surface2: "#151515",
surface3: "#222222",
border: "#aaaaaa",
link: "#8fd3ff",
buttonBg: "#a5f3fc",
buttonText: "#000000",
buttonHover: "#ffffff",
inputBg: "#000000",
inputBorder: "#dddddd",
inputText: "#ffffff",
focusRing: "#ffffff"
},
metrics: { radius: 8, shadowStrength: 0, spacingScale: 1.05 }
}),
createBuiltin("midnight", "Midnight", "Cool blue surfaces with violet highlights.", {
light: {
bg1: "#dce8ff",
bg2: "#f4f6fb",
bg3: "#eee8ff",
accent: "#4457a6",
accentAlt: "#8258b7",
link: "#354a9b",
buttonBg: "#4457a6",
buttonHover: "#34448a",
focusRing: "#6f82d8"
},
dark: {
bg1: "#10182f",
bg2: "#0b1020",
bg3: "#211630",
surface: "#141c32",
surface2: "#19233d",
surface3: "#202c49",
border: "#334160",
accent: "#91a4ff",
accentAlt: "#c49aff",
link: "#aab8ff",
buttonBg: "#91a4ff",
buttonText: "#0b1020",
buttonHover: "#b0bcff",
focusRing: "#c49aff"
}
}),
createBuiltin("soft-aurora", "Soft Aurora", "A gentle mint, lavender, and coral palette.", {
light: {
bg1: "#dcf8ee",
bg2: "#f8f5fb",
bg3: "#ffe9e4",
accent: "#397f70",
accentAlt: "#986aa8",
link: "#306f63",
buttonBg: "#397f70",
buttonHover: "#2d665a",
focusRing: "#8a6fa8"
},
dark: {
bg1: "#17352f",
bg2: "#171a22",
bg3: "#38242e",
surface: "#20262d",
surface2: "#283038",
surface3: "#313b44",
border: "#43505a",
accent: "#82d7c1",
accentAlt: "#d4a7e1",
link: "#9ce7d4",
buttonBg: "#72c9b3",
buttonText: "#10231f",
buttonHover: "#96e3d0",
focusRing: "#d4a7e1"
},
metrics: { radius: 18, shadowStrength: 0.1, spacingScale: 1.05 }
})
];
const BUILTIN_MAP = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
function cloneTheme(theme) {
return JSON.parse(JSON.stringify(theme));
}
function getBuiltinTheme(id = DEFAULT_THEME_ID) {
return BUILTIN_MAP.get(id) || BUILTIN_MAP.get(DEFAULT_THEME_ID);
}
function customKey(id) {
return `custom:${id}`;
}
function customId(themeId) {
return String(themeId || "").startsWith("custom:")
? String(themeId).slice("custom:".length)
: null;
}
function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
const source = values && typeof values === "object" ? values : {};
const normalized = {
light: mergeMode(baseTheme.light, source.light),
dark: mergeMode(baseTheme.dark, source.dark),
role: { ...baseTheme.role, ...(source.role || {}) },
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) },
typography: normalizeTypography(source.typography, baseTheme.typography)
};
for (const mode of ["light", "dark"]) {
for (const field of MODE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(normalized[mode][field])) {
normalized[mode][field] = baseTheme[mode][field];
}
}
}
for (const field of ROLE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(normalized.role[field])) {
normalized.role[field] = baseTheme.role[field];
}
}
normalized.metrics.radius = clampNumber(
normalized.metrics.radius,
0,
32,
baseTheme.metrics.radius
);
normalized.metrics.shadowStrength = clampNumber(
normalized.metrics.shadowStrength,
0,
0.35,
baseTheme.metrics.shadowStrength
);
normalized.metrics.spacingScale = clampNumber(
normalized.metrics.spacingScale,
0.75,
1.35,
baseTheme.metrics.spacingScale
);
for (const mode of ["light", "dark"]) {
if (contrastRatio(normalized[mode].text, normalized[mode].surface) < 4.5) {
normalized[mode].text = baseTheme[mode].text;
normalized[mode].surface = baseTheme[mode].surface;
}
if (contrastRatio(normalized[mode].buttonText, normalized[mode].buttonBg) < 4.5) {
normalized[mode].buttonText = baseTheme[mode].buttonText;
normalized[mode].buttonBg = baseTheme[mode].buttonBg;
}
if (contrastRatio(normalized[mode].inputText, normalized[mode].inputBg) < 4.5) {
normalized[mode].inputText = baseTheme[mode].inputText;
normalized[mode].inputBg = baseTheme[mode].inputBg;
}
}
return normalized;
}
function normalizeFontKey(value, fallback) {
return Object.prototype.hasOwnProperty.call(FONT_STACKS, value) ? value : fallback;
}
function normalizeTypography(values = {}, fallback = DEFAULT_THEME_VALUES.typography) {
const bodyFont = normalizeFontKey(values.bodyFont, fallback.bodyFont);
const displayFont = normalizeFontKey(values.displayFont, fallback.displayFont);
const monoFont = normalizeFontKey(values.monoFont, fallback.monoFont);
return {
bodyFont,
displayFont,
monoFont,
baseSize: clampNumber(values.baseSize, 14, 19, fallback.baseSize),
headingScale: clampNumber(values.headingScale, 0.9, 1.2, fallback.headingScale),
controlScale: clampNumber(values.controlScale, 0.9, 1.12, fallback.controlScale),
bodyFontStack: FONT_STACKS[bodyFont].stack,
displayFontStack: FONT_STACKS[displayFont].stack,
monoFontStack: FONT_STACKS[monoFont].stack
};
}
function clampNumber(value, min, max, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function parseHex(value) {
const raw = String(value || "").slice(1);
return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16));
}
function relativeLuminance(value) {
const channels = parseHex(value).map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(left, right) {
const a = relativeLuminance(left);
const b = relativeLuminance(right);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function validateThemeValues(values) {
const errors = [];
for (const mode of ["light", "dark"]) {
for (const field of MODE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(String(values?.[mode]?.[field] || ""))) {
errors.push(`${mode}.${field} must be a six-digit hex color.`);
}
}
}
for (const field of ROLE_COLOR_FIELDS) {
if (!COLOR_PATTERN.test(String(values?.role?.[field] || ""))) {
errors.push(`role.${field} must be a six-digit hex color.`);
}
}
const metricRules = [
["radius", 0, 32],
["shadowStrength", 0, 0.35],
["spacingScale", 0.75, 1.35]
];
for (const [field, min, max] of metricRules) {
const value = Number(values?.metrics?.[field]);
if (!Number.isFinite(value) || value < min || value > max) {
errors.push(`metrics.${field} must be between ${min} and ${max}.`);
}
}
for (const field of ["bodyFont", "displayFont", "monoFont"]) {
if (!Object.prototype.hasOwnProperty.call(FONT_STACKS, values?.typography?.[field])) {
errors.push(`typography.${field} must be a supported font preset.`);
}
}
const typographyRules = [
["baseSize", 14, 19],
["headingScale", 0.9, 1.2],
["controlScale", 0.9, 1.12]
];
for (const [field, min, max] of typographyRules) {
const value = Number(values?.typography?.[field]);
if (!Number.isFinite(value) || value < min || value > max) {
errors.push(`typography.${field} must be between ${min} and ${max}.`);
}
}
if (!errors.length) {
for (const mode of ["light", "dark"]) {
if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) {
errors.push(`${mode} text and surface colors need at least 4.5:1 contrast.`);
}
if (contrastRatio(values[mode].buttonText, values[mode].buttonBg) < 4.5) {
errors.push(`${mode} button text and background need at least 4.5:1 contrast.`);
}
if (contrastRatio(values[mode].inputText, values[mode].inputBg) < 4.5) {
errors.push(`${mode} input text and background need at least 4.5:1 contrast.`);
}
}
}
return errors;
}
function legacyThemeValues() {
const legacy = {
light: {
bg1: getSetting("theme_light_bg_1", "#ffe5c4"),
bg2: getSetting("theme_light_bg_2", "#f4efe8"),
bg3: getSetting("theme_light_bg_3", "#e9f3f1"),
text: getSetting("theme_light_text", "#121518"),
muted: getSetting("theme_light_text_muted", "#2c3137"),
accent: getSetting("theme_light_accent", "#0f6a78"),
accentAlt: getSetting("theme_light_accent_alt", "#f4a340"),
danger: getSetting("theme_light_danger", "#d66d5c"),
surface: getSetting("theme_light_surface", "#ffffff"),
surface2: getSetting("theme_light_surface_2", "#fbf9f6"),
surface3: getSetting("theme_light_surface_3", "#f9f5ef"),
border: getSetting("theme_light_border", "#e3ddd6")
},
dark: {
bg1: getSetting("theme_dark_bg_1", "#1b1d1f"),
bg2: getSetting("theme_dark_bg_2", "#16181b"),
bg3: getSetting("theme_dark_bg_3", "#0f1113"),
text: getSetting("theme_dark_text", "#f2f0ec"),
muted: getSetting("theme_dark_text_muted", "#c5bfb7"),
accent: getSetting("theme_dark_accent", "#4fb6c2"),
accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"),
danger: getSetting("theme_dark_danger", "#e08173"),
surface: getSetting("theme_dark_surface", "#232629"),
surface2: getSetting("theme_dark_surface_2", "#2b2f33"),
surface3: getSetting("theme_dark_surface_3", "#30353a"),
border: getSetting("theme_dark_border", "#34393d")
},
role: {
public: getSetting("theme_role_public", "#ffffff"),
mod: getSetting("theme_role_mod", "#2cb678"),
admin: getSetting("theme_role_admin", "#e35678")
}
};
return normalizeThemeValues(legacy, getBuiltinTheme());
}
function legacyWasCustomized(values) {
const defaults = {
light: {
bg1: "#ffe5c4", bg2: "#f4efe8", bg3: "#e9f3f1", text: "#121518",
muted: "#2c3137", accent: "#0f6a78", accentAlt: "#f4a340",
danger: "#d66d5c", surface: "#ffffff", surface2: "#fbf9f6",
surface3: "#f9f5ef", border: "#e3ddd6"
},
dark: {
bg1: "#1b1d1f", bg2: "#16181b", bg3: "#0f1113", text: "#f2f0ec",
muted: "#c5bfb7", accent: "#4fb6c2", accentAlt: "#f1b765",
danger: "#e08173", surface: "#232629", surface2: "#2b2f33",
surface3: "#30353a", border: "#34393d"
},
role: { public: "#ffffff", mod: "#2cb678", admin: "#e35678" }
};
return ["light", "dark", "role"].some((group) =>
Object.entries(defaults[group]).some(([key, value]) => values[group][key] !== value)
);
}
function ensureThemeMigration() {
if (Number(getSetting("theme_system_version", 0)) >= THEME_SYSTEM_VERSION) return;
const legacy = legacyThemeValues();
setSetting("theme_system_version", THEME_SYSTEM_VERSION);
if (legacyWasCustomized(legacy)) {
const theme = insertCustomTheme("Migrated Theme", DEFAULT_THEME_ID, legacy);
setSetting("theme_active_id", theme.id);
} else {
setSetting("theme_active_id", DEFAULT_THEME_ID);
}
}
function rowToTheme(row) {
const base = getBuiltinTheme(row.base_theme_id);
let stored = {};
try {
stored = JSON.parse(row.values_json);
} catch {
stored = {};
}
const values = normalizeThemeValues(stored, base);
return {
id: customKey(row.id),
name: row.name,
description: `Custom theme based on ${base.name}.`,
builtin: false,
readOnly: false,
baseThemeId: base.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
...values
};
}
function getThemeById(themeId) {
ensureThemeMigration();
if (BUILTIN_MAP.has(themeId)) return cloneTheme(BUILTIN_MAP.get(themeId));
const id = customId(themeId);
if (!id) return null;
const row = db.prepare("SELECT * FROM custom_themes WHERE id = ?").get(id);
return row ? rowToTheme(row) : null;
}
function listThemes() {
ensureThemeMigration();
const custom = db
.prepare("SELECT * FROM custom_themes ORDER BY lower(name), created_at")
.all()
.map(rowToTheme);
return [...BUILTIN_THEMES.map(cloneTheme), ...custom];
}
function getActiveTheme() {
ensureThemeMigration();
const requested = getSetting("theme_active_id", DEFAULT_THEME_ID);
const theme = getThemeById(requested) || cloneTheme(getBuiltinTheme());
if (theme.id !== requested) setSetting("theme_active_id", theme.id);
return theme;
}
function setActiveTheme(themeId) {
const theme = getThemeById(themeId);
if (!theme) throw new Error("Theme not found.");
setSetting("theme_active_id", theme.id);
return theme;
}
function cleanName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
if (name.length < 2 || name.length > 60) {
throw new Error("Theme name must be between 2 and 60 characters.");
}
return name;
}
function insertCustomTheme(name, baseThemeId, values) {
const clean = cleanName(name);
const base = getBuiltinTheme(baseThemeId);
const normalized = normalizeThemeValues(values, base);
const errors = validateThemeValues(normalized);
if (errors.length) throw new Error(errors[0]);
const id = crypto.randomUUID();
const now = Date.now();
db.prepare(
"INSERT INTO custom_themes (id, name, base_theme_id, values_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
).run(id, clean, base.id, JSON.stringify(normalized), now, now);
return getThemeById(customKey(id));
}
function duplicateTheme(themeId, name) {
const source = getThemeById(themeId);
if (!source) throw new Error("Theme not found.");
return insertCustomTheme(
name || `${source.name} Copy`,
source.builtin ? source.id : source.baseThemeId,
source
);
}
function saveCustomTheme(themeId, values) {
const id = customId(themeId);
if (!id) throw new Error("Built-in themes are read-only.");
const current = getThemeById(themeId);
if (!current) throw new Error("Theme not found.");
const normalized = normalizeThemeValues(values, getBuiltinTheme(current.baseThemeId));
const errors = validateThemeValues(values);
if (errors.length) {
const error = new Error(errors[0]);
error.validationErrors = errors;
throw error;
}
db.prepare(
"UPDATE custom_themes SET values_json = ?, updated_at = ? WHERE id = ?"
).run(JSON.stringify(normalized), Date.now(), id);
return getThemeById(themeId);
}
function renameCustomTheme(themeId, name) {
const id = customId(themeId);
if (!id) throw new Error("Built-in themes cannot be renamed.");
const result = db
.prepare("UPDATE custom_themes SET name = ?, updated_at = ? WHERE id = ?")
.run(cleanName(name), Date.now(), id);
if (!result.changes) throw new Error("Theme not found.");
return getThemeById(themeId);
}
function deleteCustomTheme(themeId) {
const id = customId(themeId);
if (!id) throw new Error("Built-in themes cannot be deleted.");
const activeId = getSetting("theme_active_id", DEFAULT_THEME_ID);
const result = db.prepare("DELETE FROM custom_themes WHERE id = ?").run(id);
if (!result.changes) throw new Error("Theme not found.");
if (activeId === themeId) setSetting("theme_active_id", DEFAULT_THEME_ID);
}
function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
const values = { light: {}, dark: {}, role: {}, metrics: {}, typography: {} };
for (const mode of ["light", "dark"]) {
for (const field of MODE_COLOR_FIELDS) {
values[mode][field] = String(
body?.[`${mode}_${field}`] ?? fallbackTheme[mode][field]
).trim();
}
}
for (const field of ROLE_COLOR_FIELDS) {
values.role[field] = String(
body?.[`role_${field}`] ?? fallbackTheme.role[field]
).trim();
}
values.metrics.radius = Number(body?.metrics_radius ?? fallbackTheme.metrics.radius);
values.metrics.shadowStrength = Number(
body?.metrics_shadowStrength ?? fallbackTheme.metrics.shadowStrength
);
values.metrics.spacingScale = Number(
body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale
);
values.typography.bodyFont = String(
body?.typography_bodyFont ?? fallbackTheme.typography.bodyFont
);
values.typography.displayFont = String(
body?.typography_displayFont ?? fallbackTheme.typography.displayFont
);
values.typography.monoFont = String(
body?.typography_monoFont ?? fallbackTheme.typography.monoFont
);
values.typography.baseSize = Number(
body?.typography_baseSize ?? fallbackTheme.typography.baseSize
);
values.typography.headingScale = Number(
body?.typography_headingScale ?? fallbackTheme.typography.headingScale
);
values.typography.controlScale = Number(
body?.typography_controlScale ?? fallbackTheme.typography.controlScale
);
return values;
}
module.exports = {
BUILTIN_THEMES,
DEFAULT_THEME_ID,
FONT_STACKS,
MODE_COLOR_FIELDS,
ROLE_COLOR_FIELDS,
TYPOGRAPHY_FIELDS,
contrastRatio,
deleteCustomTheme,
duplicateTheme,
getActiveTheme,
getThemeById,
listThemes,
normalizeThemeValues,
renameCustomTheme,
saveCustomTheme,
setActiveTheme,
validateThemeValues,
valuesFromRequest
};