feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6) #39

Merged
whitlocktech merged 1 commits from feat/m12-phase-6-nav-sections-links into edge 2026-08-08 12:59:55 +00:00
Member

Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3) — the last of the public nav the website publishes. Phase 5 let an admin relabel, reorder and hide the drawer's rows; this lets them group rows into sections and add links of their own. Targets edge.

§6.3 listed the paths an added link could resolve natively, and three of them are paths the site does not serve. Checked against website/client/src/App.jsx: there is no /site/news/<idOrSlug> (a news item renders on its category page; the site's one post-detail route is the newsletter's /site/newsletter/:id), no /page/<slug> (CMS pages come from a top-level /<slug>), and no /contact at all — the app's contact form is app-only, which §6.2 already said. Two real detail routes were missing, /site/atlas/:slug and /site/market/vendors/:serial.

Phase 5 hit the same class of error in §6.2's row order, and the fix is the same one: NavPaths.kt now quotes App.jsx's route list next to the SiteHeader.jsx NAV it already carried. A resolver that does not read the site's own route table cannot be right by accident, and the next person to change it should be looking at both.

Where the /<slug> catch-all earns its keep, and what it must not swallow

The likeliest added link is a page the admin wrote, so /<slug>PAGE(slug) is the rule that decides whether this feature mostly opens the app or mostly opens a browser. It comes with a reserved list — admin, account, player, site, wiki, invite, preview, api, uploads — because React Router ranks its static routes above /:slug and the app has to do the same: without it a link to the admin panel would open an in-app 404 CMS page instead of the real thing in a browser.

A path carrying a query or a fragment hands off, whatever its route part says. No app route takes either, so a native match would quietly drop what the admin typed.

Phase 6 does not re-implement phase 5, which is why AC-1 is untouched

buildNavTree hands straight to applyNavOverrides when the stored row carries no sections and no links, so an untouched instance still gets APP_MENU back by identity — the tree build only runs when the admin actually created structure. The test for it is that an items-only row through the tree equals the same row through the flat merge, plus the original assertSame still standing in NavOverridesTest.

One place the two consumers disagree, deliberately: section is read but is not part of NavOverride.isEmpty. A section-only override says nothing to a flat list, so an instance that only ever grouped rows still gets its coded list back by identity from applyNavOverrides; the tree adds its own check.

The gates, inside a section as well as outside it

visibleEntries is split into isEntryVisible so pruneNav can apply the same predicate to a sectioned row — both callers have to ask exactly one question, or a row could end up gated by a rule its top-level twin is not. The merge still runs first and the gate still decides: a Market row relabelled, moved to the front, marked hidden: false and tucked into its own section is still not shown on a shard that does not publish the market, and the section it left empty is dropped rather than drawn as a header over nothing.

What is not gated is as load-bearing as what is. An added link carries no role or feature check — the screen behind it enforces its own, exactly as typing the URL on the web does — so a section holding one is never emptied by the caller's role. Both directions are tested.

Degradation is forgiving in one specific way worth calling out: a dangling reference degrades to "no grouping", never to "no row". A member of a section that does not exist — deleted, or dropped for a blank label — is an ordinary top-level row, and a link naming an unknown section keeps its destination and loses only its grouping.

Sort keys extend phase 5's number line

A coded row still keys on its index in the website's sixteen-row nav. An admin-created section or link with no stored order keys on 16, 17, … in creation order, so it appends after the coded rows instead of jumping to the front on a 0 default. Both tie-breaks are still the web's.

Four drawer decisions, settled by the org lead before code

Static section header with indented rows (always open — a collapsible group can hide the row the admin meant to surface); a trailing ExitToApp icon on hand-off links only, so leaving the app is never a surprise while a natively-resolved link stays indistinguishable from a coded row; LocalAssetResolver for the Custom Tab's absolute URL, since it already resolves any site-relative path against the base URL at the app root; and a resolved link opening like any other drawer row (navigateTopLevel, detail screen or not) so back-press lands on Home as it does from every row.

The icon is material-icons-core's — OpenInNew lives in the extended artifact, which is not a dependency and would not be worth adding after phase 3 tripled the APK.

Tests

NavTreeTest (27) + 7 new NavPathsTest cases. 476 unit tests green (442 + 34), lintDebug and assembleDebug clean with no new lint findings.

Phase 4's limit still holds — no Robolectric, no androidTest source set — so the section header's indent, the hand-off icon, and that a Custom Tab really opens are AC-5's, not the JVM's.

Docs: RunicGateway/docs#118.


  • AI-assisted: written with Claude Code (Claude Opus 5)
Phase 6 of M12 (`docs/android/THEMING_AND_NAV.md` §6.3) — the last of the public nav the website publishes. Phase 5 let an admin relabel, reorder and hide the drawer's rows; this lets them **group** rows into sections and **add links of their own**. Targets `edge`. ### The spec's link table was wrong about the website §6.3 listed the paths an added link could resolve natively, and three of them are paths the site does not serve. Checked against `website/client/src/App.jsx`: there is no `/site/news/<idOrSlug>` (a news item renders on its category page; the site's one post-detail route is the newsletter's `/site/newsletter/:id`), no `/page/<slug>` (CMS pages come from a top-level `/<slug>`), and no `/contact` at all — the app's contact form is app-only, which §6.2 already said. Two real detail routes were missing, `/site/atlas/:slug` and `/site/market/vendors/:serial`. Phase 5 hit the same class of error in §6.2's row order, and the fix is the same one: **`NavPaths.kt` now quotes `App.jsx`'s route list** next to the `SiteHeader.jsx` `NAV` it already carried. A resolver that does not read the site's own route table cannot be right by accident, and the next person to change it should be looking at both. ### Where the `/<slug>` catch-all earns its keep, and what it must not swallow The likeliest added link is a page the admin wrote, so `/<slug>` → `PAGE(slug)` is the rule that decides whether this feature mostly opens the app or mostly opens a browser. It comes with a reserved list — `admin`, `account`, `player`, `site`, `wiki`, `invite`, `preview`, `api`, `uploads` — because React Router ranks its static routes above `/:slug` and the app has to do the same: without it a link to the admin panel would open an in-app 404 CMS page instead of the real thing in a browser. **A path carrying a query or a fragment hands off**, whatever its route part says. No app route takes either, so a native match would quietly drop what the admin typed. ### Phase 6 does not re-implement phase 5, which is why AC-1 is untouched `buildNavTree` hands straight to `applyNavOverrides` when the stored row carries no sections and no links, so an untouched instance still gets `APP_MENU` back **by identity** — the tree build only runs when the admin actually created structure. The test for it is that an items-only row through the tree equals the same row through the flat merge, plus the original `assertSame` still standing in `NavOverridesTest`. One place the two consumers disagree, deliberately: **`section` is read but is not part of `NavOverride.isEmpty`**. A section-only override says nothing to a flat list, so an instance that only ever grouped rows still gets its coded list back by identity from `applyNavOverrides`; the tree adds its own check. ### The gates, inside a section as well as outside it `visibleEntries` is split into `isEntryVisible` so `pruneNav` can apply the same predicate to a sectioned row — both callers have to ask exactly one question, or a row could end up gated by a rule its top-level twin is not. The merge still runs first and the gate still decides: a Market row relabelled, moved to the front, marked `hidden: false` and tucked into its own section is still not shown on a shard that does not publish the market, and the section it left empty is dropped rather than drawn as a header over nothing. **What is not gated is as load-bearing as what is.** An added link carries no role or feature check — the screen behind it enforces its own, exactly as typing the URL on the web does — so a section holding one is never emptied by the caller's role. Both directions are tested. Degradation is forgiving in one specific way worth calling out: a **dangling reference degrades to "no grouping", never to "no row"**. A member of a section that does not exist — deleted, or dropped for a blank label — is an ordinary top-level row, and a link naming an unknown section keeps its destination and loses only its grouping. ### Sort keys extend phase 5's number line A coded row still keys on its index in the website's sixteen-row nav. An admin-created section or link with no stored order keys on `16, 17, …` in creation order, so it appends after the coded rows instead of jumping to the front on a `0` default. Both tie-breaks are still the web's. ### Four drawer decisions, settled by the org lead before code Static section header with indented rows (always open — a collapsible group can hide the row the admin meant to surface); a trailing `ExitToApp` icon on hand-off links only, so leaving the app is never a surprise while a natively-resolved link stays indistinguishable from a coded row; `LocalAssetResolver` for the Custom Tab's absolute URL, since it already resolves any site-relative path against the base URL at the app root; and a resolved link opening like any other drawer row (`navigateTopLevel`, detail screen or not) so back-press lands on Home as it does from every row. The icon is `material-icons-core`'s — `OpenInNew` lives in the extended artifact, which is not a dependency and would not be worth adding after phase 3 tripled the APK. ### Tests `NavTreeTest` (27) + 7 new `NavPathsTest` cases. **476 unit tests green** (442 + 34), `lintDebug` and `assembleDebug` clean with no new lint findings. Phase 4's limit still holds — no Robolectric, no `androidTest` source set — so the section header's indent, the hand-off icon, and that a Custom Tab really opens are AC-5's, not the JVM's. Docs: RunicGateway/docs#118. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5)
wtclaude added 1 commit 2026-08-08 12:50:27 +00:00
Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3): the drawer gains the
sections an admin grouped rows into and the links they added of their own, the
last of the public nav the website publishes.

buildNavTree ports the web's buildPublicNav and pruneNav; a link's path is
validated by the website's own read rule and resolved through resolveWebPath,
which the app has to answer for any page on the site rather than the nav's
sixteen. A link the app can open natively does; one it cannot hands off to a
Custom Tab, absolute against the configured base URL.

Phase 6 does not re-implement phase 5: with no sections and no links stored,
buildNavTree hands straight to applyNavOverrides, so an untouched instance still
gets APP_MENU back by identity and AC-1's proof is unchanged.

visibleEntries is split into isEntryVisible so pruneNav can apply the same
predicate inside a section, and drop one the gates leave empty.

476 unit tests green (442 + 34); lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit aeda919376 into edge 2026-08-08 12:59:55 +00:00
whitlocktech deleted branch feat/m12-phase-6-nav-sections-links 2026-08-08 12:59:55 +00:00
Sign in to join this conversation.
No description provided.