Merge pull request 'docs(website): add theming & nav build contract' (#103) from docs/theming-and-nav-plan into main
Reviewed-on: #103 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -23,6 +23,7 @@ sidecar as a service, and hands you the values the website needs.
|
||||
|---|---|
|
||||
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
|
||||
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
||||
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
|
||||
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
||||
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
|
||||
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
|
||||
|
||||
539
website/THEMING_AND_NAV.md
Normal file
539
website/THEMING_AND_NAV.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# Admin-Configurable Theming & Navigation
|
||||
|
||||
> Build contract for runtime-configurable theme, brand assets, and navigation.
|
||||
> Derived from the design doc *Spec: Admin-Configurable Theming & Navigation*,
|
||||
> **corrected to match the current codebase** and with the open questions resolved.
|
||||
> Same workflow as the hero editor: design → phased build → verify.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let the site admin customize, at runtime with no rebuild or redeploy:
|
||||
|
||||
1. **Visual theme** — colors, fonts (from a curated Google Fonts shortlist), and
|
||||
corner radius / shadow depth — via three presets or per-group custom overrides.
|
||||
2. **Brand assets** — logo, hero image, favicon — uploaded to override the
|
||||
`BRAND_*` env defaults.
|
||||
3. **Navigation** — reorder, relabel, and show/hide items in the public site nav,
|
||||
admin sidebar, and player portal nav, via drag-and-drop.
|
||||
|
||||
All three follow the `settings.model.js` pattern already used for `hero_layout`:
|
||||
a JSON value stored under a settings key, exposed through `getPublic()` where
|
||||
needed, edited from an admin view, applied at runtime.
|
||||
|
||||
## 2. Core principle: `BRAND_*` env stays the default, always
|
||||
|
||||
[`server/src/config/brand.js`](../../website/server/src/config/brand.js) is the
|
||||
existing single source of instance identity, read once at startup from env with
|
||||
baked-in Runic Gateway defaults. The app ships as one prebuilt image and each
|
||||
instance re-skins itself via env. **This feature must not disturb that.**
|
||||
|
||||
Every new setting is an *override layer*, never a replacement:
|
||||
|
||||
- An instance where the admin has not touched these settings renders
|
||||
**identically to today**, driven entirely by `BRAND_*` and the current
|
||||
`theme.css` `:root`.
|
||||
- Saving one setting makes that setting — and only that setting — take
|
||||
precedence. Untouched settings keep following env.
|
||||
- This holds **per field**, not per feature. A custom accent with untouched
|
||||
fonts means the accent comes from the DB and the fonts still come from
|
||||
`--serif`/`--display`/`--sans` as `theme.css` defines them.
|
||||
- "Admin-set" means **a DB row exists for that key**. Absence of the row — not an
|
||||
empty or false value — is what triggers the env/CSS fallback. An admin who
|
||||
explicitly picks a preset that happens to equal the shipped default has still
|
||||
set it, and it is stored and honored as explicit.
|
||||
- **No migration writes defaults into the settings table.** New and existing
|
||||
installs both start with zero rows for these keys; that absence *is* the
|
||||
"use env default" state.
|
||||
|
||||
## 3. Locked decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Brand contract | **`getPublic().brand` returns effective values** (override → env). The Android app and Discord embeds track admin theming for free — see §4.5 |
|
||||
| Structural tokens | **Radius + shadow depth only.** `spacingUnit` and `borderWeight` are **cut**, not deferred — see §4.6 |
|
||||
| Radius token values | **Seeded at today's real values** (four tokens, not three), so the promotion step is a true no-op — see §4.7 |
|
||||
| Presets in v1 | **Three dark presets** — Runic Gateway, Modern, Fantasy. Parchment (light) is Phase 9 — see §4.8 |
|
||||
| Fonts | **Curated shortlist, dropdown-only**, 4 options per role, 8 web families in **one** `css2?` request — see §5 |
|
||||
| Raw custom CSS | **Out of scope entirely** — not deferred. Materially different risk profile (overlay/clickjacking tricks, tracking pixels via `background: url(...)`); would need its own feature and its own review |
|
||||
| Live preview | Out of scope for v1 |
|
||||
| Reduced-motion toggle | Out of scope for v1 |
|
||||
| Nav override power | **`label`, `order`, `hidden`, and (admin nav only) `group`.** Never `to`, `roles`, or `feature` — see §7 |
|
||||
| Reset to defaults | **Deletes the settings row.** Never writes a stored copy of the defaults |
|
||||
| Favicon uploads | **PNG only.** No `.ico` — see §4.10 |
|
||||
|
||||
## 4. Corrections to the design doc (current-code reality)
|
||||
|
||||
The design doc is structurally sound; the token architecture, the
|
||||
override-on-top-of-env principle, the nav-override security framing, and the
|
||||
reuse of `imageUpload.js` all match reality. These are the points where it does
|
||||
not, listed worst-first. §4.1–4.5 are blocking; §4.6–4.11 are scope corrections.
|
||||
|
||||
### 4.1 There is no way to delete a setting
|
||||
|
||||
The entire "Reset to defaults deletes the row" principle — which all five new
|
||||
keys rely on, and which the doc lists as an acceptance criterion — has no
|
||||
implementation.
|
||||
[`settings.db.js`](../../website/server/src/model/settings/settings.db.js)
|
||||
exposes `get` / `getAll` / `set` / `seedDefault` only, and the admin API is
|
||||
`PUT /admin/settings` taking a key/value object
|
||||
([`admin.controller.js:499`](../../website/server/src/router/v1/admin/admin.controller.js)).
|
||||
|
||||
**Fix:** add `settingsDb.remove(key)` and a `DELETE /api/v1/admin/settings/:key`
|
||||
route with an explicit key allowlist (the five new keys plus `hero_layout_draft`).
|
||||
Admin-only, same gate as the existing settings routes. Deleting a key that does
|
||||
not exist is a success, not a 404 — "reset" is idempotent.
|
||||
|
||||
### 4.2 Non-admins cannot read their own nav overrides
|
||||
|
||||
The doc says `nav_admin` / `nav_player` are admin-only settings "fetched by the
|
||||
authenticated `AdminLayout` / `PlayerPortalLayout`." But `GET /admin/settings` is
|
||||
gated `requireRole('admin')`
|
||||
([`settings.router.js:18,28`](../../website/server/src/router/v1/admin/settings.router.js)),
|
||||
while `AdminLayout` renders for **editors and moderators** and
|
||||
`PlayerPortalLayout` renders for **players**. Those users have no endpoint from
|
||||
which to read the key, so their nav would silently never apply the override.
|
||||
|
||||
**Fix:** new `GET /api/v1/settings/nav`, `isLoggedIn` only, returning
|
||||
`{ nav_admin, nav_player }`. Not in `PUBLIC_KEYS` — an anonymous visitor has no
|
||||
use for either, and the admin nav's labels leak the shape of the admin surface.
|
||||
|
||||
### 4.3 `renderIndexHtml` runs once at boot, not per request
|
||||
|
||||
[`app.js:207`](../../website/server/src/app.js) reads and templates `index.html`
|
||||
at module load and serves that one string for every SPA route forever. The doc
|
||||
describes overriding `logo`/`favicon` as "an async settings read inside a
|
||||
currently-synchronous-feeling builder" — it is actually a lifecycle change, not
|
||||
just an `await`.
|
||||
|
||||
**Fix:** keep the rendered shell cached in a module-level variable, render it
|
||||
lazily on first request, and invalidate on any successful write to
|
||||
`brand_assets`. Two hard requirements:
|
||||
|
||||
- A DB fault must never fail the page — on a read error, fall back to the
|
||||
env-only shell (the current behavior).
|
||||
- The shell must stay a single cached string in the steady state. Do not do a
|
||||
settings read per page view.
|
||||
|
||||
### 4.4 Settings values are strings, not objects
|
||||
|
||||
`settings.value` is `TEXT`
|
||||
([`schema.sql:126`](../../website/server/db/schema.sql)) and JSON-valued keys are
|
||||
stored `JSON.stringify`'d and parsed client-side — see `parseLayout` in
|
||||
[`heroLayout.js:58`](../../website/client/src/lib/heroLayout.js). The doc's
|
||||
`settings.brand_assets?.hero` and `settings.nav_public` read as if they arrive
|
||||
parsed. They do not.
|
||||
|
||||
**Fix:** one shared `parseJsonSetting(str, validator)` helper, used by every
|
||||
consumer. A malformed or wrong-shaped value is treated as **absent** (falls back
|
||||
to env/code default), never as an error and never as a partial object. This is
|
||||
the same fail-safe posture `parseLayout` already takes.
|
||||
|
||||
### 4.5 The Android app and Discord embeds are silently excluded
|
||||
|
||||
`getPublic().brand` is a **documented cross-repo contract**, not an internal
|
||||
detail. [`publicBrand.test.js:30`](../../website/server/test/publicBrand.test.js)
|
||||
locks its field list, and the Android app's `BrandDto` seeds the entire Material
|
||||
theme from `brand.accent` (`MainActivity.kt:72` → `RunicGatewayTheme`), with
|
||||
`logo` / `hero` / `favicon` fields alongside it. `brand.accentInt` — derived once
|
||||
at boot — is what Discord embeds color themselves with.
|
||||
|
||||
If theme and asset overrides live only in the new keys, an admin changes the
|
||||
accent on the website and **the phone app and the Discord bot keep the old one**.
|
||||
|
||||
**Fix (locked):** resolve the *effective* values server-side in
|
||||
`getPublic()`'s brand block
|
||||
([`settings.model.js:130-141`](../../website/server/src/model/settings/settings.model.js)):
|
||||
|
||||
```js
|
||||
accent: themeVisual?.colors?.accent ?? brand.accent
|
||||
logo: brandAssets?.logo ?? brand.logo
|
||||
hero: brandAssets?.hero ?? brand.hero
|
||||
favicon: brandAssets?.favicon ?? brand.favicon
|
||||
```
|
||||
|
||||
The web client needs **no change** for this — its existing
|
||||
`setProperty('--accent', brand.accent)` line
|
||||
([`SiteContext.jsx:30-32`](../../website/client/src/contexts/SiteContext.jsx))
|
||||
simply receives a better value. Consequences to handle:
|
||||
|
||||
- `brand.accentInt` must be **recomputed from the effective accent** per request
|
||||
rather than read from the boot-time constant, or Discord embeds drift.
|
||||
- `publicBrand.test.js` gains cases: no rows → env values unchanged (the existing
|
||||
assertions must still pass verbatim); `theme_visual` accent set → effective
|
||||
accent returned; `brand_assets.favicon` set → favicon overridden while `logo`
|
||||
and `hero` still come from env.
|
||||
- The Android app needs **no change** to pick up accent/assets. Whether it should
|
||||
also honor the full preset (radius, fonts) is a separate question for
|
||||
`docs/android/PLAN.md`, out of scope here.
|
||||
|
||||
### 4.6 `spacingUnit` and `borderWeight` are not variable renames
|
||||
|
||||
The doc treats these as the same mechanism as color. They are not:
|
||||
|
||||
- **Spacing.** `theme.css` contains **zero** `calc()`-based spacings (the 5
|
||||
`calc()` uses are all `width: min(…, calc(100% - 32px))` page shells). Every
|
||||
padding is a hand-written non-multiple — `7px 14px`, `12px 26px`, `11px 14px`,
|
||||
`13px 14px`. A density token that actually moves density means rewriting ~40
|
||||
declarations into `calc(var(--space-unit) * n)`, and most of the app's real
|
||||
spacing is inline JSX the token cannot reach anyway.
|
||||
- **Border weight.** 39 hand-written `1px` borders, several of which are
|
||||
*semantic* accents that must not scale with a density slider — `.note`'s 3px
|
||||
left rule, `.page-quote`'s 3px, `.pb-tab`'s 2px active underline.
|
||||
|
||||
**Decision:** both are **cut from v1** and do not appear in the admin form.
|
||||
Colors, fonts, radius and shadow depth cover "brand feel" cleanly; these two do
|
||||
not, and shipping them as no-op fields would be worse than not shipping them.
|
||||
|
||||
### 4.7 Six radii cannot round-trip through three tokens
|
||||
|
||||
The doc's preset blocks set `--radius-card: 8px`, but the actual values in
|
||||
`theme.css` are 14×`8px`, 4×`999px`, 4×`10px`, 1×`12px`, 1×`7px`, 1×`6px`. `.card`
|
||||
and `.panel` are **10px** today and `.panel-flat` is **12px**. Adopting the doc's
|
||||
three tokens verbatim would restyle every existing instance — including ones that
|
||||
never touch the feature — which contradicts the acceptance criterion directly
|
||||
above it.
|
||||
|
||||
**Fix (locked):** four tokens seeded at today's real values, so the promotion step
|
||||
is genuinely a no-op:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius-pill: 999px; /* .btn, .pill, .badge, .wiki-tag */
|
||||
--radius-panel: 12px; /* .panel-flat */
|
||||
--radius-card: 10px; /* .card, .panel */
|
||||
--radius-input: 8px; /* .input, .textarea, .select, .btn-sq, .note, .rte, .prose img */
|
||||
}
|
||||
```
|
||||
|
||||
The 7px (`.rte-btn`) and 6px (`.rte-linkmenu-item`) values stay literals — they are
|
||||
interior editor chrome, not brand surface. The preset blocks in §6 carry corrected
|
||||
`--radius-card` values accordingly.
|
||||
|
||||
### 4.8 Parchment is a light-mode port, not a preset
|
||||
|
||||
`theme.css` carries 28 `rgba()` literals that assume a dark background — `.pill`'s
|
||||
`rgba(11,22,48,0.5)` fill, `.note`'s background, all seven `.badge-*` fills, the
|
||||
diff add/del colors, `.moon`'s radial gradient, `#dbe2ea` prose strong — plus the
|
||||
hero overlay stacks `rgba(11,15,20,…)` hardcoded in `heroLayout.js` and four route
|
||||
files, plus `rgba(9,13,18,0.86)` inline in `SiteHeader.jsx:55`. None of that
|
||||
responds to a `[data-theme]` variable block; Parchment would inherit dark chrome
|
||||
on a light background and look broken.
|
||||
|
||||
**Decision:** three dark presets in v1. Parchment becomes **Phase 9**, scoped as a
|
||||
light-mode port with its own contrast pass across every component.
|
||||
|
||||
### 4.9 The hero already has a third override layer
|
||||
|
||||
`hero_layout.background.image_url` **already** beats `brand.hero`
|
||||
([`heroLayout.js:39-50`](../../website/client/src/lib/heroLayout.js)). The real
|
||||
resolution order is:
|
||||
|
||||
```
|
||||
hero_layout.background.image_url → brand_assets.hero → BRAND_HERO → /assets/img/runic-emblem.png
|
||||
```
|
||||
|
||||
The doc's two-link chain omits the existing top link. The admin UI must say so
|
||||
explicitly, or "I uploaded a hero and the portal ignored it" becomes a bug report
|
||||
against a working system.
|
||||
|
||||
### 4.10 Favicon `.ico` is not possible without weakening the upload path
|
||||
|
||||
`MIME_EXT` in
|
||||
[`imageUpload.js:24-30`](../../website/server/src/router/v1/admin/imageUpload.js)
|
||||
has no `image/x-icon` or `image/vnd.microsoft.icon` entry, and the stored
|
||||
extension is derived from that map — which is exactly the property that makes the
|
||||
upload path safe. The doc floats "`.ico`/`.png` only" for favicons; the `.ico`
|
||||
half would mean adding a new file type to `/uploads`.
|
||||
|
||||
**Decision:** **PNG only** for favicons. `<link rel="icon">` accepts PNG in every
|
||||
browser this app supports, and the allowlist is left untouched. A tighter size cap
|
||||
than the shared 8 MB limit is applied at the route, not in the shared multer
|
||||
config.
|
||||
|
||||
### 4.11 Smaller notes
|
||||
|
||||
- **CSP is already fine.** [`config/csp.js:50-51`](../../website/server/src/config/csp.js)
|
||||
already allows `https://fonts.googleapis.com` in `style-src` and
|
||||
`https://fonts.gstatic.com` in `font-src`. The font shortlist needs no CSP
|
||||
change — which is worth stating, because widening CSP for a cosmetic feature
|
||||
would not be worth it.
|
||||
- **Do not touch the footer badge.**
|
||||
[`SiteFooter.jsx:19`](../../website/client/src/components/SiteFooter.jsx) is the
|
||||
hardcoded "powered by Runic Gateway" emblem. It is deliberately not the instance
|
||||
logo and must not follow `brand_assets.logo`.
|
||||
- **Nav labels do not reach the portal hero.** `hero_layout`'s
|
||||
`default-quick-links` element duplicates News / Screenshots / Five on Friday /
|
||||
Newsletter / About as its own buttons. Renaming those in the nav editor will not
|
||||
rename them on the portal; they are edited in the hero editor.
|
||||
- **The nav editor must refuse to hide its own entry.** Not a lockout — hiding is
|
||||
presentation-only and the URL still resolves — but recovering by typing a URL is
|
||||
a bad enough experience to be worth one guard.
|
||||
- **Process, per `CLAUDE.md`.** Every server-side phase requires
|
||||
`npm run swagger`, `npm run routes:manifest` (`routeManifest.test.js` fails
|
||||
otherwise), and a matching edit to
|
||||
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md). None of this is in the design doc.
|
||||
|
||||
## 5. Fonts: curated Google Fonts, not free text
|
||||
|
||||
`index.html` already loads Cinzel from Google Fonts, so this extends an existing,
|
||||
already-trusted pattern rather than introducing a new one.
|
||||
|
||||
**The dropdown's value — not free text — is what is stored.** Each option's value
|
||||
*is* the full CSS `font-family` stack exactly as it will be applied, so the client
|
||||
does zero string-building from admin input and `theme_visual` stays a closed set of
|
||||
known-safe values.
|
||||
|
||||
### 5.1 The shortlist
|
||||
|
||||
| Role | Option | Stored stack |
|
||||
|---|---|---|
|
||||
| **Serif body** | EB Garamond — strongest fantasy/historic | `'EB Garamond', Georgia, serif` |
|
||||
| | Merriweather — excellent readability | `Merriweather, Georgia, serif` |
|
||||
| | Playfair Display — elegant/editorial | `'Playfair Display', Georgia, serif` |
|
||||
| | IM Fell English — strongest old-world/UO flavor | `'IM Fell English', Georgia, serif` |
|
||||
| **Display heading** | Cinzel — current Runic Gateway identity | `Cinzel, Georgia, serif` |
|
||||
| | Playfair Display — elegant alternative | `'Playfair Display', Georgia, serif` |
|
||||
| | EB Garamond — softer/classic | `'EB Garamond', Georgia, serif` |
|
||||
| | IM Fell English — very strong fantasy | `'IM Fell English', Georgia, serif` |
|
||||
| **Sans UI** | Inter — default modern UI choice | `Inter, Arial, sans-serif` |
|
||||
| | Work Sans — slightly more character | `'Work Sans', Arial, sans-serif` |
|
||||
| | Source Sans 3 — extremely readable | `'Source Sans 3', Arial, sans-serif` |
|
||||
| | Arial — safe fallback/system option | `'Helvetica Neue', Arial, sans-serif` |
|
||||
|
||||
Two properties fall out of this list and are worth keeping:
|
||||
|
||||
- **Arial is the zero-cost option** — its stack is byte-identical to today's
|
||||
`--sans`, so it needs no webfont at all and doubles as the current default.
|
||||
- **Twelve slots, eight web families.** Playfair Display, EB Garamond and IM Fell
|
||||
English each serve two roles.
|
||||
|
||||
### 5.2 Loading
|
||||
|
||||
One combined request, not eight — Google Fonts accepts multiple `family=`
|
||||
parameters per URL, and the font *binaries* are only fetched when a family is
|
||||
actually applied:
|
||||
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
```
|
||||
|
||||
Static, in `index.html`, alongside the existing `preconnect` hints — a Google
|
||||
Fonts URL is **never** built from admin input at runtime.
|
||||
|
||||
**Weight coverage gotcha:** IM Fell English ships **400 and italic only — no
|
||||
bold.** `.display` and `.h1` use `font-weight: 600`, and `.btn` / `.eyebrow` /
|
||||
`.badge` use 600–700, so choosing it yields browser-synthesized faux-bold. That is
|
||||
acceptable for the display role (it is the authentic look) but is a reason not to
|
||||
present it as a recommended body face.
|
||||
|
||||
## 6. Storage
|
||||
|
||||
Five new keys. `theme_visual`, `brand_assets` and `nav_public` join `PUBLIC_KEYS`;
|
||||
`nav_admin` and `nav_player` are served by the authenticated endpoint from §4.2.
|
||||
All are JSON strings, absent by default.
|
||||
|
||||
### 6.1 `theme_visual`
|
||||
|
||||
```json
|
||||
{ "preset": "runic-gateway", "custom": null }
|
||||
```
|
||||
|
||||
or, when the admin picks Custom:
|
||||
|
||||
```json
|
||||
{
|
||||
"preset": "custom",
|
||||
"custom": {
|
||||
"colors": { "bg": "#0e1318", "bgDeep": "#0b0f14", "panelA": "#192231", "panelB": "#141a21",
|
||||
"accent": "#7f99bd", "accentBright": "#cdd9e8", "ink": "#eef3f8", "text": "#c4cdd8" },
|
||||
"structure": { "radiusPill": "999px", "radiusPanel": "12px", "radiusCard": "10px",
|
||||
"radiusInput": "8px", "shadowDepth": "0 14px 34px rgba(0,0,0,0.3)" },
|
||||
"fonts": { "serif": "'EB Garamond', Georgia, serif",
|
||||
"display": "Cinzel, Georgia, serif",
|
||||
"sans": "Inter, Arial, sans-serif" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`colors` / `structure` / `fonts` are independently overridable groups — a custom
|
||||
accent without touching radius or fonts is expected. A group or field the admin
|
||||
never touched falls back to whatever preset or `:root` value is active. **Never
|
||||
null a field out to "clear" it** — remove it from the object.
|
||||
|
||||
### 6.2 Preset blocks
|
||||
|
||||
`:root` (no `data-theme` attribute set at all) stays the **Runic Gateway** default
|
||||
— today's actual values — so an instance with no `theme_visual` row renders
|
||||
exactly as it does now. `runic-gateway` is *also* declared as a named preset so
|
||||
that switching back to it after trying another is the same code path.
|
||||
|
||||
```css
|
||||
[data-theme="runic-gateway"] {
|
||||
--bg: #0e1318; --bg-deep: #0b0f14; --panel-a: #192231; --panel-b: #141a21;
|
||||
--accent: #7f99bd; --accent-bright: #cdd9e8; --ink: #eef3f8; --text: #c4cdd8;
|
||||
--radius-pill: 999px; --radius-panel: 12px; --radius-card: 10px; --radius-input: 8px;
|
||||
--serif: Georgia, "Times New Roman", serif;
|
||||
--display: Cinzel, Georgia, serif;
|
||||
--sans: "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Modern — flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. */
|
||||
[data-theme="modern"] {
|
||||
--bg: #101114; --bg-deep: #0a0a0c; --panel-a: #1c1d22; --panel-b: #17181c;
|
||||
--accent: #4f8ef7; --accent-bright: #a8c8ff; --ink: #f2f3f5; --text: #b8bcc4;
|
||||
--radius-pill: 8px; --radius-panel: 8px; --radius-card: 6px; --radius-input: 6px;
|
||||
--serif: Inter, Arial, sans-serif;
|
||||
--display: 'Work Sans', Arial, sans-serif;
|
||||
--sans: Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Fantasy — warmer, higher contrast, carved corners; leans into UO harder. */
|
||||
[data-theme="fantasy"] {
|
||||
--bg: #1a120b; --bg-deep: #120c07; --panel-a: #2c1f14; --panel-b: #241a10;
|
||||
--accent: #c9973f; --accent-bright: #e8c374; --ink: #f3e8d4; --text: #d3bfa0;
|
||||
--radius-pill: 4px; --radius-panel: 3px; --radius-card: 2px; --radius-input: 2px;
|
||||
--serif: 'EB Garamond', Georgia, serif;
|
||||
--display: Cinzel, Georgia, serif;
|
||||
--sans: 'EB Garamond', Georgia, serif;
|
||||
}
|
||||
```
|
||||
|
||||
**Derived-token rule (do not break this):** `--panel-grad` and `--shadow-card` must
|
||||
stay expressed *in terms of* the other variables, never written as a literal
|
||||
gradient in a preset block. If `--panel-grad` is ever hardcoded, a future light
|
||||
preset silently inherits a dark gradient and looks broken. Likewise `--mode-live`
|
||||
and `--mode-maint` (the status dots) are **semantic** — green means live — and stay
|
||||
fixed across all presets rather than being themed.
|
||||
|
||||
### 6.3 `brand_assets`
|
||||
|
||||
```json
|
||||
{ "logo": null, "hero": null, "favicon": null }
|
||||
```
|
||||
|
||||
Each field, once set, holds the stored upload URL (`/uploads/1234-abcd.png`) — the
|
||||
same shape `POST /admin/uploads` already returns. A `null` or absent field falls
|
||||
back to `brand.logo` / `brand.hero` / `brand.favicon`; uploading a logo does not
|
||||
force the admin to also pick a hero.
|
||||
|
||||
### 6.4 `nav_public` / `nav_admin` / `nav_player`
|
||||
|
||||
Keyed by the item's existing `to`:
|
||||
|
||||
```json
|
||||
{
|
||||
"/admin/posts": { "label": "Blog Posts", "order": 10 },
|
||||
"/admin/settings": { "hidden": true },
|
||||
"/admin/moderation": { "order": 5, "group": "Content" }
|
||||
}
|
||||
```
|
||||
|
||||
Any field absent for a given `to` falls back to the code default — label from
|
||||
`NAV`, natural array order, `hidden: false`, original group. **Unknown `to` values
|
||||
(not present in the current code's base array) are ignored, not stored and later
|
||||
honored**, so removing a route in code can never leave a dangling override that
|
||||
does something unexpected.
|
||||
|
||||
## 7. Navigation: hard constraint
|
||||
|
||||
The override system can **only** affect `label`, `order`, `hidden`, and — admin
|
||||
nav only — `group` (which *existing* titled section an item sits under).
|
||||
|
||||
It **cannot**:
|
||||
|
||||
- introduce a `to` that is not already in the corresponding hardcoded `NAV` array;
|
||||
- change or remove an item's `roles` (admin nav) or `feature` (public nav) gate;
|
||||
- un-hide an item for a viewer whose role or feature check would otherwise fail.
|
||||
|
||||
The existing filters in
|
||||
[`SiteHeader.jsx:43`](../../website/client/src/components/SiteHeader.jsx) and
|
||||
[`AdminLayout.jsx:155-164`](../../website/client/src/routes/admin/AdminLayout.jsx)
|
||||
run **after** the override merge, unchanged, and remain the actual security
|
||||
boundary. The override layer is presentation-only. This is the same
|
||||
"server-enforced gate, client-side is only about not advertising a dead end"
|
||||
principle already documented in `SiteHeader.jsx`'s comments, and this feature must
|
||||
not weaken it.
|
||||
|
||||
Two existing behaviors the merge must not disturb:
|
||||
|
||||
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
|
||||
redirects them out of anything else. Overrides apply before that filter, so a
|
||||
moderator can still end up with a legitimately short sidebar — but the redirect
|
||||
effect must keep working untouched.
|
||||
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
|
||||
override that hides every item in a group must produce no orphaned header.
|
||||
|
||||
### 7.1 Merge util
|
||||
|
||||
New shared pure module, `client/src/lib/navOverrides.js`:
|
||||
|
||||
```js
|
||||
function applyNavOverrides(baseNav, overrides) {
|
||||
// baseNav: the existing hardcoded array / grouped array — remains the source
|
||||
// of truth for `to`, `roles`, `feature`, `icon`, `end`
|
||||
// overrides: the parsed settings JSON, or null when the admin never touched it
|
||||
// returns: a new array of the same shape with label/order/hidden/group applied
|
||||
}
|
||||
```
|
||||
|
||||
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
|
||||
path and is the single most important case to test.
|
||||
|
||||
## 8. Build phases
|
||||
|
||||
Each phase is independently shippable and leaves the site rendering identically to
|
||||
today until the admin acts.
|
||||
|
||||
| Phase | Work |
|
||||
|---|---|
|
||||
| **0 — Settings-store groundwork** | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
|
||||
| **1 — `navOverrides.js` + tests** | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
|
||||
| **2 — Radius/shadow token groundwork** | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
|
||||
| **3 — Theme engine** | Three preset blocks, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
|
||||
| **4 — Admin theme UI** | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
|
||||
| **5 — Brand assets** | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
|
||||
| **6 — Public nav wiring** | `SiteHeader.jsx` → `nav_public`. Lowest risk of the three: no roles, no groups |
|
||||
| **7 — Nav builder UI** | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
|
||||
| **8 — Admin + Player nav** | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
|
||||
| **9 — Parchment (optional)** | Light-mode port per §4.8 — its own contrast pass across every component |
|
||||
|
||||
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
|
||||
fourth.
|
||||
|
||||
### 8.1 Admin builder UI notes
|
||||
|
||||
- Tabbed control for the three navs; drag-and-drop reorderable list.
|
||||
- **The palette is filtered to the editing admin's own visible items** — the base
|
||||
array run through *their* role/feature check — so an admin cannot drag in, and
|
||||
therefore can never accidentally expose, an item they cannot already see
|
||||
themselves. A deliberate UX guardrail on top of the merge-time enforcement.
|
||||
- Per item: label input with a "reset to default" that clears the override, an eye
|
||||
toggle for `hidden`, and on the Admin tab a group dropdown limited to the fixed
|
||||
set of titles already in `NAV`.
|
||||
- "Reset to defaults" per nav **deletes the row** (§4.1), never saves `{}`.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- Fresh instance, no admin action: colors, fonts, radii, brand assets and all
|
||||
three navs render byte-for-byte as today, driven by `BRAND_*` and the current
|
||||
hardcoded `theme.css` / `NAV` arrays.
|
||||
- After Phase 2 and before any admin UI exists, the rendered site is visually
|
||||
identical — the token promotion is a true no-op.
|
||||
- Setting `theme_visual.custom.colors` alone changes colors only; radius, fonts,
|
||||
assets and nav are unaffected.
|
||||
- Font dropdowns only ever produce values from the §5.1 shortlist. No admin input
|
||||
is concatenated into a `font-family` string or a Google Fonts URL at runtime.
|
||||
- Setting only `brand_assets.favicon` changes the served favicon only — the OG
|
||||
image and hero backgrounds still resolve from `brand.js` env values.
|
||||
- With no `brand_assets` row, the served HTML shell is **byte-identical** to
|
||||
today's. Covered by a server-side test in `publicBrand.test.js`.
|
||||
- Uploaded assets go through the existing `imageUpload.js` mimetype allowlist. No
|
||||
second upload path with weaker validation.
|
||||
- `getPublic().brand` with no new rows returns exactly what it returns today —
|
||||
the existing `publicBrand.test.js` assertions pass verbatim.
|
||||
- An admin cannot, through the nav builder, cause any user to see a nav item their
|
||||
role/feature gate would otherwise hide. Verified by overriding `hidden: false`
|
||||
on a role-gated item as a lower-privileged test admin and confirming the filter
|
||||
still hides it.
|
||||
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
|
||||
a stored copy of the defaults.
|
||||
Reference in New Issue
Block a user