docs(android): M12 phase 5 as landed

§6.2 corrected and amended, §6.1 given the Home decision, and a "Phase 5 as
landed" section added with the  in §8.

**§6.2's table was in the wrong order, and the order is now load-bearing.** The
three news categories were listed Five on Friday / Newsletter / Screenshots; the
website's array has them Screenshots / Five on Friday / Newsletter. That did not
matter while the table was only a mapping, but a stored `order` is an index into
the site's nav, so a row the admin never moved takes its sort key from this list —
and a key from the wrong list scrambles a partially-overridden nav. The rows are
now numbered 0-15 and the order is called out as verbatim.

Also written into §6.2: the `feature` values are deliberately **not** mirrored
into the table (`APP_MENU` stays the app's own source of truth for gating), and
the "no `APP_MENU` row" rule covers **seven** entries rather than four — the
three news categories as well as the hub boards.

Two decisions the org lead settled before code, both recorded where the rule
lives rather than only in the phase notes:

- **The news categories are ignored for the drawer**, on the same rule as the hub
  four. The mapping still exists for phase 6's added links, where a category tab
  is a perfectly good destination because the admin named it by path. The
  alternative — surfacing such a row only when overridden — keeps AC-1 but lets an
  override introduce navigation after all, so it was rejected.
- **An admin may hide the Home row**, mirroring the website, where `/` is hideable
  from the public header. Home stays the start destination and stays reachable by
  back-press. The contrast that makes it safe is with `/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.

"Phase 5 as landed" records the sort-key finding above as the thing the spec did
not settle, that an untouched instance gets `APP_MENU` back by **identity** so
AC-1's drawer claim is an `assertSame`, that the app's own rows are partitioned
off rather than sorted (and what that would mean for a future interleaved row),
that `group`/`section` are read and dropped, and why `Routes.NEWS_ROUTE` is
declared beside `Routes.NEWS` rather than replacing it — with the consequence that
`destination.route` now carries a query and is compared on `substringBefore('?')`.

Android-app: RunicGateway/Android-app#38.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-08 07:09:44 -05:00
parent 67e110201b
commit 02d1663c1a

View File

@@ -410,43 +410,73 @@ are unchanged by it. An override cannot introduce an app route, cannot touch
role or the shard's visibility config would otherwise withhold. Hiding is role or the shard's visibility config would otherwise withhold. Hiding is
subtractive, exactly as `applyNavOverrides` has it. subtractive, exactly as `applyNavOverrides` has it.
> **Decision (2026-08-08): an admin may hide the Home row.** The website lets `/`
> be hidden from the public header — the brand link still goes home — and the app
> mirrors it rather than inventing a policy the site does not have. Home remains
> the `NavHost`'s start destination and remains reachable by back-press; unlike
> 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. Hiding therefore stays one uniform rule with no special cases.
### 6.2 Path → app route ### 6.2 Path → app route
The public nav is keyed by **website** paths. The app needs a mapping table, and The public nav is keyed by **website** paths. The app needs a mapping table, and
it is the one new piece of cross-repo coupling this milestone introduces — so it it is the one new piece of cross-repo coupling this milestone introduces — so it
lives in one file with the website's `NAV` array quoted beside it. lives in one file with the website's `NAV` array quoted beside it.
| website `to` | app route | note | **The table is in the website's order, and that order is load-bearing.** A stored
|---|---|---| `order` is an index into the site's nav, so a row the admin never moved has to
| `/` | `Routes.HOME` | | take its sort key from the same list or explicit and implicit keys sit on two
| `/site/news` | `Routes.NEWS` | | incomparable number lines (see "Phase 5 as landed"). The rows below are verbatim
| `/site/five-on-friday` | `Routes.news(FIVE_ON_FRIDAY)` | the app's News screen already has all four categories as tabs — these three select one | from `website/client/src/components/SiteHeader.jsx` — note that the three news
| `/site/newsletter` | `Routes.news(NEWSLETTER)` | | categories are **Screenshots, Five on Friday, Newsletter** in that sequence,
| `/site/screenshots` | `Routes.news(SCREENSHOTS)` | | between News and Wiki.
| `/wiki` | `Routes.WIKI` | |
| `/site/shard` | `Routes.SHARD` | `feature: status` | | # | website `to` | app route | note |
| `/site/champs` | `Routes.SHARD_CHAMPS` | **not in `APP_MENU` today** — reached via the Shard hub | |---|---|---|---|
| `/site/guilds` | `Routes.SHARD_GUILDS` | as above | | 0 | `/` | `Routes.HOME` | |
| `/site/governors` | `Routes.SHARD_GOVERNORS` | as above | | 1 | `/site/news` | `Routes.NEWS` | |
| `/site/houses` | `Routes.SHARD_HOUSES` | as above | | 2 | `/site/screenshots` | `Routes.news(SCREENSHOTS)` | the app's News screen already has all four categories as tabs — these three select one, and none has an `APP_MENU` row |
| `/site/rules` | `Routes.SHARD_RULES` | | | 3 | `/site/five-on-friday` | `Routes.news(FIVE_ON_FRIDAY)` | as above |
| `/site/atlas` | `Routes.ATLAS` | | | 4 | `/site/newsletter` | `Routes.news(NEWSLETTER)` | as above |
| `/site/leaderboards` | `Routes.SHARD_LEADERBOARDS` | | | 5 | `/wiki` | `Routes.WIKI` | |
| `/site/market` | `Routes.SHARD_MARKET` | | | 6 | `/site/shard` | `Routes.SHARD` | `feature: status` |
| `/site/about` | `Routes.page("about")` | | | 7 | `/site/champs` | `Routes.SHARD_CHAMPS` | **not in `APP_MENU` today** — reached via the Shard hub |
| 8 | `/site/guilds` | `Routes.SHARD_GUILDS` | as above |
| 9 | `/site/governors` | `Routes.SHARD_GOVERNORS` | as above |
| 10 | `/site/houses` | `Routes.SHARD_HOUSES` | as above |
| 11 | `/site/rules` | `Routes.SHARD_RULES` | |
| 12 | `/site/atlas` | `Routes.ATLAS` | |
| 13 | `/site/leaderboards` | `Routes.SHARD_LEADERBOARDS` | |
| 14 | `/site/market` | `Routes.SHARD_MARKET` | |
| 15 | `/site/about` | `Routes.page("about")` | |
**The `feature` values are not mirrored into this table**, though the website's
array carries one on nine of these rows. `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. The table carries the mapping and nothing else.
Three asymmetries to resolve rather than paper over: Three asymmetries to resolve rather than paper over:
- **`Routes.NEWS` takes no category argument today.** It gains an optional one so - **`Routes.NEWS` takes no category argument today.** It gains an optional one so
the three category entries can land on the right tab. This is a small route a link to one of the three category pages can land on the right tab. This is a
change with its own test, not a nav concern. small route change with its own test, not a nav concern.
- **Four web entries have no `APP_MENU` row** (champs / guilds / governors / - **Seven web entries have no `APP_MENU` row** champs / guilds / governors /
houses the app puts them behind the Shard hub, which is the better phone houses (the app puts them behind the Shard hub, which is the better phone shape
shape and stays). An override for one of them therefore has a mapped route but and stays) and the three news categories (tabs on one screen). An override for
no menu entry. **Rule: an override for a path the app does not surface in its one of them therefore has a mapped route but no menu entry. **Rule: an override
menu is ignored**, exactly as the web drops an override for an unknown `to`. for a path the app does not surface in its menu is ignored**, exactly as the web
It is *not* an invitation to add the entry — the hub is a deliberate design drops an override for an unknown `to`. It is *not* an invitation to add the
choice, and a nav override may not introduce navigation. entry — the hub and the tab strip are deliberate design choices, and a nav
override may not introduce navigation.
> **Decision (2026-08-08):** this rule covers the **news categories too**, not
> only the hub four. The mapping in the table above is what phase 6's added
> links resolve against — and *there* a category tab is a perfectly good
> destination, because the admin named it by path — but neither a relabel nor a
> reorder of `/site/screenshots` puts a new row in the drawer. The alternative
> considered and rejected was surfacing such a row only when overridden, which
> keeps AC-1 but lets an override introduce navigation after all.
- **Ten app entries have no `nav_public` counterpart** — Contact, Account and - **Ten app entries have no `nav_public` counterpart** — Contact, Account and
Notifications, the three player groups, and the four staff rows. They are Notifications, the three player groups, and the four staff rows. They are
unaffected by `nav_public` and keep their coded order, appended after the unaffected by `nav_public` and keep their coded order, appended after the
@@ -585,7 +615,7 @@ PR against `main`.
| **2** ✅ | **Radii & shadow** | Ratio-scaled `Shapes` (§5.2), elevation map (§5.4) | | **2** ✅ | **Radii & shadow** | Ratio-scaled `Shapes` (§5.2), elevation map (§5.4) |
| **3** ✅ | **Fonts** | Seven bundled families + licenses; stack → `FontFamily` resolution; `Type.kt` takes its three families from the resolved theme. APK size recorded | | **3** ✅ | **Fonts** | Seven bundled families + licenses; stack → `FontFamily` resolution; `Type.kt` takes its three families from the resolved theme. APK size recorded |
| **4** ✅ | **Brand assets** | Logo in the drawer header and top bar, hero on Home (§5.6). Coil + `LocalAssetResolver` already exist; renders nothing when unset | | **4** ✅ | **Brand assets** | Logo in the drawer header and top bar, hero on Home (§5.6). Coil + `LocalAssetResolver` already exist; renders nothing when unset |
| **5** | **Public nav: label / order / hidden** | The path→route table (§6.2), `Routes.news(category)`, the merge, drawer wiring. AC-3 | | **5** | **Public nav: label / order / hidden** | The path→route table (§6.2), `Routes.news(category)`, the merge, drawer wiring. AC-3 |
| **6** | **Public nav: sections & added links** | Drawer groups, `pruneNav` port, link path validation, native-route resolution + Custom Tab fallback (§6.3) | | **6** | **Public nav: sections & added links** | Drawer groups, `pruneNav` port, link path validation, native-route resolution + Custom Tab fallback (§6.3) |
| **7** | **Authenticated navs** | ❌ **cancelled** — was: `GET /api/v1/settings/nav` behind a session-keyed repository; label/hidden for the four mapped rows (§6.4). See "Phase 7, cancelled" below | | **7** | **Authenticated navs** | ❌ **cancelled** — was: `GET /api/v1/settings/nav` behind a session-keyed repository; label/hidden for the four mapped rows (§6.4). See "Phase 7, cancelled" below |
| **8** | **Docs, coverage & cutover** | This doc's "as landed" notes and any amendments the build forces, the `PLAN.md` §9 M12 entry refreshed, Sonar coverage for the new modules, AC-5 on-device walk, then `edge``main` | | **8** | **Docs, coverage & cutover** | This doc's "as landed" notes and any amendments the build forces, the `PLAN.md` §9 M12 entry refreshed, Sonar coverage for the new modules, AC-5 on-device walk, then `edge``main` |
@@ -813,6 +843,69 @@ proven not to have moved anything.
cropped hero, a synthesised bold, and a logo's contrast against the top bar cropped hero, a synthesised bold, and a logo's contrast against the top bar
are all things only a screen shows. are all things only a screen shows.
### Phase 5 as landed
- **An untouched instance gets `APP_MENU` back by identity, not by equality.**
`applyNavOverrides` returns the *same list instance* when there is no stored row,
an empty one, or one with nothing usable in it — so AC-1's claim for the drawer
is a one-line `assertSame` rather than a structural comparison, and there is no
path where an unedited nav is rebuilt and could come out different. "Nothing
usable" is a real case worth the check: a blank label, `hidden: false`, an
unknown path, and a path the app maps but does not surface all say nothing.
- **An untouched row's implicit sort key is its index in the WEBSITE's nav, not
the app's** — the one thing the spec did not settle and the whole sort turns on.
A stored `order` is a position in the site's sixteen-row list, so a key taken
from the app's nine-row block would put 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. This
is why §6.2's table is now numbered and why its order is called load-bearing.
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.
- In practice the editor writes an order for *every* visible row when the admin
drags anything, so a mix of explicit and implicit keys is the stale-row case
rather than the normal one. It still has to resolve predictably.
- **The app's own rows are partitioned off, not sorted.** Contact, Account,
Notifications, the three player groups and the four staff rows have no website
counterpart to be reordered against, so they keep their coded order after the
public block. They already sit there today, which is what makes the partition
the current layout rather than a new one — but it does mean an `APP_MENU` that
interleaved an app-only row *among* the public ones would see it moved to the
tail. Nothing does today; a future row should be added with that in mind.
- **`group` and `section` are read and dropped.** The app renders no sections in
this phase (phase 6) and never renders the admin sidebar's groups at all, and a
value that cannot be honored is better dropped than half-applied. Both stored
shapes are read, though: website phase 10's `{items, sections, links}` and the
bare map phases 6-8 stored, which is unambiguous because every key in it is a
path and so can never be the string `items`.
- **`Routes.NEWS_ROUTE` is 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 to remember: **`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. A future route that takes an
optional argument inherits that for free; one that does not, and is compared
against by hand, will not.
- **`Routes.news()` takes the `PostCategory` enum, not a slug string** (unlike the
existing `Routes.post()`), so an unmapped category cannot reach the NavHost —
the tab strip *is* the enum's entries, and a slug it does not know would select
nothing. `NewsViewModel` falls back to the default feed for an unknown slug
anyway, since a hand-edited settings row can carry one.
- **Phase 5 ships `Routes.news(category)`; phase 6 is its first caller.** Nothing
in the drawer navigates to a category tab, by the §6.2 decision above. It ships
here because it is the table's route builder and the table is this phase's.
- Tests: `NavOverridesTest` (19) + `NavPathsTest` (11) + 2 new `NewsViewModel`
cases (and three existing ones rewritten onto a `SavedStateHandle`). **442 unit tests green** (410 + 32), `lintDebug` and
`assembleDebug` clean. AC-3 is covered three ways — a feature-gated row that an
override relabels, moves to the front and marks `hidden: false` is still not
shown to an admin whose shard does not publish it; role-gated rows stay hidden
from an anonymous caller whatever the row says; and hiding composes with the
gates rather than competing with them. What the JVM still cannot reach is the
same limit phase 4 hit: that the reordered drawer *draws* in the new order, and
that navigating to plain `news` really does match the optional-argument pattern
at runtime, are AC-5's.
### Phase 7, cancelled ### Phase 7, cancelled
Reading `nav_admin` / `nav_player` is **not scheduled**. The measurement in §6.4 Reading `nav_admin` / `nav_player` is **not scheduled**. The measurement in §6.4