Merge pull request 'docs(website): theming & nav phases 0-2 as built' (#104) from docs/theming-nav-phase-0-2 into edge

Reviewed-on: #104
This commit is contained in:
2026-08-07 23:25:06 +00:00
3 changed files with 143 additions and 6 deletions

View File

@@ -119,6 +119,17 @@ server/
vendors, chars, sales, houses
appeals.router.js (4) /player/appeals
shard.controller.js + appeals.controller.js
settings/ index.js owns the shared `noindex, requireAuth` gate
(authenticated, ANY role) and the mount table.
A fifth group, for site-wide settings that
need a login but no particular role — /public
is anonymous, /admin/settings is adminOnly
while AdminLayout renders for editors and
moderators, and /player is self-scoped data
nav.router.js (1) /settings/nav — the nav_admin and
nav_player overrides, read by the
layouts that render them
nav.controller.js
admin/ index.js mounts the capability routers below at their
own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and
@@ -151,7 +162,12 @@ server/
email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly
settings.router.js (2) /admin/settings — adminOnly
settings.router.js (3) /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
dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the
two singletons owning no path
@@ -248,6 +264,21 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
**Deliberately unseeded keys** — the theming & navigation overrides
(`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`). All
five are JSON strings, and **the absence of the row is the "use the default"
state**: colors/fonts/radii fall back to `theme.css`, assets to `BRAND_*`, navs
to the hardcoded `NAV` arrays. No migration writes defaults into them, because a
stored copy of a default would stop tracking the default. Resetting one is
therefore a `DELETE`, not a write — see `DELETABLE_KEYS` in `settings.model.js`
and [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §2.
Values are `TEXT`, so a JSON-valued key arrives as a **string** and every
consumer parses it. Server side that is `utils/settingsJson.js`
(`parseJsonSetting`), client side `parseLayout`/its callers; both treat a
malformed or wrong-shaped value as **absent** rather than as an error, so a
hand-edited row degrades to the default instead of rendering something broken.
### activity_log — append-only
| col | type | notes |
|---|---|---|
@@ -602,7 +633,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 215 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 225 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
@@ -802,6 +833,17 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
Public content GETs pass through the **siteMode** gate (§5).
### /settings (settings/index.js → §2) — behind `requireAuth` + `noindex`, no role gate
Site-wide settings that need a login but no particular role. It exists because the
other four groups each answer a different question: `/public` is anonymous,
`/admin/settings` is `adminOnly`, and `/player` is data scoped to `req.user.id`.
These rows are configuration that happens to need a login.
| Method | Path | Purpose |
|---|---|---|
| GET | `/settings/nav` | `{ nav_admin, nav_player }` — the stored nav overrides as raw JSON strings (or `null`), for the two authenticated layouts that render them. Deliberately not public: an anonymous visitor has no use for either, and the admin nav's labels describe the shape of the admin surface. Open to **any** role because `AdminLayout` renders for editors and moderators and `PlayerPortalLayout` for players, none of whom can read `GET /admin/settings`. Presentation-only — the role/feature filters in those layouts still decide what is shown, and an override can never un-hide a gated item (see [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §7) |
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
@@ -837,6 +879,7 @@ file a route sits in — that is the property the route manifest freezes.
| 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,...}` |
| 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 |
| 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) |

View File

@@ -479,6 +479,31 @@ function applyNavOverrides(baseNav, overrides) {
`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
@@ -486,9 +511,9 @@ 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 |
| **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 preset blocks, 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 |
@@ -498,7 +523,36 @@ today until the admin acts.
| **9 — Parchment (optional)** | Light-mode port per §4.8 — its own contrast pass across every component |
Phases 02 are one PR pair (website + docs), 34 a second, 5 a third, 68 a
fourth.
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 02 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)`.
### 8.1 Admin builder UI notes

View File

@@ -249,6 +249,10 @@
"method": "PUT",
"path": "/api/v1/admin/settings"
},
{
"method": "DELETE",
"path": "/api/v1/admin/settings/:key"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account"
@@ -293,6 +297,18 @@
"method": "GET",
"path": "/api/v1/admin/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/clilocs"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/clilocs/import"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/clilocs/path"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/houses"
@@ -817,10 +833,30 @@
"method": "GET",
"path": "/api/v1/public/shard/idoc"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market/meta"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market/vendors/:serial"
},
{
"method": "GET",
"path": "/api/v1/public/shard/online"
},
{
"method": "GET",
"path": "/api/v1/public/shard/points"
},
{
"method": "GET",
"path": "/api/v1/public/shard/points/:system"
},
{
"method": "GET",
"path": "/api/v1/public/shard/presence"
@@ -860,6 +896,10 @@
{
"method": "GET",
"path": "/api/v1/public/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/settings/nav"
}
],
"internal": [