Files
website/server/test/themeResolve.test.js
wtclaude 3d6b2e23a7 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>
2026-08-07 19:16:23 -05:00

263 lines
11 KiB
JavaScript

// theme_visual — the strict write path and the fail-safe read path.
//
// The two halves are deliberately asymmetric (see utils/themeResolve.js): a
// write is rejected with the offending field named, while a read drops bad
// fields one at a time and falls back to the shipped :root. These tests lock
// that asymmetry, because it is the thing most likely to get "tidied" into a
// single shared check later.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { validateThemeVisual, resolveThemeTokens, themeOptions } = require('../src/utils/themeResolve')
const { PRESETS, FONT_OPTIONS, SHADOW_OPTIONS } = require('../src/config/themePresets')
// ── validateThemeVisual (write path) ──────────────────────────────────
test('accepts a bare preset choice', () => {
for (const preset of ['runic-gateway', 'modern', 'fantasy', 'custom']) {
assert.equal(validateThemeVisual({ preset }).ok, true, preset)
}
assert.equal(validateThemeVisual({ preset: 'fantasy', custom: null }).ok, true)
})
test('rejects an unknown preset', () => {
const res = validateThemeVisual({ preset: 'parchment' })
assert.equal(res.ok, false)
assert.match(res.message, /preset must be one of/)
})
test('rejects a non-object, and unknown top-level fields', () => {
for (const bad of [null, 4, 'fantasy', []]) {
assert.equal(validateThemeVisual(bad).ok, false)
}
const res = validateThemeVisual({ preset: 'modern', mode: 'light' })
assert.equal(res.ok, false)
assert.match(res.message, /unknown field\(s\) mode/)
})
test('accepts custom colors, fonts and structure from the closed sets', () => {
const res = validateThemeVisual({
preset: 'custom',
custom: {
colors: { accent: '#c9973f', bg: '#000' },
fonts: { display: 'Cinzel, Georgia, serif', sans: 'Inter, Arial, sans-serif' },
structure: { radiusCard: '4px', shadowDepth: SHADOW_OPTIONS[0].value },
},
})
assert.equal(res.ok, true, res.message)
})
test('rejects a color that is not a hex literal', () => {
// The point of the closed set: a CSS function or keyword never reaches a
// custom property value, whatever it would or would not have done there.
for (const bad of ['red', 'rgb(1,2,3)', 'url(http://x/y)', '#12345', 'var(--bg)', '#ff0000; x']) {
const res = validateThemeVisual({ preset: 'custom', custom: { colors: { accent: bad } } })
assert.equal(res.ok, false, bad)
assert.match(res.message, /colors: invalid value for accent/)
}
})
test('rejects a font stack that is not on the shortlist', () => {
const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS, sans-serif' } } })
assert.equal(res.ok, false)
assert.match(res.message, /fonts: invalid value for sans/)
})
test('rejects a font offered for a different role', () => {
// Cinzel is a display face and is not in the sans list.
const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Cinzel, Georgia, serif' } } })
assert.equal(res.ok, false)
})
test('rejects an out-of-range or unitless radius', () => {
for (const bad of ['1000px', '4', '4em', '-4px', 'calc(4px + 1px)']) {
const res = validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: bad } } })
assert.equal(res.ok, false, bad)
}
assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: '999px' } } }).ok, true)
})
test('rejects a shadow that is not one of the listed depths', () => {
const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: '0 0 99px red' } } })
assert.equal(res.ok, false)
})
test('rejects unknown groups and unknown fields inside a group', () => {
assert.equal(validateThemeVisual({ preset: 'custom', custom: { spacing: { unit: '8px' } } }).ok, false)
const res = validateThemeVisual({ preset: 'custom', custom: { colors: { border: '#fff' } } })
assert.equal(res.ok, false)
assert.match(res.message, /invalid value for border/)
})
// ── resolveThemeTokens (read path) ────────────────────────────────────
// The acceptance criterion the whole feature rests on: absent means absent, and
// the caller writes nothing.
test('no row, or an unusable one, resolves to null', () => {
for (const nothing of [null, undefined, '', 'not json', '4', '"x"', '[]', '{}', '{"preset":"custom"}']) {
assert.equal(resolveThemeTokens(nothing), null, JSON.stringify(nothing))
}
})
test('a preset resolves to its full palette', () => {
const tokens = resolveThemeTokens(JSON.stringify({ preset: 'fantasy' }))
assert.deepEqual(tokens, PRESETS.fantasy.tokens)
// Not a partial palette: the supporting shades move with it, or a warm theme
// keeps dark-blue borders.
assert.equal(tokens['--line'], '#4a3721')
assert.equal(tokens['--blue'], '#382613')
})
test('semantic status colors are never themed', () => {
for (const preset of Object.values(PRESETS)) {
assert.equal('--mode-live' in preset.tokens, false)
assert.equal('--mode-maint' in preset.tokens, false)
}
})
test('the derived panel gradient is never written as a literal', () => {
for (const preset of Object.values(PRESETS)) {
assert.equal('--panel-grad' in preset.tokens, false)
}
})
test('runic-gateway is exactly the shipped stylesheet values', () => {
const t = PRESETS['runic-gateway'].tokens
assert.equal(t['--bg'], '#0e1318')
assert.equal(t['--accent'], '#7f99bd')
assert.equal(t['--radius-pill'], '999px')
assert.equal(t['--radius-panel'], '12px')
assert.equal(t['--radius-card'], '10px')
assert.equal(t['--radius-input'], '8px')
assert.equal(t['--serif'], 'Georgia, "Times New Roman", serif')
assert.equal(t['--display'], 'Cinzel, Georgia, serif')
assert.equal(t['--sans'], '"Helvetica Neue", Arial, sans-serif')
})
// The one place the server duplicates the stylesheet, so the one place that can
// drift: switching to runic-gateway after trying another preset must land back
// on exactly what :root ships, not on a stale copy of it.
test('the runic-gateway preset matches theme.css :root token for token', () => {
const fs = require('node:fs')
const path = require('node:path')
const cssPath = path.join(__dirname, '..', '..', 'client', 'src', 'styles', 'theme.css')
const root = /:root\s*\{([\s\S]*?)\}/.exec(fs.readFileSync(cssPath, 'utf8'))
assert.ok(root, 'theme.css has a :root block')
const declared = {}
for (const line of root[1].split(';')) {
const m = /^\s*(--[a-z0-9-]+)\s*:\s*([\s\S]+?)\s*$/i.exec(line.replace(/\/\*[\s\S]*?\*\//g, ''))
if (m) declared[m[1]] = m[2]
}
for (const [token, value] of Object.entries(PRESETS['runic-gateway'].tokens)) {
// --shadow-card is the exception: theme.css writes rgba() unspaced and the
// preset writes it spaced, which is the same computed value. Compare with
// whitespace normalized rather than exempting the token entirely.
assert.equal(
String(declared[token]).replace(/\s+/g, ''),
value.replace(/\s+/g, ''),
`${token} drifted from theme.css`,
)
}
})
test('custom fields layer on top of the preset, per field', () => {
const tokens = resolveThemeTokens(
JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#ffffff' } } }),
)
assert.equal(tokens['--accent'], '#ffffff') // overridden
assert.equal(tokens['--bg'], PRESETS.fantasy.tokens['--bg']) // untouched
assert.equal(tokens['--radius-card'], '2px') // untouched
})
test('custom with no preset base yields only the fields that were set', () => {
const tokens = resolveThemeTokens(
JSON.stringify({ preset: 'custom', custom: { structure: { radiusCard: '4px' } } }),
)
assert.deepEqual(tokens, { '--radius-card': '4px' })
})
// Fail-safe, field by field: a hand-edited row degrades to the shipped default
// for the bad field only, rather than rendering a broken site or throwing.
test('an invalid field is dropped without discarding its neighbours', () => {
const tokens = resolveThemeTokens(
JSON.stringify({ preset: 'custom', custom: { colors: { accent: 'red', bg: '#000000' } } }),
)
assert.deepEqual(tokens, { '--bg': '#000000' })
})
test('an unknown preset still applies the custom fields', () => {
const tokens = resolveThemeTokens(
JSON.stringify({ preset: 'parchment', custom: { colors: { accent: '#ffffff' } } }),
)
assert.deepEqual(tokens, { '--accent': '#ffffff' })
})
test('accepts an already-parsed object as well as the stored string', () => {
assert.deepEqual(resolveThemeTokens({ preset: 'modern' }), PRESETS.modern.tokens)
})
test('every resolved value is a plain string', () => {
const tokens = resolveThemeTokens(JSON.stringify({ preset: 'modern' }))
for (const [name, value] of Object.entries(tokens)) {
assert.match(name, /^--[a-z-]+$/, name)
assert.equal(typeof value, 'string', name)
}
})
// ── themeOptions (the catalog the admin form is built from) ───────────
// The reason the catalog is served rather than duplicated client-side: every
// option offered must be one validateThemeVisual() accepts.
test('every offered font and shadow validates', () => {
const opts = themeOptions()
for (const [role, options] of Object.entries(opts.fonts)) {
for (const o of options) {
const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { [role]: o.value } } })
assert.equal(res.ok, true, `${role}: ${o.value}${res.message || ''}`)
}
}
for (const o of opts.shadows) {
const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: o.value } } })
assert.equal(res.ok, true, o.value)
}
})
test('every offered preset id validates and every field name is editable', () => {
const opts = themeOptions()
for (const p of opts.presets) {
assert.equal(validateThemeVisual({ preset: p.id }).ok, true, p.id)
}
for (const { name } of opts.colorFields) {
assert.equal(validateThemeVisual({ preset: 'custom', custom: { colors: { [name]: '#123456' } } }).ok, true, name)
}
for (const { name } of opts.radiusFields) {
assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { [name]: '5px' } } }).ok, true, name)
}
})
// The form reads each control's current value out of the preset map by token
// name, so every advertised field must actually resolve to one.
test('every advertised field names a token the presets declare', () => {
const opts = themeOptions()
for (const { name, token } of [...opts.colorFields, ...opts.radiusFields]) {
assert.ok(token.startsWith('--'), `${name}${token}`)
assert.ok(token in opts.shippedTokens, `${token} missing from the shipped theme`)
for (const p of opts.presets) {
if (p.tokens) assert.ok(token in p.tokens, `${token} missing from preset ${p.id}`)
}
}
})
test('the shipped default font stacks are reachable from the shortlist', () => {
// An admin who customizes fonts must be able to get back to today's look
// without resetting the whole theme.
const serifValues = FONT_OPTIONS.serif.map((o) => o.value)
const sansValues = FONT_OPTIONS.sans.map((o) => o.value)
const displayValues = FONT_OPTIONS.display.map((o) => o.value)
assert.ok(serifValues.includes('Georgia, "Times New Roman", serif'))
assert.ok(sansValues.includes('"Helvetica Neue", Arial, sans-serif'))
assert.ok(displayValues.includes('Cinzel, Georgia, serif'))
})