751 lines
22 KiB
JavaScript
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
|
|
};
|