# 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 |
| Theme delivery | **The server resolves the whole effective token set** and the client writes it as CSS custom properties. No `[data-theme]` blocks — see §6.2 |
| 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.~~ **Corrected in Phase 3 — this fix as written was a no-op.**
  `getPublic().brand` never exposes `accentInt` (`publicBrand.test.js` asserts
  it is `undefined`, deliberately: it is a Discord-only integer form), and the
  server-side `brand.accentInt` has no consumer at all. Discord embeds are
  colored by **`bot/src/brand.js`, in a separate process**, reading
  `BRAND_ACCENT_COLOR` from env at boot — so there was nothing per-request to
  recompute, and the drift the note describes was real but unfixable from the
  server. What Phase 3 actually did: the bot now fetches
  `GET /public/settings` → `brand.accent` (it already has a public-API client)
  behind a 10-minute cached getter, keeping env as the fallback. See
  "Phases 3–4 as landed" below.
- `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

> **Superseded in Phase 3.** The presets below are correct as *values* and were
> built as specified, but they do **not** live in `theme.css` as `[data-theme]`
> blocks. They live in `server/src/config/themePresets.js`, and the server
> resolves the effective token set into `getPublic().theme` for the client to
> write onto `<html>`. See "Phases 3–4 as landed" for why, and note two
> corrections the build made to the palettes: each preset carries the **full**
> color set (fifteen tokens, not the eight below), and `--shadow-card` is
> themed alongside the radii.

`:root` 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.

**`nav_public` may also be a wrapper** (Phase 10), because the public header is
the one nav an admin can restructure rather than only reorder:

```json
{
  "items":    { "/site/champs": { "order": 0, "section": "sec_a1b2" } },
  "sections": [ { "id": "sec_a1b2", "label": "The World", "order": 4 } ],
  "links":    [ { "id": "lnk_c3d4", "label": "Player Guide",
                  "to": "/wiki/new-player-guide", "order": 1, "section": "sec_a1b2" } ]
}
```

- **A bare map is still read as the items map.** Every item key is a path
  starting with `/`, so it can never collide with the literal key `items` — the
  detection is unambiguous, and a nav with no sections still *stores* the bare
  map, so this feature changed nothing for one that does not use it.
- `nav_admin` / `nav_player` keep the bare map; `sections` and `links` are
  dropped for them, since neither layout can render an admin-created section.
- Top-level order is one number line shared by ungrouped entries **and
  sections**; within a section, by its members. An admin-created entity with no
  stored order appends after the coded ones rather than jumping to the front.
- **One level only.** No menu inside a menu.
- An `items[].section` or `links[].section` naming no declared section falls back
  to the top level, mirroring the "group must name an existing title" rule.

## 7. Navigation: hard constraint

> **Amended in Phase 10.** This section originally said the override layer
> "cannot introduce a `to` that is not already in the corresponding hardcoded
> `NAV` array". That is still true of every **coded** entry, but the public
> header now also lets an admin add links of their own, so the constraint is
> restated below in the narrower form that survives. Nothing about the *gates*
> changed.

The override system can affect a **coded** entry's `label`, `order`, `hidden`,
and which container it sits in — `group` on the admin nav (an *existing* titled
section) or `section` on the public header (an admin-created dropdown).

It **cannot**:

- change a coded entry's `to`, or introduce a new one in its place;
- change or remove an entry's `roles` (admin nav) or `feature` (public nav) gate;
- un-hide an entry for a viewer whose role or feature check would otherwise fail.

**The public header may additionally carry admin-created `sections` and
admin-authored `links`** (§6.4, §7.2). This is a genuine widening and is worth
stating plainly:

- A **section** is a container with a label and a position. It has no `to` and is
  never itself a link — it only opens — so it adds no reachable surface at all.
- A **link** is the one thing an admin may add to a nav, and the only place a path
  is not required to already exist in code. It is restricted to a **same-origin
  path**: no scheme, no protocol-relative `//host`, no whitespace or quotes. The
  nav is not a place to send visitors to an origin the operator does not control.
- A link carries **no `roles` or `feature` of its own, and needs none**: the page
  behind it enforces its own access, so a link to somewhere the viewer cannot
  reach behaves exactly as typing that address would. Adding a link advertises a
  route; it never grants one.

The property this rests on is structural rather than a check someone has to
remember: coded entries live in an `items` map whose keys **must** be routes the
base array declares, so that map can never introduce a route, while everything
that *can* name an arbitrary path lives in `links`, where the path rule is
applied on both the write and the read path.

The existing filters in
[`SiteHeader.jsx`](../../website/client/src/components/SiteHeader.jsx) and
[`AdminLayout.jsx`](../../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.

Three existing behaviors the merge must not disturb:

- **Empty dropdowns.** A section whose every entry is filtered out by a shard
  feature must not render at all — a menu that opens onto nothing is worse than
  no menu. `pruneNav` applies the gate inside a section and then drops one it
  leaves empty.

- **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.

**As built (Phase 1).** Two shapes are handled by the one function — flat
(`SiteHeader`, `PlayerPortalLayout`) and grouped (`AdminLayout`) — detected by
whether every entry carries an `items` array. Three rules the doc left open,
settled by the implementation and locked by tests:

- **Ordering.** An item the admin never reordered keeps its index in the base
  array as its sort key, so setting one `order` does not scramble the rest.
  Explicit and implicit keys therefore share one number line and can collide;
  ties break **explicit first** (an admin who said "0" means first, not
  "wherever the untouched item at index 0 already sits"), and two explicit
  equal orders keep code order via a stable sort. The editor writes an order for
  every item in a list the way drag-and-drop does, so ties are the stale-row
  case, not the normal one — they just have to resolve predictably.
- **`group`.** Accepted only when it names a title the base nav already
  declares; anything else is dropped, so an item can never land under a header
  that does not exist. Group *order* is not overridable — sections stay in code
  order, only membership and within-group order move.
- **Field-by-field validation.** A bad `label` does not discard a good `order`
  beside it, and `hidden` is honored only as the literal boolean `true`.
  Everything unrecognized is ignored rather than rejected, so a hand-edited row
  degrades to the code default instead of rendering a broken nav.

`hidden: false` cannot un-hide anything: hiding here is subtractive only, and
the role/feature filters still run afterward, unchanged.

## 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 presets, 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 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 — Palette-following literals + Parchment** ❌ **cancelled** | Was: promote the hue-carrying `rgba()` literals of §4.8 so they follow the palette, then the light-mode port. Not scheduled — see "Phase 9, cancelled" below |
| **10 — Public nav sections + added links** ✅ | Admin-created dropdown sections in the public header, coded entries organised into them, and admin-authored same-origin links. `NavDropdown.jsx`, `buildPublicNav`/`pruneNav`, the `nav_public` wrapper of §6.4, and the Public tab's own tree editor. **Amends §7** |

Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
fourth. **All four PR pairs target `edge`, not `main`** — the feature reaches
`main` as one `edge` → `main` merge once every phase is in, so no release ever
carries a half-wired theme engine. Phase 8 is the last one, so that merge is
what closes the feature.

### Phases 0–2 as landed

- **`/api/v1/settings` is a fifth router group**, not a route bolted onto an
  existing one. §4.2 named the URL but not where it lives, and the domain split
  leaves no group it fits: `/public` is anonymous, `/admin/settings` is
  `adminOnly` while `AdminLayout` renders for editors and moderators, and
  `/player` is data scoped to `req.user.id`. The group carries
  `noindex, requireAuth` and no role gate. The route-manifest guard test that
  asserts every `/admin/**` and `/player/**` route sits behind `requireAuth` now
  covers `/settings/**` too.
- **Reset is `DELETE /api/v1/admin/settings/:key`** with the allowlist in
  `settings.model.js` (`DELETABLE_KEYS`), which is what stops a stray request
  from dropping `site_mode` or the uo-link config. It is admin-only and
  idempotent, and a test asserts it never writes a row.
- **`parseJsonSetting` lives at `server/src/utils/settingsJson.js`.** Non-object
  JSON (`4`, `"x"`, `null`, `[]`) is treated as absent alongside syntax errors,
  and a validator rejection discards the whole object rather than half-applying
  it. The client keeps `parseLayout`; a client-side counterpart arrives with its
  first consumer in Phase 3.
- **Phase 2 was a 23-declaration promotion** — 14×`8px` → `--radius-input`,
  4×`999px` → `--radius-pill`, 4×`10px` → `--radius-card`, 1×`12px` →
  `--radius-panel` — matching the §4.7 census exactly. The `7px`/`6px` editor
  chrome and the two `50%` circles stay literal. `--shadow-card` and
  `--panel-grad` were **already** tokens and already derived, so the shadow half
  of the phase was a no-op; the only two `box-shadow` declarations in
  `theme.css` both already read `var(--shadow-card)`.

### Phases 3–4 as landed

Four things the design settled differently once it met the code.

**1. The server resolves the whole token set; there are no `[data-theme]`
blocks.** §6.2 put the presets in `theme.css` and had the client set a
`data-theme` attribute. That does not work as written: `SiteContext` writes
`--accent` as an **inline style on `<html>`** (`SiteContext.jsx:31`), and an
inline property beats any attribute-selector block. An admin who picked Fantasy
without also setting a custom accent would have had Fantasy's `#c9973f` painted
over by `brand.accent` from env — and §4.5's whole point is that
`getPublic().brand.accent` is what the phone app themes itself from, so the two
surfaces would have disagreed about the accent while both being "right".

The fix removes the conflict rather than sequencing around it. Presets live in
`server/src/config/themePresets.js`; `server/src/utils/themeResolve.js` layers
`:root` ← preset ← custom **per field** into a token map; `getPublic()` returns
it as `theme`; `client/src/lib/themeVars.js` writes it onto `<html>`. One
authority for the merge, `brand.accent` is by construction the accent the site
actually paints, and `theme.css`'s `:root` is untouched — an instance with no
row gets no `theme` block, the client writes nothing, and the page renders
byte-for-byte as today.

The client half's real logic is *removal*: inline properties are not cleared by
writing a smaller object over them, so `applyThemeTokens` tracks what it set
last time and `removeProperty`s whatever the new payload no longer mentions.
Without that, "Reset to defaults" would look broken until a reload.

**2. Presets carry the full fifteen-token palette, and theme `--shadow-card`.**
§6.2's blocks set eight colors. Applied literally, Fantasy's warm brown page
would have kept `--line: #2a3544` and `--blue: #13243c` — dark blue-grey borders
and a blue-grey active nav row — because those tokens are not in the list.
Every preset now sets `--panel-flat`, `--line`, `--line-soft`, `--head`,
`--muted`, `--dim` and `--blue` as well. The admin *form* still exposes only
§6.1's eight; the rest are supporting shades a preset gets right coherently but
that are not worth hand-picking. `--mode-live` / `--mode-maint` stay fixed
across every preset (green means live) and `--panel-grad` stays derived, both
locked by tests.

**3. The option catalog is served, not duplicated.**
`GET /api/v1/settings/theme/options` returns the presets (with their full token
maps, so a control can show what an unset field currently resolves to), the font
shortlist, the shadow depths, and the editable field names paired with the CSS
variable each drives. Duplicating those lists in client code would mean the form
could offer a font the server rejects, which surfaces as a save 400ing for no
visible reason. A test asserts every offered option validates.

Validation is deliberately asymmetric: **strict on write** (`PUT
/admin/settings` 400s and names the offending field) and **forgiving on read**
(a bad field is dropped, its neighbours keep applying). Strict-on-write gives
feedback; forgiving-on-read means a row hand-edited in the DB degrades to the
shipped default instead of rendering a broken site.

One addition to §5.1's twelve font options: **Georgia in the serif list.** The
shortlist gave the sans role a "today's default" option (Arial, byte-identical
to `--sans`) but left serif with no way back to `Georgia, "Times New Roman",
serif` short of resetting the whole theme. It pulls in no web family, so §5.2's
combined URL is unchanged.

**4. The Discord bot fetches the accent; §4.5's `accentInt` note was a no-op.**
See the correction in §4.5. `bot/src/brand.js` now reads
`GET /public/settings` → `brand.accent` through the public-API client it already
had, behind getters with a 10-minute TTL — so `brand.accentInt` stays a plain
property read at every existing call site, an embed never awaits a network call,
and any failure (site down, maintenance, malformed body) keeps the last known
good value with `BRAND_ACCENT_COLOR` as the floor.

**Found while smoke-testing: §4.8's rgba literals are not only a light-mode
problem.** The 28 dark-assuming `rgba()` literals were scoped to Phase 9 on the
reasoning that they break a *light* preset. Applying **Fantasy** on a live
instance shows they also carry a **hue**: `.btn-ghost`'s
`background: rgba(11, 22, 48, 0.45)` (essentially `--blue` at 45%) leaves the
portal's quick-link buttons reading blue on a warm brown page, and the hero
overlay stack in `heroLayout.js` is `rgba(11,15,20,…)` regardless of preset.
Nothing is broken or unreadable — it is a visible seam, not a bug — but Phase 9
should be re-scoped from "light-mode port" to "make the hue-carrying literals
follow the palette", which the dark presets need too. Not fixed here: it is the
23-declaration-style promotion Phase 2 was, and folding it into the phase that
introduced the presets would have hidden it inside an unrelated diff.

**Deferred to Phase 5, and done there:** the theme arrives with the
`/public/settings` fetch, so a themed instance painted the shipped palette for
one frame before repainting. Phase 5 had to rewrite `renderIndexHtml` into a
cached, invalidated shell anyway (§4.3), and injecting a `<style>` block with the
effective tokens there removed the flash for free rather than solving it twice.

**Also fixed in passing:** `settings/nav.controller.js` imported the logger
*factory* rather than calling it, so `log.error` was `undefined` and a DB fault
would have thrown a `TypeError` inside the catch — no response sent, request
left hanging — instead of returning a 500. Introduced in Phase 0.

### Phase 5 as landed

**The upload is one call, not two.** §8 said "upload endpoint on the existing
multer config", which reads as: reuse `POST /admin/uploads`, then `PUT` the
`brand_assets` row. Two problems with that. The generic upload is `staffOnly` —
editors can reach it — while the row it would write is `adminOnly`, and the
site's identity is not the editor tier's to change. And a run that uploaded and
then failed (or was abandoned) would leave a file in `/uploads` that nothing
references.

So: **`POST /api/v1/admin/settings/brand-asset/:slot`**, `adminOnly`, using the
shared `imageUpload.js` multer config and returning `{ url, brand_assets }`. It
read-modify-writes the row, so uploading a logo never clears a hero (§6.3). The
per-slot rules only ever *tighten* the shared allowlist, never widen it (§9):

| Slot | Types | Cap |
|---|---|---|
| `logo` | the shared image allowlist | 1 MB |
| `hero` | the shared image allowlist | 8 MB (the shared ceiling) |
| `favicon` | **PNG only** (§4.10) | 512 KB |

The cap is enforced after multer has written the file and the file is unlinked
before the response, rather than by a second multer instance with its own limits.
One upload config and one allowlist is the property worth keeping; a briefly
written file that is deleted before the request returns is not.

**There is no per-slot delete route.** Clearing one asset is a `PUT` of the
remaining ones, and clearing the last one is the existing reset-by-delete —
`{}` is never stored, because absence of the row is what selects the env
defaults (§2) and a stored empty object would be a second way to say the same
thing.

**`brand_assets` needed a validator of its own, which the design did not
anticipate.** These are the only settings values written straight into HTML as
URLs the browser then fetches — an `<img src>`, a `<link rel="icon">`, an
`og:image`. `utils/brandAssets.js` accepts a same-origin path under `/uploads/`,
`/brand/` or `/assets/` and nothing else: no scheme, no protocol-relative
`//host` (which looks like a path and loads off-origin), no `..`, no whitespace
or quotes. Same asymmetry as the theme — strict on write with the field named,
forgiving on read so one hand-edited slot does not cost the admin the other two.

**The shell cache carries a TTL as well as explicit invalidation.** §4.3 asked
for a module-level cache invalidated on write, and that is what the settings
controller does. But the cache is *per process*: in a scaled deployment the
worker that handled the write is the only one that learns of it, and every other
would serve the old favicon until the next restart. A 5-minute TTL makes the rest
converge on their own while keeping the steady state at one render per process
per five minutes — not one per page view. Concurrent first requests share a
single render, an invalidation that lands mid-render is not overwritten by the
in-flight result, and a failed settings read renders the env-only shell and
caches *that*, so an outage is not a failing query per page view.

**Theme flash: fixed here, with a handoff.** The shell now also carries the
resolved tokens as `<style id="theme-boot">:root{…}</style>`, injected last in
`<head>` so it follows the built stylesheet and wins the equal-specificity tie.
`SiteContext` removes that block once the `/public/settings` payload has arrived
and been applied — otherwise a later reset would remove the inline properties
only to reveal the stale block underneath. The removal is gated on a
**successful** fetch, not merely a finished one: a failed request leaves the app
with no theme at all, and dropping the block then would strip a themed instance
back to the shipped palette for no reason.

**The logo went into all six MoonDot surfaces, not three.** §8 named the three
persistent shells (site header, admin sidebar, portal sidebar); the admin login,
the player login/register card and the maintenance page carry the same mark and
an operator who uploads a logo means their instance, not three of its pages.
`components/BrandLogo.jsx` renders **nothing** when `brand.logo` is empty — which
is the shipped default — so every one of those surfaces is unchanged on an
untouched instance. On the three centered layouts the logo is stacked *above* the
moon rather than beside it, because turning that block into a flex row would have
changed its height on instances with no logo.

The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11):
it is the project's badge, not the instance's.

**The hero chain needed no code.** §4.9's real order —
`hero_layout.background.image_url` → `brand_assets.hero` → `BRAND_HERO` →
`/assets/img/runic-emblem.png` — already holds, because Phase 3 resolved
`brand_assets` into `getPublic().brand.hero` and `SiteContext.heroImage` reads
that. What was missing was saying so: the hero row in the admin panel now states
that a hero-editor background wins over the uploaded one, so "I uploaded a hero
and the portal ignored it" does not become a bug report against a working system.

**Observed and left alone:** the shell's `<title>` and description still come
from `BRAND_NAME`/`BRAND_DESCRIPTION`, not from the admin-set `site_title` that
`getPublic().brand.name` prefers, so an instance that renamed itself through the
admin panel still has the env name in its tab and its link previews. Fixing it
would change the served shell for instances with no `brand_assets` row, which is
exactly what §9 says must not change in this phase. It wants its own change.

### Phases 6–8 as landed

The nav half, wired end to end: the public header, the admin sidebar and the
player portal all read their override row, and `/admin/navigation` writes them.
Five things the design did not settle.

**1. The server had no way to store a nav row, and would have stored garbage.**
§8 described phases 6–8 as client work, and for the *merge* that is right. But
`updateSettings` validates and stringifies `theme_visual` and `brand_assets` and
lets everything else through to `settingsDb.set` — so a `nav_public` object would
have been written as the string `"[object Object]"`, which `parseJsonSetting`
then reads as absent. The save would have returned 200 and done nothing, for
ever. `server/src/utils/navOverrides.js` mirrors `utils/brandAssets.js`:
`validateNavOverrides` is strict on write and names the offending key,
`resolveNavOverrides` is forgiving and drops fields that would do nothing.

**2. The server cannot check that a `to` exists, and should not try.** The three
base `NAV` arrays are client constants. Shipping a copy to the server would
create a second source of truth for navigation that drifts the first time a route
is added, and it would buy nothing: `applyNavOverrides` already drops an entry
whose `to` the base array does not declare, which is the right place for it — a
route deleted in code stops mattering immediately, with no migration. **The
server validates shape; the client owns membership.** So the write path accepts
any app-internal path as a key (absolute, no scheme, no `//host`, no whitespace)
and rejects everything else, and it rejects any field that is not one of the
four — a `roles` or `to` in the body is a 400, not something quietly stored.

**3. `hidden: false` is accepted and never stored.** The editor sends it while a
row is being edited, so rejecting it would be hostile; storing it would leave a
row that reads like an instruction to *force* something visible, which this layer
must never be able to express. It is dropped on the way in, and hiding stays
subtractive.

**4. The nav editor cannot be hidden, and that is enforced three times.** An
admin who hid `/admin/navigation` would lose the only screen that can un-hide it.
The row's eye toggle is disabled with a note saying why; `resolveNavOverrides`
drops `hidden` on that one `to` for `nav_admin`; and `AdminLayout` strips it
again before merging, which is what also covers a row edited straight in the
database. Typing the URL still works regardless — the guard is about not
stranding an admin who never learned it.

**5. Orders are written only when something actually moved.** §7.1 says the
editor writes an order for every item "the way drag-and-drop does", and it does —
but only for a nav whose sequence differs from the code's. An admin who renames
one item stores exactly one field, and a route added to `NAV` later still lands
where the code puts it. The comparison is against the base **restricted to the
rows that admin can see**, so a role- or feature-gated item missing from their
palette is not mistaken for a reorder. An override for such an item is carried
through their save untouched rather than quietly reset.

Two smaller notes. The section dropdown offers "(no section)" only to rows coded
into an untitled group (Dashboard, Account): for anything else it is a move an
override cannot express (§6.4 allows an existing titled section or nothing), so
offering it would silently do nothing. And `useNavOverrides` keeps one
module-level copy of the two authenticated rows, which is what lets a save in the
editor update the sidebar the admin is looking at without a reload — and stops
the second layout to mount from flashing the coded nav first.

### Phase 10 as landed

Asked for after phases 6–8 were built and before the `edge` → `main` cutover:
the public site should support dropdown sections with links organised inside
them. Scoped to the **public header only** — the admin sidebar keeps its four
coded sections and the player portal its three flat rows — and to **same-origin
links**, which is what makes §7's amendment a narrowing rather than an opening.

**The shape change was free because nothing had shipped.** `nav_public` grew a
`{items, sections, links}` wrapper. Had this landed after the cutover it would
have needed a migration or a version field; before it, a forgiving read of the
bare map is enough, and that read is kept anyway as insurance for a row written
during review.

**Sections are entries in the top-level order, which is why the Public tab has
its own editor.** The admin sidebar's groups are a fixed frame the code declares:
only membership moves. A public section is something the admin created and can
drag among the pills. That is a tree, not a list of groups, so
`PublicNavTree.jsx` renders it with a nested `SortableContext` per section, while
the other two tabs keep the phase-7 grouped editor. The shared `Row` was
generalised — its destination `<select>` takes a list of choices instead of
knowing about admin group titles.

**Moving between containers is still the dropdown, not a drag**, exactly as on
the Admin tab. Cross-container dragging is a lot of interaction surface for
something an admin does once, and keeping every drag a simple reorder is what
lets the nested contexts stay independent.

**Deleting a section does not delete what is inside it.** The entries move back
to the top level. It is the one destructive act this screen could commit — those
are coded pages and the admin's own links — so it is locked by a test.

**The dropdown opens on click, never hover, and the trigger is not a link.** A
hover menu is unusable on touch, and making the trigger navigate means tapping to
open takes you somewhere instead. A section is a container, not a destination.
`NavDropdown.jsx` carries the rest of the contract: Escape closes and returns
focus, an outside press closes, navigating closes, Arrow Up/Down walk the items,
and `aria-haspopup`/`aria-expanded` let it be announced as a menu.

**A bug the palette filter had, found by the test for it:** `buildNavOverrides`
judged "does this route still exist?" against the *palette* — the base array
already filtered to what the editing admin can see. For the admin nav that is
harmless (an admin sees every row), but on the public header a shard-feature-gated
row is filtered out, so the guard meant to carry its override through could never
fire, and their save would have quietly reset it. Membership is now judged against
the **full** coded nav while the rows still come from the palette: they are two
different questions.

### Phase 9, cancelled

The §4.8 `rgba()` literal promotion and the Parchment light-mode port are **not
scheduled**. The finding that motivated them stands and is worth keeping: those
literals carry a *hue*, not merely a light/dark assumption — `.btn-ghost` is
`rgba(11,22,48,0.45)`, so the portal quick-links read blue on Fantasy's warm
page. It is a real rough edge in the three dark presets, not only a blocker for a
hypothetical light one. It is simply not worth the contrast pass across every
component right now. Anyone picking it up should start from the census in §4.8
and the live observation in "Phases 3–4 as landed".

### 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.
- A dropdown section whose every entry is hidden by shard visibility **does not
  render at all**, rather than opening onto an empty menu.
- An added link cannot leave the origin: a `to` carrying a scheme, a
  protocol-relative `//host`, whitespace or quotes is refused on write and dropped
  on read. An added link never grants access — the page behind it still gates
  itself.
- Deleting a dropdown section returns its entries to the top level; it never
  removes a coded page or an admin's own link.
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
  a stored copy of the defaults.
