diff --git a/README.md b/README.md
index 7836175..7d9be44 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/website/THEMING_AND_NAV.md b/website/THEMING_AND_NAV.md
new file mode 100644
index 0000000..7454e2c
--- /dev/null
+++ b/website/THEMING_AND_NAV.md
@@ -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. `` 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
+
+```
+
+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; `` 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.