docs(android): M12 theming & navigation, all phases as landed (cutover) #120

Merged
whitlocktech merged 16 commits from edge into main 2026-08-08 16:14:30 +00:00
2 changed files with 91 additions and 13 deletions
Showing only changes of commit beae4b21dd - Show all commits

View File

@@ -1030,14 +1030,19 @@ push, and Play (M6M8) follow the designed app.
`nav_admin` two (`/player`, `/account`, `/admin`, `/admin/moderation`); the sidebar's other `nav_admin` two (`/player`, `/account`, `/admin`, `/admin/moderation`); the sidebar's other
~18 rows are admin *configuration* the app excludes, and two of the app's four staff entries ~18 rows are admin *configuration* the app excludes, and two of the app's four staff entries
are aggregates with no single web row. That is why they honor `label` and `hidden` only — and are aggregates with no single web row. That is why they honor `label` and `hidden` only — and
why that phase is scheduled **last and marked optional**, so it can be dropped on its merits why that phase was scheduled **last and marked optional**, so it could be dropped on its
once the rest is working. merits. **It was: phase 7 is cancelled (2026-08-08).** The app makes no authenticated settings
call, `nav_admin` / `nav_player` are not consumed, and the player and staff drawer rows keep
their coded — and therefore localized — labels. Nothing in phases 06 was scaffolding for it.
- **Excluded**, in the same class as M10's and M11's exclusions: the admin *configuration* panels - **Excluded**, in the same class as M10's and M11's exclusions: the admin *configuration* panels
themselves. The app does not gain Appearance or Navigation editors; it is a consumer. themselves. The app does not gain Appearance or Navigation editors; it is a consumer.
Nine phases into a fresh `edge` in both repos, reaching `main` as one `edge` → `main` merge — Eight phases (06 and 8; **7 cancelled**) into a fresh `edge` in both repos, reaching `main` as
the same shape the website side used. Phase 0 (the contract and the appearance store) carries a one `edge` → `main` merge — the same shape the website side used. Phase 0 (the contract and the
hard rule: it must change nothing on screen. appearance store) carries a hard rule: it must change nothing on screen. **Phase 0 landed
2026-08-08** (Android-app#33 / docs#112): `SiteAppearance` now holds the brand, the resolved
theme tokens and the parsed `nav_public`, refreshed on resume, with nothing yet reading the last
two — see THEMING_AND_NAV.md "Phase 0 as landed".
### Deferred (not a milestone) ### Deferred (not a milestone)

View File

@@ -54,7 +54,7 @@ No backend work. Everything below is live on `website/main` today.
| `GET /public/settings` | `theme` | `Record<cssVar, string>` — 15 colors, 4 radii, `--shadow-card`, 3 font stacks. **Absent** when no row exists | | `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` | `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 /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 | | ~~`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: Two shapes to get right on the wire:
@@ -87,7 +87,7 @@ is no resolved counterpart — the merge is the *client's* job on the web too.
| 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` | | 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 | | 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 | | 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` | **`label` and `hidden` only.** No order, no group — see §6.4 | | `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 | | 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 | | Failure posture | Forgiving on read, field by field. A failed settings call renders the shipped app, never an error |
@@ -268,10 +268,11 @@ refreshed:
changing the theme on a laptop and picking the phone up should see it, and the changing the theme on a laptop and picking the phone up should see it, and the
app already pays for a resume round-trip. app already pays for a resume round-trip.
The authenticated `GET /api/v1/settings/nav` is fetched only when the session is ~~The authenticated `GET /api/v1/settings/nav` is fetched only when the session is
signed in, and re-fetched when the session changes — the same lifecycle signed in, and re-fetched when the session changes.~~ **Phase 7 is cancelled
`ShardFeaturesRepository` already uses (M11). Signing out drops the cached admin (§6.4, §8): the app makes no authenticated settings call, and there is nothing
and player overrides. 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 Every one of these is best-effort. A failed refresh keeps the last good
appearance; there is no loading state and no error surface. appearance; there is no loading state and no error surface.
@@ -435,6 +436,14 @@ 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 with the rest of the milestone already working — dropping it costs nothing that
phases 06 depend on. Unmapped keys are ignored either way. 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 **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 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, means an admin's label is **not localized** — it is one string for every locale,
@@ -474,20 +483,84 @@ PR against `main`.
| # | Phase | Ships | | # | Phase | Ships |
|---|---|---| |---|---|---|
| **0** | **Contract & appearance store** | `SettingsDto` gains `theme: Map<String,String>?` and `nav_public: String?`; `SiteAppearance` replaces the bare `BrandDto` in `AppViewModel`; second-stage JSON parse; resume refresh (§5.5). **No visual change** — this phase must be invisible | | **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 | | **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) | | **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 | | **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 | | **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 | | **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) | | **6** | **Public nav: sections & added links** | Drawer groups, `pruneNav` port, link path validation, native-route resolution + Custom Tab fallback (§6.3) |
| **7** | **Authenticated navs** | `GET /api/v1/settings/nav` behind a session-keyed repository; label/hidden for the four mapped rows (§6.4). **Optional — reconsider before starting it** | | **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` | | **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 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 screen.** Everything after it is additive on top of a store that is already
proven not to have moved anything. 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 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 ## 9. Out of scope
- **Light mode / a light preset.** The website cancelled its Parchment phase; the - **Light mode / a light preset.** The website cancelled its Parchment phase; the