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>
48 lines
2.3 KiB
JavaScript
48 lines
2.3 KiB
JavaScript
// 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
|
|
}
|