diff --git a/bot/src/brand.js b/bot/src/brand.js
index bfa72e3..a9e5b41 100644
--- a/bot/src/brand.js
+++ b/bot/src/brand.js
@@ -1,13 +1,83 @@
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
// logs carry the instance identity. Kept minimal — the bot only needs the name
// and the accent color (as an int for discord.js embeds).
+//
+// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site
+// changes `theme_visual`, which the server resolves into the effective
+// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md
+// §4.5). This process boots from env and then follows that value, so embeds
+// don't stay the old color until someone restarts the container.
+//
+// Design constraints this satisfies:
+// • env is always a working answer — a site that is down, unconfigured or
+// mid-restart never costs the bot its accent, it just keeps the last known
+// good one;
+// • reading `brand.accentInt` never awaits and never throws, because it is
+// read inline while building an embed;
+// • at most one refresh is ever in flight.
require('dotenv').config()
-const name = process.env.BRAND_NAME || 'Runic Gateway'
-const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
-const accentInt = (() => {
- const n = parseInt(String(accentHex).replace('#', ''), 16)
- return Number.isNaN(n) ? 0x7f99bd : n
-})()
+const siteApi = require('./site/siteApiClient')
+const createLogger = require('./utils/logger')
-module.exports = { name, accentHex, accentInt }
+const log = createLogger('brand')
+
+const name = process.env.BRAND_NAME || 'Runic Gateway'
+const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
+
+function toInt(hex) {
+ const n = parseInt(String(hex).replace('#', ''), 16)
+ return Number.isNaN(n) ? 0x7f99bd : n
+}
+
+// How long a fetched accent is trusted before the next read triggers a refresh.
+// A theme change reaching Discord within ten minutes is fine; a network call per
+// embed is not.
+const TTL_MS = 10 * 60 * 1000
+
+let accentHex = ENV_ACCENT
+let accentInt = toInt(ENV_ACCENT)
+let fetchedAt = 0
+let inFlight = null
+
+async function fetchAccent() {
+ const res = await siteApi.getPublicSettings()
+ // Any failure — site down, maintenance, malformed body — leaves the current
+ // value in place. Stamping fetchedAt regardless is deliberate: it stops a
+ // persistently unreachable site from firing a request on every single read.
+ fetchedAt = Date.now()
+ const accent = res.ok ? res.data?.brand?.accent : null
+ if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return
+ if (accent === accentHex) return
+ accentHex = accent
+ accentInt = toInt(accent)
+ log.info('embed accent updated from the site', { accent })
+}
+
+// Kick off a refresh if the cached value is stale. Never awaited by a reader —
+// the current value is returned immediately and the next read sees the new one.
+function refreshIfStale() {
+ if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight
+ inFlight = fetchAccent()
+ .catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message }))
+ .finally(() => {
+ inFlight = null
+ })
+ return inFlight
+}
+
+module.exports = {
+ name,
+ // Getters, not values: consumers already read `brand.accentInt` inline when
+ // building an embed, so this keeps the accent current with no call-site change.
+ get accentHex() {
+ refreshIfStale()
+ return accentHex
+ },
+ get accentInt() {
+ refreshIfStale()
+ return accentInt
+ },
+ // Awaited once at startup so the first embed of a process is already correct.
+ refreshAccent: () => refreshIfStale() || Promise.resolve(),
+}
diff --git a/bot/src/server.js b/bot/src/server.js
index 582826b..c204c41 100644
--- a/bot/src/server.js
+++ b/bot/src/server.js
@@ -21,6 +21,11 @@ async function start() {
log.info(`internal API listening on http://${HOST}:${PORT}`)
})
+ // Pick up the site's effective accent before the first embed can be built.
+ // Best-effort by design: it never rejects, and a site that is not up yet just
+ // leaves the bot on its BRAND_ACCENT_COLOR default until the next read.
+ await brand.refreshAccent()
+
await bootstrap()
setupShutdown(server)
diff --git a/bot/src/site/siteApiClient.js b/bot/src/site/siteApiClient.js
index 0fe8bfe..0c7a524 100644
--- a/bot/src/site/siteApiClient.js
+++ b/bot/src/site/siteApiClient.js
@@ -31,6 +31,15 @@ async function call(path) {
}
}
+// The site's public settings, including the brand block. Used for the embed
+// accent (see brand.js): the admin can theme the site at runtime, and the
+// server resolves the effective accent into brand.accent, so this is how the
+// bot's embeds track a theme change instead of being stuck on the value
+// BRAND_ACCENT_COLOR had when the container started.
+function getPublicSettings() {
+ return call('/settings')
+}
+
function getNewsPost(idOrSlug) {
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
}
@@ -39,4 +48,4 @@ function searchWiki(query) {
return call(`/wiki?q=${encodeURIComponent(query)}`)
}
-module.exports = { getNewsPost, searchWiki }
+module.exports = { getPublicSettings, getNewsPost, searchWiki }
diff --git a/client/index.html b/client/index.html
index d92425b..a45f2d3 100644
--- a/client/index.html
+++ b/client/index.html
@@ -7,7 +7,16 @@
-
+
+
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 3d46f16..2095d3f 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -41,6 +41,7 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
+import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
@@ -139,6 +140,17 @@ export default function App() {
} />
} />
} />
+ {/* Theme editing writes an admin-only settings key; the route sits
+ behind the same RoleGate as the sidebar entry that reaches it,
+ and PUT/DELETE /admin/settings is admin-only server-side too. */}
+
+
+
+ }
+ />
} />
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
+ // ----- settings (any authenticated account) -----
+ // Nav overrides for the layouts the caller's own role renders, and the theme
+ // catalog the appearance form is built from. A fifth group, not part of
+ // /admin, because AdminLayout renders for editors and moderators too — see
+ // docs/website/THEMING_AND_NAV.md §4.2.
+ navSettings: () => req('/settings/nav'),
+ themeOptions: () => req('/settings/theme/options'),
+
// ----- public -----
publicSettings: () => req('/public/settings'),
status: () => req('/public/status'),
@@ -280,6 +288,10 @@ export const api = {
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
+ // Reset one setting to its default by deleting the row — the theming/nav
+ // keys and the hero draft only (the server holds the allowlist). Idempotent,
+ // so the caller need not know whether a row exists.
+ resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
diff --git a/client/src/contexts/SiteContext.jsx b/client/src/contexts/SiteContext.jsx
index 958556a..71c4a2c 100644
--- a/client/src/contexts/SiteContext.jsx
+++ b/client/src/contexts/SiteContext.jsx
@@ -1,5 +1,6 @@
-import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
+import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { api } from '../api/client.js'
+import { applyThemeTokens } from '../lib/themeVars.js'
const SiteContext = createContext(null)
@@ -25,11 +26,28 @@ export function SiteProvider({ children }) {
const brand = useMemo(() => settings.brand || {}, [settings])
+ // Apply the admin's theme. The whole effective token set is resolved
+ // server-side, so this only writes it and takes back what it wrote before —
+ // see lib/themeVars.js for why the removal half matters. No theme block means
+ // the admin never themed this instance, and the shipped :root stands.
+ const appliedTokens = useRef([])
+ useEffect(() => {
+ appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current)
+ }, [settings.theme])
+
// Apply the instance accent color to the CSS variable the theme is built on,
- // so branding flows to every `var(--accent)` at runtime (no rebuild).
+ // so branding flows to every `var(--accent)` at runtime (no rebuild). This is
+ // the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR
+ // server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the
+ // theme block rather than fighting it.
+ //
+ // Deliberately ordered after the theme effect and re-run on any theme change:
+ // resetting a theme removes --accent from the token map, and this has to be
+ // the write that lands last or an instance with a custom BRAND_ACCENT_COLOR
+ // would drop to the stylesheet's default accent until the next reload.
useEffect(() => {
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
- }, [brand.accent])
+ }, [brand.accent, settings.theme])
// Memoized so consumers don't re-render on every provider render (brand is a
// fresh object each render, which would otherwise churn the context value).
diff --git a/client/src/lib/themeVars.js b/client/src/lib/themeVars.js
new file mode 100644
index 0000000..97f936f
--- /dev/null
+++ b/client/src/lib/themeVars.js
@@ -0,0 +1,47 @@
+// Apply the server-resolved theme to the document as CSS custom properties.
+//
+// The effective token set is resolved server-side and arrives on
+// `settings.theme` (see server/src/utils/themeResolve.js). The client's only
+// job is to write it onto — and, crucially, to take back what it wrote
+// last time, which is the part with actual logic and the reason this lives in
+// its own testable module.
+//
+// Why removal matters: an admin who resets the theme, or switches from a preset
+// that sets --bg to one that does not, gets a payload that no longer mentions
+// that variable. Inline properties are not cleared by writing a smaller object
+// over them, so without an explicit removeProperty the old value would stick
+// until a reload. That would make "Reset to defaults" look broken.
+//
+// Everything written here is a value the server validated against a closed set
+// (hex color, curated font stack, bounded px length, listed shadow). The client
+// deliberately does not re-validate — it would be a second, drifting authority.
+// It does refuse anything that is not a `--custom-property`, which is the one
+// check that costs nothing and stops a token map from reaching an ordinary CSS
+// property.
+
+const CUSTOM_PROPERTY = /^--[a-zA-Z0-9-_]+$/
+
+/**
+ * @param {CSSStyleDeclaration} style usually document.documentElement.style
+ * @param {Record|null|undefined} tokens the new theme, or
+ * null/absent for "no admin theme" — which clears everything previously set
+ * @param {string[]} [applied] the keys this function wrote last time
+ * @returns {string[]} the keys now applied, to pass back on the next call
+ */
+export function applyThemeTokens(style, tokens, applied = []) {
+ const next = []
+ if (tokens && typeof tokens === 'object') {
+ for (const [name, value] of Object.entries(tokens)) {
+ if (!CUSTOM_PROPERTY.test(name) || typeof value !== 'string' || value === '') continue
+ style.setProperty(name, value)
+ next.push(name)
+ }
+ }
+ // Take back only what we set ourselves. Anything else on the element's inline
+ // style belongs to someone else (SiteContext's own --accent line, a future
+ // feature) and is not ours to clear.
+ for (const name of applied) {
+ if (!next.includes(name)) style.removeProperty(name)
+ }
+ return next
+}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 200476a..341773b 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -38,6 +38,7 @@ const IconBot = () =>
const IconUser = () =>
const IconShard = () =>
+const IconPalette = () =>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -74,6 +75,7 @@ const NAV = [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
+ { to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
@@ -104,6 +106,7 @@ const TITLES = {
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
+ '/admin/appearance': 'Appearance',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
diff --git a/client/src/routes/admin/views/AppearanceAdmin.jsx b/client/src/routes/admin/views/AppearanceAdmin.jsx
new file mode 100644
index 0000000..2e9908c
--- /dev/null
+++ b/client/src/routes/admin/views/AppearanceAdmin.jsx
@@ -0,0 +1,354 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { api } from '../../../api/client.js'
+import { useSite } from '../../../contexts/SiteContext.jsx'
+
+// Admin · Appearance — the theme half of docs/website/THEMING_AND_NAV.md
+// (phases 3-4). Brand asset uploads and the nav builder are phases 5 and 7 and
+// get their own screens.
+//
+// Two things shape this form:
+//
+// • Every control is a closed set. The presets, the font shortlist and the
+// shadow depths all come from GET /settings/theme/options, which is derived
+// from the same server config the save is validated against — so the form
+// can never offer a value the server would reject. Nothing here is free
+// text except the color inputs, which are and so are
+// hex by construction.
+// • Saving means writing a settings row; resetting means DELETING it. Absence
+// of the row is what selects the shipped default, so "reset" cannot write a
+// copy of the defaults — see §2.
+
+// Human labels for the eight editable colors and four radii. The field names
+// and the CSS variables they drive both come from the server
+// (colorFields / radiusFields); this only decorates them, and a field with no
+// label here still renders under its raw name rather than vanishing.
+const COLOR_LABELS = {
+ bg: 'Background',
+ bgDeep: 'Background (deep)',
+ panelA: 'Panel (top)',
+ panelB: 'Panel (bottom)',
+ accent: 'Accent',
+ accentBright: 'Accent (bright)',
+ ink: 'Ink / headings',
+ text: 'Body text',
+}
+const RADIUS_LABELS = {
+ radiusPill: 'Pills & buttons',
+ radiusPanel: 'Flat panels',
+ radiusCard: 'Cards & panels',
+ radiusInput: 'Inputs & notes',
+}
+const FONT_LABELS = {
+ serif: 'Body serif',
+ display: 'Display / headings',
+ sans: 'Interface sans',
+}
+
+// Strip empty groups so a theme the admin cleared back out is stored as a bare
+// preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a
+// field out to "clear" it — remove it (§6.1).
+function compactCustom(custom) {
+ const out = {}
+ for (const [group, fields] of Object.entries(custom)) {
+ const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null))
+ if (Object.keys(kept).length) out[group] = kept
+ }
+ return Object.keys(out).length ? out : null
+}
+
+export default function AppearanceAdmin() {
+ const { refresh: refreshSite } = useSite()
+ const [options, setOptions] = useState(null)
+ const [preset, setPreset] = useState('runic-gateway')
+ const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} })
+ // Whether a theme_visual row exists at all. Drives the "reset" button and the
+ // "this instance is using the shipped theme" note — an admin needs to be able
+ // to tell "never themed" from "themed to look like the default".
+ const [stored, setStored] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [saved, setSaved] = useState(false)
+
+ useEffect(() => {
+ let active = true
+ Promise.all([api.themeOptions(), api.admin.getSettings()])
+ .then(([opts, all]) => {
+ if (!active) return
+ setOptions(opts)
+ // The stored value is a JSON string (settings.value is TEXT). Malformed
+ // reads as absent, exactly as the server treats it — the form then shows
+ // the shipped default rather than an error.
+ let parsed = null
+ try {
+ const raw = all.theme_visual
+ parsed = raw ? JSON.parse(raw) : null
+ } catch {
+ parsed = null
+ }
+ setStored(Boolean(all.theme_visual))
+ if (parsed && typeof parsed === 'object') {
+ setPreset(parsed.preset || 'runic-gateway')
+ setCustom({
+ colors: parsed.custom?.colors || {},
+ fonts: parsed.custom?.fonts || {},
+ structure: parsed.custom?.structure || {},
+ })
+ }
+ })
+ .catch(() => active && setError('Could not load the appearance settings.'))
+ .finally(() => active && setLoading(false))
+ return () => {
+ active = false
+ }
+ }, [])
+
+ // What an unset field currently resolves to: the selected preset's palette,
+ // or the shipped theme when the preset is Custom (which has no base). Lets a
+ // color picker open on the value the admin is actually looking at.
+ const baseTokens = useMemo(() => {
+ if (!options) return {}
+ return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens
+ }, [options, preset])
+
+ if (loading) return
+ if (error && !options) return
+
+ const setField = (group, field) => (value) => {
+ setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } }))
+ setSaved(false)
+ }
+ const clearField = (group, field) => () => {
+ setCustom((c) => {
+ const next = { ...c[group] }
+ delete next[field]
+ return { ...c, [group]: next }
+ })
+ setSaved(false)
+ }
+
+ async function save() {
+ setBusy(true)
+ setError('')
+ try {
+ await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } })
+ setStored(true)
+ setSaved(true)
+ // Repull the public settings so the surrounding admin UI re-themes itself
+ // immediately — the admin sees the change they just made.
+ await refreshSite()
+ } catch (err) {
+ setError(err.message || 'Could not save the theme.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function resetAll() {
+ setBusy(true)
+ setError('')
+ try {
+ await api.admin.resetSetting('theme_visual')
+ setPreset('runic-gateway')
+ setCustom({ colors: {}, fonts: {}, structure: {} })
+ setStored(false)
+ setSaved(false)
+ await refreshSite()
+ } catch (err) {
+ setError(err.message || 'Could not reset the theme.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+ Colors, fonts and corner radius for the public site, this admin panel and the player portal.
+ {' '}
+ {stored ? (
+ <>This instance has a saved theme. Reset to default deletes it and returns to the shipped look.>
+ ) : (
+ <>This instance has never been themed, so it uses the shipped look and its BRAND_* accent.>
+ )}
+
+
+ {preset === '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.'}
+
+