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.