fix(theme): reach Material's default arguments, and measure the theme resolvers (M12 phase 8) #40

Merged
whitlocktech merged 2 commits from chore/m12-phase-8-coverage-and-cutover into edge 2026-08-08 16:07:03 +00:00
Member

What & why

M12 phase 8 — the last phase. Scoped as docs, coverage and cutover prep; the AC-5 on-device walk turned it into a phase with code in it, which is what the walk existed to do.

1. Two theming defects the walk found

Both are phase 2's trap repeating: Material takes some themed values as default arguments, not from the theme. Phase 2 found it for card elevation and swept every Card( into ShardCard(. Nobody then asked whether colour and shape had the same problem.

  • Every ShardCard drew in Material's grey, not the shard's panel colour. CardDefaults.cardColors() takes its container from surfaceContainerHighestFilledCardTokens.ContainerColor, checked in the material3 1.3.0 bytecode — and shardColorScheme mapped surfaceContainer, …High and …Low but not …Highest. 26 call sites in 20 files. FeatureCard was unaffected because it reads LocalShardPalette directly, which is why Home looked right and every other screen did not.
  • The drawer's selected row ignored --radius-pill. NavigationDrawerItem takes shape as a default argument (CircleShape); the three call sites set colors, never shape.

The card fix changes the untouched app, deliberately. The grey cards are not an M12 regression — an untouched instance draws them too and has since M5. Fixing it moves the shipped app's cards to --panel-flat: the milestone's second deliberate change to a shard that has set nothing, alongside phase 2's shadow. There is no variant that spares untouched instances, because the shipped palette is the runic-gateway preset. AC-1 records the change rather than absorbing it — every other role is still asserted byte-for-byte against the verbatim pre-M12 scheme, and the two that moved are named, given new values, and checked to have genuinely differed before, so the test cannot pass by coincidence.

Role sweep, so nobody redoes it: of the roles left at darkColorScheme() defaults, exactly one had a live reader — surfaceContainerHighest. Checked against the token class of every Material component the app draws (NavigationDrawerTokens, SheetBottomTokens, AssistChipTokens, FilterChipTokens, TopAppBarSmallTokens, BadgeTokens); all read only mapped roles. scrim is read by the modal drawer and stays Material's black on purpose. The rest are latent — the app draws no Snackbar, tonal button, or elevated/outlined card.

2. Sonar coverage exclusion narrowed

sonar.coverage.exclusions carried a ui/theme/** directory glob from the M11 coverage push, when that directory held only constants and composables. M12 put three pure resolvers in it — ShardPalette, ShardStructure, ShardTypeface, at 98% / 100% / 100% — and the glob discarded all of them. Now scoped to ui/theme/Theme.kt, the one composable left.

This does not rescue the gate: M12's already-measured code (data/appearance/ 100%, ui/navigation/ 93–100%) clears new_coverage ≥ 50 on its own. It makes the number honest about which code the tests actually hold.

Pairs with the docs phase 8 PR. Both target edge; the cutover is a separate edgemain PR.

How it was tested

  • ./gradlew testDebugUnitTest lintDebug477 unit tests green (476 + the new card-container assertion), lint clean, assembleDebug clean.
  • AC-5 on-device walk on an AVD against a local website (adb reverse tcp:3000 tcp:3000, so the debug network_security_config's loopback exception is used unchanged):
    • Untouched pass (anonymous + admin): coded palette, coded menu in code order, no sections.
    • Themed pass (Fantasy preset, reordered + sectioned nav, one added link of each kind, uploaded logo + hero) at anonymous / player / moderator / admin.
    • Confirmed: Fantasy palette; radii visibly square; EB Garamond arriving in the sans role (phase 3's preset bypass, on a device); logo replacing the top-bar text with the 6× cap; hero centre-cropped into its 180dp band; reorder, relabel, hide; section header with indented children; native link with no badge, hand-off link with one; Custom Tab genuinely opening (CustomTabActivity in dumpsys activity, not by eye).
    • AC-3 held at every rung — anonymous saw the reordered, sectioned public nav with zero player or staff rows leaking; the player rung stopped at My houses.
    • AC-4 fell out for free when the second AVD started against an unreachable server: shipped theme, coded menu, one retry, no error surface.
  • Both fixes re-verified on device after the change: the three ShardCards on the shard screen now paint --panel-flat brown, and the selected drawer row is the 4px rectangle Fantasy asks for.
  • The local website was snapshotted before the walk and restored byte-for-byte afterwards (nav_public sha verified equal; test theme, brand-asset rows and uploaded files removed).

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)

  • No AI tools were used to produce this contribution.
  • 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 / Assisted-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 8** — the last phase. Scoped as docs, coverage and cutover prep; the AC-5 on-device walk turned it into a phase with code in it, which is what the walk existed to do. ### 1. Two theming defects the walk found Both are **phase 2's trap repeating**: Material takes some themed values as *default arguments*, not from the theme. Phase 2 found it for card elevation and swept every `Card(` into `ShardCard(`. Nobody then asked whether colour and shape had the same problem. - **Every `ShardCard` drew in Material's grey, not the shard's panel colour.** `CardDefaults.cardColors()` takes its container from `surfaceContainerHighest` — `FilledCardTokens.ContainerColor`, checked in the material3 1.3.0 bytecode — and `shardColorScheme` mapped `surfaceContainer`, `…High` and `…Low` but not `…Highest`. **26 call sites in 20 files.** `FeatureCard` was unaffected because it reads `LocalShardPalette` directly, which is why Home looked right and every other screen did not. - **The drawer's selected row ignored `--radius-pill`.** `NavigationDrawerItem` takes `shape` as a default argument (`CircleShape`); the three call sites set `colors`, never `shape`. **The card fix changes the untouched app, deliberately.** The grey cards are not an M12 regression — an untouched instance draws them too and has since M5. Fixing it moves the shipped app's cards to `--panel-flat`: the milestone's **second** deliberate change to a shard that has set nothing, alongside phase 2's shadow. There is no variant that spares untouched instances, because the shipped palette *is* the `runic-gateway` preset. **AC-1 records the change rather than absorbing it** — every other role is still asserted byte-for-byte against the verbatim pre-M12 scheme, and the two that moved are named, given new values, and checked to have genuinely differed before, so the test cannot pass by coincidence. **Role sweep, so nobody redoes it:** of the roles left at `darkColorScheme()` defaults, exactly one had a live reader — `surfaceContainerHighest`. Checked against the token class of every Material component the app draws (`NavigationDrawerTokens`, `SheetBottomTokens`, `AssistChipTokens`, `FilterChipTokens`, `TopAppBarSmallTokens`, `BadgeTokens`); all read only mapped roles. `scrim` is read by the modal drawer and stays Material's black on purpose. The rest are latent — the app draws no Snackbar, tonal button, or elevated/outlined card. ### 2. Sonar coverage exclusion narrowed `sonar.coverage.exclusions` carried a `ui/theme/**` directory glob from the M11 coverage push, when that directory held only constants and composables. M12 put three pure resolvers in it — `ShardPalette`, `ShardStructure`, `ShardTypeface`, at **98% / 100% / 100%** — and the glob discarded all of them. Now scoped to `ui/theme/Theme.kt`, the one composable left. This does not rescue the gate: M12's already-measured code (`data/appearance/` 100%, `ui/navigation/` 93–100%) clears `new_coverage ≥ 50` on its own. It makes the number honest about which code the tests actually hold. Pairs with the docs phase 8 PR. Both target `edge`; the cutover is a separate `edge` → `main` PR. ## How it was tested - `./gradlew testDebugUnitTest lintDebug` — **477 unit tests green** (476 + the new card-container assertion), lint clean, `assembleDebug` clean. - **AC-5 on-device walk** on an AVD against a local website (`adb reverse tcp:3000 tcp:3000`, so the debug `network_security_config`'s loopback exception is used unchanged): - **Untouched pass** (anonymous + admin): coded palette, coded menu in code order, no sections. - **Themed pass** (Fantasy preset, reordered + sectioned nav, one added link of each kind, uploaded logo + hero) at **anonymous / player / moderator / admin**. - Confirmed: Fantasy palette; radii visibly square; EB Garamond arriving in the *sans* role (phase 3's preset bypass, on a device); logo replacing the top-bar text with the 6× cap; hero centre-cropped into its 180dp band; reorder, relabel, hide; section header with indented children; native link with **no** badge, hand-off link **with** one; Custom Tab genuinely opening (`CustomTabActivity` in `dumpsys activity`, not by eye). - **AC-3 held at every rung** — anonymous saw the reordered, sectioned public nav with zero player or staff rows leaking; the player rung stopped at `My houses`. - **AC-4** fell out for free when the second AVD started against an unreachable server: shipped theme, coded menu, one retry, no error surface. - **Both fixes re-verified on device after the change**: the three `ShardCard`s on the shard screen now paint `--panel-flat` brown, and the selected drawer row is the 4px rectangle Fantasy asks for. - The local website was snapshotted before the walk and **restored byte-for-byte** afterwards (`nav_public` sha verified equal; test theme, brand-asset rows and uploaded files removed). ## 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) - [ ] No AI tools were used to produce this contribution. - [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` / `Assisted-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 2 commits 2026-08-08 16:04:35 +00:00
sonar.coverage.exclusions carried a ui/theme/** directory glob from the M11
coverage push (COVERAGE_PLAN.md §2 phase 0). At the time that directory held
only Color.kt, Type.kt and the composables, so excluding all of it cost nothing.

M12 put three pure resolvers in it. ShardPalette, ShardStructure and
ShardTypeface are the milestone's core logic, they are the reason phases 1-3
could prove the no-op invariant as a JVM assertion, and JaCoCo on edge measures
them at 98%, 100% and 100%. The directory glob was dropping all of that out of
the denominator, so a future change that deleted those tests would not move the
coverage number at all.

The glob is now the one file it was really about: Theme.kt, the composable
(52%). The rest of ui/theme/ is measured, all of it 93% or better.

This does not rescue the gate - M12's already-measured code (data/appearance/
and ui/navigation/) covers at 93-100% and clears new_coverage >= 50 on its own.
It makes the number honest about which code the tests actually hold.

ui/components/ stays excluded as a directory: BrandAssets.kt is 11%, and the
9 tests it does have are on brandAssetUrl, the one part of it that is not a
composable body.

Co-Authored-By: Claude <noreply@anthropic.com>
Two defects found on device by phase 8's AC-5 walk, both the same trap phase 2
hit with card elevation: Material takes these values as DEFAULT ARGUMENTS, not
from the theme, so mapping the token is not enough on its own.

1. Every ShardCard drew in Material's grey, not the shard's panel color.
   CardDefaults.cardColors() takes its container from surfaceContainerHighest -
   FilledCardTokens.ContainerColor, checked in the material3 1.3.0 artifact's
   bytecode - and shardColorScheme mapped surfaceContainer, High and Low but not
   Highest. All 26 ShardCard sites across 20 files were affected. Themed
   instances showed it worst: on Fantasy the page went brown and the cards
   stayed grey.

   This is NOT an M12 regression. The untouched app draws the same grey cards
   and has since M5; M12 only made it obvious by theming everything around them.
   Fixing it therefore changes the untouched app too - cards move from Material's
   grey to --panel-flat - which is the milestone's second deliberate change to a
   shard that has set nothing, alongside phase 2's card shadow. AC-1 is updated
   to record that rather than absorb it: every other role is still asserted
   byte-for-byte against the verbatim pre-M12 scheme, and the two that moved are
   named, given their new values, and checked to have actually differed before.

   surfaceContainerLowest is mapped alongside it for consistency with
   surfaceContainerLow. It has no reader in this app - the phase 8 sweep checked
   every Material component the app draws against the roles the mapping leaves at
   Material defaults, and surfaceContainerHighest was the only live one. The
   drawer scrim reads the unmapped `scrim`, which stays Material's black
   deliberately.

2. The drawer's selected row ignored --radius-pill. NavigationDrawerItem takes
   `shape` as a default argument (CircleShape); the three call sites set `colors`
   but never `shape`, so on Fantasy every other radius went square while the
   selected row stayed fully round.

Verified on device against a Fantasy-themed local instance: the three ShardCards
on the shard screen now paint --panel-flat, and the selected drawer row is the
4px rectangle the preset asks for.

477 unit tests green (476 + 1), lintDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-08 16:06:56 +00:00
whitlocktech merged commit 6cbfdb1e65 into edge 2026-08-08 16:07:03 +00:00
whitlocktech deleted branch chore/m12-phase-8-coverage-and-cutover 2026-08-08 16:07:04 +00:00
Sign in to join this conversation.
No description provided.