docs(website): phase 10 — public nav sections and added links, and the §7 amendment #108
@@ -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
|
subtractive. `hidden` on `/admin/navigation` is dropped for `nav_admin`, because
|
||||||
that screen is the only UI that can un-hide anything.
|
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.**
|
**`theme_visual` is resolved server-side, not shipped raw to the browser.**
|
||||||
`utils/themeResolve.js` layers `:root` ← preset ← custom, field by field, into
|
`utils/themeResolve.js` layers `:root` ← preset ← custom, field by field, into
|
||||||
the CSS custom properties `getPublic()` returns as `theme`; the SPA's only job
|
the CSS custom properties `getPublic()` returns as `theme`; the SPA's only job
|
||||||
|
|||||||
@@ -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
|
honored**, so removing a route in code can never leave a dangling override that
|
||||||
does something unexpected.
|
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
|
## 7. Navigation: hard constraint
|
||||||
|
|
||||||
The override system can **only** affect `label`, `order`, `hidden`, and — admin
|
> **Amended in Phase 10.** This section originally said the override layer
|
||||||
nav only — `group` (which *existing* titled section an item sits under).
|
> "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**:
|
It **cannot**:
|
||||||
|
|
||||||
- introduce a `to` that is not already in the corresponding hardcoded `NAV` array;
|
- change a coded entry's `to`, or introduce a new one in its place;
|
||||||
- change or remove an item's `roles` (admin nav) or `feature` (public nav) gate;
|
- change or remove an entry's `roles` (admin nav) or `feature` (public nav) gate;
|
||||||
- un-hide an item for a viewer whose role or feature check would otherwise fail.
|
- 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
|
The existing filters in
|
||||||
[`SiteHeader.jsx:43`](../../website/client/src/components/SiteHeader.jsx) and
|
[`SiteHeader.jsx`](../../website/client/src/components/SiteHeader.jsx) and
|
||||||
[`AdminLayout.jsx:155-164`](../../website/client/src/routes/admin/AdminLayout.jsx)
|
[`AdminLayout.jsx`](../../website/client/src/routes/admin/AdminLayout.jsx)
|
||||||
run **after** the override merge, unchanged, and remain the actual security
|
run **after** the override merge, unchanged, and remain the actual security
|
||||||
boundary. The override layer is presentation-only. This is the same
|
boundary. The override layer is presentation-only. This is the same
|
||||||
"server-enforced gate, client-side is only about not advertising a dead end"
|
"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
|
principle already documented in `SiteHeader.jsx`'s comments, and this feature must
|
||||||
not weaken it.
|
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
|
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
|
||||||
redirects them out of anything else. Overrides apply before that filter, so a
|
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** |
|
| **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 |
|
| **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 |
|
| **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
|
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
|
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
|
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.
|
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 `<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
|
### Phase 9, cancelled
|
||||||
|
|
||||||
The §4.8 `rgba()` literal promotion and the Parchment light-mode port are **not
|
The §4.8 `rgba()` literal promotion and the Parchment light-mode port are **not
|
||||||
@@ -861,5 +969,13 @@ and the live observation in "Phases 3–4 as landed".
|
|||||||
role/feature gate would otherwise hide. Verified by overriding `hidden: false`
|
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
|
on a role-gated item as a lower-privileged test admin and confirming the filter
|
||||||
still hides it.
|
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
|
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
|
||||||
a stored copy of the defaults.
|
a stored copy of the defaults.
|
||||||
|
|||||||
Reference in New Issue
Block a user