# 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`](../website/THEMING_AND_NAV.md). > Same workflow as the website side: design → phased build → verify. > Milestone **M12**; see [`PLAN.md`](./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. ## 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` — 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 `val`s 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` **12**dp | | panel | `--radius-panel` 12px | `large` **16**dp | | — | — | `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.25` → `large` 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. ### 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) → the role's shipped family ``` 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. **IM Fell English has no bold weight** (the website doc records the same). A `FontWeight.Bold` request against it must resolve to its single weight rather than synthesize; check what Compose does here on device and pin the behavior in the phase's notes. APK cost: roughly **1.5–2.5 MB** across seven families, variable-axis where Google Fonts publishes one (Cinzel, EB Garamond, Merriweather, Playfair Display, Inter, Work Sans, Source Sans 3) and single-weight for IM Fell English. Measure the release APK before and after, and record both numbers in the PR — R8 does not shrink `res/font/`. ### 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_OPTIONS`; anything else is the shipped default. Applied to `FeatureCard` and the Material `Card` defaults. ### 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. ### 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 `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/ → POST /wiki → WIKI /wiki/ → WIKI_PAGE /site/ → the mapped shard route (per §6.2) /site/about, /page/ → 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 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_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. - **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`](../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` | 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?`.** 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 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 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. ## 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.