feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5) #38

Merged
whitlocktech merged 1 commits from feat/m12-phase-5-public-nav into edge 2026-08-08 12:12:46 +00:00
Member

Phase 5 of M12 (docs/android/THEMING_AND_NAV.md §6) — the first time anything in the app's navigation comes from the shard, after four phases of appearance. Targets edge; the milestone reaches main as one edgemain merge.

The drawer has been the coded APP_MENU in coded order since M1. This lets an admin's nav_public row relabel, reorder and hide its public rows.

The mapping table is the coupling, so it lives in one file

The public nav is keyed by website paths, so honoring it needs a translation. That is the one new piece of cross-repo coupling the milestone introduces, and NavPaths.kt holds all of it with the website's own NAV array quoted directly above the table — visible and reviewable in one place instead of spread across the drawer's call sites.

The feature values are deliberately not mirrored. APP_MENU stays the app's own source of truth for gating; a second copy of a security-relevant value that drifts silently is worth more than it costs.

Nine of the sixteen website rows have a drawer row. The other seven map to a screen the app reaches another way — three news categories are tabs on one News screen, and champs / guilds / governors / houses sit behind the Shard hub because that is the better shape on a phone — and an override for one of them is ignored. That is §6.1's rule that a nav override may never introduce navigation: the hub and the tab strip are design choices, not accidents to correct. The mapping still exists for all sixteen because phase 6's added links resolve an admin-authored path against the same table, and there a category tab or a hub board is a perfectly good destination — the admin asked for it by path.

The merge is presentation, and the gates still are the boundary

A port of the website's applyNavOverrides, narrowed to what a drawer can express: label, order, hidden, nothing else. It runs before visibleEntries, so the two gates from M10/M11 still decide what this caller may see. An override that relabels the Market row, moves it to the front and says hidden: false shows nothing to a caller whose shard does not publish the market — hiding is subtractive, never additive. group and section are read and dropped: the app renders no sections until phase 6 and never renders the sidebar's groups at all, and a value that cannot be honored is better dropped than half-applied.

One thing §6.2 did not settle, and the sort turns on it

An untouched row's implicit sort key has to be its index in the WEBSITE's nav, not the app's. A stored order is a position in the site's sixteen-row list, so a key taken from the app's nine-row block puts explicit and implicit keys on two incomparable number lines — an unmoved About (app index 8) would sort ahead of an unmoved Market (web order 14) the moment any row carried an explicit order. The docs PR renumbers §6.2's table and calls its order load-bearing for this reason; it also corrects that order, which had the three news categories in the wrong sequence. Both tie-breaks are the web's: an explicit order beats a coincidental index, and two explicit orders keep code order because the sort is stable.

Two questions settled by the org lead before code

1. The three news-category paths get a mapped route but no drawer row, on the same rule as the hub four. The alternative considered — surfacing such a row only when the admin overrides it — keeps AC-1 but lets an override introduce navigation after all.

2. An admin may hide Home, mirroring the website, where / is hideable from the public header. Home stays the NavHost's start destination and stays reachable by back-press. The contrast that makes it safe is with the website's /admin/navigation, which has three guards because hiding it would strip the only way to undo an override — nothing about a hidden Home row is unrecoverable.

Routes.news(category) ships here; phase 6 is its first caller

Declared beside Routes.NEWS rather than replacing it, because the two are used for different things: the pattern is what composable() and destination.route speak, the bare route is what callers navigate to. Navigating to plain Routes.NEWS matches the pattern with no argument, so the drawer row and the push deep-link (Routes.forStream) are untouched. The consequence worth knowing: destination.route is now a pattern carrying a query, so RunicApp compares on substringBefore('?') for both the top-level check and the selected-row check.

It takes the PostCategory enum rather than a slug string (unlike the existing Routes.post()) so an unmapped category cannot reach the NavHost — the tab strip is the enum's entries. NewsViewModel falls back to the default feed for an unknown slug anyway, since a hand-edited settings row can carry one.

Tests

NavOverridesTest (19) + NavPathsTest (11) + 2 new NewsViewModel cases, with three existing ones rewritten onto a SavedStateHandle. 442 unit tests green (410 + 32), lintDebug and assembleDebug clean.

The strongest is AC-1's: with no stored row — or an empty one, or one with nothing usable in it — the merge returns APP_MENU itself, so the assertion is assertSame and there is no path where an unedited nav is rebuilt and could come out different. AC-3 is covered three ways: the relabelled-and-moved hidden: false row above, role-gated rows staying hidden from an anonymous caller whatever the row says, and hiding composing with the gates rather than competing with them.

What the JVM still cannot reach is phase 4's limit again — no Robolectric, no androidTest source set. That the reordered drawer draws in the new order, and that navigating to plain news really matches the optional-argument pattern at runtime, are AC-5's.

Docs: RunicGateway/docs#117.


  • AI-assisted: written with Claude Code (Claude Opus 5)
Phase 5 of M12 (`docs/android/THEMING_AND_NAV.md` §6) — the first time anything in the app's **navigation** comes from the shard, after four phases of appearance. Targets `edge`; the milestone reaches `main` as one `edge` → `main` merge. The drawer has been the coded `APP_MENU` in coded order since M1. This lets an admin's `nav_public` row relabel, reorder and hide its public rows. ### The mapping table is the coupling, so it lives in one file The public nav is keyed by **website** paths, so honoring it needs a translation. That is the one new piece of cross-repo coupling the milestone introduces, and `NavPaths.kt` holds all of it with the website's own `NAV` array quoted directly above the table — visible and reviewable in one place instead of spread across the drawer's call sites. The `feature` values are deliberately **not** mirrored. `APP_MENU` stays the app's own source of truth for gating; a second copy of a security-relevant value that drifts silently is worth more than it costs. **Nine of the sixteen website rows have a drawer row.** The other seven map to a screen the app reaches another way — three news categories are tabs on one News screen, and champs / guilds / governors / houses sit behind the Shard hub because that is the better shape on a phone — and an override for one of them is **ignored**. That is §6.1's rule that a nav override may never introduce navigation: the hub and the tab strip are design choices, not accidents to correct. The mapping still exists for all sixteen because phase 6's added links resolve an admin-authored path against the same table, and *there* a category tab or a hub board is a perfectly good destination — the admin asked for it by path. ### The merge is presentation, and the gates still are the boundary A port of the website's `applyNavOverrides`, narrowed to what a drawer can express: `label`, `order`, `hidden`, nothing else. It runs **before** `visibleEntries`, so the two gates from M10/M11 still decide what this caller may see. An override that relabels the Market row, moves it to the front and says `hidden: false` shows nothing to a caller whose shard does not publish the market — hiding is subtractive, never additive. `group` and `section` are read and dropped: the app renders no sections until phase 6 and never renders the sidebar's groups at all, and a value that cannot be honored is better dropped than half-applied. ### One thing §6.2 did not settle, and the sort turns on it **An untouched row's implicit sort key has to be its index in the WEBSITE's nav, not the app's.** A stored `order` is a position in the site's sixteen-row list, so a key taken from the app's nine-row block puts explicit and implicit keys on two incomparable number lines — an unmoved About (app index 8) would sort ahead of an unmoved Market (web order 14) the moment any row carried an explicit order. The docs PR renumbers §6.2's table and calls its order load-bearing for this reason; it also **corrects that order**, which had the three news categories in the wrong sequence. Both tie-breaks are the web's: an explicit order beats a coincidental index, and two explicit orders keep code order because the sort is stable. ### Two questions settled by the org lead before code **1. The three news-category paths get a mapped route but no drawer row**, on the same rule as the hub four. The alternative considered — surfacing such a row only when the admin overrides it — keeps AC-1 but lets an override introduce navigation after all. **2. An admin may hide Home**, mirroring the website, where `/` is hideable from the public header. Home stays the `NavHost`'s start destination and stays reachable by back-press. The contrast that makes it safe is with the website's `/admin/navigation`, which has three guards because hiding it would strip the only way to *undo* an override — nothing about a hidden Home row is unrecoverable. ### `Routes.news(category)` ships here; phase 6 is its first caller Declared **beside** `Routes.NEWS` rather than replacing it, because the two are used for different things: the pattern is what `composable()` and `destination.route` speak, the bare route is what callers navigate to. Navigating to plain `Routes.NEWS` matches the pattern with no argument, so the drawer row and the push deep-link (`Routes.forStream`) are untouched. The consequence worth knowing: **`destination.route` is now a pattern carrying a query**, so `RunicApp` compares on `substringBefore('?')` for both the top-level check and the selected-row check. It takes the `PostCategory` enum rather than a slug string (unlike the existing `Routes.post()`) so an unmapped category cannot reach the NavHost — the tab strip *is* the enum's entries. `NewsViewModel` falls back to the default feed for an unknown slug anyway, since a hand-edited settings row can carry one. ### Tests `NavOverridesTest` (19) + `NavPathsTest` (11) + 2 new `NewsViewModel` cases, with three existing ones rewritten onto a `SavedStateHandle`. **442 unit tests green** (410 + 32), `lintDebug` and `assembleDebug` clean. The strongest is AC-1's: with no stored row — or an empty one, or one with nothing usable in it — the merge returns `APP_MENU` **itself**, so the assertion is `assertSame` and there is no path where an unedited nav is rebuilt and could come out different. AC-3 is covered three ways: the relabelled-and-moved `hidden: false` row above, role-gated rows staying hidden from an anonymous caller whatever the row says, and hiding composing with the gates rather than competing with them. What the JVM still cannot reach is phase 4's limit again — no Robolectric, no `androidTest` source set. That the reordered drawer *draws* in the new order, and that navigating to plain `news` really matches the optional-argument pattern at runtime, are AC-5's. Docs: RunicGateway/docs#117. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5)
wtclaude added 1 commit 2026-08-08 12:10:23 +00:00
The drawer has been the app's coded `APP_MENU` in coded order since M1. Phase 5
lets an admin's `nav_public` row relabel, reorder and hide its public rows, which
is the first time anything in the app's navigation comes from the shard.

The public nav is keyed by **website** paths, so this needs a translation table,
and it is the one new piece of cross-repo coupling the milestone introduces. It
lives in a single file with the website's own `NAV` array quoted beside it —
`NavPaths.kt` — so the coupling is visible and reviewable in one place instead of
spread across the drawer's call sites. The `feature` values are deliberately not
mirrored: `APP_MENU` stays the app's own source of truth for gating, and a second
copy of a security-relevant value that drifts silently is worth more than it
costs.

Nine of the sixteen website rows have a drawer row. The other seven map to a
screen the app reaches another way — three news categories are tabs on one News
screen, and champs / guilds / governors / houses sit behind the Shard hub because
that is the better shape on a phone — and an override for one of them is
**ignored**, which is §6.1's rule that a nav override may never introduce
navigation. The hub is a design decision, not an accident to correct. The mapping
still exists for all sixteen because phase 6's added links resolve an
admin-authored path against the same table, and there a category tab or a hub
board is a perfectly good destination: the admin asked for it by path.

The merge is a port of the website's `applyNavOverrides`, narrowed to what a
drawer can express — `label`, `order`, `hidden`, and nothing else. It runs
**before** `visibleEntries`, so the two gates from M10/M11 still decide what this
caller may see and remain the actual boundary: an override that relabels the
Market row, moves it to the front and says `hidden: false` still shows nothing to
a caller whose shard does not publish the market. Hiding is subtractive, never
additive.

One thing the design did not settle and the sort turns on: an untouched row's
implicit key has to be its index in the **website's** nav, not the app's. A
stored `order` is a position in that list, so a key taken from the app's shorter
list would put explicit and implicit keys on two incomparable number lines and
scramble a partially-overridden nav. Both tie-breaks are the web's — an explicit
order beats a coincidental index, and two explicit orders keep code order.

`Routes.news(category)` and an optional NavHost argument ship here as the table's
route builder; phase 6 is their first caller. Navigating to plain `Routes.NEWS`
matches the new pattern with no argument and opens the default tab, so the drawer
and the push deep-link are unaffected — but `destination.route` is now a pattern
with a query, so the top-level and selected-row checks compare on the part before
it.

Two questions went to the org lead before any code. The three news-category paths
get a mapped route but no drawer row of their own, on the same rule as the hub
four. And an admin **may** hide Home, mirroring the website, where `/` is
hideable too: Home stays the NavHost's start destination and stays reachable by
back-press, and the app does not invent a policy the site doesn't have.

442 unit tests green (410 + 32), `lintDebug` and `assembleDebug` clean. The
strongest of them is AC-1's: with no stored row the merge returns `APP_MENU`
itself — identity, not equality — so an instance whose admin never touched the
nav provably gets the drawer the app shipped with.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit fbe8b0bab6 into edge 2026-08-08 12:12:46 +00:00
whitlocktech deleted branch feat/m12-phase-5-public-nav 2026-08-08 12:12:47 +00:00
Sign in to join this conversation.
No description provided.