feat(theming): admin-configurable theme, brand assets and navigation (edge → main) #126

Merged
whitlocktech merged 11 commits from edge into main 2026-08-08 06:19:37 +00:00
Member

The cutover

Every phase of docs/website/THEMING_AND_NAV.md is in. This merges edge into main as one release, so no release ever carried a half-wired theme engine. Docs pair: RunicGateway/docs#109.

Five PRs, ten phases:

#121 0–2 settings-store groundwork, the navOverrides.js merge util, radius tokens
#122 3–4 server-resolved theme engine + /admin/appearance
#123 5 brand assets (logo/hero/favicon) + a cached, settings-aware HTML shell
#124 6–8 all three navs wired + the /admin/navigation builder
#125 10 public dropdown sections + admin-authored links

Phase 9 (hue-carrying rgba() literals + a Parchment light theme) was cancelled, not deferred; the finding that motivated it is kept in the doc for whoever picks it up.

What an operator gets

Colors, fonts and corner radius from three presets or per-field overrides; uploaded logo, hero and favicon; and a navigation builder that renames, reorders and hides entries in the public header, the admin sidebar and the player portal — plus dropdown sections in the public header with links of the admin's own.

The invariant this was built around

An instance where no admin touches any of it renders byte-for-byte as it does today. "Configured" means a settings row exists, not that a value is non-empty, so a reset deletes the row rather than storing a copy of the defaults — otherwise the stored copy stops tracking the default. No migration seeds anything.

The other property worth stating: the nav override layer is presentation, never authorization. The merge runs before the role and shard-feature filters in SiteHeader.jsx / AdminLayout.jsx, which are unchanged and remain the boundary. Verified live, not only in tests — a hand-written hidden: false on a feature-gated item and on a role-gated one showed nobody anything.

Reach beyond this repo

getPublic().brand carries effective values, so the Android app and the Discord bot track admin theming with no change of their own — the bot fetches brand.accent behind a 10-minute TTL with BRAND_ACCENT_COLOR as the floor.

State at cutover

  • Server: 792 tests pass. Client: 111 pass. Client build clean.
  • Swagger regenerated; routes.manifest.json gained exactly one route across the whole feature (POST /admin/settings/brand-asset/:slot), and docs/website/api-route-inventory.json mirrors it.
  • Smoke-tested end to end on a local stack at every phase, each time finishing from zero settings rows with the site rendering as it does today.

Things deliberately left

  • The shell's <title> and meta description still come from BRAND_NAME/BRAND_DESCRIPTION, not the admin-set site_title that brand.name prefers — so an instance renamed through the admin panel still shows the env name in its browser tab and link previews. Fixing it changes the served shell for instances with no brand_assets row, which the byte-identical criterion forbade. It wants its own change.
  • .btn has the same line-height gap #125 fixed on .pill; its anchor and button forms never share a row, so nothing is visibly wrong and 80 buttons was not a blast radius worth taking here.
  • One flake, seen once across the whole feature: test/mobileSession.test.js failed under heavy full-suite load and passed on rerun. Timing-sensitive, untouched by any of this.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## The cutover Every phase of [`docs/website/THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/THEMING_AND_NAV.md) is in. This merges `edge` into `main` as one release, so no release ever carried a half-wired theme engine. Docs pair: RunicGateway/docs#109. Five PRs, ten phases: | | | |---|---| | #121 | **0–2** settings-store groundwork, the `navOverrides.js` merge util, radius tokens | | #122 | **3–4** server-resolved theme engine + `/admin/appearance` | | #123 | **5** brand assets (logo/hero/favicon) + a cached, settings-aware HTML shell | | #124 | **6–8** all three navs wired + the `/admin/navigation` builder | | #125 | **10** public dropdown sections + admin-authored links | Phase **9** (hue-carrying `rgba()` literals + a Parchment light theme) was **cancelled**, not deferred; the finding that motivated it is kept in the doc for whoever picks it up. ## What an operator gets Colors, fonts and corner radius from three presets or per-field overrides; uploaded logo, hero and favicon; and a navigation builder that renames, reorders and hides entries in the public header, the admin sidebar and the player portal — plus dropdown sections in the public header with links of the admin's own. ## The invariant this was built around **An instance where no admin touches any of it renders byte-for-byte as it does today.** "Configured" means *a settings row exists*, not that a value is non-empty, so a reset **deletes** the row rather than storing a copy of the defaults — otherwise the stored copy stops tracking the default. No migration seeds anything. The other property worth stating: **the nav override layer is presentation, never authorization.** The merge runs *before* the role and shard-feature filters in `SiteHeader.jsx` / `AdminLayout.jsx`, which are unchanged and remain the boundary. Verified live, not only in tests — a hand-written `hidden: false` on a feature-gated item and on a role-gated one showed nobody anything. ## Reach beyond this repo `getPublic().brand` carries **effective** values, so the Android app and the Discord bot track admin theming with no change of their own — the bot fetches `brand.accent` behind a 10-minute TTL with `BRAND_ACCENT_COLOR` as the floor. ## State at cutover - **Server: 792 tests pass.** **Client: 111 pass.** Client build clean. - Swagger regenerated; `routes.manifest.json` gained exactly one route across the whole feature (`POST /admin/settings/brand-asset/:slot`), and `docs/website/api-route-inventory.json` mirrors it. - Smoke-tested end to end on a local stack at every phase, each time finishing from **zero settings rows** with the site rendering as it does today. ## Things deliberately left - **The shell's `<title>` and meta description still come from `BRAND_NAME`/`BRAND_DESCRIPTION`**, not the admin-set `site_title` that `brand.name` prefers — so an instance renamed through the admin panel still shows the env name in its browser tab and link previews. Fixing it changes the served shell for instances with no `brand_assets` row, which the byte-identical criterion forbade. It wants its own change. - `.btn` has the same `line-height` gap #125 fixed on `.pill`; its anchor and button forms never share a row, so nothing is visibly wrong and 80 buttons was not a blast radius worth taking here. - **One flake, seen once across the whole feature:** `test/mobileSession.test.js` failed under heavy full-suite load and passed on rerun. Timing-sensitive, untouched by any of this. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 11 commits 2026-08-08 06:08:20 +00:00
Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI,
no consumer wiring, and an instance that never touches the new settings keys
renders exactly as it does today.

Phase 0 - settings store:
- settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to
  default" primitive. Defaults for these keys live in BRAND_* env, theme.css
  and the hardcoded NAV arrays, so reset has to delete the row rather than
  store a copy of the default. Allowlisted to the five theming/nav keys plus
  hero_layout_draft, admin-only, idempotent.
- GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout
  renders for editors and moderators and PlayerPortalLayout for players, and
  none of them can read GET /admin/settings, so without this their nav
  override would silently never apply.
- A fifth router group for it: /public is anonymous, /admin/settings is
  adminOnly, /player is self-scoped data. This is configuration that needs a
  login.
- parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so
  every JSON key arrives as a string; malformed or wrong-shaped reads as
  absent, never as an error and never half-applied.
- theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and
  nav_player deliberately do not.

Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation
only: it can set label/order/hidden and (grouped navs) group, and nothing
else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false
cannot un-hide anything - the existing filters run afterward, unchanged, and
remain the boundary.

Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at
today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome
and the two 50% circles stay literal. --shadow-card and --panel-grad were
already tokens.

Tests: 16 new server tests, 20 new client tests. The route-manifest guard now
also asserts /settings/** sits behind requireAuth. Swagger and both route
artifacts regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #121
Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font
shortlist, and /admin/appearance to drive them.

The design put the presets in theme.css as [data-theme] blocks. That does not
work: SiteContext writes --accent as an inline style on <html>, which beats any
attribute-selector block, so a preset's accent would have been painted over by
BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app
themes itself from -- reported the other one.

Presets now live in server/src/config/themePresets.js. themeResolve.js layers
:root <- preset <- custom per field into a token map, getPublic() returns it as
`theme`, and the client writes it onto <html>. One authority for the merge, and
brand.accent is by construction the accent the site paints. theme.css's :root is
untouched, so an instance with no row gets no theme block and renders as today.

Also: presets carry the full 15-token palette (eight would have left Fantasy
with blue-grey borders); the option catalog is served from
GET /settings/theme/options so the form cannot offer what the server rejects;
validation is strict on write and forgiving on read; and the Discord bot now
fetches the effective accent instead of its boot-time env copy.

Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger
factory rather than calling it, so a DB fault would have thrown a TypeError
inside the catch instead of returning 500.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #122
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon
overrides on top of the BRAND_* env defaults, delivered through an HTML
shell that is no longer built once at boot.

- utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per
  process, invalidated on a brand_assets/theme_visual write with a 5-minute
  TTL so other workers converge. A settings-read failure renders the
  env-only shell and caches that, so a DB outage is not a failing query per
  page view, and with no rows the output is byte-identical to what app.js
  served before.
- POST /admin/settings/brand-asset/:slot uploads one asset and writes the
  row in the same call, so an upload never leaves an unreferenced file. It
  reuses the shared multer allowlist and only tightens it per slot: favicons
  are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused
  files are unlinked before the response.
- utils/brandAssets.js constrains a stored asset to a same-origin path under
  /uploads, /brand or /assets — these are the only settings values written
  straight into the page as a URL. Strict on write, forgiving on read.
- The shell also carries the resolved theme as a <style id="theme-boot">
  block, removing the first-paint flash phases 3-4 deferred; SiteContext
  drops that block once a successful settings fetch has been applied.
- BrandLogo renders beside the MoonDot on all six shells and renders nothing
  when no logo is set, which is the shipped default.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #123
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin
sidebar and the player portal now read their override row, and /admin/navigation
writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a
row into another existing section.

The merge always runs BEFORE the role and shard-feature filters in the layouts,
which are unchanged and remain the boundary. An override is presentation: it
cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored
`hidden: false` on a gated item shows nobody anything.

The design scoped these phases as client work, but the server had no way to
store a nav row: updateSettings validates and stringifies theme_visual and
brand_assets and lets everything else through, so a nav object would have been
written as "[object Object]" and read as absent for ever. utils/navOverrides.js
mirrors utils/brandAssets.js — strict on write with the offending key named,
forgiving on read. It validates shape only; whether a `to` exists is settled
client-side at merge time, because the base NAV arrays are client constants and
a server-side copy would be a second source of truth that drifts.

The nav editor cannot be hidden — its own toggle is disabled, the write path
drops `hidden` on that one `to`, and AdminLayout strips it again before merging,
which also covers a row edited straight in the database.

Orders are written only when the sequence actually differs from the code's, and
the comparison is restricted to the rows the editing admin can see, so renaming
one item does not pin the position of every other one and a role- or
feature-gated item missing from their palette is not mistaken for a reorder.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #124
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.

This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.

The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.

nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.

The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.

The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.

pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.

Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.

Co-Authored-By: Claude <noreply@anthropic.com>
The public header's dropdown trigger is a <button class="pill"> sitting in a row
of <a class="pill"> links, and it rendered ~7px shorter.

It was not failing to pick up the theme: font-size, font-family, padding, border
and box-sizing all matched exactly. The one property that differed was
line-height, because form controls do not inherit it — the UA stylesheet gives
<button> `line-height: normal` (~1.15), while the anchors inherited body's 1.6.
38.02px against 31px, which is precisely 22.016 - 15.8.

Stating it on .pill fixes it at the source rather than patching the one button:
every other property in that rule is already explicit for the same reason, and
this was the remaining gap. The value matches body's 1.6, so no link pill
changes. The ~70 <button class="pill"> elsewhere in the admin gain the same 7px
and now line up with the .btn buttons they sit beside.

.btn has the same latent difference and is deliberately left alone: it is used on
80 buttons and 2 anchors, they never appear on the same row, so nothing is
visibly wrong and the blast radius is not worth it.

Co-Authored-By: Claude <noreply@anthropic.com>
Merge pull request 'feat(theming): dropdown sections and added links in the public header (phase 10)' (#125) from feat/theming-nav-phase-10 into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / client-build (pull_request) Successful in 38s
PR Checks / server-tests (pull_request) Successful in 10m17s
18815f4c7a
Reviewed-on: #125
whitlocktech approved these changes 2026-08-08 06:10:38 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-08-08 06:10:45 +00:00
whitlocktech merged commit 265042eaa5 into main 2026-08-08 06:19:37 +00:00
whitlocktech deleted branch edge 2026-08-08 06:19:38 +00:00
Sign in to join this conversation.
No description provided.