diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md index 39685f3..cf91b2e 100644 --- a/docs/lumi-ui.md +++ b/docs/lumi-ui.md @@ -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 diff --git a/scripts/verify-webui.js b/scripts/verify-webui.js index cd7e9ad..bebac98 100644 --- a/scripts/verify-webui.js +++ b/scripts/verify-webui.js @@ -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", diff --git a/src/services/settings.js b/src/services/settings.js index c1f9a74..9da301d 100644 --- a/src/services/settings.js +++ b/src/services/settings.js @@ -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", diff --git a/src/services/themes.js b/src/services/themes.js index bb9db6e..84c8e32 100644 --- a/src/services/themes.js +++ b/src/services/themes.js @@ -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, diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index f456769..1da78b3 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -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, diff --git a/src/web/public/lumi-layout.css b/src/web/public/lumi-layout.css index 42ee374..78962bd 100644 --- a/src/web/public/lumi-layout.css +++ b/src/web/public/lumi-layout.css @@ -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 { diff --git a/src/web/public/lumi-state-button.js b/src/web/public/lumi-state-button.js new file mode 100644 index 0000000..6c4a0e9 --- /dev/null +++ b/src/web/public/lumi-state-button.js @@ -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); + } + }; +})(); diff --git a/src/web/public/lumi-tokens.css b/src/web/public/lumi-tokens.css index cbeab8d..e7661b7 100644 --- a/src/web/public/lumi-tokens.css +++ b/src/web/public/lumi-tokens.css @@ -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. */ diff --git a/src/web/public/theme-editor.css b/src/web/public/theme-editor.css index 56ca52c..1ac83e9 100644 --- a/src/web/public/theme-editor.css +++ b/src/web/public/theme-editor.css @@ -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; } diff --git a/src/web/public/theme-editor.js b/src/web/public/theme-editor.js index 295d858..488670b 100644 --- a/src/web/public/theme-editor.js +++ b/src/web/public/theme-editor.js @@ -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(` + + + + + Lumi Theme Preview + + + + + + ${getPreviewMarkup()} + `); + popout.document.close(); + if (status) status.textContent = "Pop-out preview is open and updates with this editor."; + syncPopout(); }); window.addEventListener("beforeunload", () => { diff --git a/src/web/server.js b/src/web/server.js index a58f92e..7b354df 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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 }); }); diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index 9cee919..e5f16d0 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -62,9 +62,9 @@

Git update checks use the configured remote and branch.

-
-

Platform Integration

-

Enable or disable platform adapters and run the setup wizards.

+
+

Platform Integration

+

Enable or disable platform adapters and run the setup wizards.

<% (platforms || []).forEach((platform) => { %>
@@ -95,11 +95,26 @@ <% } %>
<% }) %> -
-
- - - +
+ + + <% if (localhostLoginAvailable) { %> +
+

Localhost Login

+

Development-only login shown only on localhost. Defaults are admin / admin until changed.

+
+
+ + +
+
+ + +
+ <% } %> + + +

Navigation icons

diff --git a/src/web/views/admin-theme.ejs b/src/web/views/admin-theme.ejs index 025c78b..bdc14e4 100644 --- a/src/web/views/admin-theme.ejs +++ b/src/web/views/admin-theme.ejs @@ -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; %>
<% } else { %> diff --git a/src/web/views/localhost-login.ejs b/src/web/views/localhost-login.ejs new file mode 100644 index 0000000..3d86844 --- /dev/null +++ b/src/web/views/localhost-login.ejs @@ -0,0 +1,23 @@ +<%- include("partials/layout-top", { title }) %> +
+ Development access +

Localhost Login

+

+ This login option is only available when the site is opened from localhost. + The default credentials are admin / admin until changed in settings. +

+
+
+ + +
+
+ + +
+
+ +
+
+
+<%- include("partials/layout-bottom") %> diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs index d5c95fe..b4e1b71 100644 --- a/src/web/views/partials/layout-bottom.ejs +++ b/src/web/views/partials/layout-bottom.ejs @@ -25,6 +25,7 @@ + diff --git a/src/web/views/partials/state-button.ejs b/src/web/views/partials/state-button.ejs new file mode 100644 index 0000000..dd4d9ec --- /dev/null +++ b/src/web/views/partials/state-button.ejs @@ -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(); +%> + diff --git a/src/web/views/partials/theme-vars.ejs b/src/web/views/partials/theme-vars.ejs index 764cfcc..fc1570c 100644 --- a/src/web/views/partials/theme-vars.ejs +++ b/src/web/views/partials/theme-vars.ejs @@ -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 %>;