feat(appearance): read the admin theme and nav contract into a SiteAppearance (M12 phase 0) #33

Merged
whitlocktech merged 1 commits from feat/m12-phase-0-appearance-store into edge 2026-08-08 07:01:44 +00:00
Member

What & why

M12 phase 0 — the contract and the appearance store. Design of record: docs/android/THEMING_AND_NAV.md §8. Docs pair: docs#112.

The app reads exactly one field of the website's admin theming contract — brand.accent. This lands the store the rest of the milestone builds on: GET /public/settings' theme (the resolved token map) and nav_public (the raw nav override row) are decoded, coerced and held beside the brand as one SiteAppearance, refreshed on resume alongside the session re-validation.

Nothing reads the two new fields yet, and that is the point. Phase 0's hard rule is that it must change nothing on screen — so RunicApp still takes brand: BrandDto?, the theme is still seeded from the accent alone, and AppState.Ready is the only place a type changed. Everything after this is additive on top of a store already proven not to have moved anything.

No backend work: all of it is live on website/main today.

The two judgement calls

Both in service of §2's forgiving-on-read rule, and both recorded in the doc's new "Phase 0 as landed":

  • theme is modeled as a raw JsonElement, not Map<String, String>?. §8 specified the typed map, and the contract does say so — it is a closed set of string-valued tokens, validated server-side on write. 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: one odd token would have blanked the branding and dropped the push relay URL, which is the opposite of what §2 asks for. It is coerced field-by-field in SiteAppearance.from instead, so a non-string or blank value costs exactly its own token.
  • 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 shipped defaults. Only the initial load can produce NONE.

What is deliberately not here

  • The second-stage parse stops at "is this a plain object"data/appearance/SettingsJson.kt mirrors the web client'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 / links out of the parsed object is phases 5 and 6's job, so no half-built nav model ships here.
  • theme_visual and brand_assets are not modeled. They ride along in the same payload but they are inputs — a preset id and a sparse overlay — and re-deriving a palette from them would be a second resolveThemeTokens in Kotlin, guaranteed to drift (§3).

Also in this milestone

Phase 7 is cancelled — reading nav_admin / nav_player. No code here either way (it was scheduled last), but the decision is recorded in docs#112: 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, and the player/staff drawer rows keep their coded — and so localized — labels. Nothing in phases 0–6 was scaffolding for it.

How it was tested

  • ./gradlew testDebugUnitTest lintDebug assembleDebug360 unit tests green, lint and assemble clean.
  • New: SettingsJsonTest (6 cases — plain object, the wrapped public shape, absent, malformed, non-object kinds, and that the parse validates the kind not the shape) and SiteAppearanceTest (8 — the untouched instance resolving to no overrides, a failed call being the same state, token reading, empty-vs-absent, a bad token costing only itself, a wrong-kind theme not costing the brand, the second-stage nav parse, and a malformed nav not costing the theme).
  • Three decode cases added to PublicDtoTest for the wire shapes, including that an older backend simply has both fields absent.
  • AppViewModel itself stays untested: its four collaborators are concrete classes with Context/prefs dependencies, and all of this phase's logic lives in the two pure modules above.
  • Not exercised on device — there is nothing to see, which is the phase's own acceptance criterion. The AVD walk is AC-5, in phase 8.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **M12 phase 0** — the contract and the appearance store. Design of record: [`docs/android/THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/android/THEMING_AND_NAV.md) §8. Docs pair: **docs#112**. The app reads exactly one field of the website's admin theming contract — `brand.accent`. This lands the store the rest of the milestone builds on: `GET /public/settings`' `theme` (the resolved token map) and `nav_public` (the raw nav override row) are decoded, coerced and held beside the brand as one `SiteAppearance`, refreshed on resume alongside the session re-validation. **Nothing reads the two new fields yet, and that is the point.** Phase 0's hard rule is that it must change nothing on screen — so `RunicApp` still takes `brand: BrandDto?`, the theme is still seeded from the accent alone, and `AppState.Ready` is the only place a type changed. Everything after this is additive on top of a store already proven not to have moved anything. No backend work: all of it is live on `website/main` today. ### The two judgement calls Both in service of §2's forgiving-on-read rule, and both recorded in the doc's new "Phase 0 as landed": - **`theme` is modeled as a raw `JsonElement`, not `Map<String, String>?`.** §8 specified the typed map, and the contract does say so — it is a closed set of string-valued tokens, validated server-side on write. 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`: one odd token would have blanked the branding and dropped the push relay URL, which is the opposite of what §2 asks for. It is coerced field-by-field in `SiteAppearance.from` instead, so a non-string or blank value costs exactly its own token. - **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 shipped defaults. Only the initial load can produce `NONE`. ### What is deliberately not here - **The second-stage parse stops at "is this a plain object"** — `data/appearance/SettingsJson.kt` mirrors the web client'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` / `links` out of the parsed object is phases 5 and 6's job, so no half-built nav model ships here. - **`theme_visual` and `brand_assets` are not modeled.** They ride along in the same payload but they are *inputs* — a preset id and a sparse overlay — and re-deriving a palette from them would be a second `resolveThemeTokens` in Kotlin, guaranteed to drift (§3). ### Also in this milestone **Phase 7 is cancelled** — reading `nav_admin` / `nav_player`. No code here either way (it was scheduled last), but the decision is recorded in docs#112: 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, and the player/staff drawer rows keep their coded — and so localized — labels. Nothing in phases 0–6 was scaffolding for it. ## How it was tested - `./gradlew testDebugUnitTest lintDebug assembleDebug` — **360 unit tests green**, lint and assemble clean. - New: `SettingsJsonTest` (6 cases — plain object, the wrapped public shape, absent, malformed, non-object kinds, and that the parse validates the *kind* not the shape) and `SiteAppearanceTest` (8 — the untouched instance resolving to no overrides, a failed call being the same state, token reading, empty-vs-absent, a bad token costing only itself, a wrong-kind `theme` not costing the brand, the second-stage nav parse, and a malformed nav not costing the theme). - Three decode cases added to `PublicDtoTest` for the wire shapes, including that an older backend simply has both fields absent. - `AppViewModel` itself stays untested: its four collaborators are concrete classes with `Context`/prefs dependencies, and all of this phase's logic lives in the two pure modules above. - **Not** exercised on device — there is nothing to see, which is the phase's own acceptance criterion. The AVD walk is AC-5, in phase 8. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-08-08 06:58:29 +00:00
The app has been reading exactly one field of the website's admin theming
contract -- brand.accent. This lands the store the rest of M12 builds on:
GET /public/settings' `theme` (the resolved token map) and `nav_public` (the
raw nav override row) are now decoded, coerced and held beside the brand as
one SiteAppearance, refreshed on resume alongside the session re-validation.

Nothing reads the two new fields yet. Phase 0's hard rule is that it must
change nothing on screen, so RunicApp still takes `brand: BrandDto?` and the
theme is still seeded from the accent alone; AppState.Ready is the only place
a type changed.

Two judgement calls, both in service of THEMING_AND_NAV.md section 2's
forgiving-on-read rule:

- `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` -- one odd
  token would have blanked the branding and dropped the push relay URL. It is
  coerced field-by-field instead, so a bad token costs exactly itself.
- A failed *refresh* keeps the last good appearance rather than falling back
  to NONE. Only the initial load can produce NONE, so a moment of no
  connectivity on resume cannot repaint a themed shard back to the defaults.

The second-stage parse stops at "is this a plain object", mirroring the web
client's lib/settingsJson.js exactly; reading items/sections/links out of it
is phases 5 and 6's job, so no half-built nav model ships here.

Tests: SettingsJsonTest (6) and SiteAppearanceTest (8) cover the two pure
modules, plus three decode cases in PublicDtoTest for the wire shapes.
360 unit tests green; lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-08 07:01:38 +00:00
whitlocktech merged commit 17e9451494 into edge 2026-08-08 07:01:44 +00:00
whitlocktech deleted branch feat/m12-phase-0-appearance-store 2026-08-08 07:01:45 +00:00
Sign in to join this conversation.
No description provided.