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:
@@ -748,10 +748,15 @@ const doc = {
|
||||
description: { type: 'string' },
|
||||
contactEmail: { type: 'string', example: '' },
|
||||
url: { type: 'string', example: '' },
|
||||
accent: { type: 'string', example: '#7f99bd', description: 'Seed/accent color (hex) for theming.' },
|
||||
logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo.' },
|
||||
hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path.' },
|
||||
favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path.' },
|
||||
accent: {
|
||||
type: 'string',
|
||||
example: '#7f99bd',
|
||||
description:
|
||||
'Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change.',
|
||||
},
|
||||
logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO.' },
|
||||
hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO.' },
|
||||
favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON.' },
|
||||
},
|
||||
},
|
||||
PublicSettings: {
|
||||
@@ -768,6 +773,14 @@ const doc = {
|
||||
},
|
||||
gameAccountSignup: { type: 'boolean', example: false },
|
||||
brand: { $ref: '#/components/schemas/Brand' },
|
||||
theme: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description:
|
||||
'The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow).',
|
||||
additionalProperties: { type: 'string' },
|
||||
example: { '--accent': '#c9973f', '--bg': '#1a120b', '--radius-card': '2px' },
|
||||
},
|
||||
push: {
|
||||
type: 'object',
|
||||
description:
|
||||
@@ -792,6 +805,70 @@ const doc = {
|
||||
nav_player: { type: 'string', nullable: true, example: null },
|
||||
},
|
||||
},
|
||||
ThemeOptions: {
|
||||
type: 'object',
|
||||
description:
|
||||
'The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database.',
|
||||
properties: {
|
||||
presets: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means "no preset base — the shipped theme plus whatever custom fields are set".',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'fantasy' },
|
||||
label: { type: 'string', example: 'Fantasy' },
|
||||
tokens: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
additionalProperties: { type: 'string' },
|
||||
example: { '--bg': '#1a120b', '--accent': '#c9973f' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
colorFields: {
|
||||
type: 'array',
|
||||
description: 'Editable color fields, each paired with the CSS variable it drives.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', example: 'accent' }, token: { type: 'string', example: '--accent' } },
|
||||
},
|
||||
},
|
||||
radiusFields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', example: 'radiusCard' }, token: { type: 'string', example: '--radius-card' } },
|
||||
},
|
||||
},
|
||||
shippedTokens: {
|
||||
type: 'object',
|
||||
description: 'What the stylesheet declares by default — the values an unset field resolves to when no preset is selected.',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
fonts: {
|
||||
type: 'object',
|
||||
description: 'Curated Google Fonts shortlist per role. Each option\'s `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input.',
|
||||
additionalProperties: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { value: { type: 'string' }, label: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { value: { type: 'string' }, label: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
radiusMaxPx: { type: 'integer', example: 999 },
|
||||
},
|
||||
},
|
||||
// Delete/mutation acknowledgements — each echoes the affected resource key
|
||||
// or a boolean flag rather than a { message } string.
|
||||
DeletedId: {
|
||||
|
||||
Reference in New Issue
Block a user