diff --git a/android/PLAN.md b/android/PLAN.md index 59343f1..4590373 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1045,7 +1045,12 @@ push, and Play (M6–M8) 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) diff --git a/android/THEMING_AND_NAV.md b/android/THEMING_AND_NAV.md index d14cb92..cd13b5a 100644 --- a/android/THEMING_AND_NAV.md +++ b/android/THEMING_AND_NAV.md @@ -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