// ── theme_visual: validate on write, resolve on read ─────────────────────── // // Two jobs, one closed set of rules (config/themePresets.js): // // validateThemeVisual() the WRITE path. PUT /admin/settings rejects a bad // theme_visual with a 400 rather than storing it, so an // admin gets told why instead of watching a save appear // to succeed and do nothing. // resolveThemeTokens() the READ path. Turns the stored value into the CSS // custom properties settings.getPublic() ships as // `theme`. Fail-safe, per §4.4: anything unrecognized // is dropped field-by-field and the surface falls back // to theme.css's :root — never an error, never a // half-applied palette. // // The write path is the strict one and the read path is the forgiving one on // purpose. Strict-on-write gives feedback; forgiving-on-read means a row // hand-edited in the DB, or written by an older version of this code, degrades // to the shipped default instead of rendering a broken site. // // See docs/website/THEMING_AND_NAV.md §5-§6. const { PRESETS, PRESET_IDS, CUSTOM_PRESET, COLOR_FIELDS, RADIUS_FIELDS, FONT_FIELDS, FONT_OPTIONS, SHADOW_OPTIONS, RADIUS_MAX_PX, } = require('../config/themePresets') const { parseJsonSetting } = require('./settingsJson') const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ const PX_LENGTH = /^(\d{1,3})px$/ const SHADOW_VALUES = SHADOW_OPTIONS.map((o) => o.value) const FONT_VALUES = Object.fromEntries( Object.keys(FONT_FIELDS).map((role) => [role, FONT_OPTIONS[role].map((o) => o.value)]), ) function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) } function isColor(v) { return typeof v === 'string' && HEX_COLOR.test(v) } // A bounded px length. `0` on its own is not accepted — a radius is always // written with a unit here, which keeps the stored shape uniform. function isRadius(v) { if (typeof v !== 'string') return false const m = PX_LENGTH.exec(v) return !!m && Number(m[1]) <= RADIUS_MAX_PX } function isShadow(v) { return typeof v === 'string' && SHADOW_VALUES.includes(v) } function isFont(role, v) { return typeof v === 'string' && (FONT_VALUES[role] || []).includes(v) } // Per-field check for one custom group. Returns the list of offending field // names, so the write path can say which field was wrong. function checkGroup(group, fields, check) { const bad = [] for (const [field, value] of Object.entries(group)) { if (!(field in fields)) { bad.push(field) } else if (!check(field, value)) { bad.push(field) } } return bad } /** * Strict shape check for the write path. * * @param {unknown} value the parsed theme_visual object * @returns {{ ok: true } | { ok: false, message: string }} */ function validateThemeVisual(value) { if (!isPlainObject(value)) return { ok: false, message: 'theme_visual must be a JSON object' } const keys = Object.keys(value).filter((k) => k !== 'preset' && k !== 'custom') if (keys.length) return { ok: false, message: `theme_visual: unknown field(s) ${keys.join(', ')}` } if (!PRESET_IDS.includes(value.preset)) { return { ok: false, message: `theme_visual.preset must be one of ${PRESET_IDS.join(', ')}` } } // `custom` is optional and may be explicitly null ("preset only"). const custom = value.custom if (custom === undefined || custom === null) return { ok: true } if (!isPlainObject(custom)) return { ok: false, message: 'theme_visual.custom must be an object or null' } const groups = Object.keys(custom).filter((g) => !['colors', 'structure', 'fonts'].includes(g)) if (groups.length) return { ok: false, message: `theme_visual.custom: unknown group(s) ${groups.join(', ')}` } for (const [group, spec] of [ ['colors', { fields: COLOR_FIELDS, check: (_f, v) => isColor(v) }], ['fonts', { fields: FONT_FIELDS, check: (f, v) => isFont(f, v) }], [ 'structure', { fields: { ...RADIUS_FIELDS, shadowDepth: '--shadow-card' }, check: (f, v) => (f === 'shadowDepth' ? isShadow(v) : isRadius(v)), }, ], ]) { const supplied = custom[group] if (supplied === undefined || supplied === null) continue if (!isPlainObject(supplied)) return { ok: false, message: `theme_visual.custom.${group} must be an object` } const bad = checkGroup(supplied, spec.fields, spec.check) if (bad.length) return { ok: false, message: `theme_visual.custom.${group}: invalid value for ${bad.join(', ')}` } } return { ok: true } } // Copy the fields of one custom group that pass their check onto the token map. // Field-by-field: a bad accent does not discard a good bg beside it. function applyGroup(tokens, group, fields, check) { if (!isPlainObject(group)) return for (const [field, token] of Object.entries(fields)) { const value = group[field] if (value !== undefined && check(field, value)) tokens[token] = value } } /** * The effective CSS custom properties for a stored theme_visual value. * * Layered :root ← preset ← custom, per field. `null` means "no row, or nothing * usable in it" — the caller omits the block entirely and the client applies * nothing, which is what makes an untouched instance render byte-for-byte as * today. * * @param {string|object|null|undefined} stored the raw settings value (TEXT) or * an already-parsed object * @returns {Record|null} */ function resolveThemeTokens(stored) { const parsed = typeof stored === 'string' ? parseJsonSetting(stored) : isPlainObject(stored) ? stored : null if (!parsed) return null // An unrecognized preset id falls back to no base rather than to a guess: the // admin's custom fields still apply on top of :root. const base = PRESETS[parsed.preset] const tokens = base ? { ...base.tokens } : {} const custom = parsed.custom if (isPlainObject(custom)) { applyGroup(tokens, custom.colors, COLOR_FIELDS, (_f, v) => isColor(v)) applyGroup(tokens, custom.fonts, FONT_FIELDS, (f, v) => isFont(f, v)) applyGroup(tokens, custom.structure, RADIUS_FIELDS, (_f, v) => isRadius(v)) applyGroup(tokens, custom.structure, { shadowDepth: '--shadow-card' }, (_f, v) => isShadow(v)) } // A row that parsed but yielded nothing usable (e.g. `{"preset":"custom"}` // with no custom fields) is the same as no row at all to every consumer. return Object.keys(tokens).length ? tokens : null } /** * The catalog the admin UI builds its controls from. Served rather than * duplicated client-side so the options offered can never drift from the * options validateThemeVisual() accepts. */ function themeOptions() { return { // Full token maps, not just a swatch: the form shows each control's // *effective* default for the selected preset, so an admin opening the // accent picker on Fantasy sees Fantasy's gold rather than a hardcoded // client-side copy of the shipped palette. `custom` has no map — it means // "no preset base", and the form falls back to the shipped theme, which is // the runic-gateway map. presets: [ ...Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label, tokens: p.tokens })), { id: CUSTOM_PRESET, label: 'Custom', tokens: null }, ], // Each editable field paired with the CSS variable it drives, so the form // can look its current value up in the preset map above without knowing the // naming convention that relates the two. colorFields: Object.entries(COLOR_FIELDS).map(([name, token]) => ({ name, token })), radiusFields: Object.entries(RADIUS_FIELDS).map(([name, token]) => ({ name, token })), fonts: FONT_OPTIONS, shadows: SHADOW_OPTIONS, radiusMaxPx: RADIUS_MAX_PX, // The shipped default, i.e. what theme.css's :root already declares. What // an unset field actually resolves to when no preset is selected. shippedTokens: PRESETS['runic-gateway'].tokens, } } module.exports = { validateThemeVisual, resolveThemeTokens, themeOptions }