Two records, one milestone. Phase 7 (reading nav_admin / nav_player) is cancelled. Section 6.4 already made the case against it and scheduled it last so the call could be taken on its merits with the rest working: the two authenticated navs reach four app rows between them, which does not pay for a new authenticated fetch, a session-keyed cache and its teardown. The app therefore makes no authenticated settings call at all, and the player and staff drawer rows keep their coded -- and so localized -- labels, which is the one thing given up. Section 4's locked decision, section 3's endpoint row and section 5.5's lifecycle paragraph are amended to match so the doc does not contradict itself, and section 8 gains the standing "Phase 7, cancelled" note. Phase 0 (the contract and the appearance store) landed as Android-app#33, and its "as landed" notes record two departures from what section 8 specified: - `theme` is modeled as a raw JsonElement rather than Map<String,String>?. kotlinx 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, the opposite of section 2. It is coerced field-by-field instead. - A failed refresh keeps the last good appearance rather than falling back to NONE. Section 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 shipped defaults. Co-Authored-By: Claude <noreply@anthropic.com>
31 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.
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.
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 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 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;
- 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.NEWStakes 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_MENUrow (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 unknownto. 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_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|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 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. -
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 |
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 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.
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.