Files
docs/website/THEMING_AND_NAV.md
wtclaude d33064e8d7 docs(website): add theming & nav build contract
Corrects the design doc against the current codebase and locks the open
decisions, in the same shape as HERO_EDITOR.md (locked decisions ->
corrections to reality -> phased build).

Blocking gaps found in the design doc:
  - no delete path exists for a settings row, which every "reset to
    defaults" in the feature depends on
  - editors/moderators/players have no endpoint to read their own nav
    overrides (GET /admin/settings is admin-only)
  - renderIndexHtml runs once at boot, not per request
  - settings values are JSON strings, not objects
  - getPublic().brand is a cross-repo contract the Android app and
    Discord embeds theme from; new keys would silently bypass it

Locked: effective values resolved server-side into getPublic().brand;
radius + shadow tokens only (spacing/border cut); radius tokens seeded at
today's real values so the promotion is a no-op; three dark presets in v1
with Parchment deferred; 12-option font shortlist across 8 web families
in one request; PNG-only favicons.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 17:04:54 -05:00

540 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.14.5 are blocking; §4.64.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 600700, 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 02 are one PR pair (website + docs), 34 a second, 5 a third, 68 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.