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:
2026-08-07 19:16:23 -05:00
parent 0a2ccafff6
commit 3d6b2e23a7
26 changed files with 2113 additions and 28 deletions

View 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,
}

View File

@@ -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

View File

@@ -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) } })

View File

@@ -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

View File

@@ -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

View 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 }

View 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

View 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 }