docs(website): theming phase 5 as built — brand assets and the cached shell

Records Phase 5 of THEMING_AND_NAV.md as landed and documents the new route
and the shell lifecycle in BACKEND_DESIGN.md.

Where the build differed from the design: the upload is one admin-only call
that writes the settings row too (rather than the generic staff upload plus a
PUT, which would leave unreferenced files and let editors change the site's
identity); brand_assets needed a validator of its own because these are the
only settings values written straight into HTML as URLs; the shell cache
carries a TTL as well as explicit invalidation because it is per process; and
the logo went into all six MoonDot surfaces rather than three.

Also notes what was deliberately left alone: the shell's title and description
still come from BRAND_NAME rather than the admin-set site_title, and fixing
that would change the served shell for instances with no brand_assets row —
which is exactly what the phase's acceptance criterion forbids.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 20:10:07 -05:00
parent ce6f5b8788
commit 97cb8be2d5
3 changed files with 143 additions and 9 deletions

View File

@@ -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; `<img>` 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; `<img>` logo slot beside `MoonDot` in the shells; `heroImage` chain extension |
| **6 — Public nav wiring** | `SiteHeader.jsx``nav_public`. Lowest risk of the three: no roles, no groups |
| **7 — Nav builder UI** | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
| **8 — Admin + Player nav** | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
@@ -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 `<style>` block with the effective tokens there removes the
flash for free, so it is deferred rather than solved twice.
**Deferred to Phase 5, and done there:** the theme arrives with the
`/public/settings` fetch, so a themed instance painted the shipped palette for
one frame before repainting. Phase 5 had to rewrite `renderIndexHtml` into a
cached, invalidated shell anyway (§4.3), and injecting a `<style>` block with the
effective tokens there removed the flash for free rather than solving it twice.
**Also fixed in passing:** `settings/nav.controller.js` imported the logger
*factory* rather than calling it, so `log.error` was `undefined` and a DB fault
would have thrown a `TypeError` inside the catch — no response sent, request
left hanging — instead of returning a 500. Introduced in Phase 0.
### Phase 5 as landed
**The upload is one call, not two.** §8 said "upload endpoint on the existing
multer config", which reads as: reuse `POST /admin/uploads`, then `PUT` the
`brand_assets` row. Two problems with that. The generic upload is `staffOnly`
editors can reach it — while the row it would write is `adminOnly`, and the
site's identity is not the editor tier's to change. And a run that uploaded and
then failed (or was abandoned) would leave a file in `/uploads` that nothing
references.
So: **`POST /api/v1/admin/settings/brand-asset/:slot`**, `adminOnly`, using the
shared `imageUpload.js` multer config and returning `{ url, brand_assets }`. It
read-modify-writes the row, so uploading a logo never clears a hero (§6.3). The
per-slot rules only ever *tighten* the shared allowlist, never widen it (§9):
| Slot | Types | Cap |
|---|---|---|
| `logo` | the shared image allowlist | 1 MB |
| `hero` | the shared image allowlist | 8 MB (the shared ceiling) |
| `favicon` | **PNG only** (§4.10) | 512 KB |
The cap is enforced after multer has written the file and the file is unlinked
before the response, rather than by a second multer instance with its own limits.
One upload config and one allowlist is the property worth keeping; a briefly
written file that is deleted before the request returns is not.
**There is no per-slot delete route.** Clearing one asset is a `PUT` of the
remaining ones, and clearing the last one is the existing reset-by-delete —
`{}` is never stored, because absence of the row is what selects the env
defaults (§2) and a stored empty object would be a second way to say the same
thing.
**`brand_assets` needed a validator of its own, which the design did not
anticipate.** These are the only settings values written straight into HTML as
URLs the browser then fetches — an `<img src>`, a `<link rel="icon">`, an
`og:image`. `utils/brandAssets.js` accepts a same-origin path under `/uploads/`,
`/brand/` or `/assets/` and nothing else: no scheme, no protocol-relative
`//host` (which looks like a path and loads off-origin), no `..`, no whitespace
or quotes. Same asymmetry as the theme — strict on write with the field named,
forgiving on read so one hand-edited slot does not cost the admin the other two.
**The shell cache carries a TTL as well as explicit invalidation.** §4.3 asked
for a module-level cache invalidated on write, and that is what the settings
controller does. But the cache is *per process*: in a scaled deployment the
worker that handled the write is the only one that learns of it, and every other
would serve the old favicon until the next restart. A 5-minute TTL makes the rest
converge on their own while keeping the steady state at one render per process
per five minutes — not one per page view. Concurrent first requests share a
single render, an invalidation that lands mid-render is not overwritten by the
in-flight result, and a failed settings read renders the env-only shell and
caches *that*, so an outage is not a failing query per page view.
**Theme flash: fixed here, with a handoff.** The shell now also carries the
resolved tokens as `<style id="theme-boot">:root{…}</style>`, injected last in
`<head>` so it follows the built stylesheet and wins the equal-specificity tie.
`SiteContext` removes that block once the `/public/settings` payload has arrived
and been applied — otherwise a later reset would remove the inline properties
only to reveal the stale block underneath. The removal is gated on a
**successful** fetch, not merely a finished one: a failed request leaves the app
with no theme at all, and dropping the block then would strip a themed instance
back to the shipped palette for no reason.
**The logo went into all six MoonDot surfaces, not three.** §8 named the three
persistent shells (site header, admin sidebar, portal sidebar); the admin login,
the player login/register card and the maintenance page carry the same mark and
an operator who uploads a logo means their instance, not three of its pages.
`components/BrandLogo.jsx` renders **nothing** when `brand.logo` is empty — which
is the shipped default — so every one of those surfaces is unchanged on an
untouched instance. On the three centered layouts the logo is stacked *above* the
moon rather than beside it, because turning that block into a flex row would have
changed its height on instances with no logo.
The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11):
it is the project's badge, not the instance's.
**The hero chain needed no code.** §4.9's real order —
`hero_layout.background.image_url``brand_assets.hero``BRAND_HERO`
`/assets/img/runic-emblem.png` — already holds, because Phase 3 resolved
`brand_assets` into `getPublic().brand.hero` and `SiteContext.heroImage` reads
that. What was missing was saying so: the hero row in the admin panel now states
that a hero-editor background wins over the uploaded one, so "I uploaded a hero
and the portal ignored it" does not become a bug report against a working system.
**Observed and left alone:** the shell's `<title>` and description still come
from `BRAND_NAME`/`BRAND_DESCRIPTION`, not from the admin-set `site_title` that
`getPublic().brand.name` prefers, so an instance that renamed itself through the
admin panel still has the env name in its tab and its link previews. Fixing it
would change the served shell for instances with no `brand_assets` row, which is
exactly what §9 says must not change in this phase. It wants its own change.
### 8.1 Admin builder UI notes
- Tabbed control for the three navs; drag-and-drop reorderable list.