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:
@@ -213,6 +213,95 @@ test('theme_visual / brand_assets / nav_public are public once set; nav_admin /
|
||||
assert.equal(pub.nav_player, undefined)
|
||||
})
|
||||
|
||||
// ── PUT /admin/settings — theme_visual is validated on the way in ──────────
|
||||
//
|
||||
// The read path drops anything invalid anyway, so this is about feedback, not
|
||||
// safety: an admin whose save appears to succeed and then does nothing has no
|
||||
// way to tell what was wrong.
|
||||
|
||||
test('a valid theme_visual is stored stringified', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
const written = {}
|
||||
settingsDb.set = async (key, value) => {
|
||||
written[key] = value
|
||||
}
|
||||
settingsDb.getAll = async () => []
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const theme = { preset: 'fantasy', custom: { colors: { accent: '#123456' } } }
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ theme_visual: theme }),
|
||||
})
|
||||
assert.equal(res.status, 200)
|
||||
// settings.value is TEXT — an object body must reach the store stringified.
|
||||
assert.equal(written.theme_visual, JSON.stringify(theme))
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('an invalid theme_visual is rejected and nothing is written', async () => {
|
||||
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
|
||||
settingsDb.set = () => assert.fail('an invalid theme must not be stored')
|
||||
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
|
||||
try {
|
||||
const bad = [
|
||||
{ preset: 'parchment' },
|
||||
{ preset: 'custom', custom: { colors: { accent: 'red' } } },
|
||||
{ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS' } } },
|
||||
{ preset: 'custom', custom: { structure: { radiusCard: '4em' } } },
|
||||
{ preset: 'custom', custom: { spacing: { unit: '8px' } } },
|
||||
'not json',
|
||||
]
|
||||
for (const theme_visual of bad) {
|
||||
const res = await fetch(`${app.url}/api/v1/admin/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ theme_visual }),
|
||||
})
|
||||
assert.equal(res.status, 400, JSON.stringify(theme_visual))
|
||||
const body = await res.json()
|
||||
assert.match(body.message, /theme_visual/)
|
||||
}
|
||||
} finally {
|
||||
settingsDb.set = originals.set
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /settings/theme-options — the catalog the admin form is built from ──
|
||||
|
||||
test('GET /settings/theme/options serves the catalog to an authenticated caller', async () => {
|
||||
signInAs({ id: 7, username: 'u', role: 'admin', status: 'active' })
|
||||
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/settings/theme/options`)
|
||||
assert.equal(res.status, 200)
|
||||
const body = await res.json()
|
||||
assert.ok(Array.isArray(body.presets) && body.presets.length === 4, 'three presets plus Custom')
|
||||
assert.ok(body.fonts.serif.length && body.fonts.display.length && body.fonts.sans.length)
|
||||
assert.ok(body.shadows.length)
|
||||
assert.ok(body.colorFields.some((f) => f.name === 'accent' && f.token === '--accent'))
|
||||
assert.equal(body.shippedTokens['--accent'], '#7f99bd')
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /settings/theme/options rejects an anonymous caller', async () => {
|
||||
sessionService.validateSession = () => null
|
||||
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/settings/theme/options`)
|
||||
assert.equal(res.status, 401)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── parseJsonSetting: malformed reads as absent, never as an error ─────────
|
||||
|
||||
test('parseJsonSetting returns null for absent, empty and malformed values', () => {
|
||||
|
||||
Reference in New Issue
Block a user