# 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. **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 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. **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. ### 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)`. ### 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.