feat(theming): nav wiring and the admin nav builder (phases 6-8) #124

Merged
whitlocktech merged 1 commits from feat/theming-nav-phase-6-8 into edge 2026-08-08 05:11:35 +00:00
Member

What & why

Phases 6–8 of docs/website/THEMING_AND_NAV.md, the nav half of the feature. Targets edge; docs pair: RunicGateway/docs#107.

Phase 1 landed navOverrides.js and phase 0 landed the settings keys and GET /settings/nav, but nothing consumed any of it and there was no UI that could write a row. This closes the loop: the public header, the admin sidebar and the player portal all read their override, and /admin/navigation writes them — rename, reorder by drag, hide, and on the admin sidebar move a row into another existing section.

An instance where no admin opens the screen renders exactly as before: with no row applyNavOverrides returns the base array itself.

The merge is presentation; the filters are still the boundary

The override merge runs before the role and shard-feature filters in SiteHeader.jsx and AdminLayout.jsx, which are unchanged. Verified live rather than only in tests, because this is the criterion §9 names:

  • champs disabled in Shard Visibility + a hand-written {"/site/champs":{"hidden":false,"label":"SHOULD NOT SHOW"}}Champions absent from the header, label nowhere on the page.
  • A hand-written {"/admin/users":{"hidden":false,"label":"LEAKED USERS","order":0}} viewed as a moderator → sidebar is still exactly MOD_PATHS, no LEAKED USERS, and no orphaned "System" header from the emptied group.

The gap the design did not see: the server could not store a nav row

§8 scopes 6–8 as client work, and for the merge that is right. But updateSettings validates and stringifies theme_visual and brand_assets and lets everything else fall through to settingsDb.set — so a nav_public object would have been stored as the string "[object Object]", which parseJsonSetting reads as absent. The save returns 200 and does nothing, for ever.

server/src/utils/navOverrides.js mirrors utils/brandAssets.js: validateNavOverrides strict on write and naming the offending key, resolveNavOverrides forgiving and dropping fields that would do nothing.

It deliberately does not check that a to exists. The base NAV arrays are client constants; shipping a copy to the server would create a second source of truth for navigation that drifts the first time a route is added, and buys nothing — applyNavOverrides already drops an unknown to at merge time, which is what makes deleting a route in code safe with no migration. So: the server validates shape, the client owns membership. A field that is not one of the four (label/order/hidden/group) is a 400, so a roles or to in the body is refused rather than quietly stored.

hidden: false is accepted and never stored — the editor sends it mid-edit, so rejecting it would be hostile, but storing it would leave a row that reads like an instruction to force something visible, which this layer must never be able to express.

The nav editor cannot be hidden

An admin who hid /admin/navigation would lose the only screen that can un-hide it. Three guards: the row's eye toggle is disabled with a note saying why; resolveNavOverrides drops hidden on that to for nav_admin; and AdminLayout strips it again before merging, which is the one that also covers a row edited straight in the database (confirmed live — the row said hidden: true and the sidebar entry stayed).

Orders are written only when something moved

§7.1 says the editor writes an order for every item "the way drag-and-drop does", and it does — but only for a nav whose sequence actually differs from the code's. An admin who renames one item stores one field ({"/player":{"label":"My Heroes"}}, live), so a route added to NAV later still lands where the code puts it. The comparison is against the base restricted to the rows that admin can see, or filtering the palette by role/feature would look like a reorder and pin everything — a bug this had, caught by the test for it.

An override for an item outside the editing admin's palette is carried through their save rather than silently reset.

How it was tested

  • cd server && DB_HOST=127.0.0.1 DB_PORT=59999 npm test782 pass, 0 fail (760 before): 19 new in navOverrides.test.js, 3 in settingsTheming.test.js (a nav row is stored stringified and not "[object Object]"; an invalid one is rejected with nothing written; the write path drops hidden on the editor and never stores hidden: false).
  • cd client && node --test94 pass, 0 fail (75 before), incl. the editor round trip: an untouched editor saves nothing, a rename writes no orders, reordering writes one per row, save→reload→save is stable, a group move round-trips and clears when moved back.
  • npm run build clean. npm run swagger regenerated — a one-line diff on the PUT description. npm run routes:manifest regenerated: no new routes, zero diff, so docs/website/api-route-inventory.json is unchanged too.
  • Live smoke against the local MariaDB, from zero settings rows: baseline header identical to today → rename + hide + drag on the public tab → header matched the editor exactly, Market gone → the two un-hide attempts above → admin tab group move (Houses → Content), saved and the sidebar updated live with no reload → player tab rename + hide, verified as a player (My Heroes, no Appeals) → reset each nav → zero rows, code order back everywhere.

Notes

  • New dependency: @dnd-kit/core + /sortable + /utilities (~13 kB gz), as §8 specified. Pointer, keyboard and touch sensors with screen-reader announcements is the part worth not hand-rolling. Dragging reorders within a section only; moving between sections is the dropdown, since that is what an override can express.
  • The section dropdown offers "(no section)" only to rows coded into an untitled group. For anything else it would be a move the override cannot store (§6.4), so it would silently do nothing.
  • useNavOverrides keeps one module-level copy of the two authenticated rows — that is what lets a save update the sidebar the admin is looking at, and stops the second layout to mount from flashing the coded nav.
  • AppearanceAdmin's two inline try { JSON.parse } blocks now use the new client/src/lib/settingsJson.js, the client counterpart §4.4 promised "with its first consumer".

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.
## What & why Phases 6–8 of [`docs/website/THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/THEMING_AND_NAV.md), the nav half of the feature. Targets **`edge`**; docs pair: RunicGateway/docs#107. Phase 1 landed `navOverrides.js` and phase 0 landed the settings keys and `GET /settings/nav`, but nothing consumed any of it and there was no UI that could write a row. This closes the loop: the public header, the admin sidebar and the player portal all read their override, and **`/admin/navigation`** writes them — rename, reorder by drag, hide, and on the admin sidebar move a row into another existing section. An instance where no admin opens the screen renders **exactly** as before: with no row `applyNavOverrides` returns the base array itself. ### The merge is presentation; the filters are still the boundary The override merge runs **before** the role and shard-feature filters in `SiteHeader.jsx` and `AdminLayout.jsx`, which are unchanged. Verified live rather than only in tests, because this is the criterion §9 names: - `champs` disabled in Shard Visibility + a hand-written `{"/site/champs":{"hidden":false,"label":"SHOULD NOT SHOW"}}` → **Champions absent from the header**, label nowhere on the page. - A hand-written `{"/admin/users":{"hidden":false,"label":"LEAKED USERS","order":0}}` viewed as a **moderator** → sidebar is still exactly `MOD_PATHS`, no `LEAKED USERS`, and no orphaned "System" header from the emptied group. ### The gap the design did not see: the server could not store a nav row §8 scopes 6–8 as client work, and for the merge that is right. But `updateSettings` validates and stringifies `theme_visual` and `brand_assets` and lets everything else fall through to `settingsDb.set` — so a `nav_public` **object** would have been stored as the string `"[object Object]"`, which `parseJsonSetting` reads as absent. The save returns 200 and does nothing, for ever. `server/src/utils/navOverrides.js` mirrors `utils/brandAssets.js`: `validateNavOverrides` strict on write and naming the offending key, `resolveNavOverrides` forgiving and dropping fields that would do nothing. **It deliberately does not check that a `to` exists.** The base `NAV` arrays are client constants; shipping a copy to the server would create a second source of truth for navigation that drifts the first time a route is added, and buys nothing — `applyNavOverrides` already drops an unknown `to` at merge time, which is what makes deleting a route in code safe with no migration. So: **the server validates shape, the client owns membership.** A field that is not one of the four (`label`/`order`/`hidden`/`group`) is a 400, so a `roles` or `to` in the body is refused rather than quietly stored. `hidden: false` is **accepted and never stored** — the editor sends it mid-edit, so rejecting it would be hostile, but storing it would leave a row that reads like an instruction to *force* something visible, which this layer must never be able to express. ### The nav editor cannot be hidden An admin who hid `/admin/navigation` would lose the only screen that can un-hide it. Three guards: the row's eye toggle is disabled with a note saying why; `resolveNavOverrides` drops `hidden` on that `to` for `nav_admin`; and `AdminLayout` strips it again before merging, which is the one that also covers a row edited **straight in the database** (confirmed live — the row said `hidden: true` and the sidebar entry stayed). ### Orders are written only when something moved §7.1 says the editor writes an order for every item "the way drag-and-drop does", and it does — but only for a nav whose sequence actually differs from the code's. An admin who renames one item stores one field (`{"/player":{"label":"My Heroes"}}`, live), so a route added to `NAV` later still lands where the code puts it. The comparison is against the base **restricted to the rows that admin can see**, or filtering the palette by role/feature would look like a reorder and pin everything — a bug this had, caught by the test for it. An override for an item outside the editing admin's palette is **carried through their save** rather than silently reset. ## How it was tested - `cd server && DB_HOST=127.0.0.1 DB_PORT=59999 npm test` — **782 pass, 0 fail** (760 before): 19 new in `navOverrides.test.js`, 3 in `settingsTheming.test.js` (a nav row is stored stringified and not `"[object Object]"`; an invalid one is rejected with nothing written; the write path drops `hidden` on the editor and never stores `hidden: false`). - `cd client && node --test` — **94 pass, 0 fail** (75 before), incl. the editor round trip: an untouched editor saves nothing, a rename writes no orders, reordering writes one per row, save→reload→save is stable, a group move round-trips and clears when moved back. - `npm run build` clean. `npm run swagger` regenerated — a one-line diff on the PUT description. `npm run routes:manifest` regenerated: **no new routes**, zero diff, so `docs/website/api-route-inventory.json` is unchanged too. - **Live smoke** against the local MariaDB, from zero settings rows: baseline header identical to today → rename + hide + drag on the public tab → header matched the editor exactly, Market gone → the two un-hide attempts above → admin tab group move (Houses → Content), saved and the **sidebar updated live with no reload** → player tab rename + hide, verified as a player (`My Heroes`, no Appeals) → reset each nav → **zero rows**, code order back everywhere. ## Notes - **New dependency:** `@dnd-kit/core` + `/sortable` + `/utilities` (~13 kB gz), as §8 specified. Pointer, keyboard and touch sensors with screen-reader announcements is the part worth not hand-rolling. Dragging reorders within a section only; moving between sections is the dropdown, since that is what an override can express. - The section dropdown offers "(no section)" only to rows coded into an untitled group. For anything else it would be a move the override cannot store (§6.4), so it would silently do nothing. - `useNavOverrides` keeps one module-level copy of the two authenticated rows — that is what lets a save update the sidebar the admin is looking at, and stops the second layout to mount from flashing the coded nav. - `AppearanceAdmin`'s two inline `try { JSON.parse }` blocks now use the new `client/src/lib/settingsJson.js`, the client counterpart §4.4 promised "with its first consumer". ## 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 1 commit 2026-08-08 05:03:57 +00:00
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>
whitlocktech merged commit 78f994955c into edge 2026-08-08 05:11:35 +00:00
whitlocktech deleted branch feat/theming-nav-phase-6-8 2026-08-08 05:11:36 +00:00
Sign in to join this conversation.
No description provided.