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>
203 lines
8.1 KiB
JavaScript
203 lines
8.1 KiB
JavaScript
// ── 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 }
|