feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2) #35

Merged
whitlocktech merged 1 commits from feat/m12-phase-2-structure into edge 2026-08-08 10:17:41 +00:00
Member

Phase 2 of M12 (docs/android/THEMING_AND_NAV.md §5.2, §5.4) — the structure half of the admin's Appearance page, on top of phase 1's colors. Targets edge; the milestone reaches main as one edgemain merge.

ShardStructure.resolve(theme) turns the four --radius-* tokens and --shadow-card into a Material shape scale, a pill shape and a card elevation. RunicGatewayTheme feeds the scale to MaterialTheme.shapes and the other two to a new LocalShardStructure, mirroring phase 1's palette split. Resolution is pure, so every assertion is a plain JVM test with no Compose rule.

Radii are a ratio, never a literal. The app's Shapes came from the M5 mockup and the website's from theme.css, and the two scales genuinely differ (--radius-card 10px against medium 12dp). Copying the web value in would have restyled an untouched app on day one, so each field is scaled by resolved ÷ runic-gateway baseline instead — the shipped theme and an explicitly-selected runic-gateway both give ratio 1.0 and are provable no-ops.

Three things the spec did not survive contact with

1. Card depth is not a no-op — and that is the org lead's decision, taken before any code. Material3 1.3.0's filled Card is ElevationTokens.Level0 (0dp — checked in the artifact's bytecode, not assumed) and FeatureCard was a Box drawing none of the "soft 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. §5.4 is applied as written rather than rebased on the flat baseline — rebasing would have collapsed none/Soft/Default onto 0dp and left only Deep doing anything on the phone. Every card gains 4dp on an untouched instance. §2, §5.4 and AC-1 are amended to record it (docs#114).

2. The shadow is matched by nearest blur, not by exact string. The fantasy preset publishes 0 16px 38px rgba(0, 0, 0, 0.45), which the server's SHADOW_OPTIONS does not contain — resolveThemeTokens copies a preset's tokens verbatim and they never pass through the admin form's dropdown. An exact match would have missed the one preset whose point is a heavier shadow.

3. --radius-pill is resolved as a literal px, because CircleShape is a percentage and has no shipped dp for a ratio to scale against. Below the 500px floor the resolved px is taken as dp (Fantasy 4px → 4dp, Modern 8px → 8dp). It reaches exactly one composable: the app's other two CircleShape uses are 8dp status dots, and a dot stays a dot however square an admin makes the site.

Why every Card( became a ShardCard(

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 all 24 call sites across 20 files moved to a one-line wrapper in ThemeComponents.kt. The migration is mechanical because every one of them passed nothing but a modifier, which is also why the wrapper's signature is just (Modifier, ColumnScope.() -> Unit). A Card( outside that file is now, by construction, a card the shard cannot theme.

Also: FeatureCard's literal RoundedCornerShape(12.dp) became MaterialTheme.shapes.medium — same value, now carried by the ratio. StatBar's three RoundedCornerShape(3.dp) stay literal: half the height of a 6dp meter, not a member of the card radius family.

Tests

Shapes does implement equals (the opposite of phase 1's ColorScheme finding, also checked in 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 assert == to ShardStructure.Shipped.

ShardStructureTest — 13 cases: the shipped scale against the pre-M12 copy, three no-op paths, Fantasy and Modern scaled onto the app's own 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 (373 + 13), 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.

Docs: RunicGateway/docs#114.


  • AI-assisted: written with Claude Code (Claude Opus 5)
Phase 2 of M12 (`docs/android/THEMING_AND_NAV.md` §5.2, §5.4) — the **structure** half of the admin's Appearance page, on top of phase 1's colors. Targets `edge`; the milestone reaches `main` as one `edge` → `main` merge. `ShardStructure.resolve(theme)` turns the four `--radius-*` tokens and `--shadow-card` into a Material shape scale, a pill shape and a card elevation. `RunicGatewayTheme` feeds the scale to `MaterialTheme.shapes` and the other two to a new `LocalShardStructure`, mirroring phase 1's palette split. Resolution is pure, so every assertion is a plain JVM test with no Compose rule. **Radii are a ratio, never a literal.** The app's `Shapes` came from the M5 mockup and the website's from `theme.css`, and the two scales genuinely differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in would have restyled an untouched app on day one, so each field is scaled by `resolved ÷ runic-gateway baseline` instead — the shipped theme and an explicitly-selected `runic-gateway` both give ratio 1.0 and are provable no-ops. ### Three things the spec did not survive contact with **1. Card depth is not a no-op — and that is the org lead's decision, taken before any code.** Material3 1.3.0's filled `Card` is `ElevationTokens.Level0` (0dp — checked in the artifact's bytecode, not assumed) and `FeatureCard` was a `Box` drawing none of the "soft 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. §5.4 is applied **as written** rather than rebased on the flat baseline — rebasing would have collapsed `none`/`Soft`/`Default` onto 0dp and left only `Deep` doing anything on the phone. Every card gains 4dp on an untouched instance. §2, §5.4 and AC-1 are amended to record it (docs#114). **2. The shadow is matched by nearest blur, not by exact string.** The `fantasy` preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)`, which the server's `SHADOW_OPTIONS` does **not** contain — `resolveThemeTokens` copies a preset's tokens verbatim and they never pass through the admin form's dropdown. An exact match would have missed the one preset whose point is a heavier shadow. **3. `--radius-pill` is resolved as a literal px**, because `CircleShape` is a *percentage* and has no shipped dp for a ratio to scale against. Below the 500px floor the resolved px is taken as dp (Fantasy 4px → 4dp, Modern 8px → 8dp). It reaches **exactly one** composable: the app's other two `CircleShape` uses are 8dp status dots, and a dot stays a dot however square an admin makes the site. ### Why every `Card(` became a `ShardCard(` 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 all **24 call sites across 20 files** moved to a one-line wrapper in `ThemeComponents.kt`. The migration is mechanical because every one of them passed nothing but a modifier, which is also why the wrapper's signature is just `(Modifier, ColumnScope.() -> Unit)`. A `Card(` outside that file is now, by construction, a card the shard cannot theme. Also: `FeatureCard`'s literal `RoundedCornerShape(12.dp)` became `MaterialTheme.shapes.medium` — same value, now carried by the ratio. `StatBar`'s three `RoundedCornerShape(3.dp)` stay literal: half the height of a 6dp meter, not a member of the card radius family. ### Tests `Shapes` **does** implement `equals` (the opposite of phase 1's `ColorScheme` finding, also checked in 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 assert `==` to `ShardStructure.Shipped`. `ShardStructureTest` — 13 cases: the shipped scale against the pre-M12 copy, three no-op paths, Fantasy and Modern scaled onto the app's own 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** (373 + 13), `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. Docs: RunicGateway/docs#114. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5)
wtclaude added 1 commit 2026-08-08 10:16:15 +00:00
The structure half of the admin's Appearance page. ShardStructure.resolve() turns
the four --radius-* tokens and --shadow-card into a Material shape scale, a pill
shape and a card elevation; RunicGatewayTheme feeds the scale to MaterialTheme and
the other two to a LocalShardStructure, mirroring phase 1's palette split.

Radii are applied as a ratio, never as a literal. The app's Shapes came from the
M5 mockup and the website's from theme.css, and the two scales differ - copying
the web value in would have restyled an untouched app on day one. Each field is
scaled by resolved / runic-gateway baseline instead, so the shipped theme and an
explicit runic-gateway both give ratio 1.0 and are provable no-ops.

Three things the spec did not survive contact with:

Card depth is not a no-op, and that is the org lead's decision. Material3's
filled Card is Level0 and FeatureCard drew none of the shadow its own docs
claimed, so the app has been flat since M5 - while the preset it was drawn from
selects the "Default" shadow. Section 5.4 is applied as written rather than
rebased on the flat baseline, which would have collapsed three of the admin's
four choices onto 0dp. Every card gains 4dp; sections 2, 5.4 and AC-1 record it.

The shadow is matched by nearest blur, not by exact string. The fantasy preset
publishes a --shadow-card that SHADOW_OPTIONS does not contain, because a
preset's tokens are copied verbatim and never pass through the admin dropdown -
an exact match would have missed the one preset whose point is a heavier shadow.

--radius-pill is resolved as a literal px, because CircleShape is a percentage
and has no shipped dp for a ratio to scale. It reaches exactly one composable:
the app's other two CircleShape uses are 8dp status dots, and a dot stays a dot.

ShardCard exists because Material's theme cannot carry elevation - Card takes it
as a default argument. All 24 Card( call sites across 20 files moved to the
wrapper, which is mechanical because every one of them passed only a modifier. A
Card( outside ThemeComponents.kt is now, by construction, an unthemable card.

Shapes does implement equals (unlike ColorScheme), so the structural no-op proof
is one assertion against a verbatim copy of the pre-M12 scale. 13 new tests, 386
green, lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 7acbe54f46 into edge 2026-08-08 10:17:41 +00:00
whitlocktech deleted branch feat/m12-phase-2-structure 2026-08-08 10:17:42 +00:00
Sign in to join this conversation.
No description provided.