Compare commits

..

2 Commits

Author SHA1 Message Date
Franz Rolfsvaag
cec485675c docs: document Lumi UI and theme customization 2026-06-15 23:58:30 +02:00
Franz Rolfsvaag
15bcd53c99 ui: modernize WebUI and add managed themes 2026-06-15 23:58:24 +02:00
32 changed files with 2842 additions and 273 deletions

View File

@ -53,7 +53,14 @@ Users have an internal UUID and username. Link Twitch accounts in **Profile** an
## Theming
Use **Admin → Theming** to adjust light and dark mode colors. The UI uses your OS theme preference.
Use **Admin → Theming** to select one of six read-only Lumi themes. Duplicate a
built-in or custom theme to edit colors, surfaces, controls, status colors,
focus states, radius, shadows, and spacing with a live light/dark preview.
Custom themes can be applied, renamed, duplicated, and deleted. Invalid or
incomplete values fall back safely to the selected built-in base theme.
Developer and modding conventions are documented in
[`docs/lumi-ui.md`](docs/lumi-ui.md).
## Notes

View File

@ -160,7 +160,12 @@ Important settings keys (core)
Known file locations
- Layout partials: src/web/views/partials/layout-top.ejs, layout-bottom.ejs
- Global CSS: src/web/public/styles.css
- Lumi UI tokens: src/web/public/lumi-tokens.css
- Lumi UI layout: src/web/public/lumi-layout.css
- Lumi UI components: src/web/public/lumi-components.css
- Legacy/feature CSS: src/web/public/styles.css
- Theme service: src/services/themes.js
- UI and theme conventions: docs/lumi-ui.md
- Global JS: src/web/public/app.js
- Asset versioning: res.locals.assetVersion (cache-bust for styles/app)
- Nav icons: src/web/public/icons/nav (defaults), data/nav-icons (admin uploads)

62
docs/lumi-ui.md Normal file
View File

@ -0,0 +1,62 @@
# Lumi UI and theme system
Lumi UI is the project-native design layer used by every page rendered through the
shared EJS layout. It keeps route behavior and page-specific JavaScript separate
from visual tokens and reusable components.
## Files
- `src/web/public/lumi-tokens.css`: semantic colors, spacing, radii, shadows,
typography, compatibility aliases, and reduced-motion behavior.
- `src/web/public/lumi-layout.css`: application shell, sidebar, content
containers, responsive grids, stacks, clusters, and mobile navigation.
- `src/web/public/lumi-components.css`: buttons, forms, cards, tables, lists,
badges, alerts, tabs, modals, empty/loading/error states, and tooltips.
- `src/web/public/styles.css`: legacy and feature-specific styles that still use
the shared tokens. New general-purpose styling belongs in the Lumi UI files.
- `src/web/views/partials/page-header.ejs`: standard page title and description.
- `src/web/views/partials/theme-vars.ejs`: safe active-theme variables for shell
and standalone pages.
- `src/services/themes.js`: built-in themes, custom theme CRUD, validation,
migration, fallback handling, and active-theme selection.
Use `lumi-stack`, `lumi-cluster`, `lumi-split`, `lumi-grid`, `page-header`,
`button-group`, `card`, `panel`, `table-wrap`, `empty-state`, `loading-state`,
and `status-indicator` before adding one-off layout rules. Preserve existing IDs,
field names, data attributes, and JavaScript hooks when restyling a page.
## Themes
Lumi ships with six read-only themes: Lumi Default, Lumi Dark, Lumi Light, High
Contrast, Midnight, and Soft Aurora. Admins select them from **Admin > Theming**.
Built-in themes cannot be renamed, edited, or deleted.
Open a theme's **More actions** section and duplicate it to create an editable
custom theme. Custom themes can be previewed in light and dark mode, saved,
applied globally, renamed, duplicated, or deleted. Deleting the active custom
theme falls back to Lumi Default.
The compact editor exposes common colors first. Advanced controls cover
background glows, raised surfaces, links, buttons, inputs, focus rings, radius,
shadow strength, and spacing scale. The server accepts only six-digit hex colors,
bounded metric values, and readable text/button/input contrast.
Missing or invalid stored values are replaced from the custom theme's built-in
base. Existing installations with modified legacy `theme_light_*`,
`theme_dark_*`, or `theme_role_*` settings are migrated once into a custom
**Migrated Theme** and selected automatically. The legacy `/admin/theming` POST
route remains supported and writes into an editable custom theme.
Run `npm run verify:webui` to compile every EJS view and exercise built-in theme
validation plus custom duplicate, apply, edit validation, rename, and delete.
## Visual references
- [Home, desktop](screenshots/lumi-home-desktop.png)
- [Home, 360px mobile](screenshots/lumi-home-mobile.png)
- [Custom theme editor, desktop](screenshots/lumi-theme-editor-desktop.png)
- [Custom theme editor, 360px mobile](screenshots/lumi-theme-editor-mobile.png)
The broad sidebar and content structure remains in place. Theme controls moved
from one long raw color form into the Theme Studio library and grouped custom
editor; no navigation destination or non-theme control was relocated.

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

View File

@ -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"

View File

@ -4,11 +4,15 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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>
<body>
<div class="page" style="min-height: 100vh; display: flex; align-items: center; justify-content: center;">
<div class="card" style="max-width: 720px; width: 100%;">
<body data-theme-id="<%= theme ? theme.id : '' %>">
<main class="standalone-page">
<div class="card standalone-card">
<h1>Access restricted</h1>
<p class="hint">Your account is currently restricted by moderation.</p>
<div class="stat-grid">
@ -29,7 +33,7 @@
<span class="stat-value"><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %></span>
</div>
</div>
<div class="card" style="margin-top: 16px;">
<div class="card standalone-detail">
<h2>Summary</h2>
<p><%= sanction.reason_short %></p>
<h2>Details</h2>
@ -37,6 +41,6 @@
<p class="hint">Moderator: <%= sanction.created_by_name || 'Staff' %></p>
</div>
</div>
</div>
</main>
</body>
</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
);
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,

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 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]")
);

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 { 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/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) => {
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());
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);
}
res.redirect("/admin/theming");
});

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %>
<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="card">
<h2>Settings</h2>
@ -9,8 +13,8 @@
</div>
<div class="card">
<h2>Theming</h2>
<p>Adjust light and dark mode colors.</p>
<a href="/admin/theming" class="link">Edit theme</a>
<p>Select protected presets or create editable custom themes.</p>
<a href="/admin/theming" class="link">Open theme studio</a>
</div>
<div class="card">
<h2>Commands</h2>
@ -41,6 +45,7 @@
</section>
<section class="card">
<h2>Maintenance</h2>
<div class="button-group">
<form method="post" action="/admin/check-update" class="inline-form">
<button type="submit" class="button subtle">Check for updates</button>
</form>
@ -50,6 +55,7 @@
<form method="post" action="/admin/restart" class="inline-form">
<button type="submit" class="button subtle">Restart bot</button>
</form>
</div>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %>
<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>
<div class="field">
<label>Slug</label>
@ -49,8 +53,9 @@
</form>
<h2>Existing pages</h2>
<% if (!pages.length) { %>
<p>No pages created yet.</p>
<div class="empty-state">No pages created yet.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
@ -144,6 +149,7 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<script>

View File

@ -1,10 +1,15 @@
<%- include("partials/layout-top", { title }) %>
<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>
<% if (!plugins.length) { %>
<p>No plugins installed.</p>
<div class="empty-state">No plugins installed.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
@ -36,6 +41,7 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<section class="card">

View File

@ -1,6 +1,10 @@
<%- include("partials/layout-top", { title }) %>
<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">
<div class="field">
<label>Site title</label>

View File

@ -1,122 +1,266 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Theming</h1>
<p>Update light and dark mode colors used across the WebUI.</p>
<form method="post" action="/admin/theming" class="form-grid theme-grid">
<h2>Light mode</h2>
<div class="field">
<label>Background 1</label>
<input type="color" name="theme_light_bg_1" value="<%= theme.light.bg1 %>" />
</div>
<div class="field">
<label>Background 2</label>
<input type="color" name="theme_light_bg_2" value="<%= theme.light.bg2 %>" />
</div>
<div class="field">
<label>Background 3</label>
<input type="color" name="theme_light_bg_3" value="<%= theme.light.bg3 %>" />
</div>
<div class="field">
<label>Text</label>
<input type="color" name="theme_light_text" value="<%= theme.light.text %>" />
</div>
<div class="field">
<label>Muted text</label>
<input type="color" name="theme_light_text_muted" value="<%= theme.light.muted %>" />
</div>
<div class="field">
<label>Accent</label>
<input type="color" name="theme_light_accent" value="<%= theme.light.accent %>" />
</div>
<div class="field">
<label>Accent alt</label>
<input type="color" name="theme_light_accent_alt" value="<%= theme.light.accentAlt %>" />
</div>
<div class="field">
<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>
<link rel="stylesheet" href="/theme-editor.css?v=<%= assetVersion %>" />
<%
const fieldLabels = {
bg1: "Background glow",
bg2: "Page background",
bg3: "Accent glow",
text: "Primary text",
muted: "Muted text",
accent: "Primary",
accentAlt: "Accent",
success: "Success",
warning: "Warning",
danger: "Danger",
info: "Information",
surface: "Surface",
surface2: "Subtle surface",
surface3: "Raised surface",
border: "Border",
link: "Link",
buttonBg: "Button background",
buttonText: "Button text",
buttonHover: "Button hover",
inputBg: "Input background",
inputBorder: "Input border",
inputText: "Input text",
focusRing: "Focus ring"
};
const basicFields = [
"accent", "accentAlt", "bg2", "surface", "surface2", "text", "muted",
"border", "success", "warning", "danger", "info"
];
const advancedFields = [
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
"buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
];
%>
<h2>Dark mode</h2>
<div class="field">
<label>Background 1</label>
<input type="color" name="theme_dark_bg_1" value="<%= theme.dark.bg1 %>" />
</div>
<div class="field">
<label>Background 2</label>
<input type="color" name="theme_dark_bg_2" value="<%= theme.dark.bg2 %>" />
</div>
<div class="field">
<label>Background 3</label>
<input type="color" name="theme_dark_bg_3" value="<%= theme.dark.bg3 %>" />
</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 %>" />
<header class="page-header">
<div>
<span class="eyebrow">Appearance</span>
<h1>Theme studio</h1>
<p class="command-subtitle">
Select a protected Lumi preset or duplicate one into a custom theme.
</p>
</div>
<span class="status-indicator status-success">
Active: <strong><%= activeTheme.name %></strong>
</span>
</header>
<h2>Role colors</h2>
<div class="field">
<label>Public</label>
<input type="color" name="theme_role_public" value="<%= theme.role.public %>" />
<section class="theme-library" aria-labelledby="theme-library-title">
<div class="section-header">
<div>
<h2 id="theme-library-title">Theme library</h2>
<p class="hint">Built-in themes are read-only and always remain available.</p>
</div>
<div class="field">
<label>Moderator</label>
<input type="color" name="theme_role_mod" value="<%= theme.role.mod %>" />
</div>
<div class="field">
<label>Admin</label>
<input type="color" name="theme_role_admin" value="<%= theme.role.admin %>" />
<div class="theme-card-grid">
<% themes.forEach((item) => { %>
<% const customId = item.builtin ? null : item.id.replace("custom:", ""); %>
<article class="theme-card <%= item.id === activeTheme.id ? 'is-active' : '' %>">
<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 %>;">
<span></span><span></span><span></span>
</div>
<button type="submit" class="button">Save theme</button>
<div class="theme-card-copy">
<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>
<% 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") %>

View File

@ -1,7 +1,10 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<h1>Updates</h1>
<p>Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.</p>
<%- include("partials/page-header", {
eyebrow: "Maintenance",
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>
@ -53,8 +56,9 @@
<section class="card">
<h2>Snapshots</h2>
<% if (!snapshots.length) { %>
<p>No snapshots yet.</p>
<div class="empty-state">No snapshots yet.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
@ -71,6 +75,7 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -1,8 +1,12 @@
<%- include("partials/layout-top", { title }) %>
<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) { %>
<p>No users yet.</p>
<div class="empty-state">No users yet.</div>
<% } else { %>
<div class="table-tools">
<input
@ -12,6 +16,7 @@
data-table-filter="user-list"
/>
</div>
<div class="table-wrap">
<table class="table" data-table="user-list">
<thead>
<tr>
@ -71,6 +76,7 @@
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<div class="modal-backdrop" data-notes-modal aria-hidden="true">

View File

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

View File

@ -10,6 +10,7 @@
<% } %>
</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" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
<div class="modal-header">

View File

@ -4,46 +4,13 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %> - <%= siteTitle %></title>
<link rel="stylesheet" href="/lumi-tokens.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/styles.css?v=<%= assetVersion %>" />
<% 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 %>;
--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>
<% } %>
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
<%- include("theme-vars", { theme }) %>
</head>
<body>
<body data-theme-id="<%= theme ? theme.id : '' %>">
<% 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>',
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 }) %>
<section class="card">
<h1>Initial setup</h1>
<p>Enable the platforms you plan to use, then run each wizard to configure credentials.</p>
<%- include("partials/page-header", {
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>
<div class="grid">
<% (platforms || []).forEach((platform) => { %>