From 3d6b2e23a7f823ca2792ab46e7613437a6681629 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 7 Aug 2026 19:16:23 -0500 Subject: [PATCH] feat(theming): server-resolved theme engine and admin appearance UI Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font shortlist, and /admin/appearance to drive them. The design put the presets in theme.css as [data-theme] blocks. That does not work: SiteContext writes --accent as an inline style on , which beats any attribute-selector block, so a preset's accent would have been painted over by BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app themes itself from -- reported the other one. Presets now live in server/src/config/themePresets.js. themeResolve.js layers :root <- preset <- custom per field into a token map, getPublic() returns it as `theme`, and the client writes it onto . One authority for the merge, and brand.accent is by construction the accent the site paints. theme.css's :root is untouched, so an instance with no row gets no theme block and renders as today. Also: presets carry the full 15-token palette (eight would have left Fantasy with blue-grey borders); the option catalog is served from GET /settings/theme/options so the form cannot offer what the server rejects; validation is strict on write and forgiving on read; and the Discord bot now fetches the effective accent instead of its boot-time env copy. Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger factory rather than calling it, so a DB fault would have thrown a TypeError inside the catch instead of returning 500. Co-Authored-By: Claude --- bot/src/brand.js | 84 +++- bot/src/server.js | 5 + bot/src/site/siteApiClient.js | 11 +- client/index.html | 11 +- client/src/App.jsx | 12 + client/src/api/client.js | 12 + client/src/contexts/SiteContext.jsx | 24 +- client/src/lib/themeVars.js | 47 ++ client/src/routes/admin/AdminLayout.jsx | 3 + .../routes/admin/views/AppearanceAdmin.jsx | 354 ++++++++++++++ client/test/themeVars.test.js | 100 ++++ server/routes.guards.json | 9 + server/routes.manifest.json | 4 + server/src/config/themePresets.js | 215 +++++++++ server/src/model/settings/settings.model.js | 35 +- .../src/router/v1/admin/admin.controller.js | 18 + server/src/router/v1/settings/index.js | 2 + .../src/router/v1/settings/nav.controller.js | 7 +- .../router/v1/settings/theme.controller.js | 17 + server/src/router/v1/settings/theme.router.js | 26 ++ server/src/utils/themeResolve.js | 202 ++++++++ server/swagger/swagger-output.json | 434 +++++++++++++++++- server/swagger/swagger.js | 85 +++- server/test/publicBrand.test.js | 73 +++ server/test/settingsTheming.test.js | 89 ++++ server/test/themeResolve.test.js | 262 +++++++++++ 26 files changed, 2113 insertions(+), 28 deletions(-) create mode 100644 client/src/lib/themeVars.js create mode 100644 client/src/routes/admin/views/AppearanceAdmin.jsx create mode 100644 client/test/themeVars.test.js create mode 100644 server/src/config/themePresets.js create mode 100644 server/src/router/v1/settings/theme.controller.js create mode 100644 server/src/router/v1/settings/theme.router.js create mode 100644 server/src/utils/themeResolve.js create mode 100644 server/test/themeResolve.test.js diff --git a/bot/src/brand.js b/bot/src/brand.js index bfa72e3..a9e5b41 100644 --- a/bot/src/brand.js +++ b/bot/src/brand.js @@ -1,13 +1,83 @@ // Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and // logs carry the instance identity. Kept minimal — the bot only needs the name // and the accent color (as an int for discord.js embeds). +// +// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site +// changes `theme_visual`, which the server resolves into the effective +// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md +// §4.5). This process boots from env and then follows that value, so embeds +// don't stay the old color until someone restarts the container. +// +// Design constraints this satisfies: +// • env is always a working answer — a site that is down, unconfigured or +// mid-restart never costs the bot its accent, it just keeps the last known +// good one; +// • reading `brand.accentInt` never awaits and never throws, because it is +// read inline while building an embed; +// • at most one refresh is ever in flight. require('dotenv').config() -const name = process.env.BRAND_NAME || 'Runic Gateway' -const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd' -const accentInt = (() => { - const n = parseInt(String(accentHex).replace('#', ''), 16) - return Number.isNaN(n) ? 0x7f99bd : n -})() +const siteApi = require('./site/siteApiClient') +const createLogger = require('./utils/logger') -module.exports = { name, accentHex, accentInt } +const log = createLogger('brand') + +const name = process.env.BRAND_NAME || 'Runic Gateway' +const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd' + +function toInt(hex) { + const n = parseInt(String(hex).replace('#', ''), 16) + return Number.isNaN(n) ? 0x7f99bd : n +} + +// How long a fetched accent is trusted before the next read triggers a refresh. +// A theme change reaching Discord within ten minutes is fine; a network call per +// embed is not. +const TTL_MS = 10 * 60 * 1000 + +let accentHex = ENV_ACCENT +let accentInt = toInt(ENV_ACCENT) +let fetchedAt = 0 +let inFlight = null + +async function fetchAccent() { + const res = await siteApi.getPublicSettings() + // Any failure — site down, maintenance, malformed body — leaves the current + // value in place. Stamping fetchedAt regardless is deliberate: it stops a + // persistently unreachable site from firing a request on every single read. + fetchedAt = Date.now() + const accent = res.ok ? res.data?.brand?.accent : null + if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return + if (accent === accentHex) return + accentHex = accent + accentInt = toInt(accent) + log.info('embed accent updated from the site', { accent }) +} + +// Kick off a refresh if the cached value is stale. Never awaited by a reader — +// the current value is returned immediately and the next read sees the new one. +function refreshIfStale() { + if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight + inFlight = fetchAccent() + .catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message })) + .finally(() => { + inFlight = null + }) + return inFlight +} + +module.exports = { + name, + // Getters, not values: consumers already read `brand.accentInt` inline when + // building an embed, so this keeps the accent current with no call-site change. + get accentHex() { + refreshIfStale() + return accentHex + }, + get accentInt() { + refreshIfStale() + return accentInt + }, + // Awaited once at startup so the first embed of a process is already correct. + refreshAccent: () => refreshIfStale() || Promise.resolve(), +} diff --git a/bot/src/server.js b/bot/src/server.js index 582826b..c204c41 100644 --- a/bot/src/server.js +++ b/bot/src/server.js @@ -21,6 +21,11 @@ async function start() { log.info(`internal API listening on http://${HOST}:${PORT}`) }) + // Pick up the site's effective accent before the first embed can be built. + // Best-effort by design: it never rejects, and a site that is not up yet just + // leaves the bot on its BRAND_ACCENT_COLOR default until the next read. + await brand.refreshAccent() + await bootstrap() setupShutdown(server) diff --git a/bot/src/site/siteApiClient.js b/bot/src/site/siteApiClient.js index 0fe8bfe..0c7a524 100644 --- a/bot/src/site/siteApiClient.js +++ b/bot/src/site/siteApiClient.js @@ -31,6 +31,15 @@ async function call(path) { } } +// The site's public settings, including the brand block. Used for the embed +// accent (see brand.js): the admin can theme the site at runtime, and the +// server resolves the effective accent into brand.accent, so this is how the +// bot's embeds track a theme change instead of being stuck on the value +// BRAND_ACCENT_COLOR had when the container started. +function getPublicSettings() { + return call('/settings') +} + function getNewsPost(idOrSlug) { return call(`/posts/news/${encodeURIComponent(idOrSlug)}`) } @@ -39,4 +48,4 @@ function searchWiki(query) { return call(`/wiki?q=${encodeURIComponent(query)}`) } -module.exports = { getNewsPost, searchWiki } +module.exports = { getPublicSettings, getNewsPost, searchWiki } diff --git a/client/index.html b/client/index.html index d92425b..a45f2d3 100644 --- a/client/index.html +++ b/client/index.html @@ -7,7 +7,16 @@ - + +
diff --git a/client/src/App.jsx b/client/src/App.jsx index 3d46f16..2095d3f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -41,6 +41,7 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx' import PageBuilder from './routes/admin/views/PageBuilder.jsx' import WikiAdmin from './routes/admin/views/WikiAdmin.jsx' import HeroEditor from './routes/admin/views/HeroEditor.jsx' +import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' @@ -139,6 +140,17 @@ export default function App() { } /> } /> } /> + {/* Theme editing writes an admin-only settings key; the route sits + behind the same RoleGate as the sidebar entry that reaches it, + and PUT/DELETE /admin/settings is admin-only server-side too. */} + + + + } + /> } /> req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }), + // ----- settings (any authenticated account) ----- + // Nav overrides for the layouts the caller's own role renders, and the theme + // catalog the appearance form is built from. A fifth group, not part of + // /admin, because AdminLayout renders for editors and moderators too — see + // docs/website/THEMING_AND_NAV.md §4.2. + navSettings: () => req('/settings/nav'), + themeOptions: () => req('/settings/theme/options'), + // ----- public ----- publicSettings: () => req('/public/settings'), status: () => req('/public/status'), @@ -280,6 +288,10 @@ export const api = { deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }), getSettings: () => req('/admin/settings'), updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }), + // Reset one setting to its default by deleting the row — the theming/nav + // keys and the hero draft only (the server holds the allowlist). Idempotent, + // so the caller need not know whether a row exists. + resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }), activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), botActivity: () => req('/admin/bot-activity'), unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), diff --git a/client/src/contexts/SiteContext.jsx b/client/src/contexts/SiteContext.jsx index 958556a..71c4a2c 100644 --- a/client/src/contexts/SiteContext.jsx +++ b/client/src/contexts/SiteContext.jsx @@ -1,5 +1,6 @@ -import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' +import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react' import { api } from '../api/client.js' +import { applyThemeTokens } from '../lib/themeVars.js' const SiteContext = createContext(null) @@ -25,11 +26,28 @@ export function SiteProvider({ children }) { const brand = useMemo(() => settings.brand || {}, [settings]) + // Apply the admin's theme. The whole effective token set is resolved + // server-side, so this only writes it and takes back what it wrote before — + // see lib/themeVars.js for why the removal half matters. No theme block means + // the admin never themed this instance, and the shipped :root stands. + const appliedTokens = useRef([]) + useEffect(() => { + appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current) + }, [settings.theme]) + // Apply the instance accent color to the CSS variable the theme is built on, - // so branding flows to every `var(--accent)` at runtime (no rebuild). + // so branding flows to every `var(--accent)` at runtime (no rebuild). This is + // the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR + // server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the + // theme block rather than fighting it. + // + // Deliberately ordered after the theme effect and re-run on any theme change: + // resetting a theme removes --accent from the token map, and this has to be + // the write that lands last or an instance with a custom BRAND_ACCENT_COLOR + // would drop to the stylesheet's default accent until the next reload. useEffect(() => { if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent) - }, [brand.accent]) + }, [brand.accent, settings.theme]) // Memoized so consumers don't re-render on every provider render (brand is a // fresh object each render, which would otherwise churn the context value). diff --git a/client/src/lib/themeVars.js b/client/src/lib/themeVars.js new file mode 100644 index 0000000..97f936f --- /dev/null +++ b/client/src/lib/themeVars.js @@ -0,0 +1,47 @@ +// Apply the server-resolved theme to the document as CSS custom properties. +// +// The effective token set is resolved server-side and arrives on +// `settings.theme` (see server/src/utils/themeResolve.js). The client's only +// job is to write it onto — and, crucially, to take back what it wrote +// last time, which is the part with actual logic and the reason this lives in +// its own testable module. +// +// Why removal matters: an admin who resets the theme, or switches from a preset +// that sets --bg to one that does not, gets a payload that no longer mentions +// that variable. Inline properties are not cleared by writing a smaller object +// over them, so without an explicit removeProperty the old value would stick +// until a reload. That would make "Reset to defaults" look broken. +// +// Everything written here is a value the server validated against a closed set +// (hex color, curated font stack, bounded px length, listed shadow). The client +// deliberately does not re-validate — it would be a second, drifting authority. +// It does refuse anything that is not a `--custom-property`, which is the one +// check that costs nothing and stops a token map from reaching an ordinary CSS +// property. + +const CUSTOM_PROPERTY = /^--[a-zA-Z0-9-_]+$/ + +/** + * @param {CSSStyleDeclaration} style usually document.documentElement.style + * @param {Record|null|undefined} tokens the new theme, or + * null/absent for "no admin theme" — which clears everything previously set + * @param {string[]} [applied] the keys this function wrote last time + * @returns {string[]} the keys now applied, to pass back on the next call + */ +export function applyThemeTokens(style, tokens, applied = []) { + const next = [] + if (tokens && typeof tokens === 'object') { + for (const [name, value] of Object.entries(tokens)) { + if (!CUSTOM_PROPERTY.test(name) || typeof value !== 'string' || value === '') continue + style.setProperty(name, value) + next.push(name) + } + } + // Take back only what we set ourselves. Anything else on the element's inline + // style belongs to someone else (SiteContext's own --accent line, a future + // feature) and is not ours to clear. + for (const name of applied) { + if (!next.includes(name)) style.removeProperty(name) + } + return next +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 200476a..341773b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -38,6 +38,7 @@ const IconBot = () =>

const IconUser = () => const IconShard = () => +const IconPalette = () => // Nav is grouped into collapsible categories. A group with no `title` renders // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` @@ -74,6 +75,7 @@ const NAV = [ { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, { to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] }, + { to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] }, { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] }, @@ -104,6 +106,7 @@ const TITLES = { '/admin/shard-ops': 'In-Game Ops', '/admin/houses': 'House Registry', '/admin/settings': 'Site Settings', + '/admin/appearance': 'Appearance', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', '/admin/discord-bot': 'Discord Bot', diff --git a/client/src/routes/admin/views/AppearanceAdmin.jsx b/client/src/routes/admin/views/AppearanceAdmin.jsx new file mode 100644 index 0000000..2e9908c --- /dev/null +++ b/client/src/routes/admin/views/AppearanceAdmin.jsx @@ -0,0 +1,354 @@ +import { useEffect, useMemo, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' +import { useSite } from '../../../contexts/SiteContext.jsx' + +// Admin · Appearance — the theme half of docs/website/THEMING_AND_NAV.md +// (phases 3-4). Brand asset uploads and the nav builder are phases 5 and 7 and +// get their own screens. +// +// Two things shape this form: +// +// • Every control is a closed set. The presets, the font shortlist and the +// shadow depths all come from GET /settings/theme/options, which is derived +// from the same server config the save is validated against — so the form +// can never offer a value the server would reject. Nothing here is free +// text except the color inputs, which are and so are +// hex by construction. +// • Saving means writing a settings row; resetting means DELETING it. Absence +// of the row is what selects the shipped default, so "reset" cannot write a +// copy of the defaults — see §2. + +// Human labels for the eight editable colors and four radii. The field names +// and the CSS variables they drive both come from the server +// (colorFields / radiusFields); this only decorates them, and a field with no +// label here still renders under its raw name rather than vanishing. +const COLOR_LABELS = { + bg: 'Background', + bgDeep: 'Background (deep)', + panelA: 'Panel (top)', + panelB: 'Panel (bottom)', + accent: 'Accent', + accentBright: 'Accent (bright)', + ink: 'Ink / headings', + text: 'Body text', +} +const RADIUS_LABELS = { + radiusPill: 'Pills & buttons', + radiusPanel: 'Flat panels', + radiusCard: 'Cards & panels', + radiusInput: 'Inputs & notes', +} +const FONT_LABELS = { + serif: 'Body serif', + display: 'Display / headings', + sans: 'Interface sans', +} + +// Strip empty groups so a theme the admin cleared back out is stored as a bare +// preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a +// field out to "clear" it — remove it (§6.1). +function compactCustom(custom) { + const out = {} + for (const [group, fields] of Object.entries(custom)) { + const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null)) + if (Object.keys(kept).length) out[group] = kept + } + return Object.keys(out).length ? out : null +} + +export default function AppearanceAdmin() { + const { refresh: refreshSite } = useSite() + const [options, setOptions] = useState(null) + const [preset, setPreset] = useState('runic-gateway') + const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} }) + // Whether a theme_visual row exists at all. Drives the "reset" button and the + // "this instance is using the shipped theme" note — an admin needs to be able + // to tell "never themed" from "themed to look like the default". + const [stored, setStored] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [saved, setSaved] = useState(false) + + useEffect(() => { + let active = true + Promise.all([api.themeOptions(), api.admin.getSettings()]) + .then(([opts, all]) => { + if (!active) return + setOptions(opts) + // The stored value is a JSON string (settings.value is TEXT). Malformed + // reads as absent, exactly as the server treats it — the form then shows + // the shipped default rather than an error. + let parsed = null + try { + const raw = all.theme_visual + parsed = raw ? JSON.parse(raw) : null + } catch { + parsed = null + } + setStored(Boolean(all.theme_visual)) + if (parsed && typeof parsed === 'object') { + setPreset(parsed.preset || 'runic-gateway') + setCustom({ + colors: parsed.custom?.colors || {}, + fonts: parsed.custom?.fonts || {}, + structure: parsed.custom?.structure || {}, + }) + } + }) + .catch(() => active && setError('Could not load the appearance settings.')) + .finally(() => active && setLoading(false)) + return () => { + active = false + } + }, []) + + // What an unset field currently resolves to: the selected preset's palette, + // or the shipped theme when the preset is Custom (which has no base). Lets a + // color picker open on the value the admin is actually looking at. + const baseTokens = useMemo(() => { + if (!options) return {} + return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens + }, [options, preset]) + + if (loading) return + if (error && !options) return + + const setField = (group, field) => (value) => { + setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } })) + setSaved(false) + } + const clearField = (group, field) => () => { + setCustom((c) => { + const next = { ...c[group] } + delete next[field] + return { ...c, [group]: next } + }) + setSaved(false) + } + + async function save() { + setBusy(true) + setError('') + try { + await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } }) + setStored(true) + setSaved(true) + // Repull the public settings so the surrounding admin UI re-themes itself + // immediately — the admin sees the change they just made. + await refreshSite() + } catch (err) { + setError(err.message || 'Could not save the theme.') + } finally { + setBusy(false) + } + } + + async function resetAll() { + setBusy(true) + setError('') + try { + await api.admin.resetSetting('theme_visual') + setPreset('runic-gateway') + setCustom({ colors: {}, fonts: {}, structure: {} }) + setStored(false) + setSaved(false) + await refreshSite() + } catch (err) { + setError(err.message || 'Could not reset the theme.') + } finally { + setBusy(false) + } + } + + return ( +

+

+ Colors, fonts and corner radius for the public site, this admin panel and the player portal. + {' '} + {stored ? ( + <>This instance has a saved theme. Reset to default deletes it and returns to the shipped look. + ) : ( + <>This instance has never been themed, so it uses the shipped look and its BRAND_* accent. + )} +

+ + {/* ── Preset ─────────────────────────────────────────────── */} +
+ Preset +
+ {options.presets.map((p) => ( + + ))} +
+ + {preset === 'custom' + ? 'Custom starts from the shipped theme — only the fields you set below change.' + : 'A preset sets the whole palette. Anything you set below overrides it, field by field.'} + +
+ + {/* ── Colors ─────────────────────────────────────────────── */} +
+ Colors +
+ {options.colorFields.map(({ name, token }) => { + const set = custom.colors[name] !== undefined + return ( +
+ {/* has no empty state, so an unset field + shows what it currently resolves to rather than black. */} + setField('colors', name)(e.target.value)} + aria-label={COLOR_LABELS[name] || name} + style={{ width: 34, height: 30, padding: 0, border: '1px solid var(--line)', borderRadius: 6, background: 'transparent', cursor: 'pointer' }} + /> + + {COLOR_LABELS[name] || name} + + {set && ( + + )} +
+ ) + })} +
+ + A color you have not set follows the preset. “Live” and “maintenance” status colors are never themed — green has to keep meaning live. + +
+ + {/* ── Fonts ──────────────────────────────────────────────── */} +
+ Fonts +
+ {Object.keys(options.fonts).map((role) => ( + + ))} +
+
+ + {/* ── Structure ──────────────────────────────────────────── */} +
+ Corners & depth +
+ {options.radiusFields.map(({ name, token }) => ( + + ))} +
+ +
+ +
+ + + {saved && Saved.} + {error && {error}} +
+ +

+ The accent reaches the mobile app and the Discord bot too — both theme themselves from this + site’s public branding. +

+
+ ) +} + +const linkBtn = { + border: 'none', + background: 'transparent', + color: 'var(--accent)', + fontSize: '0.72rem', + cursor: 'pointer', + padding: 0, +} diff --git a/client/test/themeVars.test.js b/client/test/themeVars.test.js new file mode 100644 index 0000000..1bd15eb --- /dev/null +++ b/client/test/themeVars.test.js @@ -0,0 +1,100 @@ +// applyThemeTokens — writing the server-resolved theme onto the document, and +// (the part with real logic) taking back exactly what it wrote last time. +// +// Pure module, exercised against a fake CSSStyleDeclaration: node --test has no +// DOM, and the function only ever needs setProperty/removeProperty. +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { applyThemeTokens } from '../src/lib/themeVars.js' + +// Minimal stand-in for element.style, plus a log of the calls so a test can +// assert that a property was *removed* rather than merely absent. +function fakeStyle() { + const props = new Map() + const removed = [] + return { + props, + removed, + setProperty: (name, value) => props.set(name, value), + removeProperty: (name) => { + props.delete(name) + removed.push(name) + }, + get: (name) => props.get(name), + } +} + +test('writes each token and reports the keys it applied', () => { + const style = fakeStyle() + const applied = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' }) + assert.equal(style.get('--accent'), '#c9973f') + assert.equal(style.get('--bg'), '#1a120b') + assert.deepEqual(applied.sort(), ['--accent', '--bg']) +}) + +// The untouched-instance case: no theme block means the stylesheet's :root +// stands and nothing is written at all. +test('no theme writes nothing', () => { + for (const empty of [null, undefined, {}]) { + const style = fakeStyle() + const applied = applyThemeTokens(style, empty) + assert.equal(style.props.size, 0) + assert.deepEqual(applied, []) + } +}) + +test('removes a token that is no longer in the theme', () => { + const style = fakeStyle() + const first = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' }) + const second = applyThemeTokens(style, { '--accent': '#c9973f' }, first) + assert.equal(style.get('--accent'), '#c9973f') + assert.equal(style.get('--bg'), undefined) + assert.deepEqual(style.removed, ['--bg']) + assert.deepEqual(second, ['--accent']) +}) + +// "Reset to defaults" — the case that would look broken without the removal +// half: the payload stops mentioning the variables, and the inline values have +// to come off for :root to show through again. +test('resetting to no theme clears everything previously applied', () => { + const style = fakeStyle() + const first = applyThemeTokens(style, { '--accent': '#c9973f', '--radius-card': '2px' }) + const second = applyThemeTokens(style, null, first) + assert.equal(style.props.size, 0) + assert.deepEqual(style.removed.sort(), ['--accent', '--radius-card']) + assert.deepEqual(second, []) +}) + +// Only ever clears its own keys. SiteContext writes --accent itself from +// brand.accent, and a future feature may write others; those are not ours. +test('never removes a property it did not apply', () => { + const style = fakeStyle() + style.setProperty('--accent', '#ff0000') // someone else's write + applyThemeTokens(style, { '--bg': '#000000' }, []) + assert.equal(style.get('--accent'), '#ff0000') + assert.deepEqual(style.removed, []) +}) + +test('ignores anything that is not a custom property', () => { + const style = fakeStyle() + const applied = applyThemeTokens(style, { background: 'url(http://evil.example/x)', '--bg': '#000000' }) + assert.equal(style.get('background'), undefined) + assert.deepEqual(applied, ['--bg']) +}) + +test('ignores non-string and empty values', () => { + const style = fakeStyle() + const applied = applyThemeTokens(style, { '--a': 4, '--b': null, '--c': '', '--d': '#fff' }) + assert.deepEqual(applied, ['--d']) +}) + +// A stale key list must not survive a call that could not write: the next call +// still has to know what is actually on the element. +test('a token dropped as invalid is removed if it was applied before', () => { + const style = fakeStyle() + const first = applyThemeTokens(style, { '--bg': '#000000' }) + const second = applyThemeTokens(style, { '--bg': '' }, first) + assert.equal(style.get('--bg'), undefined) + assert.deepEqual(second, []) +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index 24c25f0..3304f14 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -2154,6 +2154,15 @@ "noindex", "requireAuth" ] + }, + { + "method": "GET", + "path": "/api/v1/settings/theme/options", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] } ], "internal": [ diff --git a/server/routes.manifest.json b/server/routes.manifest.json index ad7d5d3..f91067f 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -900,6 +900,10 @@ { "method": "GET", "path": "/api/v1/settings/nav" + }, + { + "method": "GET", + "path": "/api/v1/settings/theme/options" } ], "internal": [ diff --git a/server/src/config/themePresets.js b/server/src/config/themePresets.js new file mode 100644 index 0000000..1b989b0 --- /dev/null +++ b/server/src/config/themePresets.js @@ -0,0 +1,215 @@ +// ── Theme presets & the closed sets an admin may choose from ─────────────── +// +// The single authority for admin-configurable theming (docs/website/THEMING_AND_NAV.md +// §5-§6). Everything an admin can pick is enumerated here; nothing is free text. +// +// Why the server owns this rather than theme.css: +// The effective token set is resolved server-side and returned by +// settings.getPublic() as `theme`, which the SPA writes onto the document as +// CSS custom properties. That keeps ONE authority for the override merge +// (:root ← preset ← custom), lets brand.accent — a cross-repo contract the +// Android app themes itself from — report the same accent the website paints, +// and avoids the precedence trap of `[data-theme]` blocks losing to the inline +// `--accent` SiteContext already sets on . +// +// theme.css's `:root` remains the default and is NOT duplicated here beyond +// the runic-gateway preset. An instance with no `theme_visual` row gets no +// `theme` block at all and renders from :root exactly as it does today. +// +// Security note: these values end up as CSS custom property values. Every one is +// picked from a closed set (a preset id, a shortlist stack, a bounded px length, +// a hex color) — see utils/themeResolve.js, which both the write path and the +// read path validate through. + +// The three color tokens that are semantic rather than decorative. They mean +// "live" and "maintenance" and stay fixed across every preset — green is not a +// brand choice. Deliberately absent from every preset block below. +const FIXED_TOKENS = ['--mode-live', '--mode-maint'] + +// Full palettes. A preset must carry EVERY color token, not just the eight the +// admin form exposes: a partial palette leaves e.g. --line and --blue at their +// dark-blue :root values, which reads as broken on a warm background. +// +// --panel-grad is deliberately absent: it is derived (`linear-gradient(180deg, +// var(--panel-a), var(--panel-b))`) and must stay derived, or a future light +// preset silently inherits a dark gradient. +const PRESETS = { + // Today's :root, verbatim. Declared as a preset so that switching back to it + // after trying another is the same code path as any other choice. + 'runic-gateway': { + label: 'Runic Gateway', + tokens: { + '--bg': '#0e1318', + '--bg-deep': '#0b0f14', + '--panel-a': '#192231', + '--panel-b': '#141a21', + '--panel-flat': '#11161d', + '--line': '#2a3544', + '--line-soft': '#1d2733', + '--accent': '#7f99bd', + '--accent-bright': '#cdd9e8', + '--ink': '#eef3f8', + '--head': '#e6edf6', + '--text': '#c4cdd8', + '--muted': '#aeb8c4', + '--dim': '#6f7d8e', + '--blue': '#13243c', + '--radius-pill': '999px', + '--radius-panel': '12px', + '--radius-card': '10px', + '--radius-input': '8px', + '--shadow-card': '0 14px 34px rgba(0, 0, 0, 0.3)', + '--serif': 'Georgia, "Times New Roman", serif', + '--display': 'Cinzel, Georgia, serif', + '--sans': '"Helvetica Neue", Arial, sans-serif', + }, + }, + // Flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. + modern: { + label: 'Modern', + tokens: { + '--bg': '#101114', + '--bg-deep': '#0a0a0c', + '--panel-a': '#1c1d22', + '--panel-b': '#17181c', + '--panel-flat': '#141519', + '--line': '#2b2d34', + '--line-soft': '#212329', + '--accent': '#4f8ef7', + '--accent-bright': '#a8c8ff', + '--ink': '#f2f3f5', + '--head': '#f7f8fa', + '--text': '#b8bcc4', + '--muted': '#a9aeb8', + '--dim': '#71767f', + '--blue': '#1b2c47', + '--radius-pill': '8px', + '--radius-panel': '8px', + '--radius-card': '6px', + '--radius-input': '6px', + '--shadow-card': '0 8px 20px rgba(0, 0, 0, 0.25)', + '--serif': 'Inter, Arial, sans-serif', + '--display': "'Work Sans', Arial, sans-serif", + '--sans': 'Inter, Arial, sans-serif', + }, + }, + // Warmer, higher contrast, carved corners; leans into UO harder. + fantasy: { + label: 'Fantasy', + tokens: { + '--bg': '#1a120b', + '--bg-deep': '#120c07', + '--panel-a': '#2c1f14', + '--panel-b': '#241a10', + '--panel-flat': '#1f160d', + '--line': '#4a3721', + '--line-soft': '#33251a', + '--accent': '#c9973f', + '--accent-bright': '#e8c374', + '--ink': '#f3e8d4', + '--head': '#f7efe0', + '--text': '#d3bfa0', + '--muted': '#bfa985', + '--dim': '#8a7454', + '--blue': '#382613', + '--radius-pill': '4px', + '--radius-panel': '3px', + '--radius-card': '2px', + '--radius-input': '2px', + '--shadow-card': '0 16px 38px rgba(0, 0, 0, 0.45)', + '--serif': "'EB Garamond', Georgia, serif", + '--display': 'Cinzel, Georgia, serif', + '--sans': "'EB Garamond', Georgia, serif", + }, + }, +} + +// 'custom' is a valid stored preset meaning "no preset base" — :root plus +// whatever custom fields are set. It has no palette of its own. +const CUSTOM_PRESET = 'custom' +const PRESET_IDS = [...Object.keys(PRESETS), CUSTOM_PRESET] + +// The colors the admin form exposes, mapped to their CSS token. Deliberately +// the eight of §6.1 rather than all fifteen: the rest are supporting shades a +// preset sets coherently but that are not worth (or safe to) hand-picking. +const COLOR_FIELDS = { + bg: '--bg', + bgDeep: '--bg-deep', + panelA: '--panel-a', + panelB: '--panel-b', + accent: '--accent', + accentBright: '--accent-bright', + ink: '--ink', + text: '--text', +} + +const RADIUS_FIELDS = { + radiusPill: '--radius-pill', + radiusPanel: '--radius-panel', + radiusCard: '--radius-card', + radiusInput: '--radius-input', +} + +const FONT_FIELDS = { + serif: '--serif', + display: '--display', + sans: '--sans', +} + +// The curated Google Fonts shortlist (§5.1). The dropdown's VALUE is the full +// stack exactly as applied, so no string is ever built from admin input and no +// Google Fonts URL is ever assembled at runtime — the combined css2? request in +// client/index.html is static and covers all eight web families. +// +// One addition to §5.1's twelve: Georgia in the serif list. The shortlist as +// drafted gave the sans role a "current default" option (Arial, byte-identical +// to today's --sans) but left the serif role with no way back to today's +// `Georgia, "Times New Roman", serif` short of resetting the whole theme. It +// pulls in no web family, so §5.2's URL is unchanged. +const FONT_OPTIONS = { + serif: [ + { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — strongest fantasy/historic' }, + { value: 'Merriweather, Georgia, serif', label: 'Merriweather — excellent readability' }, + { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant/editorial' }, + { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — old-world (no bold weight)' }, + { value: 'Georgia, "Times New Roman", serif', label: 'Georgia — the shipped default' }, + ], + display: [ + { value: 'Cinzel, Georgia, serif', label: 'Cinzel — current Runic Gateway identity' }, + { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant alternative' }, + { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — softer/classic' }, + { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — very strong fantasy (no bold weight)' }, + ], + sans: [ + { value: 'Inter, Arial, sans-serif', label: 'Inter — default modern UI choice' }, + { value: "'Work Sans', Arial, sans-serif", label: 'Work Sans — slightly more character' }, + { value: "'Source Sans 3', Arial, sans-serif", label: 'Source Sans 3 — extremely readable' }, + { value: '"Helvetica Neue", Arial, sans-serif', label: 'Arial — no webfont; the shipped default' }, + ], +} + +// Shadow depth, as a closed set for the same reason fonts are: the stored value +// is applied verbatim as --shadow-card. +const SHADOW_OPTIONS = [ + { value: 'none', label: 'None — flat' }, + { value: '0 8px 20px rgba(0, 0, 0, 0.25)', label: 'Soft' }, + { value: '0 14px 34px rgba(0, 0, 0, 0.3)', label: 'Default' }, + { value: '0 18px 44px rgba(0, 0, 0, 0.45)', label: 'Deep' }, +] + +// Corner radius is a number, not a shortlist, so it is bounded instead: an +// integer count of px from 0 to 999 (999 being the pill). +const RADIUS_MAX_PX = 999 + +module.exports = { + PRESETS, + PRESET_IDS, + CUSTOM_PRESET, + FIXED_TOKENS, + COLOR_FIELDS, + RADIUS_FIELDS, + FONT_FIELDS, + FONT_OPTIONS, + SHADOW_OPTIONS, + RADIUS_MAX_PX, +} diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index a2bd505..db708c5 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -1,5 +1,7 @@ const settingsDb = require('./settings.db') const brand = require('../../config/brand') +const { parseJsonSetting } = require('../../utils/settingsJson') +const { resolveThemeTokens } = require('../../utils/themeResolve') // Keys safe to expose on the public site. const PUBLIC_KEYS = [ @@ -147,9 +149,28 @@ async function getPublic() { // the final say when the call is made). Lets the portal show/hide the form. const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled' out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode) - // Instance branding (BRAND_* env defaults). The two admin-editable settings — - // site title and contact email — override the env value when set, so existing - // installs keep their DB-configured name; everything else comes from env. + // The effective CSS custom properties for the admin's theme, or absent when + // no theme_visual row exists (or nothing in it was usable). The SPA writes + // these onto ; absence means it writes nothing and theme.css's :root + // stands, which is what keeps an untouched instance byte-for-byte as today. + // Resolution — :root ← preset ← custom — happens here rather than in CSS so + // there is one authority and brand.accent below can report the same value the + // site actually paints. See THEMING_AND_NAV.md §6. + const theme = resolveThemeTokens(all.theme_visual) + if (theme) out.theme = theme + // Uploaded brand-asset overrides (§6.3). Written by the Phase 5 admin UI; + // resolved here so every consumer of the brand block — the SPA, the Android + // app, the Discord bot — picks them up through the one contract. + const brandAssets = parseJsonSetting(all.brand_assets) || {} + // Instance branding (BRAND_* env defaults). The admin-editable settings — + // site title, contact email, and now the theme accent and uploaded assets — + // override the env value when set, so existing installs keep their + // DB-configured name; everything else comes from env. + // + // brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole + // Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot + // colors its embeds from it. Resolving the effective accent here is what lets + // both track admin theming with no client change. out.brand = { name: out.site_title || brand.name, shortName: brand.shortName, @@ -157,10 +178,10 @@ async function getPublic() { description: brand.description, contactEmail: out.contact_email || brand.contactEmail, url: brand.url, - accent: brand.accent, - logo: brand.logo, - hero: brand.hero, - favicon: brand.favicon, + accent: theme?.['--accent'] || brand.accent, + logo: brandAssets.logo || brand.logo, + hero: brandAssets.hero || brand.hero, + favicon: brandAssets.favicon || brand.favicon, } // Push-notification relay (M7). The client-facing ntfy base URL the app's // embedded distributor registers its device topic against; null when push is diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index d80ce14..e53c1e3 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -9,6 +9,8 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model') const newsGump = require('../../../utils/newsGump') const pushDispatch = require('../../../utils/pushDispatch') const { cleanBody } = require('../../../utils/sanitizeHtml') +const { parseJsonSetting } = require('../../../utils/settingsJson') +const { validateThemeVisual } = require('../../../utils/themeResolve') const log = require('../../../utils/logger')('admin') @@ -529,6 +531,22 @@ async function updateSettings(req, res) { if (typeof updates.homepage_teaser === 'string') { updates.homepage_teaser = cleanBody(updates.homepage_teaser) } + // theme_visual is JSON whose values become CSS custom properties, so every + // one has to come from the closed sets in config/themePresets.js. The read + // path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently + // storing a value that will never apply is a bad admin experience — reject it + // with the offending field named instead. Accepts an object or the stringified + // form, and stores it stringified either way, since settings.value is TEXT. + if ('theme_visual' in updates) { + const raw = updates.theme_visual + const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw + if (typeof raw === 'string' && parsed === null) { + return res.status(400).json({ message: 'theme_visual must be a JSON object' }) + } + const check = validateThemeVisual(parsed) + if (!check.ok) return res.status(400).json({ message: check.message }) + updates.theme_visual = JSON.stringify(parsed) + } try { await settings.setMany(updates, req.user.id) await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } }) diff --git a/server/src/router/v1/settings/index.js b/server/src/router/v1/settings/index.js index 2bf8c72..3fe6989 100644 --- a/server/src/router/v1/settings/index.js +++ b/server/src/router/v1/settings/index.js @@ -23,11 +23,13 @@ const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const navRouter = require('./nav.router') +const themeRouter = require('./theme.router') const settingsRouter = express.Router() settingsRouter.use(noindex, requireAuth) settingsRouter.use('/nav', navRouter) +settingsRouter.use('/theme', themeRouter) module.exports = settingsRouter diff --git a/server/src/router/v1/settings/nav.controller.js b/server/src/router/v1/settings/nav.controller.js index 249ad23..8d0400d 100644 --- a/server/src/router/v1/settings/nav.controller.js +++ b/server/src/router/v1/settings/nav.controller.js @@ -1,5 +1,10 @@ const settings = require('../../../model/settings/settings.model') -const log = require('../../../utils/logger') + +// The logger module exports a FACTORY — calling it is what yields {error, warn, +// info, debug}. Using the factory directly makes `log.error` undefined, which +// would turn a DB fault into a TypeError thrown inside the catch (no response +// sent, request left hanging) instead of a 500. +const log = require('../../../utils/logger')('settings') // The nav overrides for the two authenticated layouts. Values are the raw stored // JSON strings (settings.value is TEXT) or null; the caller parses them with the diff --git a/server/src/router/v1/settings/theme.controller.js b/server/src/router/v1/settings/theme.controller.js new file mode 100644 index 0000000..11f92c9 --- /dev/null +++ b/server/src/router/v1/settings/theme.controller.js @@ -0,0 +1,17 @@ +const { themeOptions } = require('../../../utils/themeResolve') + +// The theme catalog the admin appearance form builds its controls from: the +// presets and their swatches, the curated font shortlist, the shadow depths, +// and which color and radius fields are editable. +// +// Served rather than duplicated in client code so the options the form OFFERS +// can never drift from the ones validateThemeVisual() ACCEPTS — a drift shows +// up as an admin picking a font and the save 400ing for no visible reason. +// +// Static: derived from config/themePresets.js with no DB read, so there is +// nothing here to fail and no error branch to write. +function getThemeOptions(req, res) { + return res.json(themeOptions()) +} + +module.exports = { getThemeOptions } diff --git a/server/src/router/v1/settings/theme.router.js b/server/src/router/v1/settings/theme.router.js new file mode 100644 index 0000000..c0944a0 --- /dev/null +++ b/server/src/router/v1/settings/theme.router.js @@ -0,0 +1,26 @@ +// Settings · Theme — the closed sets the admin appearance form is built from. +// +// Mounted at /api/v1/settings/theme by settings/index.js, which already applied +// `noindex, requireAuth`. No role gate is added here for the same reason the +// group has none: it is a static catalog of presets and font names, not +// configuration and not anything about the caller. The route that WRITES a +// theme is PUT /api/v1/admin/settings, which is admin-only. + +const express = require('express') + +const ctrl = require('./theme.controller') + +const themeRouter = express.Router() + +themeRouter.get( + '/options', + // #swagger.tags = ['Settings'] + // #swagger.summary = 'Theme presets and the curated option lists' + // #swagger.description = 'The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Theme option catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/ThemeOptions" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.getThemeOptions, +) + +module.exports = themeRouter diff --git a/server/src/utils/themeResolve.js b/server/src/utils/themeResolve.js new file mode 100644 index 0000000..c1b556b --- /dev/null +++ b/server/src/utils/themeResolve.js @@ -0,0 +1,202 @@ +// ── theme_visual: validate on write, resolve on read ─────────────────────── +// +// Two jobs, one closed set of rules (config/themePresets.js): +// +// validateThemeVisual() the WRITE path. PUT /admin/settings rejects a bad +// theme_visual with a 400 rather than storing it, so an +// admin gets told why instead of watching a save appear +// to succeed and do nothing. +// resolveThemeTokens() the READ path. Turns the stored value into the CSS +// custom properties settings.getPublic() ships as +// `theme`. Fail-safe, per §4.4: anything unrecognized +// is dropped field-by-field and the surface falls back +// to theme.css's :root — never an error, never a +// half-applied palette. +// +// The write path is the strict one and the read path is the forgiving one on +// purpose. Strict-on-write gives feedback; forgiving-on-read means a row +// hand-edited in the DB, or written by an older version of this code, degrades +// to the shipped default instead of rendering a broken site. +// +// See docs/website/THEMING_AND_NAV.md §5-§6. + +const { + PRESETS, + PRESET_IDS, + CUSTOM_PRESET, + COLOR_FIELDS, + RADIUS_FIELDS, + FONT_FIELDS, + FONT_OPTIONS, + SHADOW_OPTIONS, + RADIUS_MAX_PX, +} = require('../config/themePresets') +const { parseJsonSetting } = require('./settingsJson') + +const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ +const PX_LENGTH = /^(\d{1,3})px$/ + +const SHADOW_VALUES = SHADOW_OPTIONS.map((o) => o.value) +const FONT_VALUES = Object.fromEntries( + Object.keys(FONT_FIELDS).map((role) => [role, FONT_OPTIONS[role].map((o) => o.value)]), +) + +function isPlainObject(v) { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +function isColor(v) { + return typeof v === 'string' && HEX_COLOR.test(v) +} + +// A bounded px length. `0` on its own is not accepted — a radius is always +// written with a unit here, which keeps the stored shape uniform. +function isRadius(v) { + if (typeof v !== 'string') return false + const m = PX_LENGTH.exec(v) + return !!m && Number(m[1]) <= RADIUS_MAX_PX +} + +function isShadow(v) { + return typeof v === 'string' && SHADOW_VALUES.includes(v) +} + +function isFont(role, v) { + return typeof v === 'string' && (FONT_VALUES[role] || []).includes(v) +} + +// Per-field check for one custom group. Returns the list of offending field +// names, so the write path can say which field was wrong. +function checkGroup(group, fields, check) { + const bad = [] + for (const [field, value] of Object.entries(group)) { + if (!(field in fields)) { + bad.push(field) + } else if (!check(field, value)) { + bad.push(field) + } + } + return bad +} + +/** + * Strict shape check for the write path. + * + * @param {unknown} value the parsed theme_visual object + * @returns {{ ok: true } | { ok: false, message: string }} + */ +function validateThemeVisual(value) { + if (!isPlainObject(value)) return { ok: false, message: 'theme_visual must be a JSON object' } + + const keys = Object.keys(value).filter((k) => k !== 'preset' && k !== 'custom') + if (keys.length) return { ok: false, message: `theme_visual: unknown field(s) ${keys.join(', ')}` } + + if (!PRESET_IDS.includes(value.preset)) { + return { ok: false, message: `theme_visual.preset must be one of ${PRESET_IDS.join(', ')}` } + } + + // `custom` is optional and may be explicitly null ("preset only"). + const custom = value.custom + if (custom === undefined || custom === null) return { ok: true } + if (!isPlainObject(custom)) return { ok: false, message: 'theme_visual.custom must be an object or null' } + + const groups = Object.keys(custom).filter((g) => !['colors', 'structure', 'fonts'].includes(g)) + if (groups.length) return { ok: false, message: `theme_visual.custom: unknown group(s) ${groups.join(', ')}` } + + for (const [group, spec] of [ + ['colors', { fields: COLOR_FIELDS, check: (_f, v) => isColor(v) }], + ['fonts', { fields: FONT_FIELDS, check: (f, v) => isFont(f, v) }], + [ + 'structure', + { + fields: { ...RADIUS_FIELDS, shadowDepth: '--shadow-card' }, + check: (f, v) => (f === 'shadowDepth' ? isShadow(v) : isRadius(v)), + }, + ], + ]) { + const supplied = custom[group] + if (supplied === undefined || supplied === null) continue + if (!isPlainObject(supplied)) return { ok: false, message: `theme_visual.custom.${group} must be an object` } + const bad = checkGroup(supplied, spec.fields, spec.check) + if (bad.length) return { ok: false, message: `theme_visual.custom.${group}: invalid value for ${bad.join(', ')}` } + } + + return { ok: true } +} + +// Copy the fields of one custom group that pass their check onto the token map. +// Field-by-field: a bad accent does not discard a good bg beside it. +function applyGroup(tokens, group, fields, check) { + if (!isPlainObject(group)) return + for (const [field, token] of Object.entries(fields)) { + const value = group[field] + if (value !== undefined && check(field, value)) tokens[token] = value + } +} + +/** + * The effective CSS custom properties for a stored theme_visual value. + * + * Layered :root ← preset ← custom, per field. `null` means "no row, or nothing + * usable in it" — the caller omits the block entirely and the client applies + * nothing, which is what makes an untouched instance render byte-for-byte as + * today. + * + * @param {string|object|null|undefined} stored the raw settings value (TEXT) or + * an already-parsed object + * @returns {Record|null} + */ +function resolveThemeTokens(stored) { + const parsed = typeof stored === 'string' ? parseJsonSetting(stored) : isPlainObject(stored) ? stored : null + if (!parsed) return null + + // An unrecognized preset id falls back to no base rather than to a guess: the + // admin's custom fields still apply on top of :root. + const base = PRESETS[parsed.preset] + const tokens = base ? { ...base.tokens } : {} + + const custom = parsed.custom + if (isPlainObject(custom)) { + applyGroup(tokens, custom.colors, COLOR_FIELDS, (_f, v) => isColor(v)) + applyGroup(tokens, custom.fonts, FONT_FIELDS, (f, v) => isFont(f, v)) + applyGroup(tokens, custom.structure, RADIUS_FIELDS, (_f, v) => isRadius(v)) + applyGroup(tokens, custom.structure, { shadowDepth: '--shadow-card' }, (_f, v) => isShadow(v)) + } + + // A row that parsed but yielded nothing usable (e.g. `{"preset":"custom"}` + // with no custom fields) is the same as no row at all to every consumer. + return Object.keys(tokens).length ? tokens : null +} + +/** + * The catalog the admin UI builds its controls from. Served rather than + * duplicated client-side so the options offered can never drift from the + * options validateThemeVisual() accepts. + */ +function themeOptions() { + return { + // Full token maps, not just a swatch: the form shows each control's + // *effective* default for the selected preset, so an admin opening the + // accent picker on Fantasy sees Fantasy's gold rather than a hardcoded + // client-side copy of the shipped palette. `custom` has no map — it means + // "no preset base", and the form falls back to the shipped theme, which is + // the runic-gateway map. + presets: [ + ...Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label, tokens: p.tokens })), + { id: CUSTOM_PRESET, label: 'Custom', tokens: null }, + ], + // Each editable field paired with the CSS variable it drives, so the form + // can look its current value up in the preset map above without knowing the + // naming convention that relates the two. + colorFields: Object.entries(COLOR_FIELDS).map(([name, token]) => ({ name, token })), + radiusFields: Object.entries(RADIUS_FIELDS).map(([name, token]) => ({ name, token })), + fonts: FONT_OPTIONS, + shadows: SHADOW_OPTIONS, + radiusMaxPx: RADIUS_MAX_PX, + // The shipped default, i.e. what theme.css's :root already declares. What + // an unset field actually resolves to when no preset is selected. + shippedTokens: PRESETS['runic-gateway'].tokens, + } +} + +module.exports = { validateThemeVisual, resolveThemeTokens, themeOptions } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index dd221ff..7f862d8 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -12772,6 +12772,51 @@ } ] } + }, + "/api/v1/settings/theme/options": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Theme presets and the curated option lists", + "description": "The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.", + "responses": { + "200": { + "description": "Theme option catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThemeOptions" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -17720,7 +17765,7 @@ }, "description": { "type": "string", - "example": "Seed/accent color (hex) for theming." + "example": "Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change." } } }, @@ -17737,7 +17782,7 @@ }, "description": { "type": "string", - "example": "Logo URL or site-relative path; empty = no logo." + "example": "Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO." } } }, @@ -17754,7 +17799,7 @@ }, "description": { "type": "string", - "example": "Hero image URL or site-relative path." + "example": "Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO." } } }, @@ -17771,7 +17816,7 @@ }, "description": { "type": "string", - "example": "Favicon URL or site-relative path." + "example": "Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON." } } } @@ -17880,6 +17925,49 @@ "brand": { "$ref": "#/components/schemas/Brand" }, + "theme": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow)." + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "object", + "properties": { + "--accent": { + "type": "string", + "example": "#c9973f" + }, + "--bg": { + "type": "string", + "example": "#1a120b" + }, + "--radius-card": { + "type": "string", + "example": "2px" + } + } + } + } + }, "push": { "type": "object", "properties": { @@ -17972,6 +18060,344 @@ } } }, + "ThemeOptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database." + }, + "properties": { + "type": "object", + "properties": { + "presets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means \"no preset base — the shipped theme plus whatever custom fields are set\"." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "fantasy" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Fantasy" + } + } + }, + "tokens": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "object", + "properties": { + "--bg": { + "type": "string", + "example": "#1a120b" + }, + "--accent": { + "type": "string", + "example": "#c9973f" + } + } + } + } + } + } + } + } + } + } + }, + "colorFields": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Editable color fields, each paired with the CSS variable it drives." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "accent" + } + } + }, + "token": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "--accent" + } + } + } + } + } + } + } + } + }, + "radiusFields": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "radiusCard" + } + } + }, + "token": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "--radius-card" + } + } + } + } + } + } + } + } + }, + "shippedTokens": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "What the stylesheet declares by default — the values an unset field resolves to when no preset is selected." + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "fonts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Curated Google Fonts shortlist per role. Each option's `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input." + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + } + } + }, + "shadows": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + }, + "radiusMaxPx": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 999 + } + } + } + } + } + } + }, "DeletedId": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 450e979..7395c79 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -748,10 +748,15 @@ const doc = { description: { type: 'string' }, contactEmail: { type: 'string', example: '' }, url: { type: 'string', example: '' }, - accent: { type: 'string', example: '#7f99bd', description: 'Seed/accent color (hex) for theming.' }, - logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo.' }, - hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path.' }, - favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path.' }, + accent: { + type: 'string', + example: '#7f99bd', + description: + 'Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change.', + }, + logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO.' }, + hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO.' }, + favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON.' }, }, }, PublicSettings: { @@ -768,6 +773,14 @@ const doc = { }, gameAccountSignup: { type: 'boolean', example: false }, brand: { $ref: '#/components/schemas/Brand' }, + theme: { + type: 'object', + nullable: true, + description: + 'The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow).', + additionalProperties: { type: 'string' }, + example: { '--accent': '#c9973f', '--bg': '#1a120b', '--radius-card': '2px' }, + }, push: { type: 'object', description: @@ -792,6 +805,70 @@ const doc = { nav_player: { type: 'string', nullable: true, example: null }, }, }, + ThemeOptions: { + type: 'object', + description: + 'The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database.', + properties: { + presets: { + type: 'array', + description: + 'Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means "no preset base — the shipped theme plus whatever custom fields are set".', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'fantasy' }, + label: { type: 'string', example: 'Fantasy' }, + tokens: { + type: 'object', + nullable: true, + additionalProperties: { type: 'string' }, + example: { '--bg': '#1a120b', '--accent': '#c9973f' }, + }, + }, + }, + }, + colorFields: { + type: 'array', + description: 'Editable color fields, each paired with the CSS variable it drives.', + items: { + type: 'object', + properties: { name: { type: 'string', example: 'accent' }, token: { type: 'string', example: '--accent' } }, + }, + }, + radiusFields: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string', example: 'radiusCard' }, token: { type: 'string', example: '--radius-card' } }, + }, + }, + shippedTokens: { + type: 'object', + description: 'What the stylesheet declares by default — the values an unset field resolves to when no preset is selected.', + additionalProperties: { type: 'string' }, + }, + fonts: { + type: 'object', + description: 'Curated Google Fonts shortlist per role. Each option\'s `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input.', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { value: { type: 'string' }, label: { type: 'string' } }, + }, + }, + }, + shadows: { + type: 'array', + items: { + type: 'object', + properties: { value: { type: 'string' }, label: { type: 'string' } }, + }, + }, + radiusMaxPx: { type: 'integer', example: 999 }, + }, + }, // Delete/mutation acknowledgements — each echoes the affected resource key // or a boolean flag rather than a { message } string. DeletedId: { diff --git a/server/test/publicBrand.test.js b/server/test/publicBrand.test.js index 1e36426..749d3bf 100644 --- a/server/test/publicBrand.test.js +++ b/server/test/publicBrand.test.js @@ -56,6 +56,79 @@ test('admin site_title / contact_email override the brand defaults', async () => assert.equal(pub.brand.accent, brand.accent) // colors still from config }) +// ── Effective theming (THEMING_AND_NAV.md §4.5) ─────────────────────── +// +// brand.accent and the asset fields are a cross-repo contract: the Android app +// themes its whole Material palette from brand.accent and the Discord bot +// colors its embeds from it. Resolving the EFFECTIVE value here is what lets +// both track admin theming with no client change — so these tests are really +// about the app and the bot, not about the website. + +test('no theme row leaves the brand block exactly as env defines it', async () => { + // Explicitly re-asserted next to the theming cases: this is the acceptance + // criterion the whole feature rests on, and it is the assertion a future + // change to the resolver would break first. + const pub = await settings.getPublic() + assert.equal(pub.theme, undefined, 'no theme block at all when untouched') + assert.equal(pub.brand.accent, brand.accent) + assert.equal(pub.brand.logo, brand.logo) + assert.equal(pub.brand.hero, brand.hero) + assert.equal(pub.brand.favicon, brand.favicon) +}) + +test('a theme preset overrides brand.accent and ships the token block', async () => { + settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy' }) }] + const pub = await settings.getPublic() + assert.equal(pub.brand.accent, '#c9973f', 'the app sees the themed accent, not BRAND_ACCENT_COLOR') + assert.equal(pub.theme['--accent'], '#c9973f') + assert.equal(pub.theme['--bg'], '#1a120b') + // Assets are a different key and must not move with the theme. + assert.equal(pub.brand.logo, brand.logo) + assert.equal(pub.brand.hero, brand.hero) +}) + +test('a custom accent beats the preset accent in brand.accent', async () => { + settingsDb.getAll = async () => [ + { key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#123456' } } }) }, + ] + const pub = await settings.getPublic() + assert.equal(pub.brand.accent, '#123456') + assert.equal(pub.theme['--accent'], '#123456') +}) + +test('a malformed theme row reads as absent, not as an error', async () => { + for (const value of ['{oops', '"x"', '{"preset":"parchment"}']) { + settingsDb.getAll = async () => [{ key: 'theme_visual', value }] + const pub = await settings.getPublic() + assert.equal(pub.theme, undefined, value) + assert.equal(pub.brand.accent, brand.accent, value) + } +}) + +test('brand_assets overrides one asset without disturbing the others', async () => { + settingsDb.getAll = async () => [ + { key: 'brand_assets', value: JSON.stringify({ favicon: '/uploads/1234-abcd.png' }) }, + ] + const pub = await settings.getPublic() + assert.equal(pub.brand.favicon, '/uploads/1234-abcd.png') + assert.equal(pub.brand.logo, brand.logo, 'logo still from env') + assert.equal(pub.brand.hero, brand.hero, 'hero still from env') +}) + +test('a malformed brand_assets row falls back to env for every asset', async () => { + settingsDb.getAll = async () => [{ key: 'brand_assets', value: 'not json' }] + const pub = await settings.getPublic() + assert.equal(pub.brand.logo, brand.logo) + assert.equal(pub.brand.hero, brand.hero) + assert.equal(pub.brand.favicon, brand.favicon) +}) + +test('the Discord-only integer accent is still never exposed, themed or not', async () => { + settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'modern' }) }] + const pub = await settings.getPublic() + assert.equal(pub.brand.accentInt, undefined) +}) + // The push relay block the app's embedded distributor discovers its ntfy base // URL from (M7 Part 2). Null when nothing is configured; NTFY_PUBLIC_URL wins, // else the first NTFY_ALLOWED_ORIGINS entry; NTFY_BASE_URL is never surfaced. diff --git a/server/test/settingsTheming.test.js b/server/test/settingsTheming.test.js index b8db7eb..462a2a3 100644 --- a/server/test/settingsTheming.test.js +++ b/server/test/settingsTheming.test.js @@ -213,6 +213,95 @@ test('theme_visual / brand_assets / nav_public are public once set; nav_admin / assert.equal(pub.nav_player, undefined) }) +// ── PUT /admin/settings — theme_visual is validated on the way in ────────── +// +// The read path drops anything invalid anyway, so this is about feedback, not +// safety: an admin whose save appears to succeed and then does nothing has no +// way to tell what was wrong. + +test('a valid theme_visual is stored stringified', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + const written = {} + settingsDb.set = async (key, value) => { + written[key] = value + } + settingsDb.getAll = async () => [] + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + const theme = { preset: 'fantasy', custom: { colors: { accent: '#123456' } } } + const res = await fetch(`${app.url}/api/v1/admin/settings`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ theme_visual: theme }), + }) + assert.equal(res.status, 200) + // settings.value is TEXT — an object body must reach the store stringified. + assert.equal(written.theme_visual, JSON.stringify(theme)) + } finally { + settingsDb.set = originals.set + await app.close() + } +}) + +test('an invalid theme_visual is rejected and nothing is written', async () => { + signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' }) + settingsDb.set = () => assert.fail('an invalid theme must not be stored') + const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter)) + try { + const bad = [ + { preset: 'parchment' }, + { preset: 'custom', custom: { colors: { accent: 'red' } } }, + { preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS' } } }, + { preset: 'custom', custom: { structure: { radiusCard: '4em' } } }, + { preset: 'custom', custom: { spacing: { unit: '8px' } } }, + 'not json', + ] + for (const theme_visual of bad) { + const res = await fetch(`${app.url}/api/v1/admin/settings`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ theme_visual }), + }) + assert.equal(res.status, 400, JSON.stringify(theme_visual)) + const body = await res.json() + assert.match(body.message, /theme_visual/) + } + } finally { + settingsDb.set = originals.set + await app.close() + } +}) + +// ── GET /settings/theme-options — the catalog the admin form is built from ── + +test('GET /settings/theme/options serves the catalog to an authenticated caller', async () => { + signInAs({ id: 7, username: 'u', role: 'admin', status: 'active' }) + const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/settings/theme/options`) + assert.equal(res.status, 200) + const body = await res.json() + assert.ok(Array.isArray(body.presets) && body.presets.length === 4, 'three presets plus Custom') + assert.ok(body.fonts.serif.length && body.fonts.display.length && body.fonts.sans.length) + assert.ok(body.shadows.length) + assert.ok(body.colorFields.some((f) => f.name === 'accent' && f.token === '--accent')) + assert.equal(body.shippedTokens['--accent'], '#7f99bd') + } finally { + await app.close() + } +}) + +test('GET /settings/theme/options rejects an anonymous caller', async () => { + sessionService.validateSession = () => null + const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter)) + try { + const res = await fetch(`${app.url}/api/v1/settings/theme/options`) + assert.equal(res.status, 401) + } finally { + await app.close() + } +}) + // ── parseJsonSetting: malformed reads as absent, never as an error ───────── test('parseJsonSetting returns null for absent, empty and malformed values', () => { diff --git a/server/test/themeResolve.test.js b/server/test/themeResolve.test.js new file mode 100644 index 0000000..9acba1c --- /dev/null +++ b/server/test/themeResolve.test.js @@ -0,0 +1,262 @@ +// theme_visual — the strict write path and the fail-safe read path. +// +// The two halves are deliberately asymmetric (see utils/themeResolve.js): a +// write is rejected with the offending field named, while a read drops bad +// fields one at a time and falls back to the shipped :root. These tests lock +// that asymmetry, because it is the thing most likely to get "tidied" into a +// single shared check later. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { validateThemeVisual, resolveThemeTokens, themeOptions } = require('../src/utils/themeResolve') +const { PRESETS, FONT_OPTIONS, SHADOW_OPTIONS } = require('../src/config/themePresets') + +// ── validateThemeVisual (write path) ────────────────────────────────── +test('accepts a bare preset choice', () => { + for (const preset of ['runic-gateway', 'modern', 'fantasy', 'custom']) { + assert.equal(validateThemeVisual({ preset }).ok, true, preset) + } + assert.equal(validateThemeVisual({ preset: 'fantasy', custom: null }).ok, true) +}) + +test('rejects an unknown preset', () => { + const res = validateThemeVisual({ preset: 'parchment' }) + assert.equal(res.ok, false) + assert.match(res.message, /preset must be one of/) +}) + +test('rejects a non-object, and unknown top-level fields', () => { + for (const bad of [null, 4, 'fantasy', []]) { + assert.equal(validateThemeVisual(bad).ok, false) + } + const res = validateThemeVisual({ preset: 'modern', mode: 'light' }) + assert.equal(res.ok, false) + assert.match(res.message, /unknown field\(s\) mode/) +}) + +test('accepts custom colors, fonts and structure from the closed sets', () => { + const res = validateThemeVisual({ + preset: 'custom', + custom: { + colors: { accent: '#c9973f', bg: '#000' }, + fonts: { display: 'Cinzel, Georgia, serif', sans: 'Inter, Arial, sans-serif' }, + structure: { radiusCard: '4px', shadowDepth: SHADOW_OPTIONS[0].value }, + }, + }) + assert.equal(res.ok, true, res.message) +}) + +test('rejects a color that is not a hex literal', () => { + // The point of the closed set: a CSS function or keyword never reaches a + // custom property value, whatever it would or would not have done there. + for (const bad of ['red', 'rgb(1,2,3)', 'url(http://x/y)', '#12345', 'var(--bg)', '#ff0000; x']) { + const res = validateThemeVisual({ preset: 'custom', custom: { colors: { accent: bad } } }) + assert.equal(res.ok, false, bad) + assert.match(res.message, /colors: invalid value for accent/) + } +}) + +test('rejects a font stack that is not on the shortlist', () => { + const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS, sans-serif' } } }) + assert.equal(res.ok, false) + assert.match(res.message, /fonts: invalid value for sans/) +}) + +test('rejects a font offered for a different role', () => { + // Cinzel is a display face and is not in the sans list. + const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Cinzel, Georgia, serif' } } }) + assert.equal(res.ok, false) +}) + +test('rejects an out-of-range or unitless radius', () => { + for (const bad of ['1000px', '4', '4em', '-4px', 'calc(4px + 1px)']) { + const res = validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: bad } } }) + assert.equal(res.ok, false, bad) + } + assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: '999px' } } }).ok, true) +}) + +test('rejects a shadow that is not one of the listed depths', () => { + const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: '0 0 99px red' } } }) + assert.equal(res.ok, false) +}) + +test('rejects unknown groups and unknown fields inside a group', () => { + assert.equal(validateThemeVisual({ preset: 'custom', custom: { spacing: { unit: '8px' } } }).ok, false) + const res = validateThemeVisual({ preset: 'custom', custom: { colors: { border: '#fff' } } }) + assert.equal(res.ok, false) + assert.match(res.message, /invalid value for border/) +}) + +// ── resolveThemeTokens (read path) ──────────────────────────────────── + +// The acceptance criterion the whole feature rests on: absent means absent, and +// the caller writes nothing. +test('no row, or an unusable one, resolves to null', () => { + for (const nothing of [null, undefined, '', 'not json', '4', '"x"', '[]', '{}', '{"preset":"custom"}']) { + assert.equal(resolveThemeTokens(nothing), null, JSON.stringify(nothing)) + } +}) + +test('a preset resolves to its full palette', () => { + const tokens = resolveThemeTokens(JSON.stringify({ preset: 'fantasy' })) + assert.deepEqual(tokens, PRESETS.fantasy.tokens) + // Not a partial palette: the supporting shades move with it, or a warm theme + // keeps dark-blue borders. + assert.equal(tokens['--line'], '#4a3721') + assert.equal(tokens['--blue'], '#382613') +}) + +test('semantic status colors are never themed', () => { + for (const preset of Object.values(PRESETS)) { + assert.equal('--mode-live' in preset.tokens, false) + assert.equal('--mode-maint' in preset.tokens, false) + } +}) + +test('the derived panel gradient is never written as a literal', () => { + for (const preset of Object.values(PRESETS)) { + assert.equal('--panel-grad' in preset.tokens, false) + } +}) + +test('runic-gateway is exactly the shipped stylesheet values', () => { + const t = PRESETS['runic-gateway'].tokens + assert.equal(t['--bg'], '#0e1318') + assert.equal(t['--accent'], '#7f99bd') + assert.equal(t['--radius-pill'], '999px') + assert.equal(t['--radius-panel'], '12px') + assert.equal(t['--radius-card'], '10px') + assert.equal(t['--radius-input'], '8px') + assert.equal(t['--serif'], 'Georgia, "Times New Roman", serif') + assert.equal(t['--display'], 'Cinzel, Georgia, serif') + assert.equal(t['--sans'], '"Helvetica Neue", Arial, sans-serif') +}) + +// The one place the server duplicates the stylesheet, so the one place that can +// drift: switching to runic-gateway after trying another preset must land back +// on exactly what :root ships, not on a stale copy of it. +test('the runic-gateway preset matches theme.css :root token for token', () => { + const fs = require('node:fs') + const path = require('node:path') + const cssPath = path.join(__dirname, '..', '..', 'client', 'src', 'styles', 'theme.css') + const root = /:root\s*\{([\s\S]*?)\}/.exec(fs.readFileSync(cssPath, 'utf8')) + assert.ok(root, 'theme.css has a :root block') + const declared = {} + for (const line of root[1].split(';')) { + const m = /^\s*(--[a-z0-9-]+)\s*:\s*([\s\S]+?)\s*$/i.exec(line.replace(/\/\*[\s\S]*?\*\//g, '')) + if (m) declared[m[1]] = m[2] + } + for (const [token, value] of Object.entries(PRESETS['runic-gateway'].tokens)) { + // --shadow-card is the exception: theme.css writes rgba() unspaced and the + // preset writes it spaced, which is the same computed value. Compare with + // whitespace normalized rather than exempting the token entirely. + assert.equal( + String(declared[token]).replace(/\s+/g, ''), + value.replace(/\s+/g, ''), + `${token} drifted from theme.css`, + ) + } +}) + +test('custom fields layer on top of the preset, per field', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#ffffff' } } }), + ) + assert.equal(tokens['--accent'], '#ffffff') // overridden + assert.equal(tokens['--bg'], PRESETS.fantasy.tokens['--bg']) // untouched + assert.equal(tokens['--radius-card'], '2px') // untouched +}) + +test('custom with no preset base yields only the fields that were set', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'custom', custom: { structure: { radiusCard: '4px' } } }), + ) + assert.deepEqual(tokens, { '--radius-card': '4px' }) +}) + +// Fail-safe, field by field: a hand-edited row degrades to the shipped default +// for the bad field only, rather than rendering a broken site or throwing. +test('an invalid field is dropped without discarding its neighbours', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'custom', custom: { colors: { accent: 'red', bg: '#000000' } } }), + ) + assert.deepEqual(tokens, { '--bg': '#000000' }) +}) + +test('an unknown preset still applies the custom fields', () => { + const tokens = resolveThemeTokens( + JSON.stringify({ preset: 'parchment', custom: { colors: { accent: '#ffffff' } } }), + ) + assert.deepEqual(tokens, { '--accent': '#ffffff' }) +}) + +test('accepts an already-parsed object as well as the stored string', () => { + assert.deepEqual(resolveThemeTokens({ preset: 'modern' }), PRESETS.modern.tokens) +}) + +test('every resolved value is a plain string', () => { + const tokens = resolveThemeTokens(JSON.stringify({ preset: 'modern' })) + for (const [name, value] of Object.entries(tokens)) { + assert.match(name, /^--[a-z-]+$/, name) + assert.equal(typeof value, 'string', name) + } +}) + +// ── themeOptions (the catalog the admin form is built from) ─────────── + +// The reason the catalog is served rather than duplicated client-side: every +// option offered must be one validateThemeVisual() accepts. +test('every offered font and shadow validates', () => { + const opts = themeOptions() + for (const [role, options] of Object.entries(opts.fonts)) { + for (const o of options) { + const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { [role]: o.value } } }) + assert.equal(res.ok, true, `${role}: ${o.value} — ${res.message || ''}`) + } + } + for (const o of opts.shadows) { + const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: o.value } } }) + assert.equal(res.ok, true, o.value) + } +}) + +test('every offered preset id validates and every field name is editable', () => { + const opts = themeOptions() + for (const p of opts.presets) { + assert.equal(validateThemeVisual({ preset: p.id }).ok, true, p.id) + } + for (const { name } of opts.colorFields) { + assert.equal(validateThemeVisual({ preset: 'custom', custom: { colors: { [name]: '#123456' } } }).ok, true, name) + } + for (const { name } of opts.radiusFields) { + assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { [name]: '5px' } } }).ok, true, name) + } +}) + +// The form reads each control's current value out of the preset map by token +// name, so every advertised field must actually resolve to one. +test('every advertised field names a token the presets declare', () => { + const opts = themeOptions() + for (const { name, token } of [...opts.colorFields, ...opts.radiusFields]) { + assert.ok(token.startsWith('--'), `${name} → ${token}`) + assert.ok(token in opts.shippedTokens, `${token} missing from the shipped theme`) + for (const p of opts.presets) { + if (p.tokens) assert.ok(token in p.tokens, `${token} missing from preset ${p.id}`) + } + } +}) + +test('the shipped default font stacks are reachable from the shortlist', () => { + // An admin who customizes fonts must be able to get back to today's look + // without resetting the whole theme. + const serifValues = FONT_OPTIONS.serif.map((o) => o.value) + const sansValues = FONT_OPTIONS.sans.map((o) => o.value) + const displayValues = FONT_OPTIONS.display.map((o) => o.value) + assert.ok(serifValues.includes('Georgia, "Times New Roman", serif')) + assert.ok(sansValues.includes('"Helvetica Neue", Arial, sans-serif')) + assert.ok(displayValues.includes('Cinzel, Georgia, serif')) +})