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 <html>, 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 <html>. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- The eight web families behind the admin font shortlist
|
||||
(docs/website/THEMING_AND_NAV.md §5), in one combined css2? request.
|
||||
Static and never built from admin input: the dropdown stores a full
|
||||
font-family stack from a closed set, and only the families actually
|
||||
applied have their binaries fetched. Both hosts are already in the CSP
|
||||
(server/src/config/csp.js), so this needs no policy change. -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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() {
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
{/* 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. */}
|
||||
<Route
|
||||
path="appearance"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<AppearanceAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path="moderation"
|
||||
|
||||
@@ -97,6 +97,14 @@ export const api = {
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
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 } }),
|
||||
|
||||
@@ -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).
|
||||
|
||||
47
client/src/lib/themeVars.js
Normal file
47
client/src/lib/themeVars.js
Normal file
@@ -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 <html> — 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<string, string>|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
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><p
|
||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
||||
|
||||
// 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',
|
||||
|
||||
354
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
354
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
@@ -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 <input type="color"> 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 <Loading />
|
||||
if (error && !options) return <ErrorState message={error} />
|
||||
|
||||
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 (
|
||||
<section style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Colors, fonts and corner radius for the public site, this admin panel and the player portal.
|
||||
{' '}
|
||||
{stored ? (
|
||||
<>This instance has a saved theme. <strong style={{ color: 'var(--muted)' }}>Reset to default</strong> deletes it and returns to the shipped look.</>
|
||||
) : (
|
||||
<>This instance has never been themed, so it uses the shipped look and its <code>BRAND_*</code> accent.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* ── Preset ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Preset</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
|
||||
{options.presets.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPreset(p.id)
|
||||
setSaved(false)
|
||||
}}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: `1px solid ${preset === p.id ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: preset === p.id ? 'var(--blue)' : 'transparent',
|
||||
color: preset === p.id ? 'var(--ink)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
aria-pressed={preset === p.id}
|
||||
>
|
||||
{p.tokens ? (
|
||||
<span style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--line)' }}>
|
||||
{['--bg', '--panel-a', '--accent', '--ink'].map((t) => (
|
||||
<span key={t} style={{ width: 11, height: 18, background: p.tokens[t] }} />
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ width: 44, height: 18, borderRadius: 4, border: '1px dashed var(--line)' }} />
|
||||
)}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
{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.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Colors ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Colors</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.colorFields.map(({ name, token }) => {
|
||||
const set = custom.colors[name] !== undefined
|
||||
return (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* <input type="color"> has no empty state, so an unset field
|
||||
shows what it currently resolves to rather than black. */}
|
||||
<input
|
||||
type="color"
|
||||
value={custom.colors[name] || baseTokens[token] || '#000000'}
|
||||
onChange={(e) => 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' }}
|
||||
/>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.82rem', color: set ? 'var(--ink)' : 'var(--dim)' }}>
|
||||
{COLOR_LABELS[name] || name}
|
||||
</span>
|
||||
{set && (
|
||||
<button type="button" onClick={clearField('colors', name)} className="sans" title="Follow the preset again" style={linkBtn}>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
A color you have not set follows the preset. “Live” and “maintenance” status colors are never themed — green has to keep meaning live.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Fonts ──────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Fonts</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
|
||||
{Object.keys(options.fonts).map((role) => (
|
||||
<label key={role} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{FONT_LABELS[role] || role}
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.fonts[role] || ''}
|
||||
onChange={(e) => (e.target.value ? setField('fonts', role)(e.target.value) : clearField('fonts', role)())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.fonts[role].map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Structure ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Corners & depth</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.radiusFields.map(({ name, token }) => (
|
||||
<label key={name} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{RADIUS_LABELS[name] || name}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max={options.radiusMaxPx}
|
||||
placeholder={(baseTokens[token] || '').replace('px', '')}
|
||||
value={(custom.structure[name] || '').replace('px', '')}
|
||||
onChange={(e) =>
|
||||
e.target.value === ''
|
||||
? clearField('structure', name)()
|
||||
: setField('structure', name)(`${Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), options.radiusMaxPx)}px`)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label style={{ display: 'block', marginTop: 12 }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
Card shadow
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.structure.shadowDepth || ''}
|
||||
onChange={(e) => (e.target.value ? setField('structure', 'shadowDepth')(e.target.value) : clearField('structure', 'shadowDepth')())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.shadows.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save theme'}
|
||||
</button>
|
||||
<button onClick={resetAll} disabled={busy || !stored} className="pill" title={stored ? 'Delete the saved theme' : 'Nothing to reset'}>
|
||||
Reset to default
|
||||
</button>
|
||||
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
The accent reaches the mobile app and the Discord bot too — both theme themselves from this
|
||||
site’s public branding.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
100
client/test/themeVars.test.js
Normal file
100
client/test/themeVars.test.js
Normal file
@@ -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, [])
|
||||
})
|
||||
@@ -2154,6 +2154,15 @@
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -900,6 +900,10 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
215
server/src/config/themePresets.js
Normal file
215
server/src/config/themePresets.js
Normal file
@@ -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 <html>.
|
||||
//
|
||||
// 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,
|
||||
}
|
||||
@@ -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 <html>; 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
|
||||
|
||||
@@ -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) } })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
17
server/src/router/v1/settings/theme.controller.js
Normal file
17
server/src/router/v1/settings/theme.controller.js
Normal file
@@ -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 }
|
||||
26
server/src/router/v1/settings/theme.router.js
Normal file
26
server/src/router/v1/settings/theme.router.js
Normal file
@@ -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
|
||||
202
server/src/utils/themeResolve.js
Normal file
202
server/src/utils/themeResolve.js
Normal file
@@ -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<string, string>|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 }
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
262
server/test/themeResolve.test.js
Normal file
262
server/test/themeResolve.test.js
Normal file
@@ -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'))
|
||||
})
|
||||
Reference in New Issue
Block a user