From 15bcd53c99460272d56ac5585f9697c0d06f64c8 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Mon, 15 Jun 2026 23:58:24 +0200 Subject: [PATCH] ui: modernize WebUI and add managed themes --- package.json | 3 +- plugins/moderation/views/status.ejs | 18 +- scripts/verify-webui.js | 123 +++++ src/services/db.js | 9 + src/services/themes.js | 654 +++++++++++++++++++++++ src/web/public/app.js | 22 + src/web/public/lumi-components.css | 549 +++++++++++++++++++ src/web/public/lumi-layout.css | 260 +++++++++ src/web/public/lumi-tokens.css | 71 +++ src/web/public/theme-editor.css | 381 +++++++++++++ src/web/public/theme-editor.js | 89 +++ src/web/server.js | 218 +++++--- src/web/views/admin-dashboard.ejs | 30 +- src/web/views/admin-pages.ejs | 10 +- src/web/views/admin-plugins.ejs | 10 +- src/web/views/admin-settings.ejs | 10 +- src/web/views/admin-theme.ejs | 376 +++++++++---- src/web/views/admin-updates.ejs | 31 +- src/web/views/admin-users.ejs | 10 +- src/web/views/leaderboards.ejs | 10 +- src/web/views/partials/layout-bottom.ejs | 1 + src/web/views/partials/layout-top.ejs | 43 +- src/web/views/partials/page-header.ejs | 11 + src/web/views/partials/theme-vars.ejs | 91 ++++ src/web/views/setup.ejs | 7 +- 25 files changed, 2766 insertions(+), 271 deletions(-) create mode 100644 scripts/verify-webui.js create mode 100644 src/services/themes.js create mode 100644 src/web/public/lumi-components.css create mode 100644 src/web/public/lumi-layout.css create mode 100644 src/web/public/lumi-tokens.css create mode 100644 src/web/public/theme-editor.css create mode 100644 src/web/public/theme-editor.js create mode 100644 src/web/views/partials/page-header.ejs create mode 100644 src/web/views/partials/theme-vars.ejs diff --git a/package.json b/package.json index 7b93c08..9d1784a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "commonjs", "scripts": { "start": "node src/main.js", - "run": "node run.js" + "run": "node run.js", + "verify:webui": "node scripts/verify-webui.js" }, "engines": { "node": ">=18" diff --git a/plugins/moderation/views/status.ejs b/plugins/moderation/views/status.ejs index 7fcefc8..f0687da 100644 --- a/plugins/moderation/views/status.ejs +++ b/plugins/moderation/views/status.ejs @@ -4,11 +4,15 @@ <%= title %> - + + + + + <%- include("../../../src/web/views/partials/theme-vars", { theme }) %> - -
-
+ +
+

Access restricted

Your account is currently restricted by moderation.

@@ -29,7 +33,7 @@ <%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %>
-
+

Summary

<%= sanction.reason_short %>

Details

@@ -37,6 +41,6 @@

Moderator: <%= sanction.created_by_name || 'Staff' %>

-
+ - + diff --git a/scripts/verify-webui.js b/scripts/verify-webui.js new file mode 100644 index 0000000..cd7e9ad --- /dev/null +++ b/scripts/verify-webui.js @@ -0,0 +1,123 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const ejs = require("ejs"); + +const root = path.join(__dirname, ".."); + +function listFiles(directory, extension, output = []) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) listFiles(target, extension, output); + else if (target.endsWith(extension)) output.push(target); + } + return output; +} + +function verifyViews() { + const viewRoots = [path.join(root, "src", "web", "views"), path.join(root, "plugins")]; + const files = viewRoots.flatMap((directory) => listFiles(directory, ".ejs")); + for (const file of files) { + ejs.compile(fs.readFileSync(file, "utf8"), { filename: file }); + } + return files.length; +} + +function verifyThemeService() { + const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-theme-test-")); + const serviceDir = path.join(sandbox, "src", "services"); + let database = null; + fs.mkdirSync(serviceDir, { recursive: true }); + for (const file of ["config.js", "db.js", "settings.js", "themes.js"]) { + fs.copyFileSync( + path.join(root, "src", "services", file), + path.join(serviceDir, file) + ); + } + + try { + database = require(path.join(serviceDir, "db.js")); + database.migrate(); + require(path.join(serviceDir, "settings.js")).ensureDefaults(); + const themes = require(path.join(serviceDir, "themes.js")); + + assert.strictEqual(themes.BUILTIN_THEMES.length, 6); + for (const theme of themes.BUILTIN_THEMES) { + assert.deepStrictEqual(themes.validateThemeValues(theme), []); + } + + const library = themes.listThemes(); + assert(library.every((theme) => theme.builtin)); + assert.throws( + () => themes.saveCustomTheme("builtin:lumi-default", library[0]), + /read-only/ + ); + + const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme"); + assert.strictEqual(copy.builtin, false); + themes.setActiveTheme(copy.id); + assert.strictEqual(themes.getActiveTheme().id, copy.id); + + const invalid = JSON.parse(JSON.stringify(copy)); + invalid.light.text = "not-a-color"; + assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /hex color/); + + const renamed = themes.renameCustomTheme(copy.id, "Verified Theme"); + assert.strictEqual(renamed.name, "Verified Theme"); + + const repaired = themes.normalizeThemeValues({ + ...renamed, + light: { ...renamed.light, text: "#ffffff", surface: "#ffffff" } + }); + assert.notStrictEqual(repaired.light.text, repaired.light.surface); + + const themeView = path.join(root, "src", "web", "views", "admin-theme.ejs"); + const rendered = ejs.render(fs.readFileSync(themeView, "utf8"), { + title: "Theming", + siteTitle: "Lumi Bot", + assetVersion: "verify", + theme: renamed, + activeTheme: renamed, + themes: themes.listThemes(), + editingTheme: renamed, + botAvatar: null, + navSections: [], + user: { username: "Admin" }, + userAvatar: null, + userInitial: "A", + platformLogins: [], + flash: null, + softError: null + }, { filename: themeView }); + assert(rendered.includes("data-theme-editor")); + assert(rendered.includes("Built-in · read-only")); + + const statusView = path.join(root, "plugins", "moderation", "views", "status.ejs"); + const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), { + title: "Access restricted", + assetVersion: "verify", + theme: renamed, + sanction: { + action_type: "timeout", + status: "active", + created_at: Date.now(), + expires_at: Date.now() + 60000, + reason_short: "Verification", + reason_detail: "Standalone themed view verification.", + created_by_name: "Admin" + } + }, { filename: statusView }); + assert(statusRendered.includes("/lumi-components.css")); + assert(statusRendered.includes('data-theme-id="custom:')); + + themes.deleteCustomTheme(copy.id); + assert.strictEqual(themes.getActiveTheme().id, themes.DEFAULT_THEME_ID); + } finally { + database?.db.close(); + fs.rmSync(sandbox, { recursive: true, force: true }); + } +} + +const viewCount = verifyViews(); +verifyThemeService(); +console.log(`WebUI verification passed: ${viewCount} EJS views and theme CRUD.`); diff --git a/src/services/db.js b/src/services/db.js index 70f21f7..8de5e1b 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -18,6 +18,15 @@ function migrate() { updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS custom_themes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_theme_id TEXT NOT NULL, + values_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/services/themes.js b/src/services/themes.js new file mode 100644 index 0000000..bb9db6e --- /dev/null +++ b/src/services/themes.js @@ -0,0 +1,654 @@ +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 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 + } +}; + +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 || {}) } + }); +} + +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 || {}) } + }; + + 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 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}.`); + } + } + + 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: {} }; + 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 + ); + return values; +} + +module.exports = { + BUILTIN_THEMES, + DEFAULT_THEME_ID, + MODE_COLOR_FIELDS, + ROLE_COLOR_FIELDS, + contrastRatio, + deleteCustomTheme, + duplicateTheme, + getActiveTheme, + getThemeById, + listThemes, + normalizeThemeValues, + renameCustomTheme, + saveCustomTheme, + setActiveTheme, + validateThemeValues, + valuesFromRequest +}; diff --git a/src/web/public/app.js b/src/web/public/app.js index 04aad82..cad0e8d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1,6 +1,11 @@ (() => { const body = document.body; const media = window.matchMedia("(max-width: 900px)"); + const sidebarPreferenceKey = "lumi-sidebar-collapsed"; + + if (!media.matches && window.localStorage.getItem(sidebarPreferenceKey) === "true") { + body.classList.add("sidebar-collapsed"); + } document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => { button.addEventListener("click", () => { @@ -8,10 +13,18 @@ body.classList.toggle("sidebar-open"); } else { body.classList.toggle("sidebar-collapsed"); + window.localStorage.setItem( + sidebarPreferenceKey, + body.classList.contains("sidebar-collapsed") ? "true" : "false" + ); } }); }); + document.querySelector("[data-sidebar-dismiss]")?.addEventListener("click", () => { + body.classList.remove("sidebar-open"); + }); + document.querySelectorAll(".nav-link").forEach((link) => { link.addEventListener("click", () => { if (body.classList.contains("sidebar-open")) { @@ -20,6 +33,15 @@ }); }); + media.addEventListener?.("change", () => { + body.classList.remove("sidebar-open"); + if (media.matches) { + body.classList.remove("sidebar-collapsed"); + } else if (window.localStorage.getItem(sidebarPreferenceKey) === "true") { + body.classList.add("sidebar-collapsed"); + } + }); + const editToggles = Array.from( document.querySelectorAll("[data-edit-toggle]") ); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css new file mode 100644 index 0000000..f456769 --- /dev/null +++ b/src/web/public/lumi-components.css @@ -0,0 +1,549 @@ +::selection { + color: var(--lumi-button-text); + background: var(--lumi-primary); +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--lumi-focus) 78%, transparent); + outline-offset: 3px; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; + color: var(--lumi-text); + font-family: var(--lumi-font-display); + line-height: 1.18; + text-wrap: balance; +} + +h1 { + font-size: clamp(1.75rem, 4vw, 2.55rem); + letter-spacing: -0.035em; +} + +h2 { + font-size: clamp(1.25rem, 2.5vw, 1.6rem); + letter-spacing: -0.02em; +} + +p { + max-width: 75ch; +} + +.eyebrow { + display: inline-block; + margin-bottom: var(--lumi-space-1); + color: var(--lumi-primary); + font: 700 0.75rem/1 var(--lumi-font-display); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +a { + color: var(--lumi-link); + text-underline-offset: 0.18em; +} + +code, +pre { + font-family: var(--lumi-font-mono); +} + +.hero { + position: relative; + overflow: hidden; + padding: clamp(1.5rem, 5vw, 3.5rem); + border: 1px solid color-mix(in srgb, var(--lumi-primary) 25%, var(--lumi-border)); + border-radius: var(--lumi-radius-lg); + background: + linear-gradient(125deg, color-mix(in srgb, var(--lumi-primary) 18%, transparent), transparent 56%), + linear-gradient(310deg, color-mix(in srgb, var(--lumi-accent) 17%, transparent), transparent 50%), + var(--lumi-surface); + box-shadow: var(--lumi-shadow-md); + animation: none; +} + +.hero::after { + content: ""; + position: absolute; + width: 14rem; + height: 14rem; + right: -6rem; + top: -7rem; + border: 2.5rem solid color-mix(in srgb, var(--lumi-primary) 10%, transparent); + border-radius: 50%; + pointer-events: none; +} + +.hero h1 { + max-width: 18ch; + margin-bottom: var(--lumi-space-3); +} + +.hero > * { + position: relative; + z-index: 1; +} + +.card, +.panel, +.lumi-panel { + padding: clamp(1rem, 2vw, 1.5rem); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: color-mix(in srgb, var(--lumi-surface) 97%, transparent); + box-shadow: var(--lumi-shadow-sm); + animation: none; +} + +.grid .card { + height: 100%; +} + +.card .card { + background: var(--lumi-surface-subtle); + box-shadow: none; +} + +.card > :last-child, +.panel > :last-child { + margin-bottom: 0; +} + +section.card:has(> table.table) { + overflow-x: auto; +} + +.button, +button.button, +input[type="submit"].button { + min-height: var(--lumi-control-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--lumi-space-2); + padding: 0.65rem 1rem; + border: 1px solid transparent; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-button-bg); + color: var(--lumi-button-text); + box-shadow: var(--lumi-shadow-sm); + font: 700 0.925rem/1 var(--lumi-font-body); + text-align: center; + text-decoration: none; + transition: + transform var(--lumi-transition), + background var(--lumi-transition), + border-color var(--lumi-transition), + box-shadow var(--lumi-transition); +} + +.button:hover:not(:disabled):not(.disabled) { + background: var(--lumi-button-hover); + box-shadow: var(--lumi-shadow-md); + transform: translateY(-1px); +} + +.button:active:not(:disabled):not(.disabled) { + transform: translateY(0); +} + +.button.subtle, +.button.secondary { + border-color: var(--lumi-border); + background: var(--lumi-surface-subtle); + color: var(--lumi-text); + box-shadow: none; +} + +.button.subtle:hover:not(:disabled):not(.disabled), +.button.secondary:hover:not(:disabled):not(.disabled) { + border-color: color-mix(in srgb, var(--lumi-primary) 38%, var(--lumi-border)); + background: var(--lumi-surface-raised); +} + +.button.danger { + background: var(--lumi-danger); + color: #ffffff; +} + +.button.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--lumi-danger) 84%, black); +} + +.button:disabled, +.button.disabled, +button:disabled { + cursor: not-allowed; + opacity: 0.55; + box-shadow: none; + transform: none; +} + +.icon-button { + width: var(--lumi-control-height); + height: var(--lumi-control-height); + min-width: var(--lumi-control-height); + border-color: var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-subtle); + color: var(--lumi-text); + transition: background var(--lumi-transition), transform var(--lumi-transition); +} + +.icon-button:hover { + background: var(--lumi-surface-raised); + transform: translateY(-1px); +} + +.link { + color: var(--lumi-link); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--lumi-link) 35%, transparent); + text-underline-offset: 0.22em; +} + +.link:hover { + text-decoration-color: currentColor; +} + +.form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--lumi-space-4); +} + +.form-grid .field { + min-width: 0; + gap: var(--lumi-space-2); +} + +.field > label:first-child, +fieldset > legend { + color: var(--lumi-text); + font-weight: 700; +} + +input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]), +select, +textarea, +.table-search { + width: 100%; + min-height: var(--lumi-control-height); + padding: 0.65rem 0.8rem; + border: 1px solid var(--lumi-input-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-input-bg); + color: var(--lumi-input-text); + font: inherit; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.035); + transition: border-color var(--lumi-transition), box-shadow var(--lumi-transition); +} + +textarea { + min-height: 7rem; + resize: vertical; +} + +input:hover, +select:hover, +textarea:hover { + border-color: color-mix(in srgb, var(--lumi-primary) 35%, var(--lumi-input-border)); +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--lumi-focus); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-focus) 22%, transparent); +} + +input[type="checkbox"], +input[type="radio"] { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--lumi-primary); +} + +input[type="color"] { + min-width: 3.5rem; + box-shadow: var(--lumi-shadow-sm); +} + +.hint, +.command-subtitle, +.table-note, +.table-page-label { + color: var(--lumi-text-muted); +} + +.table-wrap { + width: 100%; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + overflow: auto; +} + +.table { + min-width: 42rem; +} + +.table th, +.table td { + padding: 0.8rem 0.9rem; +} + +.table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--lumi-surface-subtle); + color: var(--lumi-text-muted); + font: 700 0.75rem/1.3 var(--lumi-font-display); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.table tbody tr { + transition: background var(--lumi-transition); +} + +.table tbody tr:hover { + background: color-mix(in srgb, var(--lumi-primary) 5%, var(--lumi-surface)); +} + +.table tbody tr:last-child td { + border-bottom: 0; +} + +.badge, +.pill, +.status-indicator, +.level-pill, +.origin-pill { + display: inline-flex; + align-items: center; + min-height: 1.65rem; + border-radius: var(--lumi-radius-pill); +} + +.status-indicator::before { + content: ""; + width: 0.5rem; + height: 0.5rem; + margin-right: var(--lumi-space-2); + border-radius: 50%; + background: currentColor; +} + +.status-success { + color: var(--lumi-success); +} + +.status-warning { + color: var(--lumi-warning); +} + +.status-danger { + color: var(--lumi-danger); +} + +.flash, +.alert, +.callout { + padding: var(--lumi-space-3) var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-left-width: 4px; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-subtle); + color: var(--lumi-text); +} + +.flash.success, +.alert.success, +.callout.success { + border-color: color-mix(in srgb, var(--lumi-success) 45%, var(--lumi-border)); + border-left-color: var(--lumi-success); + background: color-mix(in srgb, var(--lumi-success) 10%, var(--lumi-surface)); + color: var(--lumi-text); +} + +.flash.error, +.alert.danger, +.callout.danger { + border-color: color-mix(in srgb, var(--lumi-danger) 45%, var(--lumi-border)); + border-left-color: var(--lumi-danger); + background: color-mix(in srgb, var(--lumi-danger) 10%, var(--lumi-surface)); + color: var(--lumi-text); +} + +.flash.info, +.alert.info { + border-left-color: var(--lumi-info); + background: color-mix(in srgb, var(--lumi-info) 9%, var(--lumi-surface)); +} + +.list li { + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-subtle); +} + +.modal-backdrop { + padding: var(--lumi-space-4); + background: rgba(5, 10, 12, 0.62); + backdrop-filter: blur(5px); +} + +.modal { + width: min(42rem, 100%); + max-height: min(48rem, calc(100vh - 2rem)); + overflow-y: auto; + padding: var(--lumi-space-5); + border-radius: var(--lumi-radius-lg); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-lg); +} + +.modal-header h2, +.modal-header h3 { + margin-bottom: 0; +} + +.tabs, +.ai-tabs { + display: flex; + gap: var(--lumi-space-1); + padding: var(--lumi-space-1); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); + overflow-x: auto; +} + +.tabs a, +.ai-tabs a { + min-height: 2.4rem; + display: inline-flex; + align-items: center; + padding: 0.45rem 0.8rem; + border-radius: var(--lumi-radius-sm); + color: var(--lumi-text-muted); + font-weight: 700; + text-decoration: none; + white-space: nowrap; +} + +.tabs a:hover, +.tabs a[aria-current="page"], +.ai-tabs a:hover { + background: var(--lumi-surface); + color: var(--lumi-text); + box-shadow: var(--lumi-shadow-sm); +} + +details { + scroll-margin-top: 5rem; +} + +details > summary { + border-radius: var(--lumi-radius-sm); +} + +.empty-state, +.loading-state, +.error-state { + display: grid; + place-items: center; + min-height: 10rem; + padding: var(--lumi-space-6); + border: 1px dashed var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); + color: var(--lumi-text-muted); + text-align: center; +} + +.loading-state::before { + content: ""; + width: 1.6rem; + height: 1.6rem; + border: 3px solid var(--lumi-border); + border-top-color: var(--lumi-primary); + border-radius: 50%; + animation: lumi-spin 0.75s linear infinite; +} + +[data-tooltip] { + position: relative; +} + +[data-tooltip]:hover::after, +[data-tooltip]:focus-visible::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 0.5rem); + z-index: 100; + width: max-content; + max-width: 18rem; + padding: 0.4rem 0.55rem; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-text); + color: var(--lumi-surface); + box-shadow: var(--lumi-shadow-md); + font-size: 0.8rem; + transform: translateX(-50%); +} + +@keyframes lumi-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 700px) { + .form-grid { + grid-template-columns: 1fr; + } + + .form-grid .field.full, + .form-grid h2 { + grid-column: auto; + } + + .card, + .panel, + .lumi-panel, + .modal { + padding: var(--lumi-space-4); + } + + .hero { + padding: var(--lumi-space-5); + } + + .table-tools, + .table-controls, + .log-controls { + align-items: stretch; + flex-direction: column; + } + + .table-tools > *, + .table-controls > *, + .log-controls > * { + width: 100%; + } + + .list li { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/web/public/lumi-layout.css b/src/web/public/lumi-layout.css new file mode 100644 index 0000000..42ee374 --- /dev/null +++ b/src/web/public/lumi-layout.css @@ -0,0 +1,260 @@ +html { + min-width: 320px; + background: var(--bg-2); + scroll-behavior: smooth; +} + +body { + font-family: var(--lumi-font-body); + font-size: 1rem; + line-height: 1.55; + background: + radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem), + radial-gradient(circle at 100% 100%, var(--bg-3) 0, transparent 38rem), + var(--bg-2); + background-attachment: fixed; +} + +.app-shell { + grid-template-columns: 17rem minmax(0, 1fr); +} + +.sidebar { + width: 17rem; + gap: var(--lumi-space-4); + padding: var(--lumi-space-4); + background: color-mix(in srgb, var(--lumi-surface) 94%, transparent); + border-color: var(--lumi-border); + box-shadow: var(--lumi-shadow-sm); + backdrop-filter: blur(18px); + z-index: 30; +} + +.sidebar-brand { + gap: var(--lumi-space-2); +} + +.brand-link { + min-height: 3rem; + padding: var(--lumi-space-2); +} + +.sidebar-nav { + gap: var(--lumi-space-2); + scrollbar-width: thin; + scrollbar-color: var(--lumi-border) transparent; +} + +.nav-section { + padding: var(--lumi-space-2); + border-radius: var(--lumi-radius-md); + background: transparent; +} + +.nav-section[open] { + background: var(--lumi-surface-subtle); +} + +.nav-section summary { + min-height: 2.5rem; + padding: var(--lumi-space-2); + border-radius: var(--lumi-radius-sm); +} + +.nav-section summary:hover { + background: var(--lumi-surface-raised); +} + +.nav-links { + gap: var(--lumi-space-1); + padding: var(--lumi-space-2) 0 0 1.75rem; +} + +.nav-link { + min-height: 2.5rem; + padding: var(--lumi-space-2) var(--lumi-space-3); + border: 1px solid transparent; + border-radius: var(--lumi-radius-sm); +} + +.nav-link:hover { + border-color: var(--lumi-border); +} + +.nav-link.active { + background: color-mix(in srgb, var(--lumi-primary) 14%, var(--lumi-surface)); + border-color: color-mix(in srgb, var(--lumi-primary) 28%, var(--lumi-border)); + color: var(--lumi-text); +} + +.page { + min-width: 0; +} + +.content { + width: 100%; + max-width: var(--lumi-content-max); + margin: 0 auto; + padding: clamp(1rem, 2.5vw, 2.5rem); + gap: var(--lumi-space-5); +} + +.content > * { + min-width: 0; +} + +.site-footer { + width: 100%; + max-width: var(--lumi-content-max); + margin: auto auto 0; + padding: var(--lumi-space-5) clamp(1rem, 2.5vw, 2.5rem); + font-size: 0.875rem; +} + +.grid, +.lumi-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + gap: var(--lumi-space-4); +} + +.lumi-stack { + display: flex; + flex-direction: column; + gap: var(--lumi-space-4); +} + +.lumi-cluster, +.button-group, +.page-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--lumi-space-2); +} + +.lumi-split { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--lumi-space-4); +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--lumi-space-4); +} + +.page-header h1, +.section-header h1, +.section-header h2 { + margin-bottom: 0; +} + +.sidebar-scrim { + position: fixed; + inset: 0; + display: none; + background: rgba(5, 10, 12, 0.52); + backdrop-filter: blur(2px); + z-index: 25; +} + +.standalone-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--lumi-space-4); +} + +.standalone-card { + width: min(100%, 45rem); +} + +.standalone-detail { + margin-top: var(--lumi-space-4); +} + +@media (min-width: 901px) { + body.sidebar-collapsed .app-shell { + grid-template-columns: 5.5rem minmax(0, 1fr); + } + + body.sidebar-collapsed .sidebar { + width: 5.5rem; + } +} + +@media (max-width: 1100px) and (min-width: 901px) { + .app-shell { + grid-template-columns: 14rem minmax(0, 1fr); + } + + .sidebar { + width: 14rem; + } +} + +@media (max-width: 900px) { + .sidebar { + width: min(19rem, 88vw); + padding-top: var(--lumi-space-3); + } + + body.sidebar-open { + overflow: hidden; + } + + body.sidebar-open .sidebar-scrim { + display: block; + } + + .mobile-topbar { + min-height: 4rem; + padding: var(--lumi-space-3) var(--lumi-space-4); + background: color-mix(in srgb, var(--lumi-surface) 92%, transparent); + backdrop-filter: blur(18px); + } + + .content { + padding: var(--lumi-space-4); + } +} + +@media (max-width: 600px) { + .content { + gap: var(--lumi-space-4); + padding: var(--lumi-space-3); + } + + .site-footer { + padding: var(--lumi-space-5) var(--lumi-space-4); + } + + .section-header, + .commands-header, + .stats-header, + .lumi-split { + align-items: stretch; + flex-direction: column; + } + + .section-header > *, + .commands-header > *, + .stats-header > * { + width: 100%; + } + + .button-group, + .page-actions { + align-items: stretch; + } + + .button-group .button, + .page-actions .button { + flex: 1 1 auto; + } +} diff --git a/src/web/public/lumi-tokens.css b/src/web/public/lumi-tokens.css new file mode 100644 index 0000000..cbeab8d --- /dev/null +++ b/src/web/public/lumi-tokens.css @@ -0,0 +1,71 @@ +:root { + color-scheme: light dark; + --lumi-font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, "Segoe UI", sans-serif; + --lumi-font-display: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif; + --lumi-font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace; + + --lumi-text: var(--ink, #182026); + --lumi-text-muted: var(--ink-soft, #5a6872); + --lumi-primary: var(--sea, #176b75); + --lumi-accent: var(--sun, #e58b2b); + --lumi-danger: var(--rose, #bd4d4d); + --lumi-success: #23845b; + --lumi-warning: #a96612; + --lumi-info: #3479a8; + --lumi-link: var(--lumi-primary); + --lumi-surface: var(--card, #ffffff); + --lumi-surface-subtle: var(--surface-2, #f4f7f8); + --lumi-surface-raised: var(--surface-3, #edf2f3); + --lumi-border: var(--border, #d8e0e3); + --lumi-input-bg: var(--lumi-surface); + --lumi-input-border: var(--lumi-border); + --lumi-input-text: var(--lumi-text); + --lumi-button-bg: var(--lumi-primary); + --lumi-button-text: #ffffff; + --lumi-button-hover: color-mix(in srgb, var(--lumi-button-bg) 86%, black); + --lumi-focus: color-mix(in srgb, var(--lumi-primary) 72%, white); + + --lumi-space-scale: 1; + --lumi-space-1: calc(0.25rem * var(--lumi-space-scale)); + --lumi-space-2: calc(0.5rem * var(--lumi-space-scale)); + --lumi-space-3: calc(0.75rem * var(--lumi-space-scale)); + --lumi-space-4: calc(1rem * var(--lumi-space-scale)); + --lumi-space-5: calc(1.5rem * var(--lumi-space-scale)); + --lumi-space-6: calc(2rem * var(--lumi-space-scale)); + --lumi-space-7: calc(3rem * var(--lumi-space-scale)); + + --lumi-radius-sm: calc(var(--lumi-radius, 14px) * 0.58); + --lumi-radius-md: var(--lumi-radius, 14px); + --lumi-radius-lg: calc(var(--lumi-radius, 14px) * 1.42); + --lumi-radius-pill: 999px; + --lumi-shadow-sm: 0 1px 2px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 0.7)); + --lumi-shadow-md: 0 12px 34px rgba(11, 20, 24, var(--lumi-shadow-strength, 0.14)); + --lumi-shadow-lg: 0 22px 60px rgba(11, 20, 24, calc(var(--lumi-shadow-strength, 0.14) * 1.15)); + --lumi-transition: 150ms ease; + --lumi-control-height: 2.75rem; + --lumi-content-max: 1600px; + + /* Compatibility aliases for existing core and plugin styles. */ + --text: var(--lumi-text); + --muted: var(--lumi-text-muted); + --primary: var(--lumi-primary); + --accent: var(--lumi-accent); + --danger: var(--lumi-danger); + --success: var(--lumi-success); + --warning: var(--lumi-warning); + --info: var(--lumi-info); + --panel: var(--lumi-surface); + --panel-2: var(--lumi-surface-subtle); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/web/public/theme-editor.css b/src/web/public/theme-editor.css new file mode 100644 index 0000000..56ca52c --- /dev/null +++ b/src/web/public/theme-editor.css @@ -0,0 +1,381 @@ +.theme-library { + display: grid; + gap: var(--lumi-space-4); +} + +.theme-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: var(--lumi-space-4); +} + +.theme-card { + display: flex; + flex-direction: column; + gap: var(--lumi-space-3); + padding: var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-sm); +} + +.theme-card.is-active { + border-color: var(--lumi-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumi-primary) 14%, transparent); +} + +.theme-swatch { + height: 7rem; + display: grid; + grid-template-columns: 1fr 2fr; + grid-template-rows: 1fr 1fr; + gap: var(--lumi-space-2); + padding: var(--lumi-space-3); + border: 1px solid color-mix(in srgb, var(--swatch-primary) 25%, transparent); + border-radius: var(--lumi-radius-sm); + background: var(--swatch-bg); +} + +.theme-swatch span { + border-radius: calc(var(--lumi-radius-sm) * 0.7); + background: var(--swatch-surface); + box-shadow: var(--lumi-shadow-sm); +} + +.theme-swatch span:first-child { + grid-row: 1 / -1; + background: var(--swatch-primary); +} + +.theme-swatch span:last-child { + width: 58%; + background: var(--swatch-accent); +} + +.theme-card-copy { + flex: 1; +} + +.theme-card-copy p { + margin: var(--lumi-space-2) 0 0; + color: var(--lumi-text-muted); +} + +.theme-card-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--lumi-space-2); +} + +.theme-card-title h3 { + margin-bottom: 0; +} + +.theme-kind { + flex: 0 0 auto; + padding: 0.25rem 0.45rem; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-pill); + color: var(--lumi-text-muted); + font-size: 0.7rem; + font-weight: 700; +} + +.theme-card-actions, +.theme-card-more-body { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-2); +} + +.theme-card-actions form { + display: inline-flex; +} + +.theme-card-more { + border-top: 1px solid var(--lumi-border); + padding-top: var(--lumi-space-3); +} + +.theme-card-more summary { + cursor: pointer; + color: var(--lumi-link); + font-weight: 700; +} + +.theme-card-more-body { + align-items: flex-end; + margin-top: var(--lumi-space-3); +} + +.compact-form { + flex: 1 1 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: var(--lumi-space-2); +} + +.compact-form label { + display: grid; + gap: var(--lumi-space-1); + font-weight: 700; +} + +.theme-editor-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 25rem); + align-items: start; + gap: var(--lumi-space-5); + scroll-margin-top: var(--lumi-space-4); +} + +.theme-editor-main { + display: grid; + gap: var(--lumi-space-5); +} + +.theme-edit-form { + display: grid; + gap: var(--lumi-space-5); +} + +.theme-fieldset { + min-width: 0; + margin: 0; + padding: var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-subtle); +} + +.theme-fieldset legend { + padding: 0 var(--lumi-space-2); + font: 700 1.05rem/1 var(--lumi-font-display); +} + +.theme-control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--lumi-space-3); +} + +.theme-color-control, +.theme-range-control { + display: grid; + gap: var(--lumi-space-2); + font-weight: 700; +} + +.theme-color-input { + min-height: var(--lumi-control-height); + display: flex; + align-items: center; + gap: var(--lumi-space-2); + padding: 0.35rem; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface); +} + +.theme-color-input input { + width: 3.2rem; + height: 2.2rem; + padding: 0; + border: 0; +} + +.theme-color-input output { + color: var(--lumi-text-muted); + font: 600 0.8rem/1 var(--lumi-font-mono); +} + +.theme-range-control { + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface); +} + +.theme-range-control > span { + display: flex; + justify-content: space-between; + gap: var(--lumi-space-2); +} + +.theme-range-control output { + color: var(--lumi-text-muted); +} + +.advanced-theme-controls { + margin-top: var(--lumi-space-4); +} + +.advanced-theme-controls summary { + cursor: pointer; + color: var(--lumi-link); + font-weight: 700; +} + +.advanced-theme-controls[open] summary { + margin-bottom: var(--lumi-space-3); +} + +.theme-editor-actions { + position: sticky; + bottom: var(--lumi-space-3); + z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--lumi-space-3); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: color-mix(in srgb, var(--lumi-surface) 92%, transparent); + box-shadow: var(--lumi-shadow-md); + backdrop-filter: blur(16px); +} + +.theme-preview { + position: sticky; + top: var(--lumi-space-4); + display: grid; + gap: var(--lumi-space-3); +} + +.theme-preview-window { + min-height: 26rem; + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: + radial-gradient(circle at 20% 10%, var(--bg-1), transparent 55%), + var(--bg-2); +} + +.theme-preview-nav { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--lumi-space-3); + padding: var(--lumi-space-3); + border-right: 1px solid var(--lumi-border); + background: var(--lumi-surface); +} + +.theme-preview-nav > span:not(.theme-preview-logo) { + width: 1.8rem; + height: 0.38rem; + border-radius: var(--lumi-radius-pill); + background: var(--lumi-border); +} + +.theme-preview-logo { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border-radius: var(--lumi-radius-sm); + background: var(--lumi-primary); + color: var(--lumi-button-text); + font-weight: 800; +} + +.theme-preview-content { + display: flex; + flex-direction: column; + gap: var(--lumi-space-3); + padding: var(--lumi-space-4); +} + +.theme-preview-heading { + width: 60%; + height: 1.2rem; + border-radius: var(--lumi-radius-pill); + background: var(--lumi-text); +} + +.theme-preview-lines { + display: grid; + gap: var(--lumi-space-2); +} + +.theme-preview-lines span { + width: 82%; + height: 0.5rem; + border-radius: var(--lumi-radius-pill); + background: var(--lumi-text-muted); + opacity: 0.45; +} + +.theme-preview-lines span:last-child { + width: 58%; +} + +.theme-preview-sample-card { + margin-top: var(--lumi-space-2); + padding: var(--lumi-space-4); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); + box-shadow: var(--lumi-shadow-md); +} + +.theme-preview-sample-card p { + color: var(--lumi-text-muted); +} + +.theme-preview-sample-card input { + margin-top: var(--lumi-space-3); +} + +.theme-preview-statuses { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-3); + margin-top: var(--lumi-space-3); + font-size: 0.8rem; + font-weight: 700; +} + +.is-selected { + border-color: var(--lumi-primary) !important; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--lumi-primary) 14%, transparent) !important; +} + +@media (max-width: 1100px) { + .theme-editor-shell { + grid-template-columns: 1fr; + } + + .theme-preview { + position: static; + order: -1; + } + + .theme-preview-window { + min-height: 20rem; + } +} + +@media (max-width: 600px) { + .compact-form { + grid-template-columns: 1fr; + } + + .theme-editor-actions { + align-items: stretch; + flex-direction: column; + } + + .theme-editor-actions .button-group, + .theme-editor-actions .button { + width: 100%; + } +} diff --git a/src/web/public/theme-editor.js b/src/web/public/theme-editor.js new file mode 100644 index 0000000..295d858 --- /dev/null +++ b/src/web/public/theme-editor.js @@ -0,0 +1,89 @@ +(() => { + const editor = document.querySelector("[data-theme-editor]"); + const form = editor?.querySelector("[data-theme-form]"); + if (!editor || !form) return; + + const root = document.documentElement; + const originalScheme = root.dataset.colorScheme || ""; + const tokenVariables = { + bg1: "--bg-1", + bg2: "--bg-2", + bg3: "--bg-3", + text: "--ink", + muted: "--ink-soft", + accent: "--sea", + accentAlt: "--sun", + success: "--lumi-success", + warning: "--lumi-warning", + danger: "--rose", + info: "--lumi-info", + surface: "--card", + surface2: "--surface-2", + surface3: "--surface-3", + border: "--border", + link: "--lumi-link", + buttonBg: "--lumi-button-bg", + buttonText: "--lumi-button-text", + buttonHover: "--lumi-button-hover", + inputBg: "--lumi-input-bg", + inputBorder: "--lumi-input-border", + inputText: "--lumi-input-text", + focusRing: "--lumi-focus" + }; + const metricVariables = { + radius: ["--lumi-radius", "px"], + shadowStrength: ["--lumi-shadow-strength", ""], + spacingScale: ["--lumi-space-scale", ""] + }; + let previewMode = "light"; + + const updateOutputs = () => { + form.querySelectorAll('input[type="color"]').forEach((input) => { + const output = input.closest("label")?.querySelector("output"); + if (output) output.value = input.value.toUpperCase(); + }); + form.querySelectorAll("[data-theme-metric]").forEach((input) => { + const output = input.closest("label")?.querySelector("[data-range-output]"); + if (output) output.value = `${input.value}${input.dataset.unit || ""}`; + }); + }; + + const applyPreview = () => { + root.dataset.colorScheme = previewMode; + form.querySelectorAll(`[data-theme-mode="${previewMode}"]`).forEach((input) => { + const variable = tokenVariables[input.dataset.themeToken]; + if (variable) root.style.setProperty(variable, input.value); + }); + form.querySelectorAll("[data-theme-role]").forEach((input) => { + root.style.setProperty(`--role-${input.dataset.themeRole}`, input.value); + }); + form.querySelectorAll("[data-theme-metric]").forEach((input) => { + const config = metricVariables[input.dataset.themeMetric]; + if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`); + }); + updateOutputs(); + }; + + form.addEventListener("input", applyPreview); + editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => { + button.addEventListener("click", () => { + previewMode = button.dataset.themePreviewMode; + editor.querySelectorAll("[data-theme-preview-mode]").forEach((item) => { + item.classList.toggle("is-selected", item === button); + }); + applyPreview(); + }); + }); + + editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => { + form.reset(); + applyPreview(); + }); + + window.addEventListener("beforeunload", () => { + if (originalScheme) root.dataset.colorScheme = originalScheme; + else delete root.dataset.colorScheme; + }); + + applyPreview(); +})(); diff --git a/src/web/server.js b/src/web/server.js index b372332..a58f92e 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -14,6 +14,17 @@ const BetterSqlite3Store = require("better-sqlite3-session-store")(session); const { db } = require("../services/db"); const { getSetting, setSetting, getAllSettings } = require("../services/settings"); +const { + deleteCustomTheme, + duplicateTheme, + getActiveTheme, + getThemeById, + listThemes, + renameCustomTheme, + saveCustomTheme, + setActiveTheme, + valuesFromRequest +} = require("../services/themes"); const { getRoleFlags, hasAccess } = require("../services/rbac"); const { buildDiscordAuthUrl, @@ -1373,6 +1384,12 @@ function buildCustomPageSrcdoc(page, theme) { ` --bg-1: ${theme.light.bg1};`, ` --bg-2: ${theme.light.bg2};`, ` --bg-3: ${theme.light.bg3};`, + ` --success: ${theme.light.success};`, + ` --warning: ${theme.light.warning};`, + ` --info: ${theme.light.info};`, + ` --link: ${theme.light.link};`, + ` --radius: ${theme.metrics.radius}px;`, + ` --spacing-scale: ${theme.metrics.spacingScale};`, "}", "@media (prefers-color-scheme: dark) {", " :root {", @@ -1388,6 +1405,10 @@ function buildCustomPageSrcdoc(page, theme) { ` --bg-1: ${theme.dark.bg1};`, ` --bg-2: ${theme.dark.bg2};`, ` --bg-3: ${theme.dark.bg3};`, + ` --success: ${theme.dark.success};`, + ` --warning: ${theme.dark.warning};`, + ` --info: ${theme.dark.info};`, + ` --link: ${theme.dark.link};`, " }", "}" ].join("\n") @@ -1457,41 +1478,7 @@ function setFlash(req, type, message) { } function getThemeSettings() { - return { - 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 getActiveTheme(); } function getDiscordSettings() { @@ -3969,48 +3956,137 @@ function createWebServer({ loadPlugins, discordClient }) { }); app.get("/admin/theming", requireRole("admin"), (req, res) => { + const activeTheme = getActiveTheme(); + const requestedEdit = String(req.query.edit || ""); + const editingTheme = requestedEdit + ? getThemeById(requestedEdit) + : activeTheme.builtin + ? null + : activeTheme; res.render("admin-theme", { title: "Theming", - theme: getThemeSettings() + theme: activeTheme, + activeTheme, + themes: listThemes(), + editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null }); }); - app.post("/admin/theming", requireRole("admin"), (req, res) => { - const fields = [ - "theme_light_bg_1", - "theme_light_bg_2", - "theme_light_bg_3", - "theme_light_text", - "theme_light_text_muted", - "theme_light_accent", - "theme_light_accent_alt", - "theme_light_danger", - "theme_light_surface", - "theme_light_surface_2", - "theme_light_surface_3", - "theme_light_border", - "theme_dark_bg_1", - "theme_dark_bg_2", - "theme_dark_bg_3", - "theme_dark_text", - "theme_dark_text_muted", - "theme_dark_accent", - "theme_dark_accent_alt", - "theme_dark_danger", - "theme_dark_surface", - "theme_dark_surface_2", - "theme_dark_surface_3", - "theme_dark_border", - "theme_role_public", - "theme_role_mod", - "theme_role_admin" - ]; - for (const field of fields) { - if (req.body[field] !== undefined) { - setSetting(field, req.body[field].trim()); - } + app.post("/admin/theming/select", requireRole("admin"), (req, res) => { + try { + const theme = setActiveTheme(String(req.body.theme_id || "")); + setFlash(req, "success", `${theme.name} is now active.`); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect("/admin/theming"); + }); + + app.post("/admin/theming/duplicate", requireRole("admin"), (req, res) => { + try { + const theme = duplicateTheme(req.body.theme_id, req.body.name); + setFlash(req, "success", `${theme.name} created. You can edit it below.`); + return res.redirect(`/admin/theming?edit=${encodeURIComponent(theme.id)}#theme-editor`); + } catch (error) { + setFlash(req, "error", error.message); + return res.redirect("/admin/theming"); + } + }); + + app.post("/admin/theming/custom/:id/save", requireRole("admin"), (req, res) => { + const themeId = `custom:${req.params.id}`; + try { + const current = getThemeById(themeId); + if (!current) throw new Error("Theme not found."); + const theme = saveCustomTheme(themeId, valuesFromRequest(req.body, current)); + if (req.body.apply === "on") setActiveTheme(theme.id); + setFlash( + req, + "success", + req.body.apply === "on" + ? `${theme.name} saved and applied.` + : `${theme.name} saved.` + ); + } catch (error) { + const detail = error.validationErrors?.join(" ") || error.message; + setFlash(req, "error", detail); + } + res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}#theme-editor`); + }); + + app.post("/admin/theming/custom/:id/rename", requireRole("admin"), (req, res) => { + const themeId = `custom:${req.params.id}`; + try { + const theme = renameCustomTheme(themeId, req.body.name); + setFlash(req, "success", `Theme renamed to ${theme.name}.`); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(`/admin/theming?edit=${encodeURIComponent(themeId)}`); + }); + + app.post("/admin/theming/custom/:id/delete", requireRole("admin"), (req, res) => { + try { + deleteCustomTheme(`custom:${req.params.id}`); + setFlash(req, "success", "Custom theme deleted."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect("/admin/theming"); + }); + + app.post("/admin/theming", requireRole("admin"), (req, res) => { + let createdTarget = null; + try { + let target = getActiveTheme(); + if (target.builtin) { + target = duplicateTheme(target.id, "Legacy Custom"); + createdTarget = target.id; + } + const values = JSON.parse(JSON.stringify(target)); + const legacyUpdates = []; + const legacyMap = { + theme_light_bg_1: ["light", "bg1"], + theme_light_bg_2: ["light", "bg2"], + theme_light_bg_3: ["light", "bg3"], + theme_light_text: ["light", "text"], + theme_light_text_muted: ["light", "muted"], + theme_light_accent: ["light", "accent"], + theme_light_accent_alt: ["light", "accentAlt"], + theme_light_danger: ["light", "danger"], + theme_light_surface: ["light", "surface"], + theme_light_surface_2: ["light", "surface2"], + theme_light_surface_3: ["light", "surface3"], + theme_light_border: ["light", "border"], + theme_dark_bg_1: ["dark", "bg1"], + theme_dark_bg_2: ["dark", "bg2"], + theme_dark_bg_3: ["dark", "bg3"], + theme_dark_text: ["dark", "text"], + theme_dark_text_muted: ["dark", "muted"], + theme_dark_accent: ["dark", "accent"], + theme_dark_accent_alt: ["dark", "accentAlt"], + theme_dark_danger: ["dark", "danger"], + theme_dark_surface: ["dark", "surface"], + theme_dark_surface_2: ["dark", "surface2"], + theme_dark_surface_3: ["dark", "surface3"], + theme_dark_border: ["dark", "border"], + theme_role_public: ["role", "public"], + theme_role_mod: ["role", "mod"], + theme_role_admin: ["role", "admin"] + }; + for (const [field, [group, key]] of Object.entries(legacyMap)) { + if (req.body[field] === undefined) continue; + values[group][key] = String(req.body[field]).trim(); + legacyUpdates.push([field, values[group][key]]); + } + saveCustomTheme(target.id, values); + for (const [field, value] of legacyUpdates) setSetting(field, value); + setActiveTheme(target.id); + setFlash(req, "success", "Theme updated."); + } catch (error) { + if (createdTarget) deleteCustomTheme(createdTarget); + setFlash(req, "error", error.message); } - setFlash(req, "success", "Theme updated."); res.redirect("/admin/theming"); }); diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs index 7ed105b..2778b5c 100644 --- a/src/web/views/admin-dashboard.ejs +++ b/src/web/views/admin-dashboard.ejs @@ -1,6 +1,10 @@ <%- include("partials/layout-top", { title }) %>
-

Admin dashboard

+ <%- include("partials/page-header", { + eyebrow: "Administration", + pageTitle: "Admin dashboard", + description: "Configure Lumi, manage community tools, and maintain the installation." + }) %>

Settings

@@ -9,8 +13,8 @@

Theming

-

Adjust light and dark mode colors.

- Edit theme +

Select protected presets or create editable custom themes.

+ Open theme studio

Commands

@@ -41,15 +45,17 @@

Maintenance

-
- -
-
- -
-
- -
+
+
+ +
+
+ +
+
+ +
+
<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-pages.ejs b/src/web/views/admin-pages.ejs index 8269334..bfb7eea 100644 --- a/src/web/views/admin-pages.ejs +++ b/src/web/views/admin-pages.ejs @@ -1,6 +1,10 @@ <%- include("partials/layout-top", { title }) %>
-

Custom pages

+ <%- include("partials/page-header", { + eyebrow: "Content", + pageTitle: "Custom pages", + description: "Publish role-aware HTML or Markdown pages without changing routes." + }) %>
@@ -49,8 +53,9 @@

Existing pages

<% if (!pages.length) { %> -

No pages created yet.

+
No pages created yet.
<% } else { %> +
@@ -144,6 +149,7 @@ <% }) %>
+
<% } %>
<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index cf498a5..32ab2e1 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -1,8 +1,11 @@ -<%- include("partials/layout-top", { title }) %> -
-

Updates

-

Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.

-

Rollback is handled from Safe Mode if something breaks.

+<%- include("partials/layout-top", { title }) %> +
+ <%- include("partials/page-header", { + eyebrow: "Maintenance", + pageTitle: "Updates", + description: "Apply git or ZIP updates with automatic pre-update snapshots." + }) %> +

Rollback is handled from Safe Mode if something breaks.

@@ -51,11 +54,12 @@
-

Snapshots

- <% if (!snapshots.length) { %> -

No snapshots yet.

- <% } else { %> - +

Snapshots

+ <% if (!snapshots.length) { %> +
No snapshots yet.
+ <% } else { %> +
+
@@ -70,7 +74,8 @@ <% }) %> -
Snapshot
- <% } %> -
+ +
+ <% } %> + <%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-users.ejs b/src/web/views/admin-users.ejs index 4aa0d36..e635052 100644 --- a/src/web/views/admin-users.ejs +++ b/src/web/views/admin-users.ejs @@ -1,8 +1,12 @@ <%- include("partials/layout-top", { title }) %>
-

Users

+ <%- include("partials/page-header", { + eyebrow: "Community", + pageTitle: "Users", + description: "Review linked identities, notes, and internal usernames." + }) %> <% if (!users.length) { %> -

No users yet.

+
No users yet.
<% } else { %>
+
@@ -71,6 +76,7 @@ <% }) %>
+
<% } %>
+