Phase 8 was scoped as docs, coverage and the cutover. The AC-5 walk turned it into a phase with code in it, which is the outcome the walk existed to produce - so the record is longer than a phase-8 note would normally be. THEMING_AND_NAV.md gains "Phase 8 as landed": what the walk confirmed, the two defects it found and why they are phase 2's trap repeating, the role sweep that says which Material roles are still unmapped and which of them have a reader, the coverage-glob narrowing, and the adb-reverse rig note for the next walk. The phase 2 note is amended where it claimed the card shadow was the milestone's only visible change to an untouched instance - it is now one of three, and the third (an untouched instance drawing a hero, because brand.hero carries the env default) is a correction to the record with no code behind it. PLAN.md's M12 entry stopped at phase 2; it now runs through 6, records phase 8 including the defects, and drops the same "one deliberate change" claim. COVERAGE_PLAN.md gains an amendment section explaining why the ui/theme/** directory glob became wrong the moment M12 put three pure resolvers behind it, and the general rule it suggests: prefer file globs to directory globs once a directory is mixed. Its own §5 already forbade excluding testable code. Pairs with Android-app phase 8. Co-Authored-By: Claude <noreply@anthropic.com>
69 KiB
Android: honoring admin-configurable theming & navigation
Build contract for the Android client's half of the feature shipped in
docs/website/THEMING_AND_NAV.md. Same workflow as the website side: design → phased build → verify. Milestone M12; seePLAN.md§9.
1. Goal
The website merged runtime admin theming, brand assets and navigation overrides
to main (website#126 / docs#109). An admin who re-skins the site from
Admin → Appearance and restructures the header from Admin → Navigation currently
sees none of it on the phone: the app reads exactly one field, brand.accent,
and renders a hardcoded APP_MENU.
This milestone makes the app a full consumer of that contract:
- Theme — the whole resolved color palette, the corner-radius scale, the shadow depth, and the font choice.
- Brand assets — the uploaded logo and hero, which the app has modeled in
BrandDtosince M1 and has never rendered. - Navigation — the public header's labels, order, hidden entries, dropdown sections and admin-added links, plus the label/hidden overrides for the player and staff surfaces.
2. Core principle: the shipped app is the default, always
The website's governing invariant is that an instance with no settings rows renders byte-for-byte as it did before the feature existed. The app inherits that invariant unchanged, and it is unusually cheap to honor here because of a fact worth stating plainly:
The app's
ui/theme/Color.ktpalette is already, value for value, therunic-gatewaypreset. All fifteen themable tokens match. The M5 design pass was drawn from the sametheme.cssthe preset was later extracted from.
So the fallback for every color is not a "close enough" approximation — it is the
identical value. A shard with no theme_visual row must produce a ColorScheme
that is == to today's ShardColorScheme, and that is a testable claim, not an
aspiration. It is locked by a test (§7, AC-1).
The same asymmetry the server uses applies on the client: forgiving on read.
A token that is missing, malformed, or unknown falls back field-by-field to the
shipped value. A bad --accent must not discard a good --bg beside it, and a
settings call that fails is the same state as "no overrides" — never an error
screen, never a half-painted theme.
The one sanctioned exception is card depth (§5.4). The app has been flat since M5 while the preset it was drawn from selects a shadow, so applying the shadow map as specified gives an untouched instance a depth it did not have. Approved by the org lead in phase 2 rather than rebased onto the flat baseline, because the alternative left three of the admin's four choices doing nothing on the phone. Radii and colors are unaffected: both are still provable no-ops.
3. What the server already publishes
No backend work. Everything below is live on website/main today.
| Source | Field | Shape |
|---|---|---|
GET /public/settings |
theme |
Record<cssVar, string> — 15 colors, 4 radii, --shadow-card, 3 font stacks. Absent when no row exists |
GET /public/settings |
brand.accent / .logo / .hero / .favicon |
Already effective values (override → env). The app reads accent today |
GET /public/settings |
nav_public |
Raw JSON string: a bare items map, or {items, sections, links} |
GET /api/v1/settings/nav |
nav_admin, nav_player |
Raw JSON strings. Gate is requireAuth, no role check — a player may read it. The app does not call this: phase 7 cancelled (§6.4, §8) |
Two shapes to get right on the wire:
nav_publicis a JSON string inside a JSON object, becausesettings.valueisTEXT. It is parsed a second time, exactly as the web client'sparseJsonSettingdoes.themebeing absent andthemebeing{}are the same thing to the app, and both mean "shipped defaults". The server never emits an empty map (resolveThemeTokensreturnsnullinstead), but the app must not depend on that.
The trap in this payload: read the resolved fields, never the raw rows.
theme_visual and brand_assets are in PUBLIC_KEYS, so their raw JSON strings
ride along in the same response as theme and brand. They are inputs — a
preset id and a sparse custom overlay — and re-deriving a palette from them would
be a second implementation of resolveThemeTokens, in Kotlin, guaranteed to
drift the first time a preset changes. The app consumes theme and brand,
which the server has already layered :root ← preset ← custom for it, and models
neither raw key. nav_public is the one raw row the app does read, because there
is no resolved counterpart — the merge is the client's job on the web too.
4. Locked decisions
| # | Decision |
|---|---|
| Theme depth | Colors, radii, shadow and fonts — the full token set, not accent-only |
| Fonts | Bundle the families, do not use downloadable fonts — see §5.3 |
| Radii | Applied as a ratio against the runic-gateway baseline, not as literal dp — see §5.2 |
| Semantic color | --mode-live / --mode-maint and the app's success/warning/danger pills stay fixed, never themed. Mirrors the server's FIXED_TOKENS |
| Light mode | Still out of scope. Every v1 preset is dark; the website's Parchment preset was cancelled (website §8 phase 9). The app stays dark-only, and Theme.kt keeps its single darkColorScheme |
| Favicon | No app surface. Ignored, and not modeled |
| Added links | A path matching a known app route opens the native screen; anything else hands off to a Custom Tab — see §6.3 |
nav_admin / nav_player |
Not consumed at all — phase 7 cancelled 2026-08-08 (§6.4, §8). Was: label and hidden only |
| Refresh | On connect, on process start, and on resume alongside the existing role re-validation — see §5.5 |
| Failure posture | Forgiving on read, field by field. A failed settings call renders the shipped app, never an error |
5. Theme
5.1 Colors — the 15-token map
Every themable token has exactly one home in the app palette. This table is the
contract; ui/theme/Color.kt's current constants are its right-hand column.
| CSS token | App constant | Shipped value | Material role(s) |
|---|---|---|---|
--bg |
ShardSurface |
#0E1318 |
surface, surfaceContainerLow |
--bg-deep |
ShardPage |
#0B0F14 |
background |
--panel-a |
ShardCardTop |
#192231 |
feature-card gradient top |
--panel-b |
ShardCardBottom |
#141A21 |
feature-card gradient bottom |
--panel-flat |
ShardElevated |
#11161D |
surfaceVariant, surfaceContainer, surfaceContainerHigh |
--line |
ShardOutline |
#2A3544 |
outline |
--line-soft |
ShardDivider |
#1D2733 |
outlineVariant |
--accent |
ShardAccent |
#7F99BD |
secondary, tertiary |
--accent-bright |
ShardCta |
#CDD9E8 |
primary, onSecondaryContainer |
--ink |
ShardHeading |
#EEF3F8 |
brightest headings |
--head |
ShardHeadingDim |
#E6EDF6 |
heading on surface |
--text |
ShardBody |
#C4CDD8 |
onBackground, onSurface |
--muted |
ShardMuted |
#AEB8C4 |
onSurfaceVariant |
--dim |
ShardFaint |
#6F7D8E |
meta / faint labels |
--blue |
ShardPillBg |
#13243C |
secondaryContainer |
ShardOnCta (#0B0F14) is derived, not themed: it is text drawn on the
--accent-bright fill, and it tracks --bg-deep. This mirrors the server's
derived-token rule for --panel-grad — a value expressed in terms of another
token must never be frozen as a literal, or a future light preset inherits a dark
one and looks broken.
Two consumers, one resolution. Ten of these fifteen have a Material role;
five do not — ShardCardTop, ShardCardBottom, ShardHeading, ShardHeadingDim
and ShardFaint. So the resolved palette is a single ShardPalette data class,
provided two ways:
- fed into
darkColorScheme(...)for the Material roles; - exposed as a
LocalShardPaletteCompositionLocal for the rest.
Today those non-Material colors are imported as top-level vals straight from
Color.kt. That surface was measured before scoping the phase, and it is
smaller than it looks — two files, sixteen imports:
| file | imports | themable | semantic (stay fixed) |
|---|---|---|---|
ui/components/ThemeComponents.kt |
14 | ShardCardTop, ShardCardBottom, ShardElevated, ShardFaint, ShardOutline, ShardPillBg, ShardPillFg |
the 7 success/warning/danger constants |
ui/shard/ShardComponents.kt |
2 | — | ShardSuccess, ShardSuccessDot |
So the migration is one file and seven constants; nothing else in the app
reaches past MaterialTheme.colorScheme. That is the M5 design's premise paying
off — the ~20 screens take the new palette through the ColorScheme swap with no
per-screen work, which is exactly why this milestone is affordable.
It is still the phase's correctness risk rather than its bulk: leaving a direct
ShardCardTop import behind is a card that stays blue on a Fantasy shard, and
nothing fails to compile. A grep for ui.theme.Shard imports outside
ui/theme/ — expected to return only the semantic constants once phase 1
lands — is the cheap check, and belongs in the phase's PR description.
Accent handling changes. RunicGatewayTheme(accent: Color?) currently copies
one color onto primary/secondary/tertiary. That was a reasonable stand-in
for a one-field contract and is now wrong twice over: it puts --accent on
primary, which the table above assigns to --accent-bright, and it ignores the
other fourteen. It is replaced by RunicGatewayTheme(appearance: SiteAppearance).
brand.accent remains the fallback for --accent when theme is absent but the
env accent is set — which is exactly the pre-feature branding path, and must keep
working.
5.2 Radii — a ratio, not a literal
The app's Shapes came from the M5 mockup, not from theme.css, and the two
scales genuinely differ:
| web | app | |
|---|---|---|
| input / chip | --radius-input 8px |
extraSmall/small 8dp |
| card | --radius-card 10px |
medium 12dp |
| panel | --radius-panel 12px |
large 16dp |
| — | — | extraLarge 24dp |
| pill | --radius-pill 999px |
CircleShape at call sites |
A literal mapping would restyle the untouched app the moment this milestone
ships — medium 12→10, large 16→12 — which §2 forbids. Copying the app's
scale into the server is worse: a second source of truth.
Decision: apply the radii as a ratio. For each of the four fields compute
resolved ÷ runic-gateway baseline, then scale the app's own shipped dp value by
it. Consequences, all of them wanted:
- an untouched instance, or one that explicitly picks
runic-gateway, gives ratio1.0for all four and is a provable no-op; - Fantasy (
--radius-panel: 3px) → ratio0.25→large16dp → 4dp: sharp corners, at the app's own scale; - Modern (8px) →
0.667→ 12dp; extraLargehas no web counterpart and follows--radius-panel's ratio, since it is the panel family;--radius-pillat 999 keepsCircleShape; below ~50% of baseline it resolves to a rounded rect, so an admin who squares the site off squares off the app's chips too.
Round to whole dp and clamp at 0.
The pill is the one field resolved as a literal, settled in phase 2:
CircleShape is a percentage, so it has no shipped dp for a ratio to scale
against and the rule above has nothing to compute. Below the 500px floor the
resolved px is taken as dp directly — Fantasy's 4px → 4dp, Modern's 8px → 8dp —
which reads the same on a ~22dp chip as it does on the web. It is a literal
because there is no app scale to preserve here, not as an exception to the rule.
5.3 Fonts — bundled, mapped by first family
The shortlist is 12 options across three roles, spanning eight families:
- serif — EB Garamond, Merriweather, Playfair Display, IM Fell English, Georgia*
- display — Cinzel, Playfair Display, EB Garamond, IM Fell English
- sans — Inter, Work Sans, Source Sans 3, Helvetica Neue / Arial*
* system stacks with no webfont; on Android these resolve to the platform
FontFamily.Serif / FontFamily.SansSerif, which is what the app uses today.
Cinzel is already bundled (res/font/cinzel_variable.ttf, M5). Seven more
are added: EB Garamond, Merriweather, Playfair Display, IM Fell English, Inter,
Work Sans, Source Sans 3. All SIL OFL; each needs its license file under
app/licenses/, not under res/font/ (aapt rejects a .txt there — the M5
gotcha).
Downloadable fonts were rejected: they need the Play Store font provider, so a de-Googled device silently falls back, and every text style gains an async loading state.
Resolution is by the first family name in the stack, which is how the value is constructed server-side and the only part that carries the choice:
"'EB Garamond', Georgia, serif" → EBGaramond
"Cinzel, Georgia, serif" → Cinzel
"Georgia, \"Times New Roman\", serif" → FontFamily.Serif (system)
"\"Helvetica Neue\", Arial, sans-serif" → FontFamily.SansSerif (system)
<anything unrecognized> → the role's shipped family
The lookup is one global map, never scoped to the role's option list —
settled in phase 3, and the same trap §5.4 hit with --shadow-card. The server
validates an admin-entered font against FONT_OPTIONS[role], but a preset's
tokens are copied verbatim by resolveThemeTokens and never pass through that
list: modern publishes --display: 'Work Sans', Arial, sans-serif, which the
display dropdown does not offer, and fantasy publishes
--sans: 'EB Garamond', Georgia, serif, which the sans dropdown does not either.
A per-role lookup would have missed the display face of one preset and the label
face of the other.
The three roles map onto Type.kt's existing three groups verbatim:
--display → the Cinzel display/headline/title block, --serif → the AppSerif
body block, --sans → the AppSans label block. Sizes, weights and tracking do
not move — only the family.
Because a family is not confined to the role its dropdown lives in, every
bundled family supplies all four weights the scale asks for — 400 (body), 500
and 700 (labels), 600 (display) — pinned through FontVariation on the variable
faces. Two exceptions, both upstream facts rather than choices:
- IM Fell English has one weight per style. Its 400 face answers all four requests and Android synthesises the bold; the website's dropdown labels it "(no bold weight)" for the same reason. What that synthesis actually looks like is an AC-5 observation, not something a unit test can pin.
- Cinzel keeps the 500/600/700 it shipped with in M5. It is the only family the server offers in the display role alone, so nothing can ask it for 400 and adding an instance would have edited M5's type for no reachable case.
Italics mirror the website's set, decided in phase 3: EB Garamond, IM Fell
English, Merriweather and Playfair Display carry a true italic, exactly the four
client/index.html requests one for. Inter, Work Sans, Source Sans 3 and Cinzel
are upright-only and Compose skews them — which is what the app already did for
every family before this milestone, and what the web does for its own
upright-only faces. The app draws italic in two places.
APK cost, measured, not estimated — the drafted "roughly 1.5–2.5 MB" was
wrong by more than 3×. Unsigned release APK, R8 full-mode + resource shrink, on
edge at the phase 2 merge:
| added (compressed) | release APK | |
|---|---|---|
| before phase 3 | — | 5,031,411 B — 4.80 MiB |
| after phase 3 | 8,543,292 B — 8.15 MiB | 13,574,703 B — 12.94 MiB |
Merriweather is 6.08 MiB of the 8.15 — upstream ships it as a three-axis
[opsz,wdth,wght] variable font with a full charset, 4.6 MB per style, and it
deflates only 31% where the others manage 50–60%. Bundling it verbatim anyway was
the org lead's call, taken with the cheaper options costed: Google's own static
400/700 builds would have held the whole app near 6.8 MiB, and not bundling it at
all near 6.2 MiB, at the price of a serif option that silently does nothing on
Android. R8 does not shrink res/font/; the APK's deflate is the only saving.
5.4 Shadow depth
--shadow-card is one of four closed values. Compose has no CSS box-shadow, so
it maps to card elevation:
| stored value | elevation |
|---|---|
none |
0dp |
0 8px 20px rgba(0,0,0,0.25) (Soft) |
2dp |
0 14px 34px rgba(0,0,0,0.3) (Default) |
4dp |
0 18px 44px rgba(0,0,0,0.45) (Deep) |
8dp |
Matched by exact string against the server's — matched by
nearest blur, corrected in phase 2. The SHADOW_OPTIONSfantasy preset publishes
0 16px 38px rgba(0, 0, 0, 0.45), which SHADOW_OPTIONS does not contain,
because a preset's own tokens are copied verbatim by resolveThemeTokens and
never pass through the admin form's dropdown. An exact match would therefore have
missed the one preset whose point is a heavier shadow. Blur-matching puts any
future preset on the nearest step instead of silently on the default; Fantasy's
38px lands on Default's 34 rather than Deep's 44, and none is still matched as
a literal. Applied to FeatureCard and the Material Card defaults.
This map is not a no-op, and that is the decision, not an oversight — see §2.
An untouched instance has no --shadow-card, which resolves to the shipped
default of 4dp, while the app draws its cards flat today.
5.5 When the appearance is (re-)read
Today AppViewModel.loadBrand() calls GET /public/settings once, on
process start or on connect, and holds a BrandDto. That becomes a
SiteAppearance — {brand, theme, navPublic} — held in the same place and
refreshed:
- on connect and on process start (as today);
- on resume, beside the existing
sessionViewModel.revalidate(). An admin changing the theme on a laptop and picking the phone up should see it, and the app already pays for a resume round-trip.
The authenticated Phase 7 is cancelled
(§6.4, §8): the app makes no authenticated settings call, and there is nothing
session-keyed to tear down on sign-out. GET /api/v1/settings/nav is fetched only when the session is
signed in, and re-fetched when the session changes.GET /public/settings is the whole
lifecycle.
Every one of these is best-effort. A failed refresh keeps the last good appearance; there is no loading state and no error surface.
5.6 Brand assets — the logo and the hero
brand.logo and brand.hero have been in BrandDto since M1 and have never
been rendered; the app draws brand.name as text everywhere the website draws
a logo. Nothing new is needed to fetch them — they already arrive resolved, and
AppViewModel.resolveAsset / LocalAssetResolver already turn a site-relative
/uploads/… path into an absolute URL. Coil is already a dependency.
Two surfaces, chosen to mirror the website's without inventing new layout:
- the drawer header, above the instance name that sits there today, at 32dp;
- the top bar, replacing the uppercased name when a logo exists, at 24dp.
And the hero on Home, above the title block, which is the one screen with a hero-shaped space.
Both logo slots cap their width at six times their height, mirroring the
website's maxWidth: height * 6, so a long wordmark scales down rather than
pushing the drawer header or the top bar's title out of shape.
The M5 BrandLogo rule carries over: render nothing when the slot is empty.
Not a placeholder, not a reserved gap — an instance with no uploaded logo must
lay out exactly as it does today, which is §2 applied to assets. On the centered
surfaces the website stacks the logo above rather than beside, for the same
reason: a row would change the block's height on instances that have no logo.
An asset that fails to load is the same as no asset. No broken-image icon, no retry.
The top bar is the one place where "empty" is not "nothing". Everywhere else the empty slot draws literally nothing, because something else on the surface already says the instance's name. In the top bar the logo is the title, so a 404 — or a logo that can't be fetched because the shard is down — would leave the app in an unnamed shell until the next resume refresh. There "the same as no asset" resolves to the text, since the text is what an instance with no logo shows. There is deliberately no fallback while the load is still in flight: drawing the text first would flash text → logo on every navigation for the sake of one frame, as Coil serves the second and later reads from its memory cache.
The hero is a fixed 180dp band, cropped, rather than the intrinsic aspect
ratio the app's other images (PostScreen, BlockRenderer) draw at. The
website's hero is a CSS background driven by hero_layout, which the app does
not port, so the app needs a rule of its own — and the website's default hero
is a square emblem (/assets/img/runic-emblem.png), so an uploaded square is a
case to expect rather than an edge one. At the intrinsic aspect that square
would be a ~360dp block that pushes the status card off the first screenful;
cropped to a band, a wide banner and a square give the same frame above the
title. It clips to shapes.medium, so the hero follows --radius-card like
every other surface the admin can round off (§5.2). Note that the app takes no
fallback image: where the website substitutes its own emblem for an unset
hero, the app draws nothing, because §2 outranks the mirror.
Accessibility follows the website's split: the logo is decorative wherever the
name is also on screen in text (the drawer header, where the name is the very
next line) and named only where it stands alone (the top bar). Describing
the drawer's would have a screen reader say the instance's name twice — the same
call the website's alt='' makes. The hero is always decorative.
6. Navigation
6.1 The hard constraint carries over
The website's §7 constraint is a security boundary and it survives verbatim here, with one clarification the app makes concrete: an override is presentation.
The app's two gates — MenuAccess against the session, and MenuEntry.feature
against GET /public/shard/features (M11) — run after the override merge and
are unchanged by it. An override cannot introduce an app route, cannot touch
access or feature, and hidden: false never un-hides an entry the caller's
role or the shard's visibility config would otherwise withhold. Hiding is
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 theNavHost'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
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
lives in one file with the website's NAV array quoted beside it.
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
take its sort key from the same list or explicit and implicit keys sit on two
incomparable number lines (see "Phase 5 as landed"). The rows below are verbatim
from website/client/src/components/SiteHeader.jsx — note that the three news
categories are Screenshots, Five on Friday, Newsletter in that sequence,
between News and Wiki.
| # | website to |
app route | note |
|---|---|---|---|
| 0 | / |
Routes.HOME |
|
| 1 | /site/news |
Routes.NEWS |
|
| 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 |
| 3 | /site/five-on-friday |
Routes.news(FIVE_ON_FRIDAY) |
as above |
| 4 | /site/newsletter |
Routes.news(NEWSLETTER) |
as above |
| 5 | /wiki |
Routes.WIKI |
|
| 6 | /site/shard |
Routes.SHARD |
feature: status |
| 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:
-
Routes.NEWStakes no category argument today. It gains an optional one so a link to one of the three category pages can land on the right tab. This is a small route change with its own test, not a nav concern. -
Seven web entries have no
APP_MENUrow — champs / guilds / governors / houses (the app puts them behind the Shard hub, which is the better phone shape and stays) and the three news categories (tabs on one screen). An override for one of them therefore has a mapped route but no menu entry. Rule: an override for a path the app does not surface in its menu is ignored, exactly as the web drops an override for an unknownto. It is not an invitation to add the 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/screenshotsputs 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_publiccounterpart — Contact, Account and Notifications, the three player groups, and the four staff rows. They are unaffected bynav_publicand keep their coded order, appended after the overridden public block in the drawer. (They already sit below the public entries today, so this is the current layout, not a new one.) A few of them are instead reachable throughnav_admin/nav_player— but fewer than you would expect, which is §6.4's subject.
6.3 Sections and added links
nav_public may carry sections and links (website phase 10). Both land in
the drawer:
- A section renders as a drawer group with its label as a header and its members indented beneath — the drawer's natural idiom. The website's click-to-open dropdown does not translate and is not copied; a drawer is already a vertical list.
pruneNav's rule is ported and is load-bearing: a section whose every member is hidden by the role or feature gate must not render as an empty header. The app's port drops it.- An added link carries no gate and always shows, matching the web. Its
tois validated the same way the web validates it on read — must start with a single/, no//, no whitespace or quote characters — and a value failing that is dropped rather than rendered.
An added link opens natively when its path maps to an app route, and hands off to a Custom Tab otherwise. The patterns the app can resolve:
/ → HOME
/site/news → NEWS
/site/{screenshots,five-on-friday,newsletter}
→ NEWS, that category's tab
/site/newsletter/<id> → POST
/wiki → WIKI
/wiki/<slug> → WIKI_PAGE
/site/<shard surface> → the mapped shard route (per §6.2)
/site/atlas/<slug> → ATLAS_CREATURE
/site/market/vendors/<serial> → SHARD_MARKET_VENDOR
/site/about → PAGE("about")
/<slug> → PAGE(slug), unless <slug> is reserved
anything else → WebHandoff (Custom Tab), M3's existing hand-off
Correction (2026-08-08). The table this replaces named three paths the website does not serve, and was written from the app's routes rather than the site's. Checked against
website/client/src/App.jsx: there is no/site/news/<idOrSlug>— news items render on their category page and the site's one post-detail route is the newsletter's/site/newsletter/:id; there is no/page/<slug>— CMS pages are served from a top-level/<slug>; and there is no/contactat all, the app's contact form being app-only (§6.2 says as much). The site's real detail routes for the atlas and the market were missing. A link resolver that does not read the site's own route table cannot be right by accident, so it now quotes it, next to §6.2's table and for the same reason.Decision (2026-08-08): the
/<slug>catch-all is in, with the site's non-CMS top-level segments (admin,account,player,site,wiki,invite,preview,api,uploads) excluded from it. React Router ranks its static routes above/:slugand the app has to do the same, or a link to the admin panel would open an in-app 404 instead of the real thing in a browser. The reserved list is a second piece of cross-repo coupling and it lives beside the first. It buys the case that matters most: a page the admin wrote — the likeliest added link there is — opens natively.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; the browser honors it exactly.
A native match still passes through the app's own gates: an added link to
/site/market on a shard that does not publish the market lands on the Market
screen's honest "not published here" state (M11's FEATURE_UNAVAILABLE), which
is what typing the URL on the web does too. The link itself is not gated — that
is the website's decision and the app does not second-guess it.
In the drawer (all four settled by the org lead before code, 2026-08-08):
- A section is a static header with its rows indented beneath it, always open. A collapsible group was considered and rejected: it costs remembered state per section and can hide the row the admin meant to surface.
- A link that hands off carries a trailing icon
(
Icons.AutoMirrored.Filled.ExitToApp, the only "leaves the app" glyph inmaterial-icons-core— the extended artifact is not a dependency and phase 3 already spent the APK budget). A link the app resolves natively is deliberately indistinguishable from a coded row; that is the point of resolving it. - The Custom Tab's absolute URL comes from
LocalAssetResolver, which already resolves any site-relative path against the configured base URL and is already provided at the app root. A nav path travelling through something named "asset resolver" is the cost; zero new plumbing is the benefit. - A resolved link opens like any other drawer row —
navigateTopLevel, detail screen or not — rather than pushing onto the current screen. One rule, and back-press lands on Home exactly as it does from every other row.
6.4 nav_admin and nav_player — label and hidden only
Both are bare maps and neither carries sections or links. The app honors
label and hidden, and ignores order and group.
The reason is that the app's rows are a small and differently shaped subset — and the actual overlap was measured before scoping the phase, because it turned out to be thinner than the milestone assumed:
website to |
app route | |
|---|---|---|
/player |
PLAYER_CHARACTERS |
✅ |
/account |
ACCOUNT |
✅ |
/account/appeals |
— | the app has no appeals screen at all |
/admin |
ADMIN_DASHBOARD |
✅ |
/admin/moderation |
ADMIN_MODERATION |
✅ |
| — | ADMIN_CONTENT |
an app-side aggregate of the website's separate Posts / Pages / Wiki / Activity rows |
| — | ADMIN_SUPPORT |
likewise; the nearest web row is /admin/moderation/appeals, which is not the same screen |
| — | PLAYER_VENDORS, PLAYER_HOUSES |
no player-portal row on the web |
| — | NOTIFICATIONS, CONTACT |
app-only surfaces |
So nav_player reaches two app rows and nav_admin reaches two. The website
sidebar's other ~18 rows are admin configuration the app deliberately excludes
(M10/M11), and two of the app's four staff entries are aggregates with no single
web row to be renamed from.
That is the whole case for label-and-hidden-only. Reordering two rows against a
foreign order of twenty-two is noise, and group names sections the app does not
render. Renaming "Characters" or hiding it is still a real intent that should
reach the phone, and four rows' worth of it is worth one cached call.
It is also the case for questioning whether phase 7 is worth building at all. Four rows is a thin return for a new authenticated fetch, a session-keyed cache and its teardown. It is scheduled last precisely so that decision can be taken with the rest of the milestone already working — dropping it costs nothing that phases 0–6 depend on. Unmapped keys are ignored either way.
Decision (2026-08-08): phase 7 is cancelled. The measurement above was the whole case for building it and it did not carry.
nav_adminandnav_playerare therefore not read by the app at all — the player and staff drawer rows keep their coded@StringReslabels and their coded visibility, and this section stands as the record of why rather than as a spec. See §8.
A label override replaces a @StringRes. MenuEntry.labelRes is an int; the
resolved entry carries label: String? beside it and the drawer prefers it. That
means an admin's label is not localized — it is one string for every locale,
which is what an admin typing a label means, and matches the website.
7. Acceptance criteria
-
AC-1 — the no-op proof. With
themeabsent,nav_publicabsent and nobrand_assets, the resolvedColorScheme,Shapes,Typographyand drawer entry list are equal to today's shipped values. A unit test asserts the fullColorSchemeequality, not a spot check. Card elevation is excluded by the phase 2 decision in §2/§5.4 — an untouched instance gains the 4dp therunic-gatewayshadow resolves to, and the test asserts that value rather than the app's former flat one. -
AC-2 — per-field fallback. A
thememap carrying one valid token and four malformed ones applies the one and falls back on the four. -
AC-3 — the gates still hold. An override marking a feature-gated or role-gated entry
hidden: falseshows nothing to a caller who fails that gate. A section whose members are all gated out does not render. -
AC-4 — degradation. With the settings call failing, the app renders the shipped theme and the coded menu, with no error surface.
-
AC-5 — on-device. Two passes on the AVD against a local website:
- one against an instance themed Fantasy, with a reordered and sectioned nav, one added link of each kind (native-mapped and Custom-Tab), and an uploaded logo and hero;
- one against an untouched instance, confirming AC-1 by eye as well as by test — this is the pass that catches a token a screen never read.
The role dimension reuses the existing five-rung walk (
anonymous,logged_in,player,staff,admin) from../link/v3.md§11, since §6.1's whole claim is that the override merge does not disturb the gates.
8. Build phases
Every phase targets edge in Android-app/ and docs/, cut fresh from
main in both. The feature reaches main as one edge → main merge when
all phases are done — the same shape the website side used. Do not open a phase
PR against main.
| # | Phase | Ships |
|---|---|---|
| 0 ✅ | Contract & appearance store | SettingsDto gains theme and nav_public; SiteAppearance replaces the bare BrandDto in AppViewModel; second-stage JSON parse; resume refresh (§5.5). No visual change — this phase must be invisible |
| 1 ✅ | Colors | ShardPalette + LocalShardPalette; all direct Color.kt imports migrated; RunicGatewayTheme(appearance); AC-1 + AC-2 tests |
| 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 |
| 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 |
| 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 |
| 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. The walk found two theming defects and phase 8 fixed them — see "Phase 8 as landed" |
Phase 0 is the one with a hard rule attached: it must change nothing on screen. Everything after it is additive on top of a store that is already proven not to have moved anything.
Phase 0 as landed
themeis modeled as a rawJsonElement, notMap<String, String>?. The table in §5.1 is a closed set of string-valued tokens and the server validates every one on write, so a typed map is what the contract says. Butkotlinx.serializationfails the decode of the whole object on a value of an unexpected kind, andthemeshares its payload withbrandandpush— so one odd token would have blanked the branding and dropped the push relay URL, which is the opposite of §2's forgiving-on-read. It is coerced field-by-field inSiteAppearance.frominstead: a non-string or blank value costs exactly its own token.- The second-stage parse stops at "is this a plain object".
data/appearance/SettingsJson.ktis the Kotlin counterpart of the web'slib/settingsJson.jsand makes the same single judgement — absent, malformed, or a storednull/number/string/array all read as absent. Readingitems,sectionsandlinksout of the parsed object belongs to phases 5 and 6, so phase 0 ships no half-built nav model. SiteAppearance.NONEis the shipped app, and three different things resolve to it: no settings rows, a backend that predates the feature, and a settings call that failed outright. That is §2 expressed as a value rather than as a rule to remember.- A failed refresh keeps the last good appearance rather than falling back
to
NONE. §5.5 said "best-effort"; the distinction it did not draw is that a moment of no connectivity on resume must not repaint a themed shard back to the defaults. Only the initial load can produceNONE. - The resume refresh lives in
MainActivity, notRunicApp. The appearance feeds the theme, which wraps the whole tree including the connect screen, so it sits beside the theme rather than inside the app shell. It is a secondLifecycleResumeEffectnext to the session's, and it no-ops unless the state isReady. RunicAppstill takesbrand: BrandDto?. ThreadingSiteAppearancefurther down is phase 1's and phase 5's business; leaving the shell's signature alone is what makes this phase's diff provably invisible.AppState.Readyis the only place the type changed.- Tests:
SettingsJsonTest(6),SiteAppearanceTest(8), plus three decode cases inPublicDtoTest. 360 unit tests green,lintDebugandassembleDebugclean.AppViewModelitself stays untested — its four collaborators are concrete classes withContext/prefs dependencies, and all of the phase's logic is in the two pure modules above.
Phase 1 as landed
ColorSchemedoes not implementequals. AC-1 asks for "the fullColorSchemeequality, not a spot check", and material3 1.3.0 simply has noequals/hashCodeon it (checked against the artifact, not assumed). The proof is therefore a field-by-field compare by reflection over everyColor-valued getter — 36 roles in 1.3.0 — rather than a hand-written list of the roles the mapping happens to set. A role added to Material, or one the mapping forgets, cannot escape the assertion, and a guard on the role count fails if the reflection ever stops seeing them. The expected value is a verbatim copy of the pre-M12ShardColorSchemeheld in the test, the same device the website used to lockhtmlShell's output: the proof is against what the app used to do, not against what the new code does today.ShardPillFgis derived, not a sixteenth token. §5.1 lists it amongThemeComponents.kt's themable imports but the token table has fifteen rows and none of them is it — because its value isShardCta's, both--accent-bright. It followsctaunder the same rule §5.1 states forShardOnCta, so the neutral pill's text tracks the CTA fill rather than freezing at today's literal.- An env-accent instance changes, and that is the intended fix. §5.1 called
the old
RunicGatewayTheme(accent)"wrong twice over"; correcting it has a visible consequence worth stating plainly. A shard with aBRAND_ACCENT_COLORand notheme_visualrow previously had that color onprimary/secondary/tertiary— so its filled CTA buttons carried the accent. It now seeds--accentonly, which issecondary/tertiary, andprimaryreturns to--accent-bright. Links and highlights keep the brand color; filled buttons go back to the light CTA fill the design specifies. An instance that wants its buttons accented sets the accent from Admin → Appearance, which is what the token map is for. - The palette resolution is pure and the theme is the only composable.
ShardPalette.resolve(theme, brandAccent)takes a token map, andshardColorScheme(palette)takes a palette — neither knows aboutSiteAppearance, so both AC-1 and AC-2 are plain JVM assertions with no Compose test rule.RunicGatewayThemeis the one place the two meet. - The §5.1 grep came out exactly as predicted. After the migration,
ui.theme.Shardimports outsideui/theme/are the seven semantic constants inThemeComponents.ktand the two inShardComponents.kt— nothing themable left behind, which was the phase's stated correctness risk. toneColorsbecame@Composableto read the palette. It is private and called only fromStatusPill, so this costs nothing; the alternative — passing a palette parameter through the pill's public signature — would have leaked the theme into every call site.- Tests:
ShardPaletteTest(9 — the shipped no-op, all fifteen tokens landing in the right field, AC-2's one-good-four-bad map, unknown tokens ignored, the brand-accent fallback and the token beating it, a malformed brand accent, and both derived colors) andShardColorSchemeTest(4). 373 unit tests green,lintDebugandassembleDebugclean. Not exercised on device — that is AC-5, in phase 8.
Phase 2 as landed
- The app is flat, so §5.4 is a visible default change. (It was written here
as the milestone's only one. Phase 8 found a second — the card container
colour — and a third that is not a change at all but a correction to this
record: an instance with a
BRAND_HERO_IMAGEand nobrand_assetsrow now draws a hero on Home, becausebrand.herocarries the env default. See "Phase 8 as landed".) Material3 1.3.0's filledCardisElevationTokens.Level0— 0dp, checked in the artifact's bytecode, not assumed — andFeatureCardwas aBoxwith a clip, a gradient and a border, drawing none of the "soft shadow" its own KDoc claimed. Therunic-gatewaypreset meanwhile selects the Default shadow, so reading §5.4 literally gives an untouched instance 4dp on every card. The org lead chose that over rebasing the table on the flat baseline (which would have collapsednone/Soft/Defaultonto 0dp and left onlyDeepdoing anything). §2 records the exception; the radius half remains a provable no-op. ShardCardexists because Material's theme cannot carry elevation. The color scheme and the shape scale both reach screens throughMaterialTheme, butCardtakes its elevation as a default argument — there is no composition local behindCardDefaults.cardElevation(). So the phase migrated all 24Card(call sites across 20 files to a one-line wrapper inThemeComponents.kt. Every one of them passed nothing but a modifier, which is why the wrapper's signature is(Modifier, ColumnScope.() -> Unit)and the migration is mechanical. ACard(outside that file is now, by construction, a card the shard cannot theme.--radius-pillreaches exactly one composable. The app has threeCircleShapeuses and two of them are 8dp status dots (OnlineDot,LiveChip); a dot stays a dot however square an admin makes the site. OnlyStatusPilltakes the resolved shape. §5.2 records the literal-px rule the pill needs because a percentage shape has no dp to scale.Shapesdoes implementequals— the opposite of phase 1'sColorSchemefinding, and also checked in the bytecode. So the structural no-op proof is one assertion against a verbatim copy of the pre-M12 scale rather than a reflection walk. Both the empty theme and the fullrunic-gatewaytoken map are asserted==toShardStructure.Shipped.FeatureCard's literal 12dp becameMaterialTheme.shapes.medium— the same value, so no-op, but now carried by the ratio.StatBar's threeRoundedCornerShape(3.dp)stay literal: that is half the height of a 6dp meter, not a member of the card radius family.- The structure resolution is pure, like the palette's.
ShardStructure.resolve(theme)takes a token map and returns shapes + pill + elevation, so every assertion is a plain JVM test with no Compose rule.RunicGatewayThemeremains the only composable where resolution happens. - Tests:
ShardStructureTest(13 — the shipped scale against a verbatim pre-M12 copy, three no-op paths, Fantasy and Modern scaled onto the app's dp, per-field fallback, a zero radius, the pill floor, all four shadow options, the off-catalog blurs, and an unreadable shadow). 386 unit tests green,lintDebugandassembleDebugclean. Not exercised on device — that is AC-5, in phase 8, where the new shadow should be looked at with the flat build beside it.
Phase 3 as landed
- The APK nearly tripled, and that was a decision rather than a discovery. §5.3 now carries the measured before/after and the two cheaper options that were costed and declined. The one number worth remembering: Merriweather is 75% of the payload, because upstream publishes it as a three-axis variable font that barely compresses. Anyone revisiting the app's size should start there and nowhere else.
- The per-role font list is not the set of values a role can hold. Two of the three presets publish a font their own role's dropdown does not offer (§5.3). The resolution map is therefore global, keyed by the lowercased first family name, and the tests assert both preset cases by name so a future per-role "tidy-up" fails loudly.
Typographyimplementsequals— like phase 2'sShapesand unlike phase 1'sColorScheme, checked the same way in the material3 1.3.0 bytecode. AC-1's type half is one comparison against a verbatim copy of the pre-M12 scale held in the test, so a stray edit to a size or a letter-spacing inType.ktfails there rather than quietly redefining what "shipped" means.- No
LocalShardTypeface, deliberately. The palette and the structure each needed a composition local for the partsMaterialThemecannot carry; the families need none. Every text style in the app comes fromMaterialTheme.typography, and the two places that override anything (NotificationsScreen's hint,BlockRenderer's italic title) override the style, not the family.FontFamily.Monospaceon the recovery-codes screen stays fixed, as a semantic choice rather than a themed one. Type.kt'sval TypographybecameshardTypography(faces), which is the whole migration: the three families were referenced from that one file and nowhere else, so unlike phase 1's colour imports and phase 2's 24Card(sites there was no call-site sweep at all.- Licences live in
app/licenses/, one*-OFL.txtper family, never underres/font/— aapt rejects a.txtthere, the M5 gotcha. Three of the seven carry a Reserved Font Name (Merriweather, Playfair Display, Source Sans 3), which is a further reason the binaries are taken verbatim rather than subsetted or instanced locally. - Tests:
ShardTypefaceTest(15 — the shipped families and the pre-M12 scale, therunic-gatewayno-op, both preset bypasses by name, all thirteen shortlist options, first-name parsing against quoting/casing/whitespace, per-field fallback, a colours-only theme, blank and comma-only stacks, and a themed scale proved to differ from the shipped one only in its families). 401 unit tests green (386 + 15),lintDebugandassembleDebugclean. Not exercised on device — that is AC-5, in phase 8, where IM Fell English's synthesised bold is the thing to look at.
Phase 4 as landed
- The empty slot is enforced by layout, not by a conditional at each site.
Every size and spacing modifier hangs off the image itself, so when the image
is not composed neither is its padding. A caller that wants space below the
hero passes
Modifier.padding(bottom = 16.dp)rather than a siblingSpacer, and both cases — asset and no asset — come out right without the caller knowing which it got. This is the detail that makes "renders nothing when unset" hold without abrand?.hero != nullcheck leaking intoHomeScreen. - Three §5.6 questions the spec left open, settled by the org lead before code: the top bar falls back to the text rather than going blank; the hero is a fixed cropped band rather than its intrinsic aspect; the hero sits inside Home's existing 20dp padding with themed corners rather than going full-bleed. All three are written into §5.6 above with their reasoning, so they are not re-litigated from scratch.
- A failed load is keyed on the URL.
remember(url)resets the failure flag when a resume refresh (§5.5) swaps the logo, so an instance that fixes a broken upload recovers on the next refresh instead of inheriting the old failure for the life of the process. - The blank check runs on both sides of the resolver.
BrandDtodefaults every asset field to""rather than null — the server publishes the empty string for "not set" — and a resolver with no base URL configured may hand a path straight back. Null out ofbrandAssetUrlis the "draw nothing" signal, so a blank slipping through either side would put a zero-size image request in the layout instead of no image at all. - No new dependency, no new asset, no APK cost. Unlike phase 3 this phase
adds nothing to the package:
LocalAssetResolver(M1) and Coil (M5) were both already there, andBrandDtohas carriedlogoandherosince M1 without a reader. The whole phase is one new file plus three call sites. - Tests:
BrandAssetsTest(9 — every shape "not set" arrives in, the defaultBrandDto,SiteAppearance.NONE, site-relative and absolute paths, and a resolver that returns null or blank). 410 unit tests green (401 + 9),lintDebugandassembleDebugclean. The drawing itself is untested and cannot be tested here — the app carries no Robolectric and has noandroidTestsource set, so a composable body cannot run in a JVM test. Only the decision of whether to draw is pure, which is whybrandAssetUrlis pulled out of the composables at all. Everything else about this phase is AC-5's to catch, and it is the phase with the most riding on that walk: a cropped hero, a synthesised bold, and a logo's contrast against the top bar are all things only a screen shows.
Phase 5 as landed
- An untouched instance gets
APP_MENUback by identity, not by equality.applyNavOverridesreturns 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-lineassertSamerather 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
orderis 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_MENUthat 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. groupandsectionare 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 stringitems.Routes.NEWS_ROUTEis declared besideRoutes.NEWSrather than replacing it, because the two are used for different things: the pattern is whatcomposable()anddestination.routespeak, the bare route is what callers navigate to. Navigating to plainRoutes.NEWSmatches the pattern with no argument, so the drawer row and the push deep-link (Routes.forStream) are untouched. The consequence to remember:destination.routeis now a pattern carrying a query, soRunicAppcompares onsubstringBefore('?')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 thePostCategoryenum, not a slug string (unlike the existingRoutes.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.NewsViewModelfalls 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 newNewsViewModelcases (and three existing ones rewritten onto aSavedStateHandle). 442 unit tests green (410 + 32),lintDebugandassembleDebugclean. AC-3 is covered three ways — a feature-gated row that an override relabels, moves to the front and markshidden: falseis 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 plainnewsreally does match the optional-argument pattern at runtime, are AC-5's.
Phase 6 as landed
- The spec's link table was wrong about the website, and that is the finding to
remember. Three of its rows named paths the site does not serve and two of the
site's real detail routes were missing — see the correction in §6.3. Phase 5 hit
the same class of error in §6.2's row order. Both times the fix was to quote the
website's own source beside the table, so
NavPaths.ktnow carriesApp.jsx's route list next toSiteHeader.jsx'sNAV. - Phase 6 does not re-implement phase 5, and AC-1 is unchanged because of it.
buildNavTreehands straight toapplyNavOverrideswhen the stored row carries no sections and no links, so an untouched instance still getsAPP_MENUback by identity — the tree build only runs when the admin actually created structure. The regression test is that an items-only row through the tree equals the same row through the flat merge. sectionon an item override is read but not counted as "usable". A section-only override says nothing to a flat list, soNavOverride.isEmptydeliberately excludes it and an instance that only ever grouped rows still gets its coded list back by identity fromapplyNavOverrides. The tree adds its own check. This is the one place the two consumers of a stored row disagree about what "empty" means, and they have to.- A dangling reference degrades to "no grouping", never to "no row". A member of a section that does not exist — because the admin deleted it, or its label was blank and it was dropped — is an ordinary top-level row, not a row that vanished with its section. Same for a link naming an unknown section: its destination is still good, only the grouping was wrong.
visibleEntrieswas split intoisEntryVisible.pruneNavapplies the predicate inside a section as well as at the top level, and both callers must ask exactly one question, or a sectioned row could end up gated by a rule its top-level twin is not. The list form now just filters with it.- Sort keys extend phase 5's number line rather than starting a new one. A
coded row 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 a0default. Both tie-breaks are still the web's. - 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 — so a section
holding one is never emptied by the caller's role, while a section holding only
gated rows is dropped rather than drawn as a header over nothing. Both are
tested; the second is the case
pruneNavexists for. - Tests:
NavTreeTest(27) + 7 newNavPathsTestcases. 476 unit tests green (442 + 34),lintDebugandassembleDebugclean, no new lint findings. Phase 4's limit still holds — no Robolectric and noandroidTestsource 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.
Phase 7, cancelled
Reading nav_admin / nav_player is not scheduled. The measurement in §6.4
is the reason and it stands: the two authenticated navs reach four app rows
between them (/player, /account, /admin, /admin/moderation), because the
website sidebar's other ~18 rows are admin configuration the app deliberately
excludes and two of the app's four staff entries are aggregates with no single
web row to be renamed from. Four rows does not pay for a new authenticated fetch,
a session-keyed cache and its teardown on sign-out.
Consequences, all of them wanted:
- the app never calls
GET /api/v1/settings/nav, and the "a player may read it" note in §3 becomes moot for this client; - the player and staff drawer rows keep their coded
@StringReslabels, so those labels stay localized — which is the one thing the app gives up by not honoring an admin's override, and the trade reads better in this direction for four rows; - §6.4's label-and-hidden-only rule and the
label: String?-beside-labelResmechanism still ship, because phase 5 needs both for the public nav. Nothing in phases 0–6 was scaffolding for phase 7.
If it is ever picked up, §6.4 is the spec and the merge would reuse phase 5's.
Phase 8 as landed
Phase 8 was scoped as docs, coverage and the cutover. The AC-5 walk turned it into a phase with code in it, which is the outcome the walk existed to produce.
AC-5 confirmed what the JVM could not reach. Two passes on the AVD against a
local website, the themed one walked at every role rung. Everything phases 1–6
had deferred to it held: the Fantasy palette on screen; radii visibly square
against the untouched build's rounded; EB Garamond arriving in the sans role,
which is phase 3's preset-bypass finding proved on a device rather than in a test;
the logo replacing the top bar's text with the 6× width cap holding; the hero
centre-cropped into its 180dp band with the top and bottom of a 4:3 image cut;
reorder, relabel and hide; the section header with its indented children; the
added link with a native route carrying no badge and the handing-off one
carrying it; and the Custom Tab genuinely opening — confirmed as
CustomTabActivity in dumpsys activity, not by eye. AC-3's role dimension held
at every rung: an anonymous caller saw the reordered, sectioned public nav with
no player or staff row leaking, and the player rung stopped at My houses.
AC-4 fell out for free when the second AVD started against an unreachable server:
shipped theme, coded menu, one retry, no error surface.
A correction the walk forced, with no code behind it. An untouched instance
gains a hero on Home. brand.hero carries the BRAND_HERO_IMAGE env default
— /assets/img/runic-emblem.png on this one — so "admin has set nothing" does not
mean "the slot is empty", and phase 4's BrandHero draws it. Nothing is wrong
here: the website's own Home shows the same emblem, and §5.6's rule is that a
blank slot draws nothing, which still holds. What was wrong is the record —
phase 2 claimed the card shadow was the milestone's only visible change to an
untouched app, and this was the second before the card container made it three.
Worth stating plainly because a shard operator upgrading the app sees it without
having touched Appearance at all.
Two defects, one root cause, and it is phase 2's. Material takes some themed
values as default arguments rather than from the theme. Phase 2 found this for
card elevation and answered it by sweeping every Card( into ShardCard(. Nobody
then asked whether colour and shape had the same problem. They did:
- Every
ShardCarddrew in Material's grey, not the shard's panel colour.CardDefaults.cardColors()takes its container fromsurfaceContainerHighest—FilledCardTokens.ContainerColor, checked in the material3 1.3.0 artifact's bytecode the same way phases 1 and 2 checked theirs — andshardColorSchememappedsurfaceContainer,…Highand…Lowbut not…Highest. All 26 call sites in 20 files.FeatureCardwas unaffected because it readsLocalShardPalettedirectly, which is exactly why Home looked right and every other screen did not — the single most misleading thing about the bug. - The drawer's selected row ignored
--radius-pill.NavigationDrawerItemtakesshapeas a default argument (CircleShape); the three call sites setcolorsand nevershape. On Fantasy every other radius went square while the selected row stayed a full pill.
The card fix changes the untouched app, and that is the honest framing. The
grey cards are not an M12 regression — an untouched instance draws them too, and
has since M5. M12 only made it visible by theming everything around them. So
fixing it moves the shipped app's cards from Material's grey to --panel-flat:
the milestone's second deliberate change to a shard that has set nothing,
alongside phase 2's card shadow. §9's last bullet had already called this shot —
"if a screen looks wrong under a warm preset, that is a token the screen should
have been reading and did not" — and this is that, at 26 call sites.
There is no version of the fix that spares untouched instances: the shipped
palette is the runic-gateway preset, so the value the cards should take is the
same value either way. AC-1 therefore records the change instead of absorbing
it — every other role is still asserted byte-for-byte against the verbatim pre-M12
scheme, and the two that moved are named, given their new values, and checked to
have genuinely differed before, so the test cannot pass by coincidence.
The role sweep, so the next person does not redo it. Of the roles
shardColorScheme leaves at darkColorScheme()'s defaults, exactly one had a
live reader in this app: surfaceContainerHighest. Checked against the token
class of every Material component the app actually draws —
NavigationDrawerTokens, SheetBottomTokens, AssistChipTokens,
FilterChipTokens, TopAppBarSmallTokens, BadgeTokens — all of which read only
roles already mapped. scrim is read by the modal drawer and stays Material's
black on purpose; a tinted scrim is a design change, not a gap.
surfaceContainerLowest is mapped for consistency with …Low and has no reader.
primaryContainer, tertiaryContainer, inverse*, surfaceBright, surfaceDim
and surfaceTint are latent — the app draws no Snackbar, tonal button, elevated
or outlined card. Adding one of those components means checking this list again.
Coverage: the exclusion list had gone stale under the milestone.
sonar.coverage.exclusions carried a ui/theme/** directory glob from the M11
coverage push, when that directory held only constants and composables. M12 put
three pure resolvers in it — ShardPalette, ShardStructure, ShardTypeface,
the reason phases 1–3 could prove their no-op invariants as JVM assertions — and
the glob was dropping all three out of the denominator at 98%, 100% and 100%. It
is now the one file it was really about, ui/theme/Theme.kt. This did not rescue
the gate: M12's already-measured code (data/appearance/ and ui/navigation/,
93–100%) clears new_coverage ≥ 50 on its own. It makes the number honest about
which code the tests hold, so deleting those tests would move it.
Tests: 477 green (476 + the card-container assertion), lintDebug and
assembleDebug clean. The JVM limit from phase 4 is unchanged and is why this
phase needed a device at all: no Robolectric, no androidTest source set, so a
composable body cannot run in a unit test. Both defects were invisible to 476
passing tests and obvious within two minutes of looking at a themed screen.
Rig notes, for the next walk. The emulator reaches a local website through
adb reverse tcp:3000 tcp:3000, which lets the app use http://127.0.0.1:3000
and the debug network_security_config's loopback exception unchanged — do
not add 10.0.2.2 to it for a test rig. The Custom Tab resolves against the same
base URL, so hand-off links work under the same tunnel.
9. Out of scope
- Light mode / a light preset. The website cancelled its Parchment phase; the app stays dark-only.
- Favicon. No app surface.
- Editing any of this from the app. The M10 staff surface does not include Appearance or Navigation, and this milestone does not add them. The app is a consumer.
- A theme preview. Out of scope on the web too.
- Per-screen restyling. If a screen looks wrong under a warm preset, that is a token the screen should have been reading and did not — fix the call site, do not add a special case.