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>
101 lines
3.9 KiB
JavaScript
101 lines
3.9 KiB
JavaScript
// applyThemeTokens — writing the server-resolved theme onto the document, and
|
|
// (the part with real logic) taking back exactly what it wrote last time.
|
|
//
|
|
// Pure module, exercised against a fake CSSStyleDeclaration: node --test has no
|
|
// DOM, and the function only ever needs setProperty/removeProperty.
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { applyThemeTokens } from '../src/lib/themeVars.js'
|
|
|
|
// Minimal stand-in for element.style, plus a log of the calls so a test can
|
|
// assert that a property was *removed* rather than merely absent.
|
|
function fakeStyle() {
|
|
const props = new Map()
|
|
const removed = []
|
|
return {
|
|
props,
|
|
removed,
|
|
setProperty: (name, value) => props.set(name, value),
|
|
removeProperty: (name) => {
|
|
props.delete(name)
|
|
removed.push(name)
|
|
},
|
|
get: (name) => props.get(name),
|
|
}
|
|
}
|
|
|
|
test('writes each token and reports the keys it applied', () => {
|
|
const style = fakeStyle()
|
|
const applied = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
|
|
assert.equal(style.get('--accent'), '#c9973f')
|
|
assert.equal(style.get('--bg'), '#1a120b')
|
|
assert.deepEqual(applied.sort(), ['--accent', '--bg'])
|
|
})
|
|
|
|
// The untouched-instance case: no theme block means the stylesheet's :root
|
|
// stands and nothing is written at all.
|
|
test('no theme writes nothing', () => {
|
|
for (const empty of [null, undefined, {}]) {
|
|
const style = fakeStyle()
|
|
const applied = applyThemeTokens(style, empty)
|
|
assert.equal(style.props.size, 0)
|
|
assert.deepEqual(applied, [])
|
|
}
|
|
})
|
|
|
|
test('removes a token that is no longer in the theme', () => {
|
|
const style = fakeStyle()
|
|
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
|
|
const second = applyThemeTokens(style, { '--accent': '#c9973f' }, first)
|
|
assert.equal(style.get('--accent'), '#c9973f')
|
|
assert.equal(style.get('--bg'), undefined)
|
|
assert.deepEqual(style.removed, ['--bg'])
|
|
assert.deepEqual(second, ['--accent'])
|
|
})
|
|
|
|
// "Reset to defaults" — the case that would look broken without the removal
|
|
// half: the payload stops mentioning the variables, and the inline values have
|
|
// to come off for :root to show through again.
|
|
test('resetting to no theme clears everything previously applied', () => {
|
|
const style = fakeStyle()
|
|
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--radius-card': '2px' })
|
|
const second = applyThemeTokens(style, null, first)
|
|
assert.equal(style.props.size, 0)
|
|
assert.deepEqual(style.removed.sort(), ['--accent', '--radius-card'])
|
|
assert.deepEqual(second, [])
|
|
})
|
|
|
|
// Only ever clears its own keys. SiteContext writes --accent itself from
|
|
// brand.accent, and a future feature may write others; those are not ours.
|
|
test('never removes a property it did not apply', () => {
|
|
const style = fakeStyle()
|
|
style.setProperty('--accent', '#ff0000') // someone else's write
|
|
applyThemeTokens(style, { '--bg': '#000000' }, [])
|
|
assert.equal(style.get('--accent'), '#ff0000')
|
|
assert.deepEqual(style.removed, [])
|
|
})
|
|
|
|
test('ignores anything that is not a custom property', () => {
|
|
const style = fakeStyle()
|
|
const applied = applyThemeTokens(style, { background: 'url(http://evil.example/x)', '--bg': '#000000' })
|
|
assert.equal(style.get('background'), undefined)
|
|
assert.deepEqual(applied, ['--bg'])
|
|
})
|
|
|
|
test('ignores non-string and empty values', () => {
|
|
const style = fakeStyle()
|
|
const applied = applyThemeTokens(style, { '--a': 4, '--b': null, '--c': '', '--d': '#fff' })
|
|
assert.deepEqual(applied, ['--d'])
|
|
})
|
|
|
|
// A stale key list must not survive a call that could not write: the next call
|
|
// still has to know what is actually on the element.
|
|
test('a token dropped as invalid is removed if it was applied before', () => {
|
|
const style = fakeStyle()
|
|
const first = applyThemeTokens(style, { '--bg': '#000000' })
|
|
const second = applyThemeTokens(style, { '--bg': '' }, first)
|
|
assert.equal(style.get('--bg'), undefined)
|
|
assert.deepEqual(second, [])
|
|
})
|