Files
docs/website/THEMING_AND_NAV.md
wtclaude 252733644b docs(website): theming & nav phases 0-2 as built
Matches RunicGateway/website's phases 0-2 of THEMING_AND_NAV.md.

BACKEND_DESIGN.md:
- The new /settings router group and its one route, plus why it is a fifth
  group rather than a route on an existing one.
- DELETE /admin/settings/:key in the admin route table, with the allowlist and
  why reset deletes instead of writing.
- The five unseeded theming/nav keys under the settings schema: absence of the
  row is the "use the default" state, values are TEXT so consumers parse, and
  malformed reads as absent.
- Route count 215 -> 225.

THEMING_AND_NAV.md:
- Phases 0-2 marked landed, with an "as landed" section recording the three
  things the design left open: where /settings/nav lives, where
  parseJsonSetting lives, and the exact 23-declaration radius promotion.
- The nav merge util's ordering rules, settled by the implementation: an
  untouched item keeps its index as its sort key, an explicit order wins a tie
  against a coincidental index, equal explicit orders keep code order, and
  `group` is honored only when it names an existing section.
- All four PR pairs target `edge`; the feature reaches `main` as one merge.

api-route-inventory.json: resynced from server/routes.manifest.json. Picks up
the two new routes plus eight that were already missing from the mirror since
the Protocol 3.0 cutover (shard clilocs, market, points).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 18:15:50 -05:00

31 KiB
Raw Blame History

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 is the existing single source of instance identity, read once at startup from env with baked-in Runic Gateway defaults. The app ships as one prebuilt image and each instance re-skins itself via env. This feature must not disturb that.

Every new setting is an override layer, never a replacement:

  • An instance where the admin has not touched these settings renders identically to today, driven entirely by BRAND_* and the current theme.css :root.
  • Saving one setting makes that setting — and only that setting — take precedence. Untouched settings keep following env.
  • This holds per field, not per feature. A custom accent with untouched fonts means the accent comes from the DB and the fonts still come from --serif/--display/--sans as theme.css defines them.
  • "Admin-set" means a DB row exists for that key. Absence of the row — not an empty or false value — is what triggers the env/CSS fallback. An admin who explicitly picks a preset that happens to equal the shipped default has still set it, and it is stored and honored as explicit.
  • No migration writes defaults into the settings table. New and existing installs both start with zero rows for these keys; that absence is the "use env default" state.

3. Locked decisions

# Decision
Brand contract getPublic().brand returns effective values (override → env). The Android app and Discord embeds track admin theming for free — see §4.5
Structural tokens Radius + shadow depth only. spacingUnit and borderWeight are cut, not deferred — see §4.6
Radius token values Seeded at today's real values (four tokens, not three), so the promotion step is a true no-op — see §4.7
Presets in v1 Three dark presets — Runic Gateway, Modern, Fantasy. Parchment (light) is Phase 9 — see §4.8
Fonts Curated shortlist, dropdown-only, 4 options per role, 8 web families in one css2? request — see §5
Raw custom CSS Out of scope entirely — not deferred. Materially different risk profile (overlay/clickjacking tricks, tracking pixels via background: url(...)); would need its own feature and its own review
Live preview Out of scope for v1
Reduced-motion toggle Out of scope for v1
Nav override power label, order, hidden, and (admin nav only) group. Never to, roles, or feature — see §7
Reset to defaults Deletes the settings row. Never writes a stored copy of the defaults
Favicon uploads PNG only. No .ico — see §4.10

4. Corrections to the design doc (current-code reality)

The design doc is structurally sound; the token architecture, the override-on-top-of-env principle, the nav-override security framing, and the reuse of imageUpload.js all match reality. These are the points where it does not, listed worst-first. §4.14.5 are blocking; §4.64.11 are scope corrections.

4.1 There is no way to delete a setting

The entire "Reset to defaults deletes the row" principle — which all five new keys rely on, and which the doc lists as an acceptance criterion — has no implementation. settings.db.js exposes get / getAll / set / seedDefault only, and the admin API is PUT /admin/settings taking a key/value object (admin.controller.js:499).

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), 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 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) and JSON-valued keys are stored JSON.stringify'd and parsed client-side — see parseLayout in heroLayout.js:58. 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 locks its field list, and the Android app's BrandDto seeds the entire Material theme from brand.accent (MainActivity.kt:72RunicGatewayTheme), 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):

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) 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:

: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). 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 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 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 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. 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:

<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap" rel="stylesheet" />

Static, in index.html, alongside the existing preconnect hints — a Google Fonts URL is never built from admin input at runtime.

Weight coverage gotcha: IM Fell English ships 400 and italic only — no bold. .display and .h1 use font-weight: 600, and .btn / .eyebrow / .badge use 600700, so choosing it yields browser-synthesized faux-bold. That is acceptable for the display role (it is the authentic look) but is a reason not to present it as a recommended body face.

6. Storage

Five new keys. theme_visual, brand_assets and nav_public join PUBLIC_KEYS; nav_admin and nav_player are served by the authenticated endpoint from §4.2. All are JSON strings, absent by default.

6.1 theme_visual

{ "preset": "runic-gateway", "custom": null }

or, when the admin picks Custom:

{
  "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.

[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

{ "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:

{
  "/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 and AdminLayout.jsx:155-164 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:

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; <img> logo slot beside MoonDot in the three shells; heroImage chain extension
6 — Public nav wiring SiteHeader.jsxnav_public. Lowest risk of the three: no roles, no groups
7 — Nav builder UI NavEditor.jsx with @dnd-kit (new dependency), Public tab only
8 — Admin + Player nav Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use
9 — Parchment (optional) Light-mode port per §4.8 — its own contrast pass across every component

Phases 02 are one PR pair (website + docs), 34 a second, 5 a third, 68 a fourth. All four PR pairs target edge, not main — the feature reaches main as one edgemain merge once every phase is in, so no release ever carries a half-wired theme engine.

Phases 02 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.