feat(theming): dropdown sections and added links in the public header (phase 10) #125

Merged
whitlocktech merged 2 commits from feat/theming-nav-phase-10 into edge 2026-08-08 06:07:17 +00:00
Member

What & why

The capability asked for before the cutover: the public header can have dropdown sections, with links organised inside them. Phase 10 of THEMING_AND_NAV.md; docs pair: RunicGateway/docs#108. Targets edge, on top of the now-merged #124.

An admin can create a dropdown section, drag the coded entries into it, and add links of their own. An instance where nobody opens the screen renders exactly as before.

This amends §7 on purpose

§7 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, and the amended constraint says what one may be:

  • a same-origin path — no scheme, no protocol-relative //host, no whitespace or quotes. The nav is not a place to send visitors to an origin the operator does not control;
  • carrying no roles/feature of its own, and needing none: the page behind it enforces its own access, so an added link advertises a route and never grants one. A link to /admin/users is accepted and 403s for anyone who could not already reach it — exactly as typing the address would.

A section has a label and a position and no route at all — it only opens, never navigates — so it adds no reachable surface whatsoever.

The property 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, while everything that can name an arbitrary path lives in links, which is the one place the path rule is applied — on the write path and the read path, so a hand-edited row cannot put an off-site link in the header either.

The storage change was free, and only now

nav_public grew a { items, sections, links } wrapper. A bare map still reads as the items map (every item key is a path, so it can never collide with the literal key items), and a nav with no sections still stores the bare map — so this changed nothing for a nav that does not use it. Doing this before the cutover costs a forgiving read; after it, it would have cost a migration or a version field.

sections/links are dropped for nav_admin/nav_player, whose layouts cannot render them.

Why the Public tab needed its own editor

The admin sidebar's four sections are a fixed frame the code declares — only membership moves. A public section is something the admin created and can drag among the pills, so it is an entry in the top-level order. That is a tree, not a list of groups. PublicNavTree.jsx renders it with a nested SortableContext per section; the Admin and Player tabs keep the phase-7 grouped editor untouched, and the shared Row was generalised so its destination <select> takes a list of choices instead of knowing about admin group titles.

Moving between containers is still the dropdown, not a drag — same as the Admin tab. Deleting a section returns its entries to the top level, never removes them: those are coded pages and the admin's own links, and it is the one destructive act this screen could otherwise commit. Locked by a test.

The menu itself

NavDropdown.jsx opens on click, never 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 to the trigger, an outside press closes, navigating closes, Arrow Up/Down walk the items, aria-haspopup/aria-expanded let it be announced as a menu, and the menu clamps to calc(100vw - 24px) so it cannot push a narrow page sideways.

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

A bug this surfaced in #124

The save path judged "does this route still exist?" against the palette — the base array already filtered to what the editing admin can see. For the admin nav that is harmless (an admin sees every row), but on the public header a shard-feature-gated row is filtered out, so the guard meant to carry its override through could never fire and their save would have silently reset it. Membership is now judged against the full coded nav while the rows still come from the palette: two different questions. Caught by the test written for it.

How it was tested

  • cd server && DB_HOST=127.0.0.1 DB_PORT=59999 npm test792 pass, 0 fail (782 before). New: ids and caps, duplicate ids, an off-origin to refused six ways, a link to a gated path accepted, a dangling section falling to the top level, and sections/links dropped for the other two navs.
  • cd client && node --test111 pass, 0 fail (94 before), incl. the empty-section drop, the bare-map compatibility read, an added link never pruned, the round trip's stability, and deleting a section returning its children.
  • npm run build clean. npm run swagger regenerated (one description line); npm run routes:manifestno new routes, zero diff, so api-route-inventory.json is unchanged.
  • Live smoke from zero rows: created "The World", moved Champions/Guilds/Governors in, tried https://discord.gg/…refused with a readable message, added /wiki/new-player-guide instead, saved → the header showed The World ⌄ with all four inside, Escape closed it; then disabled the features behind its only two coded members → the dropdown disappeared entirely while the added link stayed (it has no gate); reset → zero rows and today's header, byte for byte.

One flake, seen once: a full-suite run failed one test under load and passed on a clean rerun — the same timing-sensitive behaviour noted in #123. Untouched by this PR.

Notes

  • The section dropdown offers "(no section)" only where an override could express it.
  • Bundle: +14 kB raw over #124 (the tree editor and the menu), no new dependency — @dnd-kit arrived in #124.

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 The capability asked for before the cutover: **the public header can have dropdown sections, with links organised inside them.** Phase 10 of [`THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/THEMING_AND_NAV.md); docs pair: RunicGateway/docs#108. Targets **`edge`**, on top of the now-merged #124. An admin can create a dropdown section, drag the coded entries into it, and add links of their own. An instance where nobody opens the screen renders **exactly** as before. ### This amends §7 on purpose §7 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**, and the amended constraint says what one may be: - a **same-origin path** — no scheme, no protocol-relative `//host`, no whitespace or quotes. The nav is not a place to send visitors to an origin the operator does not control; - carrying **no `roles`/`feature` of its own, and needing none**: the page behind it enforces its own access, so an added link **advertises** a route and never **grants** one. A link to `/admin/users` is accepted and 403s for anyone who could not already reach it — exactly as typing the address would. A **section** has a label and a position and no route at all — it only opens, never navigates — so it adds no reachable surface whatsoever. The property 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, while everything that *can* name an arbitrary path lives in `links`, which is the one place the path rule is applied — on the write path *and* the read path, so a hand-edited row cannot put an off-site link in the header either. ### The storage change was free, and only now `nav_public` grew a `{ items, sections, links }` wrapper. **A bare map still reads as the items map** (every item key is a path, so it can never collide with the literal key `items`), and a nav with no sections still *stores* the bare map — so this changed nothing for a nav that does not use it. Doing this before the cutover costs a forgiving read; after it, it would have cost a migration or a version field. `sections`/`links` are dropped for `nav_admin`/`nav_player`, whose layouts cannot render them. ### Why the Public tab needed its own editor The admin sidebar's four sections are a fixed frame the code declares — only membership moves. A public section is something the **admin created and can drag among the pills**, so it is an entry in the top-level order. That is a tree, not a list of groups. `PublicNavTree.jsx` renders it with a nested `SortableContext` per section; the Admin and Player tabs keep the phase-7 grouped editor untouched, and the shared `Row` was generalised so its destination `<select>` takes a list of choices instead of knowing about admin group titles. Moving between containers is still the **dropdown, not a drag** — same as the Admin tab. **Deleting a section returns its entries to the top level**, never removes them: those are coded pages and the admin's own links, and it is the one destructive act this screen could otherwise commit. Locked by a test. ### The menu itself `NavDropdown.jsx` opens on **click, never 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 to the trigger, an outside press closes, navigating closes, Arrow Up/Down walk the items, `aria-haspopup`/`aria-expanded` let it be announced as a menu, and the menu clamps to `calc(100vw - 24px)` so it cannot push a narrow page sideways. **`pruneNav` applies the shard-feature gate inside a section and drops one it leaves empty**, so a dropdown never opens onto nothing. ## A bug this surfaced in #124 The save path judged *"does this route still exist?"* against the **palette** — the base array already filtered to what the editing admin can see. For the admin nav that is harmless (an admin sees every row), but on the public header a shard-feature-gated row **is** filtered out, so the guard meant to carry its override through could never fire and their save would have silently reset it. Membership is now judged against the **full** coded nav while the rows still come from the palette: two different questions. Caught by the test written for it. ## How it was tested - `cd server && DB_HOST=127.0.0.1 DB_PORT=59999 npm test` — **792 pass, 0 fail** (782 before). New: ids and caps, duplicate ids, an off-origin `to` refused six ways, a link to a gated path *accepted*, a dangling `section` falling to the top level, and sections/links dropped for the other two navs. - `cd client && node --test` — **111 pass, 0 fail** (94 before), incl. the empty-section drop, the bare-map compatibility read, an added link never pruned, the round trip's stability, and deleting a section returning its children. - `npm run build` clean. `npm run swagger` regenerated (one description line); `npm run routes:manifest` — **no new routes**, zero diff, so `api-route-inventory.json` is unchanged. - **Live smoke** from zero rows: created "The World", moved Champions/Guilds/Governors in, tried `https://discord.gg/…` → **refused with a readable message**, added `/wiki/new-player-guide` instead, saved → the header showed **The World ⌄** with all four inside, Escape closed it; then disabled the features behind its only two coded members → **the dropdown disappeared entirely** while the added link stayed (it has no gate); reset → zero rows and today's header, byte for byte. **One flake, seen once:** a full-suite run failed one test under load and passed on a clean rerun — the same timing-sensitive behaviour noted in #123. Untouched by this PR. ## Notes - The section dropdown offers "(no section)" only where an override could express it. - Bundle: +14 kB raw over #124 (the tree editor and the menu), no new dependency — `@dnd-kit` arrived in #124. ## 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:44:30 +00:00
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>
wtclaude added 1 commit 2026-08-08 05:56:57 +00:00
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>
whitlocktech merged commit 18815f4c7a into edge 2026-08-08 06:07:17 +00:00
whitlocktech deleted branch feat/theming-nav-phase-10 2026-08-08 06:07:18 +00:00
Sign in to join this conversation.
No description provided.