Merge pull request 'docs(android): M12 phase 2 as landed' (#114) from docs/android-theming-nav-phase-2 into edge

Reviewed-on: #114
This commit is contained in:
2026-08-08 10:16:48 +00:00
2 changed files with 84 additions and 5 deletions

View File

@@ -1045,7 +1045,12 @@ push, and Play (M6M8) follow the designed app.
two — see THEMING_AND_NAV.md "Phase 0 as landed". **Phase 1 landed 2026-08-08**
(Android-app#34 / docs#113): the fifteen color tokens now resolve into a `ShardPalette` that
feeds both the Material scheme and a `LocalShardPalette`, and the no-op invariant is a test —
the shipped palette reproduces the pre-M12 `ColorScheme` role for role.
the shipped palette reproduces the pre-M12 `ColorScheme` role for role. **Phase 2 landed
2026-08-08** (Android-app#35 / docs#114): the four radii scale the app's own dp by their ratio
to the `runic-gateway` baseline, and `--shadow-card` maps onto card elevation. The shadow is
the milestone's **one deliberate change to an untouched instance** — the app has been flat
since M5 while the preset it was drawn from selects a shadow — and is why every `Card(` became
a `ShardCard(`: Material takes elevation as a default argument, not from the theme.
### Deferred (not a milestone)

View File

@@ -45,6 +45,13 @@ 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.
@@ -193,6 +200,13 @@ it. Consequences, all of them wanted:
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:
@@ -253,8 +267,19 @@ it maps to card elevation:
| `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`; anything else is
the shipped default. Applied to `FeatureCard` and the Material `Card` defaults.
~~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
@@ -454,7 +479,10 @@ which is what an admin typing a label means, and matches the website.
- **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.
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
@@ -485,7 +513,7 @@ PR against `main`.
|---|---|---|
| **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) |
| **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 |
@@ -586,6 +614,52 @@ proven not to have moved anything.
`lintDebug` and `assembleDebug` clean. Not exercised on device — that is AC-5,
in phase 8.
### Phase 2 as landed
- **The app is flat, so §5.4 is the milestone's one visible default change.**
Material3 1.3.0's filled `Card` is `ElevationTokens.Level0` — 0dp, checked in
the artifact's bytecode, not assumed — and `FeatureCard` was a `Box` with a
clip, a gradient and a border, drawing none of the "soft shadow" its own KDoc
claimed. The `runic-gateway` preset meanwhile selects the *Default* shadow, so
reading §5.4 literally gives an untouched instance 4dp on every card. The org
lead chose that over rebasing the table on the flat baseline (which would have
collapsed `none`/`Soft`/`Default` onto 0dp and left only `Deep` doing
anything). §2 records the exception; the radius half remains a provable no-op.
- **`ShardCard` exists because Material's theme cannot carry elevation.** The
color scheme and the shape scale both reach screens through `MaterialTheme`,
but `Card` takes its elevation as a **default argument** — there is no
composition local behind `CardDefaults.cardElevation()`. So the phase migrated
all **24 `Card(` call sites across 20 files** to a one-line wrapper in
`ThemeComponents.kt`. Every one of them passed nothing but a modifier, which is
why the wrapper's signature is `(Modifier, ColumnScope.() -> Unit)` and the
migration is mechanical. A `Card(` outside that file is now, by construction, a
card the shard cannot theme.
- **`--radius-pill` reaches exactly one composable.** The app has three
`CircleShape` uses and two of them are 8dp status dots (`OnlineDot`,
`LiveChip`); a dot stays a dot however square an admin makes the site. Only
`StatusPill` takes the resolved shape. §5.2 records the literal-px rule the
pill needs because a percentage shape has no dp to scale.
- **`Shapes` *does* implement `equals`** — the opposite of phase 1's
`ColorScheme` finding, and also checked in the bytecode. So the structural
no-op proof is one assertion against a verbatim copy of the pre-M12 scale
rather than a reflection walk. Both the empty theme and the full
`runic-gateway` token map are asserted `==` to `ShardStructure.Shipped`.
- **`FeatureCard`'s literal 12dp became `MaterialTheme.shapes.medium`** — the
same value, so no-op, but now carried by the ratio. `StatBar`'s three
`RoundedCornerShape(3.dp)` stay literal: that is half the height of a 6dp
meter, not a member of the card radius family.
- **The structure resolution is pure, like the palette's.**
`ShardStructure.resolve(theme)` takes a token map and returns shapes + pill +
elevation, so every assertion is a plain JVM test with no Compose rule.
`RunicGatewayTheme` remains the only composable where resolution happens.
- Tests: `ShardStructureTest` (13 — the shipped scale against a verbatim pre-M12
copy, three no-op paths, Fantasy and Modern scaled onto the app's dp, per-field
fallback, a zero radius, the pill floor, all four shadow options, the
off-catalog blurs, and an unreadable shadow). **386 unit tests green**,
`lintDebug` and `assembleDebug` clean. Not exercised on device — that is AC-5,
in phase 8, where the new shadow should be looked at with the flat build beside
it.
### Phase 7, cancelled
Reading `nav_admin` / `nav_player` is **not scheduled**. The measurement in §6.4