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>
796 lines
44 KiB
Markdown
796 lines
44 KiB
Markdown
# 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`](../../website/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 |
|
||
| Theme delivery | **The server resolves the whole effective token set** and the client writes it as CSS custom properties. No `[data-theme]` blocks — see §6.2 |
|
||
| 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.1–4.5 are blocking; §4.6–4.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`](../../website/server/src/model/settings/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`](../../website/server/src/router/v1/admin/admin.controller.js)).
|
||
|
||
**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`](../../website/server/src/router/v1/admin/settings.router.js)),
|
||
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`](../../website/server/src/app.js) 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`](../../website/server/db/schema.sql)) and JSON-valued keys are
|
||
stored `JSON.stringify`'d and parsed client-side — see `parseLayout` in
|
||
[`heroLayout.js:58`](../../website/client/src/lib/heroLayout.js). 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`](../../website/server/test/publicBrand.test.js)
|
||
locks its field list, and the Android app's `BrandDto` seeds the entire Material
|
||
theme from `brand.accent` (`MainActivity.kt:72` → `RunicGatewayTheme`), 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`](../../website/server/src/model/settings/settings.model.js)):
|
||
|
||
```js
|
||
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`](../../website/client/src/contexts/SiteContext.jsx))
|
||
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.~~ **Corrected in Phase 3 — this fix as written was a no-op.**
|
||
`getPublic().brand` never exposes `accentInt` (`publicBrand.test.js` asserts
|
||
it is `undefined`, deliberately: it is a Discord-only integer form), and the
|
||
server-side `brand.accentInt` has no consumer at all. Discord embeds are
|
||
colored by **`bot/src/brand.js`, in a separate process**, reading
|
||
`BRAND_ACCENT_COLOR` from env at boot — so there was nothing per-request to
|
||
recompute, and the drift the note describes was real but unfixable from the
|
||
server. What Phase 3 actually did: the bot now fetches
|
||
`GET /public/settings` → `brand.accent` (it already has a public-API client)
|
||
behind a 10-minute cached getter, keeping env as the fallback. See
|
||
"Phases 3–4 as landed" below.
|
||
- `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:
|
||
|
||
```css
|
||
: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`](../../website/client/src/lib/heroLayout.js)). 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`](../../website/server/src/router/v1/admin/imageUpload.js)
|
||
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`](../../website/server/src/config/csp.js)
|
||
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`](../../website/client/src/components/SiteFooter.jsx) 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`](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:
|
||
|
||
```html
|
||
<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 600–700, 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`
|
||
|
||
```json
|
||
{ "preset": "runic-gateway", "custom": null }
|
||
```
|
||
|
||
or, when the admin picks Custom:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
> **Superseded in Phase 3.** The presets below are correct as *values* and were
|
||
> built as specified, but they do **not** live in `theme.css` as `[data-theme]`
|
||
> blocks. They live in `server/src/config/themePresets.js`, and the server
|
||
> resolves the effective token set into `getPublic().theme` for the client to
|
||
> write onto `<html>`. See "Phases 3–4 as landed" for why, and note two
|
||
> corrections the build made to the palettes: each preset carries the **full**
|
||
> color set (fifteen tokens, not the eight below), and `--shadow-card` is
|
||
> themed alongside the radii.
|
||
|
||
`:root` 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.
|
||
|
||
```css
|
||
[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`
|
||
|
||
```json
|
||
{ "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`:
|
||
|
||
```json
|
||
{
|
||
"/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`](../../website/client/src/components/SiteHeader.jsx) and
|
||
[`AdminLayout.jsx:155-164`](../../website/client/src/routes/admin/AdminLayout.jsx)
|
||
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`:
|
||
|
||
```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 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 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 |
|
||
| **9 — Palette-following literals + Parchment (optional)** | Promote the hue-carrying `rgba()` literals of §4.8 so they follow the palette (the **dark** presets need this too — see "Phases 3–4 as landed"), then the light-mode port with its own contrast pass across every component |
|
||
|
||
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
|
||
fourth. **All four PR pairs target `edge`, not `main`** — the feature reaches
|
||
`main` as one `edge` → `main` merge once every phase is in, so no release ever
|
||
carries a half-wired theme engine.
|
||
|
||
### Phases 0–2 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)`.
|
||
|
||
### Phases 3–4 as landed
|
||
|
||
Four things the design settled differently once it met the code.
|
||
|
||
**1. The server resolves the whole token set; there are no `[data-theme]`
|
||
blocks.** §6.2 put the presets in `theme.css` and had the client set a
|
||
`data-theme` attribute. That does not work as written: `SiteContext` writes
|
||
`--accent` as an **inline style on `<html>`** (`SiteContext.jsx:31`), and an
|
||
inline property beats any attribute-selector block. An admin who picked Fantasy
|
||
without also setting a custom accent would have had Fantasy's `#c9973f` painted
|
||
over by `brand.accent` from env — and §4.5's whole point is that
|
||
`getPublic().brand.accent` is what the phone app themes itself from, so the two
|
||
surfaces would have disagreed about the accent while both being "right".
|
||
|
||
The fix removes the conflict rather than sequencing around it. Presets live in
|
||
`server/src/config/themePresets.js`; `server/src/utils/themeResolve.js` layers
|
||
`:root` ← preset ← custom **per field** into a token map; `getPublic()` returns
|
||
it as `theme`; `client/src/lib/themeVars.js` writes it onto `<html>`. One
|
||
authority for the merge, `brand.accent` is by construction the accent the site
|
||
actually paints, and `theme.css`'s `:root` is untouched — an instance with no
|
||
row gets no `theme` block, the client writes nothing, and the page renders
|
||
byte-for-byte as today.
|
||
|
||
The client half's real logic is *removal*: inline properties are not cleared by
|
||
writing a smaller object over them, so `applyThemeTokens` tracks what it set
|
||
last time and `removeProperty`s whatever the new payload no longer mentions.
|
||
Without that, "Reset to defaults" would look broken until a reload.
|
||
|
||
**2. Presets carry the full fifteen-token palette, and theme `--shadow-card`.**
|
||
§6.2's blocks set eight colors. Applied literally, Fantasy's warm brown page
|
||
would have kept `--line: #2a3544` and `--blue: #13243c` — dark blue-grey borders
|
||
and a blue-grey active nav row — because those tokens are not in the list.
|
||
Every preset now sets `--panel-flat`, `--line`, `--line-soft`, `--head`,
|
||
`--muted`, `--dim` and `--blue` as well. The admin *form* still exposes only
|
||
§6.1's eight; the rest are supporting shades a preset gets right coherently but
|
||
that are not worth hand-picking. `--mode-live` / `--mode-maint` stay fixed
|
||
across every preset (green means live) and `--panel-grad` stays derived, both
|
||
locked by tests.
|
||
|
||
**3. The option catalog is served, not duplicated.**
|
||
`GET /api/v1/settings/theme/options` returns the presets (with their full token
|
||
maps, so a control can show what an unset field currently resolves to), the font
|
||
shortlist, the shadow depths, and the editable field names paired with the CSS
|
||
variable each drives. Duplicating those lists in client code would mean the form
|
||
could offer a font the server rejects, which surfaces as a save 400ing for no
|
||
visible reason. A test asserts every offered option validates.
|
||
|
||
Validation is deliberately asymmetric: **strict on write** (`PUT
|
||
/admin/settings` 400s and names the offending field) and **forgiving on read**
|
||
(a bad field is dropped, its neighbours keep applying). Strict-on-write gives
|
||
feedback; forgiving-on-read means a row hand-edited in the DB degrades to the
|
||
shipped default instead of rendering a broken site.
|
||
|
||
One addition to §5.1's twelve font options: **Georgia in the serif list.** The
|
||
shortlist gave the sans role a "today's default" option (Arial, byte-identical
|
||
to `--sans`) but left serif with no way back to `Georgia, "Times New Roman",
|
||
serif` short of resetting the whole theme. It pulls in no web family, so §5.2's
|
||
combined URL is unchanged.
|
||
|
||
**4. The Discord bot fetches the accent; §4.5's `accentInt` note was a no-op.**
|
||
See the correction in §4.5. `bot/src/brand.js` now reads
|
||
`GET /public/settings` → `brand.accent` through the public-API client it already
|
||
had, behind getters with a 10-minute TTL — so `brand.accentInt` stays a plain
|
||
property read at every existing call site, an embed never awaits a network call,
|
||
and any failure (site down, maintenance, malformed body) keeps the last known
|
||
good value with `BRAND_ACCENT_COLOR` as the floor.
|
||
|
||
**Found while smoke-testing: §4.8's rgba literals are not only a light-mode
|
||
problem.** The 28 dark-assuming `rgba()` literals were scoped to Phase 9 on the
|
||
reasoning that they break a *light* preset. Applying **Fantasy** on a live
|
||
instance shows they also carry a **hue**: `.btn-ghost`'s
|
||
`background: rgba(11, 22, 48, 0.45)` (essentially `--blue` at 45%) leaves the
|
||
portal's quick-link buttons reading blue on a warm brown page, and the hero
|
||
overlay stack in `heroLayout.js` is `rgba(11,15,20,…)` regardless of preset.
|
||
Nothing is broken or unreadable — it is a visible seam, not a bug — but Phase 9
|
||
should be re-scoped from "light-mode port" to "make the hue-carrying literals
|
||
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.
|
||
|
||
**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.
|
||
- **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.
|