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:
@@ -56,6 +56,79 @@ test('admin site_title / contact_email override the brand defaults', async () =>
|
||||
assert.equal(pub.brand.accent, brand.accent) // colors still from config
|
||||
})
|
||||
|
||||
// ── Effective theming (THEMING_AND_NAV.md §4.5) ───────────────────────
|
||||
//
|
||||
// brand.accent and the asset fields are a cross-repo contract: the Android app
|
||||
// themes its whole Material palette from brand.accent and the Discord bot
|
||||
// colors its embeds from it. Resolving the EFFECTIVE value here is what lets
|
||||
// both track admin theming with no client change — so these tests are really
|
||||
// about the app and the bot, not about the website.
|
||||
|
||||
test('no theme row leaves the brand block exactly as env defines it', async () => {
|
||||
// Explicitly re-asserted next to the theming cases: this is the acceptance
|
||||
// criterion the whole feature rests on, and it is the assertion a future
|
||||
// change to the resolver would break first.
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.theme, undefined, 'no theme block at all when untouched')
|
||||
assert.equal(pub.brand.accent, brand.accent)
|
||||
assert.equal(pub.brand.logo, brand.logo)
|
||||
assert.equal(pub.brand.hero, brand.hero)
|
||||
assert.equal(pub.brand.favicon, brand.favicon)
|
||||
})
|
||||
|
||||
test('a theme preset overrides brand.accent and ships the token block', async () => {
|
||||
settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy' }) }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.accent, '#c9973f', 'the app sees the themed accent, not BRAND_ACCENT_COLOR')
|
||||
assert.equal(pub.theme['--accent'], '#c9973f')
|
||||
assert.equal(pub.theme['--bg'], '#1a120b')
|
||||
// Assets are a different key and must not move with the theme.
|
||||
assert.equal(pub.brand.logo, brand.logo)
|
||||
assert.equal(pub.brand.hero, brand.hero)
|
||||
})
|
||||
|
||||
test('a custom accent beats the preset accent in brand.accent', async () => {
|
||||
settingsDb.getAll = async () => [
|
||||
{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#123456' } } }) },
|
||||
]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.accent, '#123456')
|
||||
assert.equal(pub.theme['--accent'], '#123456')
|
||||
})
|
||||
|
||||
test('a malformed theme row reads as absent, not as an error', async () => {
|
||||
for (const value of ['{oops', '"x"', '{"preset":"parchment"}']) {
|
||||
settingsDb.getAll = async () => [{ key: 'theme_visual', value }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.theme, undefined, value)
|
||||
assert.equal(pub.brand.accent, brand.accent, value)
|
||||
}
|
||||
})
|
||||
|
||||
test('brand_assets overrides one asset without disturbing the others', async () => {
|
||||
settingsDb.getAll = async () => [
|
||||
{ key: 'brand_assets', value: JSON.stringify({ favicon: '/uploads/1234-abcd.png' }) },
|
||||
]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.favicon, '/uploads/1234-abcd.png')
|
||||
assert.equal(pub.brand.logo, brand.logo, 'logo still from env')
|
||||
assert.equal(pub.brand.hero, brand.hero, 'hero still from env')
|
||||
})
|
||||
|
||||
test('a malformed brand_assets row falls back to env for every asset', async () => {
|
||||
settingsDb.getAll = async () => [{ key: 'brand_assets', value: 'not json' }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.logo, brand.logo)
|
||||
assert.equal(pub.brand.hero, brand.hero)
|
||||
assert.equal(pub.brand.favicon, brand.favicon)
|
||||
})
|
||||
|
||||
test('the Discord-only integer accent is still never exposed, themed or not', async () => {
|
||||
settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'modern' }) }]
|
||||
const pub = await settings.getPublic()
|
||||
assert.equal(pub.brand.accentInt, undefined)
|
||||
})
|
||||
|
||||
// The push relay block the app's embedded distributor discovers its ntfy base
|
||||
// URL from (M7 Part 2). Null when nothing is configured; NTFY_PUBLIC_URL wins,
|
||||
// else the first NTFY_ALLOWED_ORIGINS entry; NTFY_BASE_URL is never surfaced.
|
||||
|
||||
Reference in New Issue
Block a user