docs(android): M12 phase 0 as landed, and phase 7 cancelled
Two records, one milestone. Phase 7 (reading nav_admin / nav_player) is cancelled. Section 6.4 already made the case against it and scheduled it last so the call could be taken on its merits with the rest working: the two authenticated navs reach four app rows between them, which does not pay for a new authenticated fetch, a session-keyed cache and its teardown. The app therefore makes no authenticated settings call at all, and the player and staff drawer rows keep their coded -- and so localized -- labels, which is the one thing given up. Section 4's locked decision, section 3's endpoint row and section 5.5's lifecycle paragraph are amended to match so the doc does not contradict itself, and section 8 gains the standing "Phase 7, cancelled" note. Phase 0 (the contract and the appearance store) landed as Android-app#33, and its "as landed" notes record two departures from what section 8 specified: - `theme` is modeled as a raw JsonElement rather than Map<String,String>?. kotlinx fails the decode of the whole object on a value of an unexpected kind, and `theme` shares its payload with `brand` and `push` -- so one odd token would have blanked the branding and dropped the push relay URL, the opposite of section 2. It is coerced field-by-field instead. - A failed refresh keeps the last good appearance rather than falling back to NONE. Section 5.5 said best-effort; the distinction it did not draw is that a moment of no connectivity on resume must not repaint a themed shard back to the shipped defaults. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1030,14 +1030,19 @@ push, and Play (M6–M8) 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 0–6 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 (0–6 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 0–6 depend on. Unmapped keys are ignored either way.
|
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
|
**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 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
|
## 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
|
||||||
|
|||||||
Reference in New Issue
Block a user