ui: modernize WebUI and add managed themes

This commit is contained in:
Franz Rolfsvaag 2026-06-15 23:58:24 +02:00
parent c8208b78b7
commit 15bcd53c99
25 changed files with 2766 additions and 271 deletions

View File

@ -5,7 +5,8 @@
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
"start": "node src/main.js", "start": "node src/main.js",
"run": "node run.js" "run": "node run.js",
"verify:webui": "node scripts/verify-webui.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View File

@ -4,11 +4,15 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title> <title><%= title %></title>
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
<%- include("../../../src/web/views/partials/theme-vars", { theme }) %>
</head> </head>
<body> <body data-theme-id="<%= theme ? theme.id : '' %>">
<div class="page" style="min-height: 100vh; display: flex; align-items: center; justify-content: center;"> <main class="standalone-page">
<div class="card" style="max-width: 720px; width: 100%;"> <div class="card standalone-card">
<h1>Access restricted</h1> <h1>Access restricted</h1>
<p class="hint">Your account is currently restricted by moderation.</p> <p class="hint">Your account is currently restricted by moderation.</p>
<div class="stat-grid"> <div class="stat-grid">
@ -29,7 +33,7 @@
<span class="stat-value"><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %></span> <span class="stat-value"><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %></span>
</div> </div>
</div> </div>
<div class="card" style="margin-top: 16px;"> <div class="card standalone-detail">
<h2>Summary</h2> <h2>Summary</h2>
<p><%= sanction.reason_short %></p> <p><%= sanction.reason_short %></p>
<h2>Details</h2> <h2>Details</h2>
@ -37,6 +41,6 @@
<p class="hint">Moderator: <%= sanction.created_by_name || 'Staff' %></p> <p class="hint">Moderator: <%= sanction.created_by_name || 'Staff' %></p>
</div> </div>
</div> </div>
</div> </main>
</body> </body>
</html> </html>

123
scripts/verify-webui.js Normal file
View File

@ -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 &middot; 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.`);

View File

@ -18,6 +18,15 @@ function migrate() {
updated_at INTEGER NOT NULL 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 ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,

654
src/services/themes.js Normal file
View File

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

View File

@ -1,6 +1,11 @@
(() => { (() => {
const body = document.body; const body = document.body;
const media = window.matchMedia("(max-width: 900px)"); 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) => { document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@ -8,10 +13,18 @@
body.classList.toggle("sidebar-open"); body.classList.toggle("sidebar-open");
} else { } else {
body.classList.toggle("sidebar-collapsed"); 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) => { document.querySelectorAll(".nav-link").forEach((link) => {
link.addEventListener("click", () => { link.addEventListener("click", () => {
if (body.classList.contains("sidebar-open")) { 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( const editToggles = Array.from(
document.querySelectorAll("[data-edit-toggle]") document.querySelectorAll("[data-edit-toggle]")
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
})();

View File

@ -14,6 +14,17 @@ const BetterSqlite3Store = require("better-sqlite3-session-store")(session);
const { db } = require("../services/db"); const { db } = require("../services/db");
const { getSetting, setSetting, getAllSettings } = require("../services/settings"); 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 { getRoleFlags, hasAccess } = require("../services/rbac");
const { const {
buildDiscordAuthUrl, buildDiscordAuthUrl,
@ -1373,6 +1384,12 @@ function buildCustomPageSrcdoc(page, theme) {
` --bg-1: ${theme.light.bg1};`, ` --bg-1: ${theme.light.bg1};`,
` --bg-2: ${theme.light.bg2};`, ` --bg-2: ${theme.light.bg2};`,
` --bg-3: ${theme.light.bg3};`, ` --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) {", "@media (prefers-color-scheme: dark) {",
" :root {", " :root {",
@ -1388,6 +1405,10 @@ function buildCustomPageSrcdoc(page, theme) {
` --bg-1: ${theme.dark.bg1};`, ` --bg-1: ${theme.dark.bg1};`,
` --bg-2: ${theme.dark.bg2};`, ` --bg-2: ${theme.dark.bg2};`,
` --bg-3: ${theme.dark.bg3};`, ` --bg-3: ${theme.dark.bg3};`,
` --success: ${theme.dark.success};`,
` --warning: ${theme.dark.warning};`,
` --info: ${theme.dark.info};`,
` --link: ${theme.dark.link};`,
" }", " }",
"}" "}"
].join("\n") ].join("\n")
@ -1457,41 +1478,7 @@ function setFlash(req, type, message) {
} }
function getThemeSettings() { function getThemeSettings() {
return { return getActiveTheme();
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")
}
};
} }
function getDiscordSettings() { function getDiscordSettings() {
@ -3969,48 +3956,137 @@ function createWebServer({ loadPlugins, discordClient }) {
}); });
app.get("/admin/theming", requireRole("admin"), (req, res) => { 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", { res.render("admin-theme", {
title: "Theming", title: "Theming",
theme: getThemeSettings() theme: activeTheme,
activeTheme,
themes: listThemes(),
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null
}); });
}); });
app.post("/admin/theming", requireRole("admin"), (req, res) => { app.post("/admin/theming/select", requireRole("admin"), (req, res) => {
const fields = [ try {
"theme_light_bg_1", const theme = setActiveTheme(String(req.body.theme_id || ""));
"theme_light_bg_2", setFlash(req, "success", `${theme.name} is now active.`);
"theme_light_bg_3", } catch (error) {
"theme_light_text", setFlash(req, "error", error.message);
"theme_light_text_muted", }
"theme_light_accent", res.redirect("/admin/theming");
"theme_light_accent_alt", });
"theme_light_danger",
"theme_light_surface", app.post("/admin/theming/duplicate", requireRole("admin"), (req, res) => {
"theme_light_surface_2", try {
"theme_light_surface_3", const theme = duplicateTheme(req.body.theme_id, req.body.name);
"theme_light_border", setFlash(req, "success", `${theme.name} created. You can edit it below.`);
"theme_dark_bg_1", return res.redirect(`/admin/theming?edit=${encodeURIComponent(theme.id)}#theme-editor`);
"theme_dark_bg_2", } catch (error) {
"theme_dark_bg_3", setFlash(req, "error", error.message);
"theme_dark_text", return res.redirect("/admin/theming");
"theme_dark_text_muted", }
"theme_dark_accent", });
"theme_dark_accent_alt",
"theme_dark_danger", app.post("/admin/theming/custom/:id/save", requireRole("admin"), (req, res) => {
"theme_dark_surface", const themeId = `custom:${req.params.id}`;
"theme_dark_surface_2", try {
"theme_dark_surface_3", const current = getThemeById(themeId);
"theme_dark_border", if (!current) throw new Error("Theme not found.");
"theme_role_public", const theme = saveCustomTheme(themeId, valuesFromRequest(req.body, current));
"theme_role_mod", if (req.body.apply === "on") setActiveTheme(theme.id);
"theme_role_admin" setFlash(
]; req,
for (const field of fields) { "success",
if (req.body[field] !== undefined) { req.body.apply === "on"
setSetting(field, req.body[field].trim()); ? `${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"); res.redirect("/admin/theming");
}); });

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Admin dashboard</h1> <%- include("partials/page-header", {
eyebrow: "Administration",
pageTitle: "Admin dashboard",
description: "Configure Lumi, manage community tools, and maintain the installation."
}) %>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h2>Settings</h2> <h2>Settings</h2>
@ -9,8 +13,8 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Theming</h2> <h2>Theming</h2>
<p>Adjust light and dark mode colors.</p> <p>Select protected presets or create editable custom themes.</p>
<a href="/admin/theming" class="link">Edit theme</a> <a href="/admin/theming" class="link">Open theme studio</a>
</div> </div>
<div class="card"> <div class="card">
<h2>Commands</h2> <h2>Commands</h2>
@ -41,15 +45,17 @@
</section> </section>
<section class="card"> <section class="card">
<h2>Maintenance</h2> <h2>Maintenance</h2>
<form method="post" action="/admin/check-update" class="inline-form"> <div class="button-group">
<button type="submit" class="button subtle">Check for updates</button> <form method="post" action="/admin/check-update" class="inline-form">
</form> <button type="submit" class="button subtle">Check for updates</button>
<form method="post" action="/admin/update" class="inline-form"> </form>
<button type="submit" class="button">Update from git</button> <form method="post" action="/admin/update" class="inline-form">
</form> <button type="submit" class="button">Update from git</button>
<form method="post" action="/admin/restart" class="inline-form"> </form>
<button type="submit" class="button subtle">Restart bot</button> <form method="post" action="/admin/restart" class="inline-form">
</form> <button type="submit" class="button subtle">Restart bot</button>
</form>
</div>
</section> </section>
<%- include("partials/layout-bottom") %> <%- include("partials/layout-bottom") %>

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Custom pages</h1> <%- include("partials/page-header", {
eyebrow: "Content",
pageTitle: "Custom pages",
description: "Publish role-aware HTML or Markdown pages without changing routes."
}) %>
<form method="post" action="/admin/pages" class="form-grid" data-page-form> <form method="post" action="/admin/pages" class="form-grid" data-page-form>
<div class="field"> <div class="field">
<label>Slug</label> <label>Slug</label>
@ -49,8 +53,9 @@
</form> </form>
<h2>Existing pages</h2> <h2>Existing pages</h2>
<% if (!pages.length) { %> <% if (!pages.length) { %>
<p>No pages created yet.</p> <div class="empty-state">No pages created yet.</div>
<% } else { %> <% } else { %>
<div class="table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -144,6 +149,7 @@
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
</div>
<% } %> <% } %>
</section> </section>
<script> <script>

View File

@ -1,10 +1,15 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Plugins</h1> <%- include("partials/page-header", {
eyebrow: "Extensions",
pageTitle: "Plugins",
description: "Install, update, enable, and remove Lumi modules."
}) %>
<h2>Installed plugins</h2> <h2>Installed plugins</h2>
<% if (!plugins.length) { %> <% if (!plugins.length) { %>
<p>No plugins installed.</p> <div class="empty-state">No plugins installed.</div>
<% } else { %> <% } else { %>
<div class="table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -36,6 +41,7 @@
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
</div>
<% } %> <% } %>
</section> </section>
<section class="card"> <section class="card">

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Settings</h1> <%- include("partials/page-header", {
eyebrow: "Administration",
pageTitle: "Settings",
description: "Manage core behavior, updates, and platform integrations."
}) %>
<form method="post" action="/admin/settings" class="form-grid"> <form method="post" action="/admin/settings" class="form-grid">
<div class="field"> <div class="field">
<label>Site title</label> <label>Site title</label>

View File

@ -1,122 +1,266 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <link rel="stylesheet" href="/theme-editor.css?v=<%= assetVersion %>" />
<h1>Theming</h1> <%
<p>Update light and dark mode colors used across the WebUI.</p> const fieldLabels = {
<form method="post" action="/admin/theming" class="form-grid theme-grid"> bg1: "Background glow",
<h2>Light mode</h2> bg2: "Page background",
<div class="field"> bg3: "Accent glow",
<label>Background 1</label> text: "Primary text",
<input type="color" name="theme_light_bg_1" value="<%= theme.light.bg1 %>" /> muted: "Muted text",
</div> accent: "Primary",
<div class="field"> accentAlt: "Accent",
<label>Background 2</label> success: "Success",
<input type="color" name="theme_light_bg_2" value="<%= theme.light.bg2 %>" /> warning: "Warning",
</div> danger: "Danger",
<div class="field"> info: "Information",
<label>Background 3</label> surface: "Surface",
<input type="color" name="theme_light_bg_3" value="<%= theme.light.bg3 %>" /> surface2: "Subtle surface",
</div> surface3: "Raised surface",
<div class="field"> border: "Border",
<label>Text</label> link: "Link",
<input type="color" name="theme_light_text" value="<%= theme.light.text %>" /> buttonBg: "Button background",
</div> buttonText: "Button text",
<div class="field"> buttonHover: "Button hover",
<label>Muted text</label> inputBg: "Input background",
<input type="color" name="theme_light_text_muted" value="<%= theme.light.muted %>" /> inputBorder: "Input border",
</div> inputText: "Input text",
<div class="field"> focusRing: "Focus ring"
<label>Accent</label> };
<input type="color" name="theme_light_accent" value="<%= theme.light.accent %>" /> const basicFields = [
</div> "accent", "accentAlt", "bg2", "surface", "surface2", "text", "muted",
<div class="field"> "border", "success", "warning", "danger", "info"
<label>Accent alt</label> ];
<input type="color" name="theme_light_accent_alt" value="<%= theme.light.accentAlt %>" /> const advancedFields = [
</div> "bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
<div class="field"> "buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
<label>Danger</label> ];
<input type="color" name="theme_light_danger" value="<%= theme.light.danger %>" /> %>
</div>
<div class="field">
<label>Surface</label>
<input type="color" name="theme_light_surface" value="<%= theme.light.surface %>" />
</div>
<div class="field">
<label>Surface 2</label>
<input type="color" name="theme_light_surface_2" value="<%= theme.light.surface2 %>" />
</div>
<div class="field">
<label>Surface 3</label>
<input type="color" name="theme_light_surface_3" value="<%= theme.light.surface3 %>" />
</div>
<div class="field">
<label>Border</label>
<input type="color" name="theme_light_border" value="<%= theme.light.border %>" />
</div>
<h2>Dark mode</h2> <header class="page-header">
<div class="field"> <div>
<label>Background 1</label> <span class="eyebrow">Appearance</span>
<input type="color" name="theme_dark_bg_1" value="<%= theme.dark.bg1 %>" /> <h1>Theme studio</h1>
</div> <p class="command-subtitle">
<div class="field"> Select a protected Lumi preset or duplicate one into a custom theme.
<label>Background 2</label> </p>
<input type="color" name="theme_dark_bg_2" value="<%= theme.dark.bg2 %>" /> </div>
</div> <span class="status-indicator status-success">
<div class="field"> Active: <strong><%= activeTheme.name %></strong>
<label>Background 3</label> </span>
<input type="color" name="theme_dark_bg_3" value="<%= theme.dark.bg3 %>" /> </header>
</div>
<div class="field">
<label>Text</label>
<input type="color" name="theme_dark_text" value="<%= theme.dark.text %>" />
</div>
<div class="field">
<label>Muted text</label>
<input type="color" name="theme_dark_text_muted" value="<%= theme.dark.muted %>" />
</div>
<div class="field">
<label>Accent</label>
<input type="color" name="theme_dark_accent" value="<%= theme.dark.accent %>" />
</div>
<div class="field">
<label>Accent alt</label>
<input type="color" name="theme_dark_accent_alt" value="<%= theme.dark.accentAlt %>" />
</div>
<div class="field">
<label>Danger</label>
<input type="color" name="theme_dark_danger" value="<%= theme.dark.danger %>" />
</div>
<div class="field">
<label>Surface</label>
<input type="color" name="theme_dark_surface" value="<%= theme.dark.surface %>" />
</div>
<div class="field">
<label>Surface 2</label>
<input type="color" name="theme_dark_surface_2" value="<%= theme.dark.surface2 %>" />
</div>
<div class="field">
<label>Surface 3</label>
<input type="color" name="theme_dark_surface_3" value="<%= theme.dark.surface3 %>" />
</div>
<div class="field">
<label>Border</label>
<input type="color" name="theme_dark_border" value="<%= theme.dark.border %>" />
</div>
<h2>Role colors</h2> <section class="theme-library" aria-labelledby="theme-library-title">
<div class="field"> <div class="section-header">
<label>Public</label> <div>
<input type="color" name="theme_role_public" value="<%= theme.role.public %>" /> <h2 id="theme-library-title">Theme library</h2>
<p class="hint">Built-in themes are read-only and always remain available.</p>
</div> </div>
<div class="field"> </div>
<label>Moderator</label> <div class="theme-card-grid">
<input type="color" name="theme_role_mod" value="<%= theme.role.mod %>" /> <% themes.forEach((item) => { %>
</div> <% const customId = item.builtin ? null : item.id.replace("custom:", ""); %>
<div class="field"> <article class="theme-card <%= item.id === activeTheme.id ? 'is-active' : '' %>">
<label>Admin</label> <div class="theme-swatch" style="--swatch-bg: <%= item.light.bg2 %>; --swatch-surface: <%= item.light.surface %>; --swatch-primary: <%= item.light.accent %>; --swatch-accent: <%= item.light.accentAlt %>;">
<input type="color" name="theme_role_admin" value="<%= theme.role.admin %>" /> <span></span><span></span><span></span>
</div> </div>
<button type="submit" class="button">Save theme</button> <div class="theme-card-copy">
</form> <div class="theme-card-title">
<h3><%= item.name %></h3>
<span class="theme-kind"><%- item.builtin ? "Built-in &middot; read-only" : "Custom" %></span>
</div>
<p><%= item.description %></p>
</div>
<div class="theme-card-actions">
<% if (item.id !== activeTheme.id) { %>
<form method="post" action="/admin/theming/select">
<input type="hidden" name="theme_id" value="<%= item.id %>" />
<button type="submit" class="button subtle">Apply</button>
</form>
<% } else { %>
<span class="button subtle disabled" aria-current="true">Active</span>
<% } %>
<% if (!item.builtin) { %>
<a class="button subtle" href="/admin/theming?edit=<%= encodeURIComponent(item.id) %>#theme-editor">Edit</a>
<% } %>
</div>
<details class="theme-card-more">
<summary>More actions</summary>
<div class="theme-card-more-body">
<form method="post" action="/admin/theming/duplicate" class="compact-form">
<input type="hidden" name="theme_id" value="<%= item.id %>" />
<label>
<span>Copy name</span>
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
</label>
<button type="submit" class="button">Duplicate</button>
</form>
<% if (!item.builtin) { %>
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
<label>
<span>Theme name</span>
<input name="name" value="<%= item.name %>" maxlength="60" required />
</label>
<button type="submit" class="button subtle">Rename</button>
</form>
<form
method="post"
action="/admin/theming/custom/<%= customId %>/delete"
data-confirm-title="Delete custom theme"
data-confirm-text="Delete <%= item.name %>? Built-in themes are not affected."
>
<button type="submit" class="button danger">Delete</button>
</form>
<% } %>
</div>
</details>
</article>
<% }) %>
</div>
</section> </section>
<% if (editingTheme) { %>
<section class="theme-editor-shell" id="theme-editor" data-theme-editor>
<div class="theme-editor-main card">
<div class="section-header">
<div>
<span class="eyebrow">Custom theme</span>
<h2>Edit <%= editingTheme.name %></h2>
<p class="hint">Based on <%= themes.find((item) => item.id === editingTheme.baseThemeId)?.name || "Lumi Default" %>.</p>
</div>
<div class="button-group" aria-label="Preview color scheme">
<button type="button" class="button subtle is-selected" data-theme-preview-mode="light">Light preview</button>
<button type="button" class="button subtle" data-theme-preview-mode="dark">Dark preview</button>
</div>
</div>
<form
method="post"
action="/admin/theming/custom/<%= editingTheme.id.replace('custom:', '') %>/save"
class="theme-edit-form"
data-theme-form
>
<% ["light", "dark"].forEach((mode) => { %>
<fieldset class="theme-fieldset" data-theme-mode-fields="<%= mode %>">
<legend><%= mode === "light" ? "Light mode" : "Dark mode" %></legend>
<div class="theme-control-grid">
<% basicFields.forEach((field) => { %>
<label class="theme-color-control">
<span><%= fieldLabels[field] %></span>
<span class="theme-color-input">
<input
type="color"
name="<%= mode %>_<%= field %>"
value="<%= editingTheme[mode][field] %>"
data-theme-token="<%= field %>"
data-theme-mode="<%= mode %>"
/>
<output><%= editingTheme[mode][field] %></output>
</span>
</label>
<% }) %>
</div>
<details class="advanced-theme-controls">
<summary>Advanced <%= mode %> variables</summary>
<div class="theme-control-grid">
<% advancedFields.forEach((field) => { %>
<label class="theme-color-control">
<span><%= fieldLabels[field] %></span>
<span class="theme-color-input">
<input
type="color"
name="<%= mode %>_<%= field %>"
value="<%= editingTheme[mode][field] %>"
data-theme-token="<%= field %>"
data-theme-mode="<%= mode %>"
/>
<output><%= editingTheme[mode][field] %></output>
</span>
</label>
<% }) %>
</div>
</details>
</fieldset>
<% }) %>
<fieldset class="theme-fieldset">
<legend>Roles and shape</legend>
<div class="theme-control-grid">
<% ["public", "mod", "admin"].forEach((role) => { %>
<label class="theme-color-control">
<span><%= role.charAt(0).toUpperCase() + role.slice(1) %> role</span>
<span class="theme-color-input">
<input type="color" name="role_<%= role %>" value="<%= editingTheme.role[role] %>" data-theme-role="<%= role %>" />
<output><%= editingTheme.role[role] %></output>
</span>
</label>
<% }) %>
<label class="theme-range-control">
<span>Border radius <output data-range-output><%= editingTheme.metrics.radius %>px</output></span>
<input type="range" name="metrics_radius" min="0" max="32" step="1" value="<%= editingTheme.metrics.radius %>" data-theme-metric="radius" data-unit="px" />
</label>
<label class="theme-range-control">
<span>Shadow strength <output data-range-output><%= editingTheme.metrics.shadowStrength %></output></span>
<input type="range" name="metrics_shadowStrength" min="0" max="0.35" step="0.01" value="<%= editingTheme.metrics.shadowStrength %>" data-theme-metric="shadowStrength" />
</label>
<label class="theme-range-control">
<span>Spacing scale <output data-range-output><%= editingTheme.metrics.spacingScale %></output></span>
<input type="range" name="metrics_spacingScale" min="0.75" max="1.35" step="0.05" value="<%= editingTheme.metrics.spacingScale %>" data-theme-metric="spacingScale" />
</label>
</div>
</fieldset>
<div class="theme-editor-actions">
<label class="switch">
<input type="checkbox" class="switch-input" name="apply" <%= editingTheme.id === activeTheme.id ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Apply globally after saving</span>
</label>
<div class="button-group">
<button type="button" class="button subtle" data-theme-reset>Revert unsaved</button>
<a href="/admin/theming" class="button subtle">Cancel</a>
<button type="submit" class="button">Save theme</button>
</div>
</div>
</form>
</div>
<aside class="theme-preview card" aria-label="Live theme preview">
<span class="eyebrow">Live preview</span>
<div class="theme-preview-window">
<div class="theme-preview-nav">
<span class="theme-preview-logo">L</span>
<span></span><span></span><span></span>
</div>
<div class="theme-preview-content">
<div class="theme-preview-heading"></div>
<div class="theme-preview-lines"><span></span><span></span></div>
<div class="theme-preview-sample-card">
<strong>Community overview</strong>
<p>Preview text, surfaces, borders, and controls before saving.</p>
<div class="button-group">
<span class="button">Primary</span>
<span class="button subtle">Secondary</span>
</div>
<input value="Input preview" readonly aria-label="Input preview" />
<div class="theme-preview-statuses">
<span class="status-success">Success</span>
<span class="status-warning">Warning</span>
<span class="status-danger">Danger</span>
</div>
</div>
</div>
</div>
<p class="hint">Preview changes are local to this page until you save.</p>
</aside>
</section>
<% } else { %>
<section class="empty-state">
<div>
<h2>Create a custom theme to edit</h2>
<p>Open "More actions" on any built-in or custom theme, then duplicate it.</p>
</div>
</section>
<% } %>
<script src="/theme-editor.js?v=<%= assetVersion %>"></script>
<%- include("partials/layout-bottom") %> <%- include("partials/layout-bottom") %>

View File

@ -1,8 +1,11 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Updates</h1> <%- include("partials/page-header", {
<p>Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.</p> eyebrow: "Maintenance",
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p> pageTitle: "Updates",
description: "Apply git or ZIP updates with automatic pre-update snapshots."
}) %>
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
</section> </section>
<section class="card"> <section class="card">
@ -51,11 +54,12 @@
</section> </section>
<section class="card"> <section class="card">
<h2>Snapshots</h2> <h2>Snapshots</h2>
<% if (!snapshots.length) { %> <% if (!snapshots.length) { %>
<p>No snapshots yet.</p> <div class="empty-state">No snapshots yet.</div>
<% } else { %> <% } else { %>
<table class="table"> <div class="table-wrap">
<table class="table">
<thead> <thead>
<tr> <tr>
<th>Snapshot</th> <th>Snapshot</th>
@ -70,7 +74,8 @@
</tr> </tr>
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
<% } %> </div>
</section> <% } %>
</section>
<%- include("partials/layout-bottom") %> <%- include("partials/layout-bottom") %>

View File

@ -1,8 +1,12 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Users</h1> <%- include("partials/page-header", {
eyebrow: "Community",
pageTitle: "Users",
description: "Review linked identities, notes, and internal usernames."
}) %>
<% if (!users.length) { %> <% if (!users.length) { %>
<p>No users yet.</p> <div class="empty-state">No users yet.</div>
<% } else { %> <% } else { %>
<div class="table-tools"> <div class="table-tools">
<input <input
@ -12,6 +16,7 @@
data-table-filter="user-list" data-table-filter="user-list"
/> />
</div> </div>
<div class="table-wrap">
<table class="table" data-table="user-list"> <table class="table" data-table="user-list">
<thead> <thead>
<tr> <tr>
@ -71,6 +76,7 @@
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
</div>
<% } %> <% } %>
</section> </section>
<div class="modal-backdrop" data-notes-modal aria-hidden="true"> <div class="modal-backdrop" data-notes-modal aria-hidden="true">

View File

@ -1,10 +1,14 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Leaderboards</h1> <%- include("partials/page-header", {
eyebrow: "Community",
pageTitle: "Leaderboards",
description: "Browse activity across core features and installed plugins."
}) %>
</section> </section>
<% if (!sections || !sections.length) { %> <% if (!sections || !sections.length) { %>
<section class="card"> <section class="empty-state">
<p>No activity recorded yet.</p> <p>No activity recorded yet.</p>
</section> </section>
<% } else { %> <% } else { %>
@ -20,6 +24,7 @@
<% if (!board.rows || !board.rows.length) { %> <% if (!board.rows || !board.rows.length) { %>
<p><%= board.emptyMessage || "No data recorded yet." %></p> <p><%= board.emptyMessage || "No data recorded yet." %></p>
<% } else { %> <% } else { %>
<div class="table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -46,6 +51,7 @@
<% }) %> <% }) %>
</tbody> </tbody>
</table> </table>
</div>
<% } %> <% } %>
<% }) %> <% }) %>
<% } %> <% } %>

View File

@ -10,6 +10,7 @@
<% } %> <% } %>
</div> </div>
</div> </div>
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
<div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true"> <div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title"> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
<div class="modal-header"> <div class="modal-header">

View File

@ -4,46 +4,13 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %> - <%= siteTitle %></title> <title><%= title %> - <%= siteTitle %></title>
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" /> <link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
<% if (theme) { %> <link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
<style> <link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
:root { <%- include("theme-vars", { theme }) %>
--ink: <%= theme.light.text %>;
--ink-soft: <%= theme.light.muted %>;
--sea: <%= theme.light.accent %>;
--sun: <%= theme.light.accentAlt %>;
--rose: <%= theme.light.danger %>;
--card: <%= theme.light.surface %>;
--surface-2: <%= theme.light.surface2 %>;
--surface-3: <%= theme.light.surface3 %>;
--border: <%= theme.light.border %>;
--bg-1: <%= theme.light.bg1 %>;
--bg-2: <%= theme.light.bg2 %>;
--bg-3: <%= theme.light.bg3 %>;
--role-public: <%= theme.role.public %>;
--role-mod: <%= theme.role.mod %>;
--role-admin: <%= theme.role.admin %>;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: <%= theme.dark.text %>;
--ink-soft: <%= theme.dark.muted %>;
--sea: <%= theme.dark.accent %>;
--sun: <%= theme.dark.accentAlt %>;
--rose: <%= theme.dark.danger %>;
--card: <%= theme.dark.surface %>;
--surface-2: <%= theme.dark.surface2 %>;
--surface-3: <%= theme.dark.surface3 %>;
--border: <%= theme.dark.border %>;
--bg-1: <%= theme.dark.bg1 %>;
--bg-2: <%= theme.dark.bg2 %>;
--bg-3: <%= theme.dark.bg3 %>;
}
}
</style>
<% } %>
</head> </head>
<body> <body data-theme-id="<%= theme ? theme.id : '' %>">
<% const icons = { <% const icons = {
home: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5L12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" fill="currentColor"/></svg>', home: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5L12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" fill="currentColor"/></svg>',
spark: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l2.4 5.6L20 11l-5.6 2.4L12 19l-2.4-5.6L4 11l5.6-2.4z" fill="currentColor"/></svg>', spark: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l2.4 5.6L20 11l-5.6 2.4L12 19l-2.4-5.6L4 11l5.6-2.4z" fill="currentColor"/></svg>',

View File

@ -0,0 +1,11 @@
<header class="page-header">
<div>
<% if (typeof eyebrow !== "undefined" && eyebrow) { %>
<span class="eyebrow"><%= eyebrow %></span>
<% } %>
<h1><%= pageTitle %></h1>
<% if (typeof description !== "undefined" && description) { %>
<p class="command-subtitle"><%= description %></p>
<% } %>
</div>
</header>

View File

@ -0,0 +1,91 @@
<% if (theme) { %>
<style>
:root {
--ink: <%= theme.light.text %>;
--ink-soft: <%= theme.light.muted %>;
--sea: <%= theme.light.accent %>;
--sun: <%= theme.light.accentAlt %>;
--rose: <%= theme.light.danger %>;
--card: <%= theme.light.surface %>;
--surface-2: <%= theme.light.surface2 %>;
--surface-3: <%= theme.light.surface3 %>;
--border: <%= theme.light.border %>;
--bg-1: <%= theme.light.bg1 %>;
--bg-2: <%= theme.light.bg2 %>;
--bg-3: <%= theme.light.bg3 %>;
--lumi-success: <%= theme.light.success %>;
--lumi-warning: <%= theme.light.warning %>;
--lumi-info: <%= theme.light.info %>;
--lumi-link: <%= theme.light.link %>;
--lumi-button-bg: <%= theme.light.buttonBg %>;
--lumi-button-text: <%= theme.light.buttonText %>;
--lumi-button-hover: <%= theme.light.buttonHover %>;
--lumi-input-bg: <%= theme.light.inputBg %>;
--lumi-input-border: <%= theme.light.inputBorder %>;
--lumi-input-text: <%= theme.light.inputText %>;
--lumi-focus: <%= theme.light.focusRing %>;
--lumi-radius: <%= theme.metrics.radius %>px;
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
--lumi-space-scale: <%= theme.metrics.spacingScale %>;
--role-public: <%= theme.role.public %>;
--role-mod: <%= theme.role.mod %>;
--role-admin: <%= theme.role.admin %>;
}
@media (prefers-color-scheme: dark) {
:root:not([data-color-scheme="light"]) {
--ink: <%= theme.dark.text %>;
--ink-soft: <%= theme.dark.muted %>;
--sea: <%= theme.dark.accent %>;
--sun: <%= theme.dark.accentAlt %>;
--rose: <%= theme.dark.danger %>;
--card: <%= theme.dark.surface %>;
--surface-2: <%= theme.dark.surface2 %>;
--surface-3: <%= theme.dark.surface3 %>;
--border: <%= theme.dark.border %>;
--bg-1: <%= theme.dark.bg1 %>;
--bg-2: <%= theme.dark.bg2 %>;
--bg-3: <%= theme.dark.bg3 %>;
--lumi-success: <%= theme.dark.success %>;
--lumi-warning: <%= theme.dark.warning %>;
--lumi-info: <%= theme.dark.info %>;
--lumi-link: <%= theme.dark.link %>;
--lumi-button-bg: <%= theme.dark.buttonBg %>;
--lumi-button-text: <%= theme.dark.buttonText %>;
--lumi-button-hover: <%= theme.dark.buttonHover %>;
--lumi-input-bg: <%= theme.dark.inputBg %>;
--lumi-input-border: <%= theme.dark.inputBorder %>;
--lumi-input-text: <%= theme.dark.inputText %>;
--lumi-focus: <%= theme.dark.focusRing %>;
}
}
:root[data-color-scheme="dark"] {
color-scheme: dark;
--ink: <%= theme.dark.text %>;
--ink-soft: <%= theme.dark.muted %>;
--sea: <%= theme.dark.accent %>;
--sun: <%= theme.dark.accentAlt %>;
--rose: <%= theme.dark.danger %>;
--card: <%= theme.dark.surface %>;
--surface-2: <%= theme.dark.surface2 %>;
--surface-3: <%= theme.dark.surface3 %>;
--border: <%= theme.dark.border %>;
--bg-1: <%= theme.dark.bg1 %>;
--bg-2: <%= theme.dark.bg2 %>;
--bg-3: <%= theme.dark.bg3 %>;
--lumi-success: <%= theme.dark.success %>;
--lumi-warning: <%= theme.dark.warning %>;
--lumi-info: <%= theme.dark.info %>;
--lumi-link: <%= theme.dark.link %>;
--lumi-button-bg: <%= theme.dark.buttonBg %>;
--lumi-button-text: <%= theme.dark.buttonText %>;
--lumi-button-hover: <%= theme.dark.buttonHover %>;
--lumi-input-bg: <%= theme.dark.inputBg %>;
--lumi-input-border: <%= theme.dark.inputBorder %>;
--lumi-input-text: <%= theme.dark.inputText %>;
--lumi-focus: <%= theme.dark.focusRing %>;
}
:root[data-color-scheme="light"] {
color-scheme: light;
}
</style>
<% } %>

View File

@ -1,7 +1,10 @@
<%- include("partials/layout-top", { title }) %> <%- include("partials/layout-top", { title }) %>
<section class="card"> <section class="card">
<h1>Initial setup</h1> <%- include("partials/page-header", {
<p>Enable the platforms you plan to use, then run each wizard to configure credentials.</p> eyebrow: "Get started",
pageTitle: "Initial setup",
description: "Enable the platforms you plan to use, then configure each connection."
}) %>
<p class="hint">Once at least one platform is configured, you can log in and manage everything from the WebUI.</p> <p class="hint">Once at least one platform is configured, you can log in and manage everything from the WebUI.</p>
<div class="grid"> <div class="grid">
<% (platforms || []).forEach((platform) => { %> <% (platforms || []).forEach((platform) => { %>