Files
docs/android/THEMING_AND_NAV.md
wtclaude 1746ad4e37 docs(android): M12 phase 2 as landed
The radii-and-shadow phase, shipped as Android-app#35. Seven notes, of which
three are amendments to the spec rather than records of it.

Section 5.4's elevation map is not a no-op and now says so, with section 2 and
AC-1 amended to match. Material3's filled Card is Level0 and FeatureCard drew
none of the shadow its own KDoc claimed, so the app has been flat since M5 -
while the runic-gateway preset it was drawn from selects the "Default" shadow.
The org lead chose to apply the map as written rather than rebase it on the flat
baseline, because rebasing would have collapsed none/Soft/Default onto 0dp and
left only Deep doing anything on the phone. The radius half is untouched by this
and remains a provable no-op.

Section 5.4's exact-string match is corrected to a nearest-blur match. The
fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS' four values,
because resolveThemeTokens copies a preset's tokens verbatim and they never pass
through the admin form's dropdown.

Section 5.2 gains the rule the pill needed: CircleShape is a percentage, so it
has no shipped dp for a ratio to scale and the resolved px is taken as dp below
the 500px floor. It is a literal because there is no app scale to preserve, not
as an exception to the ratio rule.

Also recorded: why all 24 Card( call sites became ShardCard( (Material takes
elevation as a default argument, not from the theme), that --radius-pill reaches
only StatusPill, and that Shapes - unlike ColorScheme - does implement equals,
so the structural no-op proof is one assertion rather than a reflection walk.

PLAN.md 9's M12 entry records phase 2 landed.

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

699 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
**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 `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.
**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 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.52.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`~~ — **matched 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.
### 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/<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`](../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<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 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.