diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 3b1654f..7328a98 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 diff --git a/website/THEMING_AND_NAV.md b/website/THEMING_AND_NAV.md index d782c78..3611d54 100644 --- a/website/THEMING_AND_NAV.md +++ b/website/THEMING_AND_NAV.md @@ -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 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 @@ -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 6–8 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 `