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 };