Corrects §6.3's link-resolution table, which named three website paths the site does not serve (/site/news/<idOrSlug>, /page/<slug>, /contact) and missed two it does (/site/atlas/:slug, /site/market/vendors/:serial), and records the four drawer decisions taken before code: the /<slug> CMS catch-all with the site's reserved segments excluded, the static section header, the hand-off icon, and reusing LocalAssetResolver for the Custom Tab's absolute URL. Adds "Phase 6 as landed" and ticks the phase in §8. Co-Authored-By: Claude <noreply@anthropic.com>
1037 lines
62 KiB
Markdown
1037 lines
62 KiB
Markdown
# 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 lookup is one global map, never scoped to the role's option list** —
|
||
settled in phase 3, and the same trap §5.4 hit with `--shadow-card`. The server
|
||
validates an *admin-entered* font against `FONT_OPTIONS[role]`, but a preset's
|
||
tokens are copied verbatim by `resolveThemeTokens` and never pass through that
|
||
list: `modern` publishes `--display: 'Work Sans', Arial, sans-serif`, which the
|
||
display dropdown does not offer, and `fantasy` publishes
|
||
`--sans: 'EB Garamond', Georgia, serif`, which the sans dropdown does not either.
|
||
A per-role lookup would have missed the display face of one preset and the label
|
||
face of the other.
|
||
|
||
The three roles map onto `Type.kt`'s existing three groups verbatim:
|
||
`--display` → the Cinzel display/headline/title block, `--serif` → the `AppSerif`
|
||
body block, `--sans` → the `AppSans` label block. Sizes, weights and tracking do
|
||
not move — only the family.
|
||
|
||
Because a family is not confined to the role its dropdown lives in, **every
|
||
bundled family supplies all four weights the scale asks for** — 400 (body), 500
|
||
and 700 (labels), 600 (display) — pinned through `FontVariation` on the variable
|
||
faces. Two exceptions, both upstream facts rather than choices:
|
||
|
||
- **IM Fell English has one weight per style.** Its 400 face answers all four
|
||
requests and Android synthesises the bold; the website's dropdown labels it
|
||
"(no bold weight)" for the same reason. What that synthesis actually looks like
|
||
is an AC-5 observation, not something a unit test can pin.
|
||
- **Cinzel keeps the 500/600/700 it shipped with in M5.** It is the only family
|
||
the server offers in the display role alone, so nothing can ask it for 400 and
|
||
adding an instance would have edited M5's type for no reachable case.
|
||
|
||
**Italics mirror the website's set**, decided in phase 3: EB Garamond, IM Fell
|
||
English, Merriweather and Playfair Display carry a true italic, exactly the four
|
||
`client/index.html` requests one for. Inter, Work Sans, Source Sans 3 and Cinzel
|
||
are upright-only and Compose skews them — which is what the app already did for
|
||
every family before this milestone, and what the web does for its own
|
||
upright-only faces. The app draws italic in two places.
|
||
|
||
APK cost, **measured, not estimated** — the drafted "roughly 1.5–2.5 MB" was
|
||
wrong by more than 3×. Unsigned release APK, R8 full-mode + resource shrink, on
|
||
`edge` at the phase 2 merge:
|
||
|
||
| | added (compressed) | release APK |
|
||
|---|---|---|
|
||
| before phase 3 | — | 5,031,411 B — 4.80 MiB |
|
||
| after phase 3 | 8,543,292 B — 8.15 MiB | 13,574,703 B — **12.94 MiB** |
|
||
|
||
**Merriweather is 6.08 MiB of the 8.15** — upstream ships it as a three-axis
|
||
`[opsz,wdth,wght]` variable font with a full charset, 4.6 MB per style, and it
|
||
deflates only 31% where the others manage 50–60%. Bundling it verbatim anyway was
|
||
the org lead's call, taken with the cheaper options costed: Google's own static
|
||
400/700 builds would have held the whole app near 6.8 MiB, and not bundling it at
|
||
all near 6.2 MiB, at the price of a serif option that silently does nothing on
|
||
Android. R8 does not shrink `res/font/`; the APK's deflate is the only saving.
|
||
|
||
### 5.4 Shadow depth
|
||
|
||
`--shadow-card` is one of four closed values. Compose has no CSS box-shadow, so
|
||
it maps to card elevation:
|
||
|
||
| stored value | elevation |
|
||
|---|---|
|
||
| `none` | 0dp |
|
||
| `0 8px 20px rgba(0,0,0,0.25)` (Soft) | 2dp |
|
||
| `0 14px 34px rgba(0,0,0,0.3)` (Default) | 4dp |
|
||
| `0 18px 44px rgba(0,0,0,0.45)` (Deep) | 8dp |
|
||
|
||
~~Matched by exact string against the server's `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, at 32dp;
|
||
- **the top bar**, replacing the uppercased name when a logo exists, at 24dp.
|
||
|
||
And the hero on **Home**, above the title block, which is the one screen with a
|
||
hero-shaped space.
|
||
|
||
Both logo slots cap their width at **six times their height**, mirroring the
|
||
website's `maxWidth: height * 6`, so a long wordmark scales down rather than
|
||
pushing the drawer header or the top bar's title out of shape.
|
||
|
||
The M5 `BrandLogo` rule carries over: **render nothing when the slot is empty.**
|
||
Not a placeholder, not a reserved gap — an instance with no uploaded logo must
|
||
lay out exactly as it does today, which is §2 applied to assets. On the centered
|
||
surfaces the website stacks the logo *above* rather than beside, for the same
|
||
reason: a row would change the block's height on instances that have no logo.
|
||
|
||
An asset that fails to load is the same as no asset. No broken-image icon, no
|
||
retry.
|
||
|
||
**The top bar is the one place where "empty" is not "nothing".** Everywhere else
|
||
the empty slot draws literally nothing, because something else on the surface
|
||
already says the instance's name. In the top bar the logo *is* the title, so a
|
||
404 — or a logo that can't be fetched because the shard is down — would leave
|
||
the app in an unnamed shell until the next resume refresh. There "the same as no
|
||
asset" resolves to the text, since the text is what an instance with no logo
|
||
shows. There is deliberately **no fallback while the load is still in flight**:
|
||
drawing the text first would flash text → logo on every navigation for the sake
|
||
of one frame, as Coil serves the second and later reads from its memory cache.
|
||
|
||
**The hero is a fixed 180dp band, cropped**, rather than the intrinsic aspect
|
||
ratio the app's other images (`PostScreen`, `BlockRenderer`) draw at. The
|
||
website's hero is a CSS background driven by `hero_layout`, which the app does
|
||
not port, so the app needs a rule of its own — and the website's *default* hero
|
||
is a square emblem (`/assets/img/runic-emblem.png`), so an uploaded square is a
|
||
case to expect rather than an edge one. At the intrinsic aspect that square
|
||
would be a ~360dp block that pushes the status card off the first screenful;
|
||
cropped to a band, a wide banner and a square give the same frame above the
|
||
title. It clips to `shapes.medium`, so the hero follows `--radius-card` like
|
||
every other surface the admin can round off (§5.2). Note that the app takes **no
|
||
fallback image**: where the website substitutes its own emblem for an unset
|
||
hero, the app draws nothing, because §2 outranks the mirror.
|
||
|
||
Accessibility follows the website's split: the logo is **decorative wherever the
|
||
name is also on screen in text** (the drawer header, where the name is the very
|
||
next line) and **named only where it stands alone** (the top bar). Describing
|
||
the drawer's would have a screen reader say the instance's name twice — the same
|
||
call the website's `alt=''` makes. The hero is always decorative.
|
||
|
||
## 6. Navigation
|
||
|
||
### 6.1 The hard constraint carries over
|
||
|
||
The website's §7 constraint is a security boundary and it survives verbatim here,
|
||
with one clarification the app makes concrete: **an override is presentation.**
|
||
|
||
The app's two gates — `MenuAccess` against the session, and `MenuEntry.feature`
|
||
against `GET /public/shard/features` (M11) — run **after** the override merge and
|
||
are unchanged by it. An override cannot introduce an app route, cannot touch
|
||
`access` or `feature`, and `hidden: false` never un-hides an entry the caller's
|
||
role or the shard's visibility config would otherwise withhold. Hiding is
|
||
subtractive, exactly as `applyNavOverrides` has it.
|
||
|
||
> **Decision (2026-08-08): an admin may hide the Home row.** The website lets `/`
|
||
> be hidden from the public header — the brand link still goes home — and the app
|
||
> mirrors it rather than inventing a policy the site does not have. Home remains
|
||
> the `NavHost`'s start destination and remains reachable by back-press; unlike
|
||
> the website's `/admin/navigation`, which has three guards because hiding it
|
||
> would strip the only way to *undo* an override, nothing about a hidden Home row
|
||
> is unrecoverable. Hiding therefore stays one uniform rule with no special cases.
|
||
|
||
### 6.2 Path → app route
|
||
|
||
The public nav is keyed by **website** paths. The app needs a mapping table, and
|
||
it is the one new piece of cross-repo coupling this milestone introduces — so it
|
||
lives in one file with the website's `NAV` array quoted beside it.
|
||
|
||
**The table is in the website's order, and that order is load-bearing.** A stored
|
||
`order` is an index into the site's nav, so a row the admin never moved has to
|
||
take its sort key from the same list or explicit and implicit keys sit on two
|
||
incomparable number lines (see "Phase 5 as landed"). The rows below are verbatim
|
||
from `website/client/src/components/SiteHeader.jsx` — note that the three news
|
||
categories are **Screenshots, Five on Friday, Newsletter** in that sequence,
|
||
between News and Wiki.
|
||
|
||
| # | website `to` | app route | note |
|
||
|---|---|---|---|
|
||
| 0 | `/` | `Routes.HOME` | |
|
||
| 1 | `/site/news` | `Routes.NEWS` | |
|
||
| 2 | `/site/screenshots` | `Routes.news(SCREENSHOTS)` | the app's News screen already has all four categories as tabs — these three select one, and none has an `APP_MENU` row |
|
||
| 3 | `/site/five-on-friday` | `Routes.news(FIVE_ON_FRIDAY)` | as above |
|
||
| 4 | `/site/newsletter` | `Routes.news(NEWSLETTER)` | as above |
|
||
| 5 | `/wiki` | `Routes.WIKI` | |
|
||
| 6 | `/site/shard` | `Routes.SHARD` | `feature: status` |
|
||
| 7 | `/site/champs` | `Routes.SHARD_CHAMPS` | **not in `APP_MENU` today** — reached via the Shard hub |
|
||
| 8 | `/site/guilds` | `Routes.SHARD_GUILDS` | as above |
|
||
| 9 | `/site/governors` | `Routes.SHARD_GOVERNORS` | as above |
|
||
| 10 | `/site/houses` | `Routes.SHARD_HOUSES` | as above |
|
||
| 11 | `/site/rules` | `Routes.SHARD_RULES` | |
|
||
| 12 | `/site/atlas` | `Routes.ATLAS` | |
|
||
| 13 | `/site/leaderboards` | `Routes.SHARD_LEADERBOARDS` | |
|
||
| 14 | `/site/market` | `Routes.SHARD_MARKET` | |
|
||
| 15 | `/site/about` | `Routes.page("about")` | |
|
||
|
||
**The `feature` values are not mirrored into this table**, though the website's
|
||
array carries one on nine of these rows. `APP_MENU` stays the app's own source of
|
||
truth for gating: a second copy of a security-relevant value that drifts silently
|
||
is worth more than it costs. The table carries the mapping and nothing else.
|
||
|
||
Three asymmetries to resolve rather than paper over:
|
||
|
||
- **`Routes.NEWS` takes no category argument today.** It gains an optional one so
|
||
a link to one of the three category pages can land on the right tab. This is a
|
||
small route change with its own test, not a nav concern.
|
||
- **Seven web entries have no `APP_MENU` row** — champs / guilds / governors /
|
||
houses (the app puts them behind the Shard hub, which is the better phone shape
|
||
and stays) and the three news categories (tabs on one screen). An override for
|
||
one of them therefore has a mapped route but no menu entry. **Rule: an override
|
||
for a path the app does not surface in its menu is ignored**, exactly as the web
|
||
drops an override for an unknown `to`. It is *not* an invitation to add the
|
||
entry — the hub and the tab strip are deliberate design choices, and a nav
|
||
override may not introduce navigation.
|
||
|
||
> **Decision (2026-08-08):** this rule covers the **news categories too**, not
|
||
> only the hub four. The mapping in the table above is what phase 6's added
|
||
> links resolve against — and *there* a category tab is a perfectly good
|
||
> destination, because the admin named it by path — but neither a relabel nor a
|
||
> reorder of `/site/screenshots` puts a new row in the drawer. The alternative
|
||
> considered and rejected was surfacing such a row only when overridden, which
|
||
> keeps AC-1 but lets an override introduce navigation after all.
|
||
- **Ten app entries have no `nav_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 → NEWS
|
||
/site/{screenshots,five-on-friday,newsletter}
|
||
→ NEWS, that category's tab
|
||
/site/newsletter/<id> → POST
|
||
/wiki → WIKI
|
||
/wiki/<slug> → WIKI_PAGE
|
||
/site/<shard surface> → the mapped shard route (per §6.2)
|
||
/site/atlas/<slug> → ATLAS_CREATURE
|
||
/site/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||
/site/about → PAGE("about")
|
||
/<slug> → PAGE(slug), unless <slug> is reserved
|
||
anything else → WebHandoff (Custom Tab), M3's existing hand-off
|
||
```
|
||
|
||
> **Correction (2026-08-08).** The table this replaces named three paths the
|
||
> website does not serve, and was written from the app's routes rather than the
|
||
> site's. Checked against `website/client/src/App.jsx`: there is **no
|
||
> `/site/news/<idOrSlug>`** — news items render on their category page and the
|
||
> site's one post-detail route is the newsletter's `/site/newsletter/:id`; there
|
||
> is **no `/page/<slug>`** — CMS pages are served from a top-level `/<slug>`; and
|
||
> there is **no `/contact`** at all, the app's contact form being app-only (§6.2
|
||
> says as much). The site's real detail routes for the atlas and the market were
|
||
> missing. A link resolver that does not read the site's own route table cannot be
|
||
> right by accident, so it now quotes it, next to §6.2's table and for the same
|
||
> reason.
|
||
>
|
||
> **Decision (2026-08-08): the `/<slug>` catch-all is in**, with the site's
|
||
> non-CMS top-level segments (`admin`, `account`, `player`, `site`, `wiki`,
|
||
> `invite`, `preview`, `api`, `uploads`) excluded from it. React Router ranks its
|
||
> static routes above `/:slug` and the app has to do the same, or a link to the
|
||
> admin panel would open an in-app 404 instead of the real thing in a browser.
|
||
> The reserved list is a second piece of cross-repo coupling and it lives beside
|
||
> the first. It buys the case that matters most: a page the admin wrote — the
|
||
> likeliest added link there is — opens natively.
|
||
>
|
||
> **A path carrying a query or a fragment hands off**, whatever its route part
|
||
> says. No app route takes either, so a native match would quietly drop what the
|
||
> admin typed; the browser honors it exactly.
|
||
|
||
A native match still passes through the app's own gates: an added link to
|
||
`/site/market` on a shard that does not publish the market lands on the Market
|
||
screen's honest "not published here" state (M11's `FEATURE_UNAVAILABLE`), which
|
||
is what typing the URL on the web does too. The link itself is not gated — that
|
||
is the website's decision and the app does not second-guess it.
|
||
|
||
**In the drawer** (all four settled by the org lead before code, 2026-08-08):
|
||
|
||
- **A section is a static header with its rows indented beneath it**, always
|
||
open. A collapsible group was considered and rejected: it costs remembered
|
||
state per section and can hide the row the admin meant to surface.
|
||
- **A link that hands off carries a trailing icon**
|
||
(`Icons.AutoMirrored.Filled.ExitToApp`, the only "leaves the app" glyph in
|
||
`material-icons-core` — the extended artifact is not a dependency and phase 3
|
||
already spent the APK budget). A link the app resolves natively is deliberately
|
||
indistinguishable from a coded row; that is the point of resolving it.
|
||
- **The Custom Tab's absolute URL comes from `LocalAssetResolver`**, which
|
||
already resolves any site-relative path against the configured base URL and is
|
||
already provided at the app root. A nav path travelling through something named
|
||
"asset resolver" is the cost; zero new plumbing is the benefit.
|
||
- **A resolved link opens like any other drawer row** — `navigateTopLevel`,
|
||
detail screen or not — rather than pushing onto the current screen. One rule,
|
||
and back-press lands on Home exactly as it does from every other row.
|
||
|
||
### 6.4 `nav_admin` and `nav_player` — label and hidden only
|
||
|
||
Both are bare maps and neither carries sections or links. The app honors
|
||
**`label` and `hidden`, and ignores `order` and `group`.**
|
||
|
||
The reason is that the app's rows are a small and *differently shaped* subset —
|
||
and the actual overlap was measured before scoping the phase, because it turned
|
||
out to be thinner than the milestone assumed:
|
||
|
||
| website `to` | app route | |
|
||
|---|---|---|
|
||
| `/player` | `PLAYER_CHARACTERS` | ✅ |
|
||
| `/account` | `ACCOUNT` | ✅ |
|
||
| `/account/appeals` | — | the app has no appeals screen at all |
|
||
| `/admin` | `ADMIN_DASHBOARD` | ✅ |
|
||
| `/admin/moderation` | `ADMIN_MODERATION` | ✅ |
|
||
| — | `ADMIN_CONTENT` | an app-side aggregate of the website's separate Posts / Pages / Wiki / Activity rows |
|
||
| — | `ADMIN_SUPPORT` | likewise; the nearest web row is `/admin/moderation/appeals`, which is not the same screen |
|
||
| — | `PLAYER_VENDORS`, `PLAYER_HOUSES` | no player-portal row on the web |
|
||
| — | `NOTIFICATIONS`, `CONTACT` | app-only surfaces |
|
||
|
||
**So `nav_player` reaches two app rows and `nav_admin` reaches two.** The website
|
||
sidebar's other ~18 rows are admin *configuration* the app deliberately excludes
|
||
(M10/M11), and two of the app's four staff entries are aggregates with no single
|
||
web row to be renamed from.
|
||
|
||
That is the whole case for label-and-hidden-only. Reordering two rows against a
|
||
foreign order of twenty-two is noise, and `group` names sections the app does not
|
||
render. Renaming "Characters" or hiding it is still a real intent that should
|
||
reach the phone, and four rows' worth of it is worth one cached call.
|
||
|
||
**It is also the case for questioning whether phase 7 is worth building at all.**
|
||
Four rows is a thin return for a new authenticated fetch, a session-keyed cache
|
||
and its teardown. It is scheduled last precisely so that decision can be taken
|
||
with the rest of the milestone already working — dropping it costs nothing that
|
||
phases 0–6 depend on. Unmapped keys are ignored either way.
|
||
|
||
> **Decision (2026-08-08): phase 7 is cancelled.** The measurement above was the
|
||
> whole case for building it and it did not carry. `nav_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 3 as landed
|
||
|
||
- **The APK nearly tripled, and that was a decision rather than a discovery.**
|
||
§5.3 now carries the measured before/after and the two cheaper options that
|
||
were costed and declined. The one number worth remembering: Merriweather is
|
||
75% of the payload, because upstream publishes it as a three-axis variable font
|
||
that barely compresses. Anyone revisiting the app's size should start there and
|
||
nowhere else.
|
||
- **The per-role font list is not the set of values a role can hold.** Two of the
|
||
three presets publish a font their own role's dropdown does not offer (§5.3).
|
||
The resolution map is therefore global, keyed by the lowercased first family
|
||
name, and the tests assert both preset cases by name so a future per-role
|
||
"tidy-up" fails loudly.
|
||
- **`Typography` implements `equals`** — like phase 2's `Shapes` and unlike phase
|
||
1's `ColorScheme`, checked the same way in the material3 1.3.0 bytecode. AC-1's
|
||
type half is one comparison against a verbatim copy of the pre-M12 scale held in
|
||
the test, so a stray edit to a size or a letter-spacing in `Type.kt` fails there
|
||
rather than quietly redefining what "shipped" means.
|
||
- **No `LocalShardTypeface`, deliberately.** The palette and the structure each
|
||
needed a composition local for the parts `MaterialTheme` cannot carry; the
|
||
families need none. Every text style in the app comes from
|
||
`MaterialTheme.typography`, and the two places that override anything
|
||
(`NotificationsScreen`'s hint, `BlockRenderer`'s italic title) override the
|
||
*style*, not the family. `FontFamily.Monospace` on the recovery-codes screen
|
||
stays fixed, as a semantic choice rather than a themed one.
|
||
- **`Type.kt`'s `val Typography` became `shardTypography(faces)`**, which is the
|
||
whole migration: the three families were referenced from that one file and
|
||
nowhere else, so unlike phase 1's colour imports and phase 2's 24 `Card(` sites
|
||
there was no call-site sweep at all.
|
||
- **Licences live in `app/licenses/`**, one `*-OFL.txt` per family, never under
|
||
`res/font/` — aapt rejects a `.txt` there, the M5 gotcha. Three of the seven
|
||
carry a Reserved Font Name (Merriweather, Playfair Display, Source Sans 3),
|
||
which is a further reason the binaries are taken verbatim rather than subsetted
|
||
or instanced locally.
|
||
- Tests: `ShardTypefaceTest` (15 — the shipped families and the pre-M12 scale, the
|
||
`runic-gateway` no-op, both preset bypasses by name, all thirteen shortlist
|
||
options, first-name parsing against quoting/casing/whitespace, per-field
|
||
fallback, a colours-only theme, blank and comma-only stacks, and a themed scale
|
||
proved to differ from the shipped one *only* in its families). **401 unit tests
|
||
green** (386 + 15), `lintDebug` and `assembleDebug` clean. Not exercised on
|
||
device — that is AC-5, in phase 8, where IM Fell English's synthesised bold is
|
||
the thing to look at.
|
||
|
||
### Phase 4 as landed
|
||
|
||
- **The empty slot is enforced by layout, not by a conditional at each site.**
|
||
Every size and spacing modifier hangs off the image itself, so when the image
|
||
is not composed neither is its padding. A caller that wants space below the
|
||
hero passes `Modifier.padding(bottom = 16.dp)` rather than a sibling `Spacer`,
|
||
and both cases — asset and no asset — come out right without the caller
|
||
knowing which it got. This is the detail that makes "renders nothing when
|
||
unset" hold without a `brand?.hero != null` check leaking into `HomeScreen`.
|
||
- **Three §5.6 questions the spec left open, settled by the org lead before
|
||
code**: the top bar falls back to the text rather than going blank; the hero
|
||
is a fixed cropped band rather than its intrinsic aspect; the hero sits inside
|
||
Home's existing 20dp padding with themed corners rather than going full-bleed.
|
||
All three are written into §5.6 above with their reasoning, so they are not
|
||
re-litigated from scratch.
|
||
- **A failed load is keyed on the URL.** `remember(url)` resets the failure flag
|
||
when a resume refresh (§5.5) swaps the logo, so an instance that fixes a broken
|
||
upload recovers on the next refresh instead of inheriting the old failure for
|
||
the life of the process.
|
||
- **The blank check runs on both sides of the resolver.** `BrandDto` defaults
|
||
every asset field to `""` rather than null — the server publishes the empty
|
||
string for "not set" — and a resolver with no base URL configured may hand a
|
||
path straight back. Null out of `brandAssetUrl` is the "draw nothing" signal,
|
||
so a blank slipping through either side would put a zero-size image request in
|
||
the layout instead of no image at all.
|
||
- **No new dependency, no new asset, no APK cost.** Unlike phase 3 this phase
|
||
adds nothing to the package: `LocalAssetResolver` (M1) and Coil (M5) were both
|
||
already there, and `BrandDto` has carried `logo` and `hero` since M1 without a
|
||
reader. The whole phase is one new file plus three call sites.
|
||
- Tests: `BrandAssetsTest` (9 — every shape "not set" arrives in, the default
|
||
`BrandDto`, `SiteAppearance.NONE`, site-relative and absolute paths, and a
|
||
resolver that returns null or blank). **410 unit tests green** (401 + 9),
|
||
`lintDebug` and `assembleDebug` clean. **The drawing itself is untested and
|
||
cannot be tested here** — the app carries no Robolectric and has no
|
||
`androidTest` source set, so a composable body cannot run in a JVM test. Only
|
||
the decision of *whether* to draw is pure, which is why `brandAssetUrl` is
|
||
pulled out of the composables at all. Everything else about this phase is
|
||
AC-5's to catch, and it is the phase with the most riding on that walk: a
|
||
cropped hero, a synthesised bold, and a logo's contrast against the top bar
|
||
are all things only a screen shows.
|
||
|
||
### Phase 5 as landed
|
||
|
||
- **An untouched instance gets `APP_MENU` back by identity, not by equality.**
|
||
`applyNavOverrides` returns the *same list instance* when there is no stored row,
|
||
an empty one, or one with nothing usable in it — so AC-1's claim for the drawer
|
||
is a one-line `assertSame` rather than a structural comparison, and there is no
|
||
path where an unedited nav is rebuilt and could come out different. "Nothing
|
||
usable" is a real case worth the check: a blank label, `hidden: false`, an
|
||
unknown path, and a path the app maps but does not surface all say nothing.
|
||
- **An untouched row's implicit sort key is its index in the WEBSITE's nav, not
|
||
the app's** — the one thing the spec did not settle and the whole sort turns on.
|
||
A stored `order` is a position in the site's sixteen-row list, so a key taken
|
||
from the app's nine-row block would put explicit and implicit keys on two
|
||
incomparable number lines: an unmoved About (app index 8) would sort ahead of an
|
||
unmoved Market (web order 14) the moment any row carried an explicit order. This
|
||
is why §6.2's table is now numbered and why its order is called load-bearing.
|
||
Both tie-breaks are the web's — an explicit order beats a coincidental index,
|
||
and two explicit orders keep code order because the sort is stable.
|
||
- In practice the editor writes an order for *every* visible row when the admin
|
||
drags anything, so a mix of explicit and implicit keys is the stale-row case
|
||
rather than the normal one. It still has to resolve predictably.
|
||
- **The app's own rows are partitioned off, not sorted.** Contact, Account,
|
||
Notifications, the three player groups and the four staff rows have no website
|
||
counterpart to be reordered against, so they keep their coded order after the
|
||
public block. They already sit there today, which is what makes the partition
|
||
the current layout rather than a new one — but it does mean an `APP_MENU` that
|
||
interleaved an app-only row *among* the public ones would see it moved to the
|
||
tail. Nothing does today; a future row should be added with that in mind.
|
||
- **`group` and `section` are read and dropped.** The app renders no sections in
|
||
this phase (phase 6) and never renders the admin sidebar's groups at all, and a
|
||
value that cannot be honored is better dropped than half-applied. Both stored
|
||
shapes are read, though: website phase 10's `{items, sections, links}` and the
|
||
bare map phases 6-8 stored, which is unambiguous because every key in it is a
|
||
path and so can never be the string `items`.
|
||
- **`Routes.NEWS_ROUTE` is declared beside `Routes.NEWS` rather than replacing
|
||
it**, because the two are used for different things: the pattern is what
|
||
`composable()` and `destination.route` speak, the bare route is what callers
|
||
navigate to. Navigating to plain `Routes.NEWS` matches the pattern with no
|
||
argument, so the drawer row and the push deep-link (`Routes.forStream`) are
|
||
untouched. The consequence to remember: **`destination.route` is now a pattern
|
||
carrying a query**, so `RunicApp` compares on `substringBefore('?')` for both
|
||
the top-level check and the selected-row check. A future route that takes an
|
||
optional argument inherits that for free; one that does not, and is compared
|
||
against by hand, will not.
|
||
- **`Routes.news()` takes the `PostCategory` enum, not a slug string** (unlike the
|
||
existing `Routes.post()`), so an unmapped category cannot reach the NavHost —
|
||
the tab strip *is* the enum's entries, and a slug it does not know would select
|
||
nothing. `NewsViewModel` falls back to the default feed for an unknown slug
|
||
anyway, since a hand-edited settings row can carry one.
|
||
- **Phase 5 ships `Routes.news(category)`; phase 6 is its first caller.** Nothing
|
||
in the drawer navigates to a category tab, by the §6.2 decision above. It ships
|
||
here because it is the table's route builder and the table is this phase's.
|
||
- Tests: `NavOverridesTest` (19) + `NavPathsTest` (11) + 2 new `NewsViewModel`
|
||
cases (and three existing ones rewritten onto a `SavedStateHandle`). **442 unit tests green** (410 + 32), `lintDebug` and
|
||
`assembleDebug` clean. AC-3 is covered three ways — a feature-gated row that an
|
||
override relabels, moves to the front and marks `hidden: false` is still not
|
||
shown to an admin whose shard does not publish it; role-gated rows stay hidden
|
||
from an anonymous caller whatever the row says; and hiding composes with the
|
||
gates rather than competing with them. What the JVM still cannot reach is the
|
||
same limit phase 4 hit: that the reordered drawer *draws* in the new order, and
|
||
that navigating to plain `news` really does match the optional-argument pattern
|
||
at runtime, are AC-5's.
|
||
|
||
### Phase 6 as landed
|
||
|
||
- **The spec's link table was wrong about the website, and that is the finding to
|
||
remember.** Three of its rows named paths the site does not serve and two of the
|
||
site's real detail routes were missing — see the correction in §6.3. Phase 5 hit
|
||
the same class of error in §6.2's row order. Both times the fix was to quote the
|
||
website's own source beside the table, so `NavPaths.kt` now carries `App.jsx`'s
|
||
route list next to `SiteHeader.jsx`'s `NAV`.
|
||
- **Phase 6 does not re-implement phase 5, and AC-1 is unchanged because of it.**
|
||
`buildNavTree` hands straight to `applyNavOverrides` when the stored row carries
|
||
no sections and no links, so an untouched instance still gets `APP_MENU` back by
|
||
identity — the tree build only runs when the admin actually created structure.
|
||
The regression test is that an items-only row through the tree equals the same
|
||
row through the flat merge.
|
||
- **`section` on an item override is read but not counted as "usable".** A
|
||
section-only override says nothing to a flat list, so `NavOverride.isEmpty`
|
||
deliberately excludes it and an instance that only ever grouped rows still gets
|
||
its coded list back by identity from `applyNavOverrides`. The tree adds its own
|
||
check. This is the one place the two consumers of a stored row disagree about
|
||
what "empty" means, and they have to.
|
||
- **A dangling reference degrades to "no grouping", never to "no row".** A member
|
||
of a section that does not exist — because the admin deleted it, or its label
|
||
was blank and it was dropped — is an ordinary top-level row, not a row that
|
||
vanished with its section. Same for a link naming an unknown section: its
|
||
destination is still good, only the grouping was wrong.
|
||
- **`visibleEntries` was split into `isEntryVisible`.** `pruneNav` applies the
|
||
predicate inside a section as well as at the top level, and both callers must
|
||
ask exactly one question, or a sectioned row could end up gated by a rule its
|
||
top-level twin is not. The list form now just filters with it.
|
||
- **Sort keys extend phase 5's number line rather than starting a new one.** A
|
||
coded row keys on its index in the website's sixteen-row nav; an admin-created
|
||
section or link with no stored order keys on `16, 17, …` in creation order, so
|
||
it appends after the coded rows instead of jumping to the front on a `0`
|
||
default. Both tie-breaks are still the web's.
|
||
- **What is not gated is as load-bearing as what is.** An added link carries no
|
||
role or feature check — the screen behind it enforces its own — so a section
|
||
holding one is never emptied by the caller's role, while a section holding only
|
||
gated rows is dropped rather than drawn as a header over nothing. Both are
|
||
tested; the second is the case `pruneNav` exists for.
|
||
- Tests: `NavTreeTest` (27) + 7 new `NavPathsTest` cases. **476 unit tests green**
|
||
(442 + 34), `lintDebug` and `assembleDebug` clean, no new lint findings.
|
||
Phase 4's limit still holds — no Robolectric and no `androidTest` source set —
|
||
so the section header's indent, the hand-off icon, and that a Custom Tab really
|
||
opens are AC-5's, not the JVM's.
|
||
|
||
### Phase 7, cancelled
|
||
|
||
Reading `nav_admin` / `nav_player` is **not scheduled**. The measurement in §6.4
|
||
is the reason and it stands: the two authenticated navs reach four app rows
|
||
between them (`/player`, `/account`, `/admin`, `/admin/moderation`), because the
|
||
website sidebar's other ~18 rows are admin *configuration* the app deliberately
|
||
excludes and two of the app's four staff entries are aggregates with no single
|
||
web row to be renamed from. Four rows does not pay for a new authenticated fetch,
|
||
a session-keyed cache and its teardown on sign-out.
|
||
|
||
Consequences, all of them wanted:
|
||
|
||
- the app never calls `GET /api/v1/settings/nav`, and the "a player may read it"
|
||
note in §3 becomes moot for this client;
|
||
- the player and staff drawer rows keep their coded `@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.
|