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>
263 lines
11 KiB
JavaScript
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'))
|
|
})
|