From 1746ad4e376d995e912eb230ad72b84b1c314a1c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 8 Aug 2026 05:15:21 -0500 Subject: [PATCH] docs(android): M12 phase 2 as landed The radii-and-shadow phase, shipped as Android-app#35. Seven notes, of which three are amendments to the spec rather than records of it. Section 5.4's elevation map is not a no-op and now says so, with section 2 and AC-1 amended to match. Material3's filled Card is Level0 and FeatureCard drew none of the shadow its own KDoc claimed, so the app has been flat since M5 - while the runic-gateway preset it was drawn from selects the "Default" shadow. The org lead chose to apply the map as written rather than rebase it on the flat baseline, because rebasing would have collapsed none/Soft/Default onto 0dp and left only Deep doing anything on the phone. The radius half is untouched by this and remains a provable no-op. Section 5.4's exact-string match is corrected to a nearest-blur match. The fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS' four values, because resolveThemeTokens copies a preset's tokens verbatim and they never pass through the admin form's dropdown. Section 5.2 gains the rule the pill needed: CircleShape is a percentage, so it has no shipped dp for a ratio to scale and the resolved px is taken as dp below the 500px floor. It is a literal because there is no app scale to preserve, not as an exception to the ratio rule. Also recorded: why all 24 Card( call sites became ShardCard( (Material takes elevation as a default argument, not from the theme), that --radius-pill reaches only StatusPill, and that Shapes - unlike ColorScheme - does implement equals, so the structural no-op proof is one assertion rather than a reflection walk. PLAN.md 9's M12 entry records phase 2 landed. Co-Authored-By: Claude --- android/PLAN.md | 7 +++- android/THEMING_AND_NAV.md | 82 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 5 deletions(-) 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