docs(website): phase 10 — public nav sections and added links, and the §7 amendment

Records the capability asked for before the edge -> main cutover: dropdown
sections in the public header, with the coded entries organised into them and
admin-authored links alongside.

§7 is amended rather than quietly contradicted. It said the override layer
"cannot introduce a `to` that is not already in the hardcoded NAV array"; that
remains true of every CODED entry, and the restated constraint spells out what an
added link may be — a same-origin path, carrying no gate of its own, advertising
a route rather than granting one — plus why the property is structural: coded
entries live in a map keyed by routes the base array declares, and everything
that can name an arbitrary path lives in `links`, where the rule is applied.

§6.4 gains the { items, sections, links } wrapper, including the two properties
worth knowing: a bare map still reads as the items map, and a nav with no
sections still stores one.

§9 gains three acceptance criteria — the empty dropdown does not render, an added
link cannot leave the origin, and deleting a section returns its entries to the
top level rather than removing them.

"Phase 10 as landed" records why the Public tab needed its own tree editor, why
moving between containers stayed a dropdown rather than a cross-container drag,
why the menu opens on click and its trigger is not a link, and the palette-vs-full-nav
bug this surfaced in the phase 6-8 save path.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-08 00:42:39 -05:00
parent 5c24ff1378
commit 2784cad6e4
2 changed files with 137 additions and 8 deletions

View File

@@ -301,6 +301,19 @@ accepted (the editor sends it mid-edit) but never stored, so hiding stays
subtractive. `hidden` on `/admin/navigation` is dropped for `nav_admin`, because
that screen is the only UI that can un-hide anything.
**`nav_public` may also carry dropdown sections and admin-authored links**, as
`{ items, sections, links }` — a bare map still reads as `items`, and a nav with
no sections still stores one. A **section** has a label and a position and no
route at all: it only opens, so it adds no reachable surface. A **link** is the
one place a path may be named that the code does not declare, and is therefore
the one place the path rule applies: same-origin only, no scheme and 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. Coded entries stay in `items`, keyed by a route the base
array must declare, which is what keeps "an override cannot introduce a route"
structurally true. Sections and links are dropped for `nav_admin` / `nav_player`,
whose layouts cannot render them.
**`theme_visual` is resolved server-side, not shipped raw to the browser.**
`utils/themeResolve.js` layers `:root` ← preset ← custom, field by field, into
the CSS custom properties `getPublic()` returns as `theme`; the SPA's only job

View File

@@ -455,27 +455,86 @@ Any field absent for a given `to` falls back to the code default — label from
honored**, so removing a route in code can never leave a dangling override that
does something unexpected.
**`nav_public` may also be a wrapper** (Phase 10), because the public header is
the one nav an admin can restructure rather than only reorder:
```json
{
"items": { "/site/champs": { "order": 0, "section": "sec_a1b2" } },
"sections": [ { "id": "sec_a1b2", "label": "The World", "order": 4 } ],
"links": [ { "id": "lnk_c3d4", "label": "Player Guide",
"to": "/wiki/new-player-guide", "order": 1, "section": "sec_a1b2" } ]
}
```
- **A bare map is still read as the items map.** Every item key is a path
starting with `/`, so it can never collide with the literal key `items` — the
detection is unambiguous, and a nav with no sections still *stores* the bare
map, so this feature changed nothing for one that does not use it.
- `nav_admin` / `nav_player` keep the bare map; `sections` and `links` are
dropped for them, since neither layout can render an admin-created section.
- Top-level order is one number line shared by ungrouped entries **and
sections**; within a section, by its members. An admin-created entity with no
stored order appends after the coded ones rather than jumping to the front.
- **One level only.** No menu inside a menu.
- An `items[].section` or `links[].section` naming no declared section falls back
to the top level, mirroring the "group must name an existing title" rule.
## 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).
> **Amended in Phase 10.** This section originally said the override layer
> "cannot introduce a `to` that is not already in the corresponding hardcoded
> `NAV` array". That is still true of every **coded** entry, but the public
> header now also lets an admin add links of their own, so the constraint is
> restated below in the narrower form that survives. Nothing about the *gates*
> changed.
The override system can affect a **coded** entry's `label`, `order`, `hidden`,
and which container it sits in — `group` on the admin nav (an *existing* titled
section) or `section` on the public header (an admin-created dropdown).
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.
- change a coded entry's `to`, or introduce a new one in its place;
- change or remove an entry's `roles` (admin nav) or `feature` (public nav) gate;
- un-hide an entry for a viewer whose role or feature check would otherwise fail.
**The public header may additionally carry admin-created `sections` and
admin-authored `links`** (§6.4, §7.2). This is a genuine widening and is worth
stating plainly:
- A **section** is a container with a label and a position. It has no `to` and is
never itself a link — it only opens — so it adds no reachable surface at all.
- A **link** is the one thing an admin may add to a nav, and the only place a path
is not required to already exist in code. It is restricted to 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.
- A link carries **no `roles` or `feature` of its own, and needs none**: the page
behind it enforces its own access, so a link to somewhere the viewer cannot
reach behaves exactly as typing that address would. Adding a link advertises a
route; it never grants one.
The property this rests on is structural rather than a check someone has to
remember: coded entries live in an `items` map whose keys **must** be routes the
base array declares, so that map can never introduce a route, while everything
that *can* name an arbitrary path lives in `links`, where the path rule is
applied on both the write and the read path.
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)
[`SiteHeader.jsx`](../../website/client/src/components/SiteHeader.jsx) and
[`AdminLayout.jsx`](../../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:
Three existing behaviors the merge must not disturb:
- **Empty dropdowns.** A section whose every entry is filtered out by a shard
feature must not render at all — a menu that opens onto nothing is worse than
no menu. `pruneNav` applies the gate inside a section and then drops one it
leaves empty.
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
redirects them out of anything else. Overrides apply before that filter, so a
@@ -542,6 +601,7 @@ today until the admin acts.
| **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****cancelled** | Was: promote the hue-carrying `rgba()` literals of §4.8 so they follow the palette, then the light-mode port. Not scheduled — see "Phase 9, cancelled" below |
| **10 — Public nav sections + added links** ✅ | Admin-created dropdown sections in the public header, coded entries organised into them, and admin-authored same-origin links. `NavDropdown.jsx`, `buildPublicNav`/`pruneNav`, the `nav_public` wrapper of §6.4, and the Public tab's own tree editor. **Amends §7** |
Phases 02 are one PR pair (website + docs), 34 a second, 5 a third, 68 a
fourth. **All four PR pairs target `edge`, not `main`** — the feature reaches
@@ -815,6 +875,54 @@ module-level copy of the two authenticated rows, which is what lets a save in th
editor update the sidebar the admin is looking at without a reload — and stops
the second layout to mount from flashing the coded nav first.
### Phase 10 as landed
Asked for after phases 68 were built and before the `edge``main` cutover:
the public site should support dropdown sections with links organised inside
them. Scoped to the **public header only** — the admin sidebar keeps its four
coded sections and the player portal its three flat rows — and to **same-origin
links**, which is what makes §7's amendment a narrowing rather than an opening.
**The shape change was free because nothing had shipped.** `nav_public` grew a
`{items, sections, links}` wrapper. Had this landed after the cutover it would
have needed a migration or a version field; before it, a forgiving read of the
bare map is enough, and that read is kept anyway as insurance for a row written
during review.
**Sections are entries in the top-level order, which is why the Public tab has
its own editor.** The admin sidebar's groups are a fixed frame the code declares:
only membership moves. A public section is something the admin created and can
drag among the pills. That is a tree, not a list of groups, so
`PublicNavTree.jsx` renders it with a nested `SortableContext` per section, while
the other two tabs keep the phase-7 grouped editor. The shared `Row` was
generalised — 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**, exactly as on
the Admin tab. Cross-container dragging is a lot of interaction surface for
something an admin does once, and keeping every drag a simple reorder is what
lets the nested contexts stay independent.
**Deleting a section does not delete what is inside it.** The entries move back
to the top level. It is the one destructive act this screen could commit — those
are coded pages and the admin's own links — so it is locked by a test.
**The dropdown opens on click, never hover, and the trigger is not a link.** A
hover menu is unusable on touch, and making the trigger navigate means tapping to
open takes you somewhere instead. A section is a container, not a destination.
`NavDropdown.jsx` carries the rest of the contract: Escape closes and returns
focus, an outside press closes, navigating closes, Arrow Up/Down walk the items,
and `aria-haspopup`/`aria-expanded` let it be announced as a menu.
**A bug the palette filter had, found by the test for it:** `buildNavOverrides`
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 quietly reset it. Membership is now judged against
the **full** coded nav while the rows still come from the palette: they are two
different questions.
### Phase 9, cancelled
The §4.8 `rgba()` literal promotion and the Parchment light-mode port are **not
@@ -861,5 +969,13 @@ and the live observation in "Phases 34 as landed".
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.
- A dropdown section whose every entry is hidden by shard visibility **does not
render at all**, rather than opening onto an empty menu.
- An added link cannot leave the origin: a `to` carrying a scheme, a
protocol-relative `//host`, whitespace or quotes is refused on write and dropped
on read. An added link never grants access — the page behind it still gates
itself.
- Deleting a dropdown section returns its entries to the top level; it never
removes a coded page or an admin's own link.
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
a stored copy of the defaults.