feat(theme): make the app a full consumer of the shard's admin theming and nav (M12 cutover) #41

Merged
whitlocktech merged 17 commits from edge into main 2026-08-08 16:28:53 +00:00
Member

What & why

The M12 cutoveredgemain, all eight phases of the Android theming & navigation milestone in one merge, the same shape the website side used for its own.

Before this, the app read exactly one field of the website's admin theming contract (brand.accent) and rendered a hardcoded APP_MENU. An admin who re-skinned the site and restructured its header saw none of it on the phone. After this, the app consumes the contract.

Design of record: docs/android/THEMING_AND_NAV.md, anchored as M12 in docs/android/PLAN.md §9. No backend work — everything consumed has been live on website/main since website#126.

The phases

# Ships PR
0 SiteAppearance — the contract, second-stage JSON parse, resume refresh. Invisible by rule #33
1 Colors — ShardPalette, the fifteen tokens into the Material scheme #34
2 Radii & shadow — ratio-scaled Shapes, --shadow-card onto elevation #35
3 Fonts — seven bundled families, resolved from the theme's stacks #36
4 Brand assets — logo in drawer + top bar, hero on Home #37
5 Public nav — label, order, hidden #38
6 Public nav — sections and added links, native route or Custom Tab #39
7 cancelled — the authenticated navs reach four app rows between them; that does not pay for a new authenticated fetch and a session-keyed cache
8 Docs, coverage, the AC-5 walk — and the two defects it found #40

What a reader of main should know

  • The shipped palette is the runic-gateway preset, value for value — M5 was drawn from the same theme.css the preset was later extracted from. That is what makes "an untouched instance is unchanged" a testable assertion rather than an approximation, and it is why the milestone was cheap.
  • Radii apply as a ratio, not literal dp. The app's Shapes came from the M5 mockup and genuinely differ from the web tokens; a literal map would have restyled the untouched app on day one.
  • Three deliberate changes to an instance that has set nothing, all documented: card shadow (phase 2 — the app had been flat since M5 while the preset selects a shadow), card container colour (phase 8 — the same staleness, found on device), and a hero on Home (phase 4 — brand.hero carries the BRAND_HERO_IMAGE env default, so "admin set nothing" ≠ "slot is empty"). The first two are code; the third is a correction to the record.
  • A nav override may never introduce navigation. MenuAccess and MenuEntry.feature still run after the merge, so hidden: false cannot un-hide what a role or the shard's visibility config withholds. Added links are the one thing that can name an arbitrary path, and they advertise a route rather than granting one — the screen behind still gates itself.
  • The APK nearly tripled: 4.80 → 12.94 MiB, from phase 3's bundled fonts. Merriweather alone is 6.08 MiB because upstream publishes it as a barely-compressible three-axis variable font. Verbatim bundling was chosen with the cheaper options costed and declined; anyone revisiting app size should start there and nowhere else.

How it was tested

  • 477 unit tests green, lintDebug and assembleDebug clean, re-run against this exact merge commit (6cbfdb1).
  • AC-5 on-device walk (phase 8): two AVD passes against a local website — an untouched instance, and a Fantasy-themed one with a reordered + sectioned nav, one added link of each kind, and uploaded logo and hero — the themed pass walked at anonymous / player / moderator / admin. AC-3 held at every rung: no player or staff row leaked to an anonymous caller through a reordered nav. AC-4 confirmed against an unreachable server. The Custom Tab hand-off was verified as CustomTabActivity in dumpsys activity, not by eye.
  • The walk is also what found phase 8's two defects, both invisible to 476 passing tests: ShardCard's container and the drawer's selected-row shape, each a value Material takes as a default argument rather than from the theme.

Note

This is the first CI run of the entire milestone. pr-checks.yml triggers on pull_request: branches: [main], and every phase PR targeted edge — so phases 0–8 were verified locally but never by CI. That is the one thing worth watching on this PR.

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 **The M12 cutover** — `edge` → `main`, all eight phases of the Android theming & navigation milestone in one merge, the same shape the website side used for its own. Before this, the app read exactly **one** field of the website's admin theming contract (`brand.accent`) and rendered a hardcoded `APP_MENU`. An admin who re-skinned the site and restructured its header saw none of it on the phone. After this, the app consumes the contract. Design of record: [`docs/android/THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/android/THEMING_AND_NAV.md), anchored as **M12** in `docs/android/PLAN.md` §9. **No backend work** — everything consumed has been live on `website/main` since website#126. ### The phases | # | Ships | PR | |---|---|---| | **0** | `SiteAppearance` — the contract, second-stage JSON parse, resume refresh. Invisible by rule | #33 | | **1** | Colors — `ShardPalette`, the fifteen tokens into the Material scheme | #34 | | **2** | Radii & shadow — ratio-scaled `Shapes`, `--shadow-card` onto elevation | #35 | | **3** | Fonts — seven bundled families, resolved from the theme's stacks | #36 | | **4** | Brand assets — logo in drawer + top bar, hero on Home | #37 | | **5** | Public nav — label, order, hidden | #38 | | **6** | Public nav — sections and added links, native route or Custom Tab | #39 | | **7** | ❌ **cancelled** — the authenticated navs reach four app rows between them; that does not pay for a new authenticated fetch and a session-keyed cache | — | | **8** | Docs, coverage, the AC-5 walk — **and the two defects it found** | #40 | ### What a reader of `main` should know - **The shipped palette *is* the `runic-gateway` preset**, value for value — M5 was drawn from the same `theme.css` the preset was later extracted from. That is what makes "an untouched instance is unchanged" a testable assertion rather than an approximation, and it is why the milestone was cheap. - **Radii apply as a ratio, not literal dp.** The app's `Shapes` came from the M5 mockup and genuinely differ from the web tokens; a literal map would have restyled the untouched app on day one. - **Three deliberate changes to an instance that has set nothing**, all documented: card **shadow** (phase 2 — the app had been flat since M5 while the preset selects a shadow), card **container colour** (phase 8 — the same staleness, found on device), and a **hero on Home** (phase 4 — `brand.hero` carries the `BRAND_HERO_IMAGE` env default, so "admin set nothing" ≠ "slot is empty"). The first two are code; the third is a correction to the record. - **A nav override may never introduce navigation.** `MenuAccess` and `MenuEntry.feature` still run *after* the merge, so `hidden: false` cannot un-hide what a role or the shard's visibility config withholds. Added links are the one thing that can name an arbitrary path, and they *advertise* a route rather than granting one — the screen behind still gates itself. - **The APK nearly tripled**: 4.80 → 12.94 MiB, from phase 3's bundled fonts. Merriweather alone is 6.08 MiB because upstream publishes it as a barely-compressible three-axis variable font. Verbatim bundling was chosen with the cheaper options costed and declined; anyone revisiting app size should start there and nowhere else. ## How it was tested - **477 unit tests green**, `lintDebug` and `assembleDebug` clean, re-run against this exact merge commit (`6cbfdb1`). - **AC-5 on-device walk** (phase 8): two AVD passes against a local website — an untouched instance, and a Fantasy-themed one with a reordered + sectioned nav, one added link of each kind, and uploaded logo and hero — the themed pass walked at **anonymous / player / moderator / admin**. AC-3 held at every rung: no player or staff row leaked to an anonymous caller through a reordered nav. AC-4 confirmed against an unreachable server. The Custom Tab hand-off was verified as `CustomTabActivity` in `dumpsys activity`, not by eye. - The walk is also what found phase 8's two defects, both invisible to 476 passing tests: `ShardCard`'s container and the drawer's selected-row shape, each a value **Material takes as a default argument rather than from the theme**. > [!NOTE] > **This is the first CI run of the entire milestone.** `pr-checks.yml` triggers on `pull_request: branches: [main]`, and every phase PR targeted `edge` — so phases 0–8 were verified locally but never by CI. That is the one thing worth watching on this PR. ## 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 17 commits 2026-08-08 16:12:21 +00:00
The app has been reading exactly one field of the website's admin theming
contract -- brand.accent. This lands the store the rest of M12 builds on:
GET /public/settings' `theme` (the resolved token map) and `nav_public` (the
raw nav override row) are now decoded, coerced and held beside the brand as
one SiteAppearance, refreshed on resume alongside the session re-validation.

Nothing reads the two new fields yet. Phase 0's hard rule is that it must
change nothing on screen, so RunicApp still takes `brand: BrandDto?` and the
theme is still seeded from the accent alone; AppState.Ready is the only place
a type changed.

Two judgement calls, both in service of THEMING_AND_NAV.md section 2's
forgiving-on-read rule:

- `theme` is modeled as a raw JsonElement rather than Map<String,String>?.
  kotlinx fails the decode of the whole object on a value of an unexpected
  kind, and `theme` shares its payload with `brand` and `push` -- one odd
  token would have blanked the branding and dropped the push relay URL. It is
  coerced field-by-field instead, so a bad token costs exactly itself.
- A failed *refresh* keeps the last good appearance rather than falling back
  to NONE. Only the initial load can produce NONE, so a moment of no
  connectivity on resume cannot repaint a themed shard back to the defaults.

The second-stage parse stops at "is this a plain object", mirroring the web
client's lib/settingsJson.js exactly; reading items/sections/links out of it
is phases 5 and 6's job, so no half-built nav model ships here.

Tests: SettingsJsonTest (6) and SiteAppearanceTest (8) cover the two pure
modules, plus three decode cases in PublicDtoTest for the wire shapes.
360 unit tests green; lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
The fifteen themable tokens of GET /public/settings' theme map are parsed into
a ShardPalette and applied field by field over the shipped M5 palette, which is
the runic-gateway preset value for value — so an instance with no theme_visual
row resolves back to a color scheme identical to the one the app shipped, not
an approximation of it (THEMING_AND_NAV.md §2, §5.1).

Ten tokens have a Material role and go through darkColorScheme; the other five
reach screens through LocalShardPalette. ShardOnCta and ShardPillFg are derived
rather than themed — they track --bg-deep and --accent-bright, following the
server's rule that a value expressed in terms of another token is never frozen
as a literal.

RunicGatewayTheme(accent) becomes RunicGatewayTheme(appearance). The old
signature put --accent on primary, which the contract assigns to
--accent-bright; brand.accent now seeds --accent alone, and the server already
resolves it as theme['--accent'] || env so the two can never disagree.

ThemeComponents.kt was the only file reaching past MaterialTheme.colorScheme
for a themable color; its seven now come from the palette and its seven
semantic constants stay imported.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #34
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>
Reviewed-on: #35
Phase 3 of M12 (docs/android/THEMING_AND_NAV.md §5.3) — the fonts third of the
admin's Appearance page, after phase 1's colors and phase 2's structure.

ShardTypeface.resolve(theme) maps the three font stacks onto three FontFamily
values and shardTypography(faces) draws the M5 type scale in them. Only the
family moves: every size, weight, line height and tracking is the M5 value, so
an unthemed instance reproduces the pre-M12 scale exactly. Resolution is pure,
so every assertion is a plain JVM test with no Compose rule.

Seven families are bundled beside the existing Cinzel (EB Garamond,
Merriweather, Playfair Display, IM Fell English, Inter, Work Sans,
Source Sans 3), taken verbatim from google/fonts the way M5 took Cinzel, each
with its SIL OFL licence under app/licenses/. Italics for the four families
client/index.html requests one for; the rest are skewed, as they were before.

Three things worth knowing:

1. The per-role font list is not the set of values a role can hold. The server
   validates admin-entered fonts against FONT_OPTIONS[role], but a preset's
   tokens are copied verbatim by resolveThemeTokens and never pass through it —
   `modern` publishes --display: 'Work Sans' and `fantasy` publishes
   --sans: 'EB Garamond', neither of which its own dropdown offers. The lookup
   is therefore one global map keyed by the lowercased first family name, and
   both preset cases are asserted by name so a per-role "tidy-up" fails loudly.
   Same trap phase 2 hit with --shadow-card, in a different token group.

2. The APK nearly tripled, and that was a decision, not a discovery. Measured
   unsigned release, R8 + resource shrink: 5,031,411 B (4.80 MiB) before,
   13,574,703 B (12.94 MiB) after — +8.15 MiB against a drafted estimate of
   1.5-2.5 MB. Merriweather alone is 6.08 MiB of that, because upstream ships
   it as a three-axis [opsz,wdth,wght] variable font that deflates only 31%.
   The org lead chose to bundle it verbatim with the cheaper options costed:
   Google's own static 400/700 builds would have held the app near 6.8 MiB,
   and dropping it near 6.2 MiB at the price of a serif option that silently
   does nothing on Android.

3. Typography implements equals — like phase 2's Shapes, unlike phase 1's
   ColorScheme, checked the same way in the material3 1.3.0 bytecode. AC-1's
   type half is one comparison against a verbatim copy of the pre-M12 scale
   held in the test.

No LocalShardTypeface: unlike the palette and the structure, MaterialTheme
carries the families completely, and the two composables that override
anything override the style rather than the family. Type.kt's `val Typography`
becoming a function is the whole migration — the three families were
referenced from that one file and nowhere else.

Tests: ShardTypefaceTest (15). 401 unit tests green (386 + 15), lintDebug and
assembleDebug clean. Not exercised on device — that is AC-5, in phase 8, where
IM Fell English's synthesised bold is the thing to look at.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #36
`brand.logo` and `brand.hero` have ridden in `BrandDto` since M1 and neither
has ever been drawn — the app spells the instance out in text everywhere the
website shows a mark. Phase 4 renders them on the three surfaces §5.6 names:
the logo above the name in the drawer header, the logo in place of the
uppercased title in the top bar, and the hero as a band above Home's title
block.

Nothing new is fetched. `LocalAssetResolver` already turns a site-relative
`/uploads/…` path into an absolute URL and Coil is already a dependency, so
this phase is entirely presentation.

The rule that governs the file is §5.6's: an empty slot renders nothing — not
a placeholder, not a reserved gap. Every size modifier hangs off the image
itself, so when the image is not composed neither is its padding, and a caller
that wants space below a hero passes `Modifier.padding` instead of a sibling
`Spacer`. A failed load is an empty slot: no broken-image icon, no retry.

The top bar is the one place where "empty" is not "nothing". The logo replaces
the title there, so a 404 would strand the app in an unnamed shell until the
next resume refresh; it falls back to the text, which is what empty already
showed. There is no fallback while the load is in flight — drawing the text
first would flash text to logo on every navigation for one frame.

The hero is a fixed 180dp band, cropped, rather than the intrinsic aspect the
app's other images draw at. The website's hero is a CSS background driven by
`hero_layout`, which the app does not port, and the website's default hero is a
square emblem — at the intrinsic aspect an uploaded square would be a ~360dp
block that pushes the status card off the first screenful. It clips to
`shapes.medium`, so it follows `--radius-card` like every other surface.

The logo carries a content description only in the top bar, where it stands
alone; beside the name in text it is decorative, the same call the website's
`alt=''` makes.

410 unit tests green (401 + 9), `lintDebug` and `assembleDebug` clean. The
drawing itself is out of reach for JVM tests — the app carries no Robolectric,
so a composable body cannot run — but the decision of *whether* to draw is
pure, and `brandAssetUrl` is pulled out so it can be pinned.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #37
The drawer has been the app's coded `APP_MENU` in coded order since M1. Phase 5
lets an admin's `nav_public` row relabel, reorder and hide its public rows, which
is the first time anything in the app's navigation comes from the shard.

The public nav is keyed by **website** paths, so this needs a translation table,
and it is the one new piece of cross-repo coupling the milestone introduces. It
lives in a single file with the website's own `NAV` array quoted beside it —
`NavPaths.kt` — so the coupling is visible and reviewable in one place instead of
spread across the drawer's call sites. The `feature` values are deliberately not
mirrored: `APP_MENU` stays the app's own source of truth for gating, and a second
copy of a security-relevant value that drifts silently is worth more than it
costs.

Nine of the sixteen website rows have a drawer row. The other seven map to a
screen the app reaches another way — three news categories are tabs on one News
screen, and champs / guilds / governors / houses sit behind the Shard hub because
that is the better shape on a phone — and an override for one of them is
**ignored**, which is §6.1's rule that a nav override may never introduce
navigation. The hub is a design decision, not an accident to correct. The mapping
still exists for all sixteen because phase 6's added links resolve an
admin-authored path against the same table, and there a category tab or a hub
board is a perfectly good destination: the admin asked for it by path.

The merge is a port of the website's `applyNavOverrides`, narrowed to what a
drawer can express — `label`, `order`, `hidden`, and nothing else. It runs
**before** `visibleEntries`, so the two gates from M10/M11 still decide what this
caller may see and remain the actual boundary: an override that relabels the
Market row, moves it to the front and says `hidden: false` still shows nothing to
a caller whose shard does not publish the market. Hiding is subtractive, never
additive.

One thing the design did not settle and the sort turns on: an untouched row's
implicit key has to be its index in the **website's** nav, not the app's. A
stored `order` is a position in that list, so a key taken from the app's shorter
list would put explicit and implicit keys on two incomparable number lines and
scramble a partially-overridden nav. Both tie-breaks are the web's — an explicit
order beats a coincidental index, and two explicit orders keep code order.

`Routes.news(category)` and an optional NavHost argument ship here as the table's
route builder; phase 6 is their first caller. Navigating to plain `Routes.NEWS`
matches the new pattern with no argument and opens the default tab, so the drawer
and the push deep-link are unaffected — but `destination.route` is now a pattern
with a query, so the top-level and selected-row checks compare on the part before
it.

Two questions went to the org lead before any code. The three news-category paths
get a mapped route but no drawer row of their own, on the same rule as the hub
four. And an admin **may** hide Home, mirroring the website, where `/` is
hideable too: Home stays the NavHost's start destination and stays reachable by
back-press, and the app does not invent a policy the site doesn't have.

442 unit tests green (410 + 32), `lintDebug` and `assembleDebug` clean. The
strongest of them is AC-1's: with no stored row the merge returns `APP_MENU`
itself — identity, not equality — so an instance whose admin never touched the
nav provably gets the drawer the app shipped with.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #38
Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3): the drawer gains the
sections an admin grouped rows into and the links they added of their own, the
last of the public nav the website publishes.

buildNavTree ports the web's buildPublicNav and pruneNav; a link's path is
validated by the website's own read rule and resolved through resolveWebPath,
which the app has to answer for any page on the site rather than the nav's
sixteen. A link the app can open natively does; one it cannot hands off to a
Custom Tab, absolute against the configured base URL.

Phase 6 does not re-implement phase 5: with no sections and no links stored,
buildNavTree hands straight to applyNavOverrides, so an untouched instance still
gets APP_MENU back by identity and AC-1's proof is unchanged.

visibleEntries is split into isEntryVisible so pruneNav can apply the same
predicate inside a section, and drop one the gates leave empty.

476 unit tests green (442 + 34); lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #39
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>
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
whitlocktech approved these changes 2026-08-08 16:16:22 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-08-08 16:16:28 +00:00
whitlocktech merged commit c55ee7f47e into main 2026-08-08 16:28:53 +00:00
whitlocktech deleted branch edge 2026-08-08 16:28:54 +00:00
Sign in to join this conversation.
No description provided.