Files
docs/android/THEMING_AND_NAV.md
wtclaude d95632db04 docs(android): M12 phase 3 as landed
§5.3 rewritten against what the build measured rather than what the plan
estimated, plus a "Phase 3 as landed" section and the  in §8.

Three corrections to the drafted section:

- The APK estimate was wrong by more than 3×. "Roughly 1.5-2.5 MB" becomes a
  measured before/after table: 5,031,411 B → 13,574,703 B, +8.15 MiB. The
  section now records that Merriweather is 6.08 MiB of that on its own, and the
  two cheaper options the org lead costed and declined, so the number is not
  re-litigated from scratch later.
- Family lookup must be global, never scoped to the role's option list. Two of
  the three presets publish a font their own role's dropdown does not offer,
  because resolveThemeTokens copies preset tokens verbatim — the same bypass
  §5.4 already records for --shadow-card.
- Italics mirror the website's four rather than being skipped, and every
  bundled family supplies the four weights the scale asks for (with IM Fell
  English's single weight and Cinzel's untouched 500/600/700 as the two
  documented exceptions), because a family is not confined to the role its
  dropdown lives in.

Android-app: RunicGateway/Android-app#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 05:46:02 -05:00

44 KiB
Raw Blame History

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; see PLAN.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:

  1. Theme — the whole resolved color palette, the corner-radius scale, the shadow depth, and the font choice.
  2. Brand assets — the uploaded logo and hero, which the app has modeled in BrandDto since M1 and has never rendered.
  3. 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.kt palette is already, value for value, the runic-gateway preset. All fifteen themable tokens match. The M5 design pass was drawn from the same theme.css the 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_public is a JSON string inside a JSON object, because settings.value is TEXT. It is parsed a second time, exactly as the web client's parseJsonSetting does.
  • theme being absent and theme being {} are the same thing to the app, and both mean "shipped defaults". The server never emits an empty map (resolveThemeTokens returns null instead), 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 LocalShardPalette CompositionLocal 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 ratio 1.0 for all four and is a provable no-op;
  • Fantasy (--radius-panel: 3px) → ratio 0.25large 16dp → 4dp: sharp corners, at the app's own scale;
  • Modern (8px) → 0.667 → 12dp;
  • extraLarge has no web counterpart and follows --radius-panel's ratio, since it is the panel family;
  • --radius-pill at 999 keeps CircleShape; 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.52.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 5060%. 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 SHADOW_OPTIONSmatched by nearest blur, corrected in phase 2. The fantasy 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 GET /api/v1/settings/nav is fetched only when the session is signed in, and re-fetched when the session changes. 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 /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;
  • the top bar, replacing the uppercased name when a logo exists.

And the hero on Home, above the title block, which is the one screen with a hero-shaped space.

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.

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.

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.

website to app route note
/ Routes.HOME
/site/news Routes.NEWS
/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
/site/newsletter Routes.news(NEWSLETTER)
/site/screenshots Routes.news(SCREENSHOTS)
/wiki Routes.WIKI
/site/shard Routes.SHARD feature: status
/site/champs Routes.SHARD_CHAMPS not in APP_MENU today — reached via the Shard hub
/site/guilds Routes.SHARD_GUILDS as above
/site/governors Routes.SHARD_GOVERNORS as above
/site/houses Routes.SHARD_HOUSES as above
/site/rules Routes.SHARD_RULES
/site/atlas Routes.ATLAS
/site/leaderboards Routes.SHARD_LEADERBOARDS
/site/market Routes.SHARD_MARKET
/site/about Routes.page("about")

Three asymmetries to resolve rather than paper over:

  • 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 change with its own test, not a nav concern.
  • Four web entries have no APP_MENU row (champs / guilds / governors / houses — the app puts them behind the Shard hub, which is the better phone shape and stays). 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 unknown to. It is not an invitation to add the entry — the hub is a deliberate design choice, and a nav override may not introduce navigation.
  • Ten app entries have no nav_public counterpart — Contact, Account and Notifications, the three player groups, and the four staff rows. They are unaffected by nav_public and 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 through nav_admin / nav_player — but fewer than you would expect, which is §6.4's subject.

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 to is 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|five-on-friday|newsletter|screenshots  → NEWS (category)
/site/news/<idOrSlug>      → POST
/wiki                      → WIKI
/wiki/<slug>               → WIKI_PAGE
/site/<shard surface>      → the mapped shard route (per §6.2)
/site/about, /page/<slug>  → PAGE
/contact                   → CONTACT
anything else              → WebHandoff (Custom Tab), M3's existing hand-off

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.

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 06 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_admin and nav_player are therefore not read by the app at all — the player and staff drawer rows keep their coded @StringRes labels 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 theme absent, nav_public absent and no brand_assets, the resolved ColorScheme, Shapes, Typography and drawer entry list are equal to today's shipped values. A unit test asserts the full ColorScheme equality, not a spot check. Card elevation is excluded by the phase 2 decision in §2/§5.4 — an untouched instance gains the 4dp the runic-gateway shadow resolves to, and the test asserts that value rather than the app's former flat one.

  • AC-2 — per-field fallback. A theme map 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: false shows 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 edgemain 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 edgemain

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

  • theme is modeled as a raw JsonElement, not Map<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. But kotlinx.serialization fails the decode of the whole object on a value of an unexpected kind, and theme shares its payload with brand and push — 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 in SiteAppearance.from instead: 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.kt is the Kotlin counterpart of the web's lib/settingsJson.js and makes the same single judgement — absent, malformed, or a stored null/number/string/array all read as absent. Reading items, sections and links out of the parsed object belongs to phases 5 and 6, so phase 0 ships no half-built nav model.
  • SiteAppearance.NONE is 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 produce NONE.
  • The resume refresh lives in MainActivity, not RunicApp. 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 second LifecycleResumeEffect next to the session's, and it no-ops unless the state is Ready.
  • RunicApp still takes brand: BrandDto?. Threading SiteAppearance further 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.Ready is the only place the type changed.
  • Tests: SettingsJsonTest (6), SiteAppearanceTest (8), plus three decode cases in PublicDtoTest. 360 unit tests green, lintDebug and assembleDebug clean. AppViewModel itself stays untested — its four collaborators are concrete classes with Context/prefs dependencies, and all of the phase's logic is in the two pure modules above.

Phase 1 as landed

  • ColorScheme does not implement equals. AC-1 asks for "the full ColorScheme equality, not a spot check", and material3 1.3.0 simply has no equals/hashCode on it (checked against the artifact, not assumed). The proof is therefore a field-by-field compare by reflection over every Color-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-M12 ShardColorScheme held in the test, the same device the website used to lock htmlShell's output: the proof is against what the app used to do, not against what the new code does today.
  • ShardPillFg is derived, not a sixteenth token. §5.1 lists it among ThemeComponents.kt's themable imports but the token table has fifteen rows and none of them is it — because its value is ShardCta's, both --accent-bright. It follows cta under the same rule §5.1 states for ShardOnCta, 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 a BRAND_ACCENT_COLOR and no theme_visual row previously had that color on primary/secondary/tertiary — so its filled CTA buttons carried the accent. It now seeds --accent only, which is secondary/tertiary, and primary returns 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, and shardColorScheme(palette) takes a palette — neither knows about SiteAppearance, so both AC-1 and AC-2 are plain JVM assertions with no Compose test rule. RunicGatewayTheme is the one place the two meet.
  • The §5.1 grep came out exactly as predicted. After the migration, ui.theme.Shard imports outside ui/theme/ are the seven semantic constants in ThemeComponents.kt and the two in ShardComponents.kt — nothing themable left behind, which was the phase's stated correctness risk.
  • toneColors became @Composable to read the palette. It is private and called only from StatusPill, 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) and ShardColorSchemeTest (4). 373 unit tests green, lintDebug and assembleDebug clean. Not exercised on device — that is AC-5, in phase 8.

Phase 2 as landed

  • The app is flat, so §5.4 is the milestone's one visible default change. Material3 1.3.0's filled Card is ElevationTokens.Level0 — 0dp, checked in the artifact's bytecode, not assumed — and FeatureCard was a Box with a clip, a gradient and a border, drawing none of the "soft shadow" its own KDoc claimed. The runic-gateway preset 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 collapsed none/Soft/Default onto 0dp and left only Deep doing anything). §2 records the exception; the radius half remains a provable no-op.
  • ShardCard exists because Material's theme cannot carry elevation. The color scheme and the shape scale both reach screens through MaterialTheme, but Card takes its elevation as a default argument — there is no composition local behind CardDefaults.cardElevation(). So the phase migrated all 24 Card( call sites across 20 files to a one-line wrapper in ThemeComponents.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. A Card( outside that file is now, by construction, a card the shard cannot theme.
  • --radius-pill reaches exactly one composable. The app has three CircleShape uses and two of them are 8dp status dots (OnlineDot, LiveChip); a dot stays a dot however square an admin makes the site. Only StatusPill takes the resolved shape. §5.2 records the literal-px rule the pill needs because a percentage shape has no dp to scale.
  • Shapes does implement equals — the opposite of phase 1's ColorScheme finding, 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 full runic-gateway token map are asserted == to ShardStructure.Shipped.
  • FeatureCard's literal 12dp became MaterialTheme.shapes.medium — the same value, so no-op, but now carried by the ratio. StatBar's three RoundedCornerShape(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. RunicGatewayTheme remains 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, lintDebug and assembleDebug clean. 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.
  • Typography implements equals — like phase 2's Shapes and unlike phase 1's ColorScheme, 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 in Type.kt fails there rather than quietly redefining what "shipped" means.
  • No LocalShardTypeface, deliberately. The palette and the structure each needed a composition local for the parts MaterialTheme cannot carry; the families need none. Every text style in the app comes from MaterialTheme.typography, and the two places that override anything (NotificationsScreen's hint, BlockRenderer's italic title) override the style, not the family. FontFamily.Monospace on the recovery-codes screen stays fixed, as a semantic choice rather than a themed one.
  • Type.kt's val Typography became shardTypography(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 24 Card( sites there was no call-site sweep at all.
  • Licences live in app/licenses/, one *-OFL.txt per family, never under res/font/ — aapt rejects a .txt there, 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, the runic-gateway no-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), lintDebug and assembleDebug clean. 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 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 @StringRes labels, 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-labelRes mechanism still ship, because phase 5 needs both for the public nav. Nothing in phases 06 was scaffolding for phase 7.

If it is ever picked up, §6.4 is the spec and the merge would reuse phase 5's.

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.