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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user