diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md
index c4e2143..676f71f 100644
--- a/website/BACKEND_DESIGN.md
+++ b/website/BACKEND_DESIGN.md
@@ -165,12 +165,15 @@ server/
email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly
- settings.router.js (3) /admin/settings — adminOnly. The
+ settings.router.js (4) /admin/settings — adminOnly. The
DELETE /:key is "reset to default"
and carries its own key allowlist
(theming/nav keys + the hero draft)
so it can never drop site_mode or
- the uo-link config
+ the uo-link config; POST
+ /brand-asset/:slot uploads a
+ logo/hero/favicon and writes the
+ brand_assets row in the same call
dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the
two singletons owning no path
@@ -893,8 +896,9 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
-| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}`. Enum-constrained keys are validated on the way in; `theme_visual` additionally has every value checked against the closed sets in `config/themePresets.js` (hex color, shortlisted font stack, bounded px radius, listed shadow) and is stored stringified. The read path drops bad fields anyway, so the `400` is about **feedback** — a save that appears to succeed and then does nothing is worse than a rejection |
+| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}`. Enum-constrained keys are validated on the way in; `theme_visual` additionally has every value checked against the closed sets in `config/themePresets.js` (hex color, shortlisted font stack, bounded px radius, listed shadow) and is stored stringified, and `brand_assets` has every slot checked against `utils/brandAssets.js` — a same-origin path under `/uploads/`, `/brand/` or `/assets/`, never an off-origin or protocol-relative URL, since these values are written straight into the page as an `` / `` / `og:image`. Cleared slots are dropped rather than stored as `null`. A write to `brand_assets` or `theme_visual` invalidates the cached HTML shell. The read path drops bad fields anyway, so the `400` is about **feedback** — a save that appears to succeed and then does nothing is worse than a rejection |
| DELETE | `/settings/:key` | reset one setting to its default by deleting the row. Allowlisted to the keys whose default lives outside the store (`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`, `hero_layout_draft`) — anything else is `400`. Idempotent: resetting a key that was never set succeeds |
+| POST | `/settings/brand-asset/:slot` | upload one brand asset (`logo` · `hero` · `favicon`) **and** point `brand_assets` at it, in one call → `{ url, brand_assets }`. One call rather than "upload, then PUT" so a half-completed save never leaves an unreferenced file in `/uploads`. Uses the shared `imageUpload.js` multer config — the mimetype allowlist is never widened, only tightened per slot: favicons are **PNG only** (§4.10 of [THEMING_AND_NAV.md](THEMING_AND_NAV.md)) and capped at 512 KB, logos at 1 MB, heroes at the shared 8 MB. A refused file is unlinked before the response. Merges into the existing overrides, so uploading a logo never clears a hero. `adminOnly` — tighter than the generic `POST /admin/uploads`, which editors may reach |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
@@ -910,6 +914,42 @@ file a route sits in — that is the property the route manifest freezes.
Every admin write logs to `activity_log`.
+### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)
+
+The SPA catch-all serves `client/dist/index.html` with this instance's branding templated into the
+`
` — title, meta description, Open Graph / Twitter tags, `` — so one prebuilt
+image serves per-instance metadata to a crawler that never runs the JavaScript.
+
+That used to be a single render at module load, from `BRAND_*` env only. It cannot be, now that the
+favicon and OG image can come from the admin's `brand_assets` row: the shell depends on state that
+changes while the process runs. `utils/htmlShell.js` owns the lifecycle, and three properties are
+deliberate:
+
+- **A cached string in the steady state.** The shell is rendered lazily on first request and reused;
+ a settings read per page view would put the database on the critical path of every SPA route,
+ including during an outage where the API is already degraded. Concurrent first requests share one
+ render.
+- **A DB fault never fails the page.** A failed read renders the env-only shell — exactly the
+ pre-feature behavior — and that result is cached like any other, so an outage does not become a
+ failing query per page view.
+- **Byte-identical with no rows.** An instance that has never been themed and has uploaded nothing
+ gets the same bytes it got before the feature existed. Locked by `test/htmlShell.test.js`, which
+ keeps a verbatim copy of the old renderer as its reference.
+
+Invalidation is explicit — the settings controller calls `htmlShell.invalidate()` after a successful
+write to `brand_assets` or `theme_visual` — with a **5-minute TTL as a safety net**, because the cache
+is per process: in a scaled deployment the worker that handled the write is the only one that learns
+of it, and without the TTL every other worker would serve the old favicon until the next restart.
+
+The shell also carries the resolved theme as a `` block, last
+in `` so it follows the built stylesheet and wins the equal-specificity tie. It exists only to
+stop a themed instance painting the shipped palette for one frame; `SiteContext` removes it once the
+`/public/settings` payload has arrived and applied — gated on a **successful** fetch, since dropping
+it after a failed one would strip a themed instance back to the shipped colors. Token names and
+values are re-checked against conservative patterns on the way into the block: everything there comes
+from a closed set already, and this keeps that a property of the HTML writer rather than of a
+validator three modules away.
+
---
## 5. Site mode (LIVE / MAINTENANCE)
diff --git a/website/THEMING_AND_NAV.md b/website/THEMING_AND_NAV.md
index 4993625..24e53f6 100644
--- a/website/THEMING_AND_NAV.md
+++ b/website/THEMING_AND_NAV.md
@@ -537,7 +537,7 @@ today until the admin acts.
| **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; `` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
+| **5 — Brand assets** ✅ | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `` 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 |
@@ -655,17 +655,107 @@ 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.
-**Still open, by decision:** the theme arrives with the `/public/settings` fetch,
-so a themed instance paints the shipped palette for one frame before repainting.
-Phase 5 has to rewrite `renderIndexHtml` into a cached, invalidated shell anyway
-(§4.3) — injecting a ``, injected last in
+`` 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 `` 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.
+
### 8.1 Admin builder UI notes
- Tabbed control for the three navs; drag-and-drop reorderable list.
diff --git a/website/api-route-inventory.json b/website/api-route-inventory.json
index f91067f..3e97f78 100644
--- a/website/api-route-inventory.json
+++ b/website/api-route-inventory.json
@@ -253,6 +253,10 @@
"method": "DELETE",
"path": "/api/v1/admin/settings/:key"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/settings/brand-asset/:slot"
+ },
{
"method": "POST",
"path": "/api/v1/admin/shard/account"