From 97cb8be2d5c2c7eda48f24b76611ac26b9b434b8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 7 Aug 2026 20:10:07 -0500 Subject: [PATCH] =?UTF-8?q?docs(website):=20theming=20phase=205=20as=20bui?= =?UTF-8?q?lt=20=E2=80=94=20brand=20assets=20and=20the=20cached=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- website/BACKEND_DESIGN.md | 46 +++++++++++++- website/THEMING_AND_NAV.md | 102 +++++++++++++++++++++++++++++-- website/api-route-inventory.json | 4 ++ 3 files changed, 143 insertions(+), 9 deletions(-) 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"