ui: refine theme editor and add localhost login

This commit is contained in:
Franz Rolfsvaag 2026-06-16 01:41:04 +02:00
parent 7ab2b61110
commit 0512fa5931
17 changed files with 833 additions and 45 deletions

View File

@ -11,7 +11,11 @@ from visual tokens and reusable components.
- `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.
badges, alerts, tabs, modals, empty/loading/error states, stateful action
buttons, and tooltips.
- `src/web/public/lumi-state-button.js` and
`src/web/views/partials/state-button.ejs`: reusable multi-state button
behavior for submit/loading/success actions.
- `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.
@ -38,8 +42,15 @@ 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.
shadow strength, and spacing scale. Typography controls use constrained font
presets plus bounded base-size, heading-scale, and control-density ranges. The
server accepts only six-digit hex colors, supported font presets, bounded metric
values, and readable text/button/input contrast.
The live preview updates colors, role colors, metrics, and typography before
save. The editor also shows contrast warnings for the current preview mode,
offers a reset-to-base action for inherited custom themes, and provides an
optional desktop pop-out preview window that stays synchronized with the editor.
Missing or invalid stored values are replaced from the custom theme's built-in
base. Existing installations with modified legacy `theme_light_*`,
@ -48,7 +59,20 @@ base. Existing installations with modified legacy `theme_light_*`,
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.
validation plus custom duplicate, apply, edit validation, typography validation,
stateful theme actions, localhost login rendering, rename, and delete.
## Localhost Login
Development builds opened from `localhost`, `127.0.0.1`, or `::1` show a
**Localhost Login** option. It defaults to username `admin` and password `admin`
unless those settings have already been changed. The option is not inserted into
the login list, cannot be used, and does not satisfy setup requirements for
non-localhost requests.
Admins can change the localhost username and password from **Admin > Settings**
when the settings page itself is accessed through localhost. Leaving the
password field blank keeps the existing password.
## Visual references

View File

@ -55,12 +55,16 @@ function verifyThemeService() {
const copy = themes.duplicateTheme("builtin:midnight", "Verification Theme");
assert.strictEqual(copy.builtin, false);
assert.strictEqual(copy.typography.bodyFont, "lumi");
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/);
invalid.light.text = copy.light.text;
invalid.typography.bodyFont = "external-css";
assert.throws(() => themes.saveCustomTheme(copy.id, invalid), /font preset/);
const renamed = themes.renameCustomTheme(copy.id, "Verified Theme");
assert.strictEqual(renamed.name, "Verified Theme");
@ -80,6 +84,8 @@ function verifyThemeService() {
activeTheme: renamed,
themes: themes.listThemes(),
editingTheme: renamed,
editingBaseTheme: themes.getThemeById(renamed.baseThemeId),
fontStacks: themes.FONT_STACKS,
botAvatar: null,
navSections: [],
user: { username: "Admin" },
@ -90,8 +96,29 @@ function verifyThemeService() {
softError: null
}, { filename: themeView });
assert(rendered.includes("data-theme-editor"));
assert(rendered.includes("data-theme-font"));
assert(rendered.includes("data-theme-popout"));
assert(rendered.includes("data-lumi-state-button"));
assert(rendered.includes("Built-in · read-only"));
const loginView = path.join(root, "src", "web", "views", "localhost-login.ejs");
const loginRendered = ejs.render(fs.readFileSync(loginView, "utf8"), {
title: "Localhost Login",
username: "admin",
siteTitle: "Lumi Bot",
assetVersion: "verify",
theme: renamed,
botAvatar: null,
navSections: [],
user: null,
userAvatar: null,
userInitial: "",
platformLogins: [{ id: "localhost", label: "Localhost Login", configured: true, loginPath: "/auth/localhost" }],
flash: null,
softError: null
}, { filename: loginView });
assert(loginRendered.includes("admin / admin"));
const statusView = path.join(root, "plugins", "moderation", "views", "status.ejs");
const statusRendered = ejs.render(fs.readFileSync(statusView, "utf8"), {
title: "Access restricted",

View File

@ -86,6 +86,8 @@ function ensureDefaults() {
youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""),
youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""),
youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""),
localhost_login_username: "admin",
localhost_login_password: "admin",
theme_light_bg_1: "#ffe5c4",
theme_light_bg_2: "#f4efe8",
theme_light_bg_3: "#e9f3f1",

View File

@ -5,6 +5,30 @@ const { getSetting, setSetting } = require("./settings");
const DEFAULT_THEME_ID = "builtin:lumi-default";
const THEME_SYSTEM_VERSION = 1;
const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const FONT_STACKS = Object.freeze({
lumi: {
label: "Lumi Sans",
stack: '"Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
system: {
label: "System UI",
stack: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
},
rounded: {
label: "Rounded",
stack: 'ui-rounded, "Nunito Sans", "Aptos", Inter, ui-sans-serif, system-ui, sans-serif'
},
editorial: {
label: "Editorial",
stack: 'Georgia, "Times New Roman", ui-serif, serif'
},
mono: {
label: "Mono",
stack: '"Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace'
}
});
const TYPOGRAPHY_FIELDS = ["bodyFont", "displayFont", "monoFont", "baseSize", "headingScale", "controlScale"];
const MODE_COLOR_FIELDS = [
"bg1",
@ -98,6 +122,17 @@ const DEFAULT_THEME_VALUES = {
radius: 14,
shadowStrength: 0.14,
spacingScale: 1
},
typography: {
bodyFont: "lumi",
displayFont: "system",
monoFont: "mono",
baseSize: 16,
headingScale: 1,
controlScale: 1,
bodyFontStack: FONT_STACKS.lumi.stack,
displayFontStack: FONT_STACKS.system.stack,
monoFontStack: FONT_STACKS.mono.stack
}
};
@ -118,7 +153,8 @@ function createBuiltin(id, name, description, overrides = {}) {
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 || {}) }
metrics: { ...DEFAULT_THEME_VALUES.metrics, ...(overrides.metrics || {}) },
typography: normalizeTypography(overrides.typography, DEFAULT_THEME_VALUES.typography)
});
}
@ -294,7 +330,8 @@ function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
light: mergeMode(baseTheme.light, source.light),
dark: mergeMode(baseTheme.dark, source.dark),
role: { ...baseTheme.role, ...(source.role || {}) },
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) }
metrics: { ...baseTheme.metrics, ...(source.metrics || {}) },
typography: normalizeTypography(source.typography, baseTheme.typography)
};
for (const mode of ["light", "dark"]) {
@ -344,6 +381,27 @@ function normalizeThemeValues(values, baseTheme = getBuiltinTheme()) {
return normalized;
}
function normalizeFontKey(value, fallback) {
return Object.prototype.hasOwnProperty.call(FONT_STACKS, value) ? value : fallback;
}
function normalizeTypography(values = {}, fallback = DEFAULT_THEME_VALUES.typography) {
const bodyFont = normalizeFontKey(values.bodyFont, fallback.bodyFont);
const displayFont = normalizeFontKey(values.displayFont, fallback.displayFont);
const monoFont = normalizeFontKey(values.monoFont, fallback.monoFont);
return {
bodyFont,
displayFont,
monoFont,
baseSize: clampNumber(values.baseSize, 14, 19, fallback.baseSize),
headingScale: clampNumber(values.headingScale, 0.9, 1.2, fallback.headingScale),
controlScale: clampNumber(values.controlScale, 0.9, 1.12, fallback.controlScale),
bodyFontStack: FONT_STACKS[bodyFont].stack,
displayFontStack: FONT_STACKS[displayFont].stack,
monoFontStack: FONT_STACKS[monoFont].stack
};
}
function clampNumber(value, min, max, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
@ -398,6 +456,24 @@ function validateThemeValues(values) {
}
}
for (const field of ["bodyFont", "displayFont", "monoFont"]) {
if (!Object.prototype.hasOwnProperty.call(FONT_STACKS, values?.typography?.[field])) {
errors.push(`typography.${field} must be a supported font preset.`);
}
}
const typographyRules = [
["baseSize", 14, 19],
["headingScale", 0.9, 1.2],
["controlScale", 0.9, 1.12]
];
for (const [field, min, max] of typographyRules) {
const value = Number(values?.typography?.[field]);
if (!Number.isFinite(value) || value < min || value > max) {
errors.push(`typography.${field} must be between ${min} and ${max}.`);
}
}
if (!errors.length) {
for (const mode of ["light", "dark"]) {
if (contrastRatio(values[mode].text, values[mode].surface) < 4.5) {
@ -611,7 +687,7 @@ function deleteCustomTheme(themeId) {
}
function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
const values = { light: {}, dark: {}, role: {}, metrics: {} };
const values = { light: {}, dark: {}, role: {}, metrics: {}, typography: {} };
for (const mode of ["light", "dark"]) {
for (const field of MODE_COLOR_FIELDS) {
values[mode][field] = String(
@ -631,14 +707,34 @@ function valuesFromRequest(body, fallbackTheme = getBuiltinTheme()) {
values.metrics.spacingScale = Number(
body?.metrics_spacingScale ?? fallbackTheme.metrics.spacingScale
);
values.typography.bodyFont = String(
body?.typography_bodyFont ?? fallbackTheme.typography.bodyFont
);
values.typography.displayFont = String(
body?.typography_displayFont ?? fallbackTheme.typography.displayFont
);
values.typography.monoFont = String(
body?.typography_monoFont ?? fallbackTheme.typography.monoFont
);
values.typography.baseSize = Number(
body?.typography_baseSize ?? fallbackTheme.typography.baseSize
);
values.typography.headingScale = Number(
body?.typography_headingScale ?? fallbackTheme.typography.headingScale
);
values.typography.controlScale = Number(
body?.typography_controlScale ?? fallbackTheme.typography.controlScale
);
return values;
}
module.exports = {
BUILTIN_THEMES,
DEFAULT_THEME_ID,
FONT_STACKS,
MODE_COLOR_FIELDS,
ROLE_COLOR_FIELDS,
TYPOGRAPHY_FIELDS,
contrastRatio,
deleteCustomTheme,
duplicateTheme,

View File

@ -22,12 +22,12 @@ h6 {
}
h1 {
font-size: clamp(1.75rem, 4vw, 2.55rem);
font-size: calc(clamp(1.75rem, 4vw, 2.55rem) * var(--lumi-heading-scale));
letter-spacing: -0.035em;
}
h2 {
font-size: clamp(1.25rem, 2.5vw, 1.6rem);
font-size: calc(clamp(1.25rem, 2.5vw, 1.6rem) * var(--lumi-heading-scale));
letter-spacing: -0.02em;
}
@ -185,6 +185,42 @@ button:disabled {
transform: none;
}
.lumi-state-btn {
position: relative;
}
.lumi-state-btn[aria-busy="true"] {
cursor: progress;
}
.lumi-state-btn-content {
display: grid;
grid-template-areas: "stack";
align-items: center;
justify-items: center;
}
.lumi-state-btn [data-state-view] {
grid-area: stack;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--lumi-space-2);
}
.lumi-state-btn [data-state-view][hidden] {
display: none !important;
}
.lumi-state-btn-spinner {
width: 1em;
height: 1em;
border: 0.14em solid color-mix(in srgb, currentColor 28%, transparent);
border-top-color: currentColor;
border-radius: 50%;
animation: lumi-state-spin 750ms linear infinite;
}
.icon-button {
width: var(--lumi-control-height);
height: var(--lumi-control-height);
@ -508,7 +544,21 @@ details > summary {
}
}
@keyframes lumi-state-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 700px) {
h1 {
font-size: calc(clamp(1.45rem, 8vw, 2rem) * var(--lumi-heading-scale));
}
h2 {
font-size: calc(clamp(1.15rem, 6vw, 1.45rem) * var(--lumi-heading-scale));
}
.form-grid {
grid-template-columns: 1fr;
}
@ -522,11 +572,34 @@ details > summary {
.panel,
.lumi-panel,
.modal {
padding: var(--lumi-space-4);
padding: var(--lumi-space-3);
}
.hero {
padding: var(--lumi-space-5);
padding: var(--lumi-space-4);
}
.hero::after {
width: 9rem;
height: 9rem;
right: -5rem;
top: -5rem;
}
.button,
button.button,
input[type="submit"].button,
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
select,
textarea,
.table-search {
min-height: min(var(--lumi-control-height), 2.5rem);
}
.button,
button.button,
input[type="submit"].button {
padding: 0.55rem 0.8rem;
}
.table-tools,

View File

@ -6,7 +6,7 @@ html {
body {
font-family: var(--lumi-font-body);
font-size: 1rem;
font-size: var(--lumi-font-size-base);
line-height: 1.55;
background:
radial-gradient(circle at 8% 0%, var(--bg-1) 0, transparent 34rem),
@ -199,6 +199,12 @@ body {
}
@media (max-width: 900px) {
body.sidebar-collapsed .app-shell,
.app-shell {
grid-template-columns: minmax(0, 1fr);
}
body.sidebar-collapsed .sidebar,
.sidebar {
width: min(19rem, 88vw);
padding-top: var(--lumi-space-3);
@ -225,9 +231,18 @@ body {
}
@media (max-width: 600px) {
body {
font-size: calc(var(--lumi-font-size-base) * 0.94);
}
.mobile-topbar {
min-height: 3.5rem;
padding: var(--lumi-space-2) var(--lumi-space-3);
}
.content {
gap: var(--lumi-space-4);
padding: var(--lumi-space-3);
gap: var(--lumi-space-3);
padding: max(0.75rem, env(safe-area-inset-top)) max(0.75rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
}
.site-footer {

View File

@ -0,0 +1,64 @@
(() => {
const buttons = new Set();
const getViews = (button) => Array.from(button.querySelectorAll("[data-state-view]"));
const setState = (button, state, options = {}) => {
if (!button) return;
const nextState = state || button.dataset.defaultState || "idle";
const busyState = button.dataset.loadingState || "loading";
const isBusy = nextState === busyState || options.busy === true;
button.dataset.state = nextState;
button.setAttribute("aria-busy", isBusy ? "true" : "false");
getViews(button).forEach((view) => {
const isVisible = view.dataset.stateView === nextState;
view.hidden = !isVisible;
view.setAttribute("aria-hidden", isVisible ? "false" : "true");
});
if (button.dataset.disableWhileBusy === "true") {
button.disabled = isBusy;
}
};
const reset = (button) => {
setState(button, button?.dataset.defaultState || "idle");
};
const scheduleReset = (button) => {
const delay = Number(button?.dataset.resetDelay || 0);
if (delay > 0) window.setTimeout(() => reset(button), delay);
};
const initButton = (button) => {
if (buttons.has(button)) return;
buttons.add(button);
button.classList.add("lumi-state-btn");
setState(button, button.dataset.state || button.dataset.defaultState || "idle");
};
document.querySelectorAll("[data-lumi-state-button]").forEach(initButton);
document.addEventListener("submit", (event) => {
const submitter = event.submitter;
if (!submitter?.matches?.("[data-lumi-state-button]")) return;
initButton(submitter);
setState(submitter, submitter.dataset.loadingState || "loading", { busy: true });
}, true);
window.LumiStateButton = {
init: initButton,
setState,
reset,
success(button) {
setState(button, button?.dataset.successState || "success");
scheduleReset(button);
},
error(button) {
setState(button, button?.dataset.errorState || "error");
scheduleReset(button);
}
};
})();

View File

@ -4,6 +4,9 @@
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-font-size-base: 16px;
--lumi-heading-scale: 1;
--lumi-control-scale: 1;
--lumi-text: var(--ink, #182026);
--lumi-text-muted: var(--ink-soft, #5a6872);
@ -43,7 +46,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-control-height: calc(2.75rem * var(--lumi-control-scale));
--lumi-content-max: 1600px;
/* Compatibility aliases for existing core and plugin styles. */

View File

@ -163,6 +163,7 @@
}
.theme-color-control,
.theme-select-control,
.theme-range-control {
display: grid;
gap: var(--lumi-space-2);
@ -209,6 +210,38 @@
color: var(--lumi-text-muted);
}
.theme-select-control {
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-sm);
background: var(--lumi-surface);
}
.theme-inline-actions {
display: flex;
flex-wrap: wrap;
gap: var(--lumi-space-2);
margin-top: var(--lumi-space-4);
}
.theme-validation-panel {
display: grid;
gap: var(--lumi-space-2);
}
.theme-validation-panel:empty {
display: none;
}
.theme-warning {
padding: var(--lumi-space-2) var(--lumi-space-3);
border: 1px solid color-mix(in srgb, var(--lumi-warning) 40%, var(--lumi-border));
border-radius: var(--lumi-radius-sm);
background: color-mix(in srgb, var(--lumi-warning) 11%, var(--lumi-surface));
color: var(--lumi-text);
font-size: 0.9rem;
}
.advanced-theme-controls {
margin-top: var(--lumi-space-4);
}
@ -247,6 +280,23 @@
gap: var(--lumi-space-3);
}
.theme-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--lumi-space-2);
}
.theme-preview-header .eyebrow {
margin-bottom: 0;
}
.theme-popout-button {
min-height: 2.25rem;
padding: 0.45rem 0.7rem;
font-size: 0.8rem;
}
.theme-preview-window {
min-height: 26rem;
display: grid;
@ -365,6 +415,45 @@
}
@media (max-width: 600px) {
.theme-library,
.theme-editor-main,
.theme-edit-form,
.theme-editor-shell {
gap: var(--lumi-space-3);
}
.theme-card-grid,
.theme-control-grid {
grid-template-columns: minmax(0, 1fr);
gap: var(--lumi-space-2);
}
.theme-card,
.theme-fieldset,
.theme-range-control,
.theme-select-control {
padding: var(--lumi-space-3);
}
.theme-swatch {
height: 5.5rem;
}
.theme-preview-window {
min-height: 16rem;
grid-template-columns: 3.25rem minmax(0, 1fr);
}
.theme-preview-nav,
.theme-preview-content,
.theme-preview-sample-card {
padding: var(--lumi-space-3);
}
.theme-popout-button {
display: none;
}
.compact-form {
grid-template-columns: 1fr;
}

View File

@ -35,6 +35,15 @@
shadowStrength: ["--lumi-shadow-strength", ""],
spacingScale: ["--lumi-space-scale", ""]
};
const typographyVariables = {
body: "--lumi-font-body",
display: "--lumi-font-display",
mono: "--lumi-font-mono",
baseSize: ["--lumi-font-size-base", "px"],
headingScale: ["--lumi-heading-scale", ""],
controlScale: ["--lumi-control-scale", ""]
};
let popout = null;
let previewMode = "light";
const updateOutputs = () => {
@ -46,6 +55,10 @@
const output = input.closest("label")?.querySelector("[data-range-output]");
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
});
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
const output = input.closest("label")?.querySelector("[data-range-output]");
if (output) output.value = `${input.value}${input.dataset.unit || ""}`;
});
};
const applyPreview = () => {
@ -61,10 +74,89 @@
const config = metricVariables[input.dataset.themeMetric];
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
});
form.querySelectorAll("[data-theme-font]").forEach((select) => {
const variable = typographyVariables[select.dataset.themeFont];
const stack = select.selectedOptions[0]?.dataset.fontStack;
if (variable && stack) root.style.setProperty(variable, stack);
});
form.querySelectorAll("[data-theme-typography]").forEach((input) => {
const config = typographyVariables[input.dataset.themeTypography];
if (config) root.style.setProperty(config[0], `${input.value}${config[1]}`);
});
updateOutputs();
updateWarnings();
syncPopout();
};
const parseHex = (value) => {
const hex = String(value || "").replace("#", "");
return [0, 2, 4].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
};
const luminance = (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];
};
const contrastRatio = (left, right) => {
const a = luminance(left);
const b = luminance(right);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
};
const getModeValue = (token) =>
form.querySelector(`[data-theme-mode="${previewMode}"][data-theme-token="${token}"]`)?.value;
const updateWarnings = () => {
const panel = editor.querySelector("[data-theme-warnings]");
if (!panel) return;
const checks = [
["Text on surface", getModeValue("text"), getModeValue("surface")],
["Button text", getModeValue("buttonText"), getModeValue("buttonBg")],
["Input text", getModeValue("inputText"), getModeValue("inputBg")]
];
const warnings = checks
.map(([label, foreground, background]) => [label, contrastRatio(foreground, background)])
.filter(([, ratio]) => Number.isFinite(ratio) && ratio < 4.5);
panel.replaceChildren(
...warnings.map(([label, ratio]) => {
const item = document.createElement("div");
item.className = "theme-warning";
item.textContent = `${label} contrast is ${ratio.toFixed(2)}:1. Save will reject values below 4.5:1.`;
return item;
})
);
};
const getPreviewMarkup = () => editor.querySelector(".theme-preview-window")?.outerHTML || "";
const currentPreviewVariables = () => {
const variables = [];
Object.values(tokenVariables).forEach((name) => variables.push([name, root.style.getPropertyValue(name)]));
["--role-public", "--role-mod", "--role-admin"].forEach((name) => variables.push([name, root.style.getPropertyValue(name)]));
Object.values(metricVariables).forEach(([name]) => variables.push([name, root.style.getPropertyValue(name)]));
Object.values(typographyVariables).forEach((config) => {
const name = Array.isArray(config) ? config[0] : config;
variables.push([name, root.style.getPropertyValue(name)]);
});
return variables.filter(([, value]) => value);
};
const syncPopout = () => {
if (!popout || popout.closed) return;
popout.document.documentElement.dataset.colorScheme = previewMode;
currentPreviewVariables().forEach(([name, value]) => {
popout.document.documentElement.style.setProperty(name, value);
});
};
form.addEventListener("input", applyPreview);
form.addEventListener("change", applyPreview);
editor.querySelectorAll("[data-theme-preview-mode]").forEach((button) => {
button.addEventListener("click", () => {
previewMode = button.dataset.themePreviewMode;
@ -78,6 +170,40 @@
editor.querySelector("[data-theme-reset]")?.addEventListener("click", () => {
form.reset();
applyPreview();
window.LumiStateButton?.success(editor.querySelector("[data-theme-reset]"));
});
editor.querySelector("[data-theme-reset-to-base]")?.addEventListener("click", () => {
form.querySelectorAll("[data-base-value]").forEach((input) => {
input.value = input.dataset.baseValue;
});
applyPreview();
});
editor.querySelector("[data-theme-popout]")?.addEventListener("click", () => {
popout = window.open("", "lumi-theme-preview", "width=430,height=760,menubar=no,toolbar=no,location=no,status=no");
const status = editor.querySelector("[data-theme-popout-status]");
if (!popout) {
if (status) status.textContent = "Pop-out preview was blocked by the browser.";
return;
}
popout.document.open();
popout.document.write(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumi Theme Preview</title>
<link rel="stylesheet" href="/lumi-tokens.css">
<link rel="stylesheet" href="/lumi-components.css">
<link rel="stylesheet" href="/theme-editor.css">
<style>body{margin:0;padding:1rem;background:var(--bg-2);font-family:var(--lumi-font-body);font-size:var(--lumi-font-size-base)}.theme-preview-window{width:100%;min-height:calc(100vh - 2rem)}</style>
</head>
<body>${getPreviewMarkup()}</body>
</html>`);
popout.document.close();
if (status) status.textContent = "Pop-out preview is open and updates with this editor.";
syncPopout();
});
window.addEventListener("beforeunload", () => {

View File

@ -17,6 +17,7 @@ const { getSetting, setSetting, getAllSettings } = require("../services/settings
const {
deleteCustomTheme,
duplicateTheme,
FONT_STACKS,
getActiveTheme,
getThemeById,
listThemes,
@ -112,7 +113,39 @@ function isConfigured() {
return platforms.some((platform) => platform.configured);
}
function getPrimaryLoginPlatform() {
function normalizeHostName(value) {
const raw = String(value || "").toLowerCase();
if (raw.startsWith("[")) {
return raw.slice(1, raw.indexOf("]"));
}
if (raw === "::1" || raw === "::ffff:127.0.0.1") {
return raw;
}
return raw.replace(/:\d+$/, "");
}
function isLocalhostLoginAvailable(req) {
if (!req) return false;
const host = normalizeHostName(req.hostname || req.get("host"));
return host === "localhost" || host === "::1" || host === "::ffff:127.0.0.1" || host === "127.0.0.1" || host.startsWith("127.");
}
function getLocalhostLoginPlatform(req) {
if (!isLocalhostLoginAvailable(req)) return null;
return {
id: "localhost",
label: "Localhost Login",
configured: true,
enabled: true,
supported: true,
supportsLogin: true,
loginPath: "/auth/localhost"
};
}
function getPrimaryLoginPlatform(req) {
const localhostPlatform = getLocalhostLoginPlatform(req);
if (localhostPlatform) return localhostPlatform;
const platforms = getPlatformStatus().filter(
(platform) =>
platform.supported && platform.enabled && platform.supportsLogin
@ -123,13 +156,13 @@ function getPrimaryLoginPlatform() {
return platforms.find((platform) => platform.configured) || platforms[0];
}
function getLoginRedirectPath() {
const platform = getPrimaryLoginPlatform();
function getLoginRedirectPath(req) {
const platform = getPrimaryLoginPlatform(req);
return platform?.loginPath || "/setup";
}
function requireConfigured(req, res, next) {
if (!isConfigured() && !req.path.startsWith("/setup")) {
if (!isConfigured() && !isLocalhostLoginAvailable(req) && !req.path.startsWith("/setup")) {
return res.redirect("/setup");
}
next();
@ -137,7 +170,7 @@ function requireConfigured(req, res, next) {
function requireAuth(req, res, next) {
if (!req.session.user) {
return res.redirect(getLoginRedirectPath());
return res.redirect(getLoginRedirectPath(req));
}
next();
}
@ -181,7 +214,7 @@ function formatDuration(totalMs) {
function requireRole(role) {
return (req, res, next) => {
if (!req.session.user) {
return res.redirect(getLoginRedirectPath());
return res.redirect(getLoginRedirectPath(req));
}
if (!hasAccess(req.session.user, role)) {
return res.status(403).render("error", {
@ -1943,9 +1976,14 @@ function createWebServer({ loadPlugins, discordClient }) {
res.locals.botAvatar = getSetting("bot_avatar_url", null);
const platformStatus = getPlatformStatus();
res.locals.platforms = platformStatus;
res.locals.platformLogins = platformStatus.filter(
(platform) => platform.supported && platform.enabled && platform.supportsLogin
);
const localhostLogin = getLocalhostLoginPlatform(req);
res.locals.localhostLoginAvailable = Boolean(localhostLogin);
res.locals.platformLogins = [
...(localhostLogin ? [localhostLogin] : []),
...platformStatus.filter(
(platform) => platform.supported && platform.enabled && platform.supportsLogin
)
];
res.locals.platformLinks = platformStatus.filter(
(platform) => platform.supported && platform.enabled && platform.supportsLink
);
@ -2564,6 +2602,53 @@ function createWebServer({ loadPlugins, discordClient }) {
}
});
app.get("/auth/localhost", (req, res) => {
if (!isLocalhostLoginAvailable(req)) {
return res.status(404).render("error", {
title: "Login unavailable",
message: "Localhost Login is only available from a localhost request."
});
}
res.render("localhost-login", {
title: "Localhost Login",
username: getSetting("localhost_login_username", "admin")
});
});
app.post("/auth/localhost", (req, res) => {
if (!isLocalhostLoginAvailable(req)) {
return res.status(404).render("error", {
title: "Login unavailable",
message: "Localhost Login is only available from a localhost request."
});
}
const username = String(req.body.username || "").trim();
const password = String(req.body.password || "");
const expectedUsername = String(getSetting("localhost_login_username", "admin"));
const expectedPassword = String(getSetting("localhost_login_password", "admin"));
if (username !== expectedUsername || password !== expectedPassword) {
setFlash(req, "error", "Invalid localhost username or password.");
return res.redirect("/auth/localhost");
}
const profile = ensureUserForIdentity({
provider: "localhost",
providerUserId: expectedUsername,
displayName: expectedUsername,
fallbackName: "Localhost Admin"
});
req.session.user = {
id: profile.id,
username: profile.internal_username,
avatar: null,
roles: ["localhost-admin"],
isAdmin: true,
isMod: true,
isLocalhost: true
};
setFlash(req, "success", "Logged in with Localhost Login.");
res.redirect("/");
});
app.post("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.redirect("/");
@ -3480,6 +3565,7 @@ function createWebServer({ loadPlugins, discordClient }) {
title: "Settings",
settings: getAllSettings(),
platforms: getPlatformStatus(),
localhostLoginAvailable: isLocalhostLoginAvailable(req),
navIconItems: buildNavIconItems(req.session.user, navItems, req.path)
});
});
@ -3505,6 +3591,16 @@ function createWebServer({ loadPlugins, discordClient }) {
}
}
}
if (isLocalhostLoginAvailable(req)) {
const localhostUsername = String(req.body.localhost_login_username || "").trim();
if (localhostUsername) {
setSetting("localhost_login_username", localhostUsername);
}
const localhostPassword = String(req.body.localhost_login_password || "");
if (localhostPassword) {
setSetting("localhost_login_password", localhostPassword);
}
}
const platformStatus = getPlatformStatus();
const nextPlatformValues = new Map();
for (const platform of platformStatus) {
@ -3516,7 +3612,7 @@ function createWebServer({ loadPlugins, discordClient }) {
platform.supportsLogin &&
nextPlatformValues.get(platform.id)
);
if (!hasLoginPlatform) {
if (!hasLoginPlatform && !isLocalhostLoginAvailable(req)) {
setFlash(
req,
"error",
@ -3963,12 +4059,17 @@ function createWebServer({ loadPlugins, discordClient }) {
: activeTheme.builtin
? null
: activeTheme;
const editingBaseTheme = editingTheme && !editingTheme.builtin
? getThemeById(editingTheme.baseThemeId)
: null;
res.render("admin-theme", {
title: "Theming",
theme: activeTheme,
activeTheme,
themes: listThemes(),
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null
editingTheme: editingTheme && !editingTheme.builtin ? editingTheme : null,
editingBaseTheme,
fontStacks: FONT_STACKS
});
});

View File

@ -98,6 +98,21 @@
</div>
</div>
<% if (localhostLoginAvailable) { %>
<div class="field full">
<h2>Localhost Login</h2>
<p class="hint">Development-only login shown only on localhost. Defaults are admin / admin until changed.</p>
</div>
<div class="field">
<label>Localhost username</label>
<input name="localhost_login_username" value="<%= settings.localhost_login_username || 'admin' %>" autocomplete="off" />
</div>
<div class="field">
<label>New localhost password</label>
<input name="localhost_login_password" type="password" placeholder="Leave blank to keep current password" autocomplete="new-password" />
</div>
<% } %>
<button type="submit" class="button">Save settings</button>
</form>
</section>

View File

@ -34,6 +34,8 @@
"bg1", "bg3", "surface3", "link", "buttonBg", "buttonText",
"buttonHover", "inputBg", "inputBorder", "inputText", "focusRing"
];
const fontStackEntries = Object.entries((typeof fontStacks !== "undefined" && fontStacks) || {});
const baseTheme = (typeof editingBaseTheme !== "undefined" && editingBaseTheme) || editingTheme || activeTheme;
%>
<header class="page-header">
@ -74,7 +76,15 @@
<% 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>
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Apply" },
{ id: "loading", text: "Applying", spinner: true },
{ id: "success", text: "Applied" }
]
}) %>
</form>
<% } else { %>
<span class="button subtle disabled" aria-current="true">Active</span>
@ -92,7 +102,14 @@
<span>Copy name</span>
<input name="name" value="<%= item.name %> Copy" maxlength="60" required />
</label>
<button type="submit" class="button">Duplicate</button>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Duplicate" },
{ id: "loading", text: "Duplicating", spinner: true },
{ id: "success", text: "Created" }
]
}) %>
</form>
<% if (!item.builtin) { %>
<form method="post" action="/admin/theming/custom/<%= customId %>/rename" class="compact-form">
@ -100,7 +117,15 @@
<span>Theme name</span>
<input name="name" value="<%= item.name %>" maxlength="60" required />
</label>
<button type="submit" class="button subtle">Rename</button>
<%- include("partials/state-button", {
type: "submit",
classes: "subtle",
states: [
{ id: "idle", text: "Rename" },
{ id: "loading", text: "Renaming", spinner: true },
{ id: "success", text: "Renamed" }
]
}) %>
</form>
<form
method="post"
@ -153,6 +178,7 @@
value="<%= editingTheme[mode][field] %>"
data-theme-token="<%= field %>"
data-theme-mode="<%= mode %>"
data-base-value="<%= baseTheme[mode][field] %>"
/>
<output><%= editingTheme[mode][field] %></output>
</span>
@ -172,6 +198,7 @@
value="<%= editingTheme[mode][field] %>"
data-theme-token="<%= field %>"
data-theme-mode="<%= mode %>"
data-base-value="<%= baseTheme[mode][field] %>"
/>
<output><%= editingTheme[mode][field] %></output>
</span>
@ -189,26 +216,67 @@
<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 %>" />
<input type="color" name="role_<%= role %>" value="<%= editingTheme.role[role] %>" data-theme-role="<%= role %>" data-base-value="<%= baseTheme.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" />
<input type="range" name="metrics_radius" min="0" max="32" step="1" value="<%= editingTheme.metrics.radius %>" data-theme-metric="radius" data-unit="px" data-base-value="<%= baseTheme.metrics.radius %>" />
</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" />
<input type="range" name="metrics_shadowStrength" min="0" max="0.35" step="0.01" value="<%= editingTheme.metrics.shadowStrength %>" data-theme-metric="shadowStrength" data-base-value="<%= baseTheme.metrics.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" />
<input type="range" name="metrics_spacingScale" min="0.75" max="1.35" step="0.05" value="<%= editingTheme.metrics.spacingScale %>" data-theme-metric="spacingScale" data-base-value="<%= baseTheme.metrics.spacingScale %>" />
</label>
</div>
</fieldset>
<fieldset class="theme-fieldset">
<legend>Typography</legend>
<div class="theme-control-grid">
<% [
["bodyFont", "Body font", "data-theme-font='body'"],
["displayFont", "Heading font", "data-theme-font='display'"],
["monoFont", "Monospace font", "data-theme-font='mono'"]
].forEach(([field, label, dataAttr]) => { %>
<label class="theme-select-control">
<span><%= label %></span>
<select
name="typography_<%= field %>"
<%- dataAttr %>
data-base-value="<%= baseTheme.typography[field] %>"
>
<% fontStackEntries.forEach(([key, font]) => { %>
<option value="<%= key %>" data-font-stack="<%= font.stack %>" <%= editingTheme.typography[field] === key ? "selected" : "" %>><%= font.label %></option>
<% }) %>
</select>
</label>
<% }) %>
<label class="theme-range-control">
<span>Base text size <output data-range-output><%= editingTheme.typography.baseSize %>px</output></span>
<input type="range" name="typography_baseSize" min="14" max="19" step="1" value="<%= editingTheme.typography.baseSize %>" data-theme-typography="baseSize" data-unit="px" data-base-value="<%= baseTheme.typography.baseSize %>" />
</label>
<label class="theme-range-control">
<span>Heading scale <output data-range-output><%= editingTheme.typography.headingScale %></output></span>
<input type="range" name="typography_headingScale" min="0.9" max="1.2" step="0.01" value="<%= editingTheme.typography.headingScale %>" data-theme-typography="headingScale" data-base-value="<%= baseTheme.typography.headingScale %>" />
</label>
<label class="theme-range-control">
<span>Control density <output data-range-output><%= editingTheme.typography.controlScale %></output></span>
<input type="range" name="typography_controlScale" min="0.9" max="1.12" step="0.01" value="<%= editingTheme.typography.controlScale %>" data-theme-typography="controlScale" data-base-value="<%= baseTheme.typography.controlScale %>" />
</label>
</div>
<div class="theme-inline-actions">
<button type="button" class="button subtle" data-theme-reset-to-base>Reset changed controls to base theme</button>
</div>
</fieldset>
<div class="theme-validation-panel" data-theme-warnings aria-live="polite"></div>
<div class="theme-editor-actions">
<label class="switch">
<input type="checkbox" class="switch-input" name="apply" <%= editingTheme.id === activeTheme.id ? "checked" : "" %> />
@ -216,16 +284,35 @@
<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>
<%- include("partials/state-button", {
type: "button",
classes: "subtle",
attrs: "data-theme-reset",
resetDelay: 1200,
states: [
{ id: "idle", text: "Revert unsaved" },
{ id: "success", text: "Reverted" }
]
}) %>
<a href="/admin/theming" class="button subtle">Cancel</a>
<button type="submit" class="button">Save theme</button>
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Save theme" },
{ id: "loading", text: "Saving", spinner: true },
{ id: "success", text: "Saved" }
]
}) %>
</div>
</div>
</form>
</div>
<aside class="theme-preview card" aria-label="Live theme preview">
<span class="eyebrow">Live preview</span>
<div class="theme-preview-header">
<span class="eyebrow">Live preview</span>
<button type="button" class="button subtle theme-popout-button" data-theme-popout>Pop out</button>
</div>
<div class="theme-preview-window">
<div class="theme-preview-nav">
<span class="theme-preview-logo">L</span>
@ -250,7 +337,7 @@
</div>
</div>
</div>
<p class="hint">Preview changes are local to this page until you save.</p>
<p class="hint" data-theme-popout-status>Preview changes are local to this page until you save.</p>
</aside>
</section>
<% } else { %>

View File

@ -0,0 +1,23 @@
<%- include("partials/layout-top", { title }) %>
<section class="standalone-card card">
<span class="eyebrow">Development access</span>
<h1>Localhost Login</h1>
<p class="hint">
This login option is only available when the site is opened from localhost.
The default credentials are admin / admin until changed in settings.
</p>
<form method="post" action="/auth/localhost" class="form-grid">
<div class="field full">
<label>Username</label>
<input name="username" value="<%= username || 'admin' %>" autocomplete="username" required />
</div>
<div class="field full">
<label>Password</label>
<input name="password" type="password" autocomplete="current-password" required />
</div>
<div class="field full">
<button type="submit" class="button">Login locally</button>
</div>
</form>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -25,6 +25,7 @@
</div>
</div>
<script src="/assistant-panels.js?v=<%= assetVersion %>"></script>
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
<script src="/app.js?v=<%= assetVersion %>"></script>
</body>
</html>

View File

@ -0,0 +1,36 @@
<%
const stateButtonStates = (typeof states !== "undefined" && states) || [
{ id: "idle", text: (typeof text !== "undefined" && text) || "Submit" },
{ id: "loading", text: (typeof loadingText !== "undefined" && loadingText) || "Working...", spinner: true },
{ id: "success", text: (typeof successText !== "undefined" && successText) || "Done" }
];
const stateButtonDefault = (typeof defaultState !== "undefined" && defaultState) || "idle";
const stateButtonAttrs = (typeof attrs !== "undefined" && attrs) || "";
const stateButtonClass = `button lumi-state-btn ${(typeof classes !== "undefined" && classes) || ""}`.trim();
%>
<button
type="<%= (typeof type !== "undefined" && type) || 'button' %>"
class="<%= stateButtonClass %>"
data-lumi-state-button
data-state="<%= stateButtonDefault %>"
data-default-state="<%= stateButtonDefault %>"
data-loading-state="<%= (typeof loadingState !== "undefined" && loadingState) || 'loading' %>"
data-success-state="<%= (typeof successState !== "undefined" && successState) || 'success' %>"
data-error-state="<%= (typeof errorState !== "undefined" && errorState) || 'error' %>"
data-reset-delay="<%= (typeof resetDelay !== "undefined" && resetDelay) || 0 %>"
data-disable-while-busy="<%= typeof disableWhileBusy !== "undefined" && disableWhileBusy === false ? 'false' : 'true' %>"
<%- typeof name !== "undefined" && name ? `name="${name}"` : "" %>
<%- typeof value !== "undefined" && value ? `value="${value}"` : "" %>
<%- typeof ariaLabel !== "undefined" && ariaLabel ? `aria-label="${ariaLabel}"` : "" %>
<%- typeof disabled !== "undefined" && disabled ? "disabled" : "" %>
<%- stateButtonAttrs %>
>
<span class="lumi-state-btn-content">
<% stateButtonStates.forEach((state) => { %>
<span data-state-view="<%= state.id %>" <%= state.id === stateButtonDefault ? "" : "hidden" %>>
<% if (state.spinner) { %><span class="lumi-state-btn-spinner" aria-hidden="true"></span><% } %>
<span><%= state.text %></span>
</span>
<% }) %>
</span>
</button>

View File

@ -27,6 +27,12 @@
--lumi-radius: <%= theme.metrics.radius %>px;
--lumi-shadow-strength: <%= theme.metrics.shadowStrength %>;
--lumi-space-scale: <%= theme.metrics.spacingScale %>;
--lumi-font-body: <%- theme.typography.bodyFontStack %>;
--lumi-font-display: <%- theme.typography.displayFontStack %>;
--lumi-font-mono: <%- theme.typography.monoFontStack %>;
--lumi-font-size-base: <%= theme.typography.baseSize %>px;
--lumi-heading-scale: <%= theme.typography.headingScale %>;
--lumi-control-scale: <%= theme.typography.controlScale %>;
--role-public: <%= theme.role.public %>;
--role-mod: <%= theme.role.mod %>;
--role-admin: <%= theme.role.admin %>;