Phase 7 becomes a record. Three things in it are corrections to the plan rather than notes on it, and all three are the kind that only surface once somebody tries: - `MotionSchemeKeyTokens`, which the plan says every spec should come from, is `internal` to material3 and not addressable from an app. `MaterialTheme.motionScheme` is the public surface and gives the same six specs. - "every state change in the app is a hard cut" was true of screen states and not of navigation, whose default is a 700ms fade in navigation-compose's internals. Still worth replacing -- three times M3's duration, and a literal in a dependency -- but for a different reason than the one written down. - Android has no reduce-motion setting. It has "Remove animations", which zeroes the animation duration scales, and Compose ignores those scales entirely. Also what was deliberately left: the container transform between a list item and its detail screen. It is `SharedTransitionLayout` work, and above the expanded breakpoint the detail is already beside the list, so there is no container to transform -- doing it before the pane split settles means writing it twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1046 lines
57 KiB
Markdown
1046 lines
57 KiB
Markdown
# Bringing the UI in line with Material Design 3
|
||
|
||
What the M3 foundations actually require, where this app's 43 screens stand
|
||
against them today, and a phased order of work that makes each phase mechanical
|
||
by the time it starts.
|
||
|
||
The app already builds on Material 3 — `compose-material3:1.10.0-alpha05`, a
|
||
generated `ColorScheme`, `Scaffold`/`TopAppBar`/`ListItem` throughout. What is
|
||
missing is not the library. It is that **the design decisions live at the call
|
||
site rather than in the theme**, so there is no single place to change and no
|
||
way to check whether a screen conforms. Every phase below moves one class of
|
||
decision out of 18,000 lines of screen code and into something a test can read.
|
||
|
||
Sources are the current foundations pages on <https://m3.material.io/foundations>,
|
||
read September 2026 — after the May 2026 revision that renamed window size
|
||
classes to breakpoints and published the spacing system as tokens. Numbers
|
||
quoted here are from those pages, not from memory of older M3.
|
||
|
||
## What the spec asks for
|
||
|
||
### The seven foundations
|
||
|
||
`/foundations` lists seven areas. Each maps onto something concrete in this
|
||
codebase:
|
||
|
||
| foundation | what it governs | where it lands here |
|
||
|---|---|---|
|
||
| Accessibility | contrast, target size, labels, focus order, structure | `Icon` labels, `.clickable` targets, colour pairings, keyboard flow on desktop |
|
||
| Content design | UX writing, sentence case, alt text, global writing | 334 string literals in composables |
|
||
| Customizing Material | brand colour through the role system, dynamic colour | `Color.kt`, `Theme.kt`, `BluePill`/`RedPill` |
|
||
| Design tokens | style values named by role, never hardcoded | `10.dp` × 132, `20.dp` × 115 |
|
||
| Interaction states | enabled/disabled/hover/focus/press/drag, state layers | `Clickable.kt`, custom `Card` colours |
|
||
| Layout | breakpoints, panes, grids, spacing, bidirectionality | one phone layout on three platforms |
|
||
| Material A-Z | shared vocabulary | naming in this document |
|
||
|
||
### The numbers
|
||
|
||
**Breakpoints** (renamed from window size classes; apply to Android and web,
|
||
and are the right vocabulary for the desktop target too):
|
||
|
||
| breakpoint | width | panes | navigation |
|
||
|---|---|---|---|
|
||
| Compact | under 600dp | 1 | navigation bar, modal expanded rail |
|
||
| Medium | 600–839dp | 1 recommended, 2 possible | collapsed navigation rail |
|
||
| Expanded | 840–1199dp | 2 recommended | collapsed or standard expanded rail |
|
||
| Large | 1200–1599dp | 2 recommended | standard expanded rail |
|
||
| Extra-large | 1600dp+ | up to 3 | standard expanded rail |
|
||
|
||
Two rules from the same page are easy to miss and both bite here: *"across all
|
||
breakpoints, adjust margins and type styles to keep text between 40–60
|
||
characters per line"*, and *"don't use two panes in medium layouts with high
|
||
information density"*.
|
||
|
||
**Spacing.** The system is an 8dp scale where `space100 = 8dp`. Material defines
|
||
only the recommended stops, including sub-8 nested units:
|
||
|
||
| token | dp | | token | dp |
|
||
|---|---|---|---|---|
|
||
| space0 | 0 | | space250 | 20 |
|
||
| space25 | 2 | | space300 | 24 |
|
||
| space50 | 4 | | space400 | 32 |
|
||
| space75 | 6 | | space450 | 36 |
|
||
| space100 | 8 | | space500 | 40 |
|
||
| space125 | 10 | | space600 | 48 |
|
||
| space150 | 12 | | space700 | 56 |
|
||
| space175 | 14 | | space800 | 64 |
|
||
| space200 | 16 | | space900 | 72 |
|
||
|
||
Spacing has three categories with different rules: **padding** (inside an
|
||
element), **gap** (between elements in a container), **margin** (outside an
|
||
element). The spec is explicit that margins are a last resort — *"define padding
|
||
and gaps on the parent container"*, *"avoid defining margins on child
|
||
elements"*. The note that these tokens are Compose-only is in our favour: this
|
||
is a Compose app.
|
||
|
||
**Targets.** Touch targets at least 48×48dp; pointer targets at least 44×44dp;
|
||
targets separated by 8dp or more. An icon may be 24dp while its target is 48dp —
|
||
the padding is part of the target, not decoration.
|
||
|
||
**Contrast.** Small text at least 4.5:1 against its background; large text
|
||
(14pt bold / 18pt regular and up) and graphics at least 3:1. Disabled states are
|
||
exempt. Clustered non-text elements — a group of buttons — need 3:1 between
|
||
container and background; a standalone element such as a FAB does not.
|
||
|
||
**State layers.** A fixed overlay in the content colour: hover 8%, focus 10%,
|
||
press 10%, drag 16%, disabled 38%. State layer 40dp, interactive target 48dp.
|
||
|
||
### What the May 2026 revision changed
|
||
|
||
Worth knowing before reading older guidance or older code:
|
||
|
||
- window size class → **breakpoint**, and there are now five, not three;
|
||
- responsive design → **adaptive design**;
|
||
- the spacing system is published as tokens, on an 8dp scale, Compose-first;
|
||
- the **layout scaffold** (bars, rails, panes, rulers) is the recommended
|
||
structure, replacing hand-rolled adaptive branches;
|
||
- there are now expressive spacing guidelines, and a stated position that
|
||
spacing carries product personality rather than being neutral.
|
||
|
||
## Where this app stands
|
||
|
||
Counts below are over `composeApp/src/commonMain/kotlin/press/mantra/compose/ui`
|
||
unless stated. Phase 0 turns them into a script so they can be re-run.
|
||
|
||
### The theme is incomplete, and two entry points bypass it
|
||
|
||
`Color.kt` defines six schemes — light, dark, and medium/high contrast variants
|
||
of each — and `Theme.kt` wires four of them into `lightColorScheme`/
|
||
`darkColorScheme`. That is more than most apps do, and the generated pairs hold
|
||
up: every `onX`-on-`X` pair in every scheme clears 4.5:1, the tightest being
|
||
`onPrimaryContainer` on `primaryContainer` at 4.61:1 (light) and 4.56:1 (dark).
|
||
|
||
Three gaps:
|
||
|
||
1. **Twelve roles are never set.** `primaryFixed`, `primaryFixedDim`,
|
||
`onPrimaryFixed`, `onPrimaryFixedVariant` and the secondary/tertiary
|
||
equivalents are absent from both schemes, so they fall through to
|
||
`ColorLightTokens.PrimaryFixed` — `PaletteTokens.Primary90`, which is
|
||
`#EADDFF`. Any component reaching for a fixed role paints **Material baseline
|
||
lavender** into a monochrome app, in light and dark alike. Nothing uses them
|
||
today; the trap springs the first time an expressive component does.
|
||
|
||
2. **The medium and high contrast schemes are dead code.** All four are declared
|
||
`private val`; `TorchTheme` only ever selects `darkScheme` or `lightScheme`.
|
||
There is no plumbing to the platform's contrast setting, so the two schemes
|
||
that would honour it are unreachable. The accessibility foundation's first
|
||
principle is *honour individuals* — *"supporting varying preferences and
|
||
choices"* — and the values to do it are already sitting in the file.
|
||
|
||
3. **`AuxTypography` is `Typography()`** — the baseline, in package
|
||
`com.example.ui.theme`, in a file otherwise unused. No shapes and no motion
|
||
scheme are passed to `MaterialTheme` at all.
|
||
|
||
Two entry points render outside the theme:
|
||
|
||
- `composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt:126` —
|
||
`PassphraseGate` sits in the `else` branch beside `MantraApp`, so it composes
|
||
under the **default** `MaterialTheme`. Its `MaterialTheme.colorScheme.error`
|
||
and `typography.headlineSmall` are baseline M3, not this app's. It is the
|
||
first screen a desktop user sees.
|
||
- `Profile.kt:183` and 50 other `@Preview` bodies wrap in `TorchTheme` by hand,
|
||
which is correct, but there is no preview that exercises dark, high-contrast
|
||
or a wide window — so nothing catches the two problems below.
|
||
|
||
### Colour is decided at the call site, and one pairing is invisible
|
||
|
||
Eleven sites hardcode a `Color`, and 14 more derive one with `.copy(alpha = …)`.
|
||
Measured against the light scheme:
|
||
|
||
| site | pairing | ratio | needs |
|
||
|---|---|---|---|
|
||
| `ProposalListScreen.kt:228` | `ListItem` headline (`onSurface`) on a `Card` of `primaryContainer` | **1.00:1** | 4.5:1 |
|
||
| `HomeScreen.kt:113` | `titleContentColor = primary` on `containerColor = primaryContainer` | **1.22:1** | 4.5:1 |
|
||
| `ArticleCard.kt:67`, `QuotedNote.kt:101`, `QuotedAddressableNote.kt:62`, `LiveStreamCardContent.kt:42` | `onSurfaceVariant.copy(alpha = 0.5f)` on surface — these four are `CircularProgressIndicator` colours, not text, so the threshold is 3:1 rather than 4.5:1. Still under it. | 2.49:1 | 3:1 |
|
||
| `CreateProfileScreen.kt:329` | `Color.DarkGray` on `BluePill` | 2.90:1 | 4.5:1 |
|
||
| `ArticleCard.kt:154` | `onSurfaceVariant.copy(alpha = 0.7f)` on surface | 3.96:1 | 4.5:1 |
|
||
| `CreateProfileScreen.kt:311` | `Color.White` on `RedPill` (75% alpha over surface) | 3.50:1 | 4.5:1 |
|
||
| `LiveStreamCardContent.kt:86` | `Color.White` on `#E53935` | 4.23:1 | 4.5:1 |
|
||
|
||
The first is the worst and is worth spelling out, because it is not obvious from
|
||
reading the call:
|
||
|
||
```kotlin
|
||
Card(
|
||
onClick = onClick,
|
||
colors = if (proposal.awaitsYou) {
|
||
CardDefaults.cardColors(
|
||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||
)
|
||
} else {
|
||
CardDefaults.cardColors()
|
||
}
|
||
) {
|
||
ListItem(
|
||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||
```
|
||
|
||
`cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`,
|
||
so `LocalContentColor` inside the card is right. But `ListItem` does not read
|
||
`LocalContentColor` — its headline colour comes from `ListTokens.ItemLabelTextColor`,
|
||
which is `onSurface`, and the call site overrides only `containerColor`. In the
|
||
light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. **The
|
||
proposals that await your signature are the ones rendered invisible.**
|
||
|
||
`BluePill` and `RedPill` are brand colours held as raw `Color` values outside the
|
||
role system. The customization foundation's whole argument is that a brand colour
|
||
becomes a *source colour* generating a role family — `container`/`on`/
|
||
`onContainer` — so contrast is handled and dynamic colour still works.
|
||
|
||
Note what is *not* a finding: `outlineVariant` at 1.61:1 against surface, and
|
||
`secondaryContainer` at 1.65:1. M3's own baseline is in the same range, and the
|
||
3:1 rule is for clustered interactive containers, not dividers or tonal
|
||
surfaces. `onSurface.copy(alpha = 0.38f)` in `TranslationChapterScreen.kt:203`
|
||
is the specified disabled opacity and is exempt.
|
||
|
||
### Spacing is a habit, not a system
|
||
|
||
527 `.dp` literals. 419 land on a defined spacing stop, 19 are dimensions rather
|
||
than spacing (a hairline border, an avatar, an image height), and 89 are off-scale:
|
||
|
||
```
|
||
5dp × 10 15dp × 14 18dp × 1 22dp × 1 30dp × 2
|
||
35dp × 3 50dp × 53 55dp × 2 70dp × 2 75dp × 1
|
||
```
|
||
|
||
The off-scale values are the smaller half of the problem. `10.dp` (132 uses) and
|
||
`20.dp` (115) — the two dominant values — *are* on the scale, as `space125` and
|
||
`space250`, so a sweep for off-grid numbers would change almost nothing. What is
|
||
wrong is that **nothing records which of padding, gap or margin any of these
|
||
is**, so there is no way to adapt them per breakpoint or density later, and no
|
||
way to tell a deliberate 15dp from a typo.
|
||
|
||
`Modifier.height(50.dp)` appears 49 times, almost always as a `Spacer` pushing
|
||
an empty or error message down the screen. It is the same three lines copied
|
||
into 16 files.
|
||
|
||
### Typography uses half the scale, weighted small
|
||
|
||
240 `MaterialTheme.typography.*` reads, which is good — only two `fontSize`
|
||
literals in the whole tree. The distribution is the problem:
|
||
|
||
```
|
||
labelMedium 49 bodySmall 46 bodyLarge 41 bodyMedium 30 labelSmall 28
|
||
labelLarge 13 titleSmall 12 titleMedium 6 titleLarge 4 headlineSmall 4
|
||
headlineMedium 2 headlineLarge 2 displayMedium 1
|
||
```
|
||
|
||
`label*` roles are 92 of 240 uses. Labels are for component text — buttons,
|
||
tabs, chips — not for body copy or list content, and they are the smallest and
|
||
tightest roles in the scale. Reading a screen where `labelMedium` carries the
|
||
prose is the visual equivalent of everything being at the same pitch. Meanwhile
|
||
the three `display*` and three `headline*` roles carry 9 uses between them
|
||
across 43 screens, so almost nothing establishes hierarchy.
|
||
|
||
The pinned material3 ships **30** type roles, not 15 — every role has an
|
||
`…Emphasized` variant. Two are used (`labelSmallEmphasized`,
|
||
`labelLargeEmphasized`).
|
||
|
||
### Targets and labels are mostly right, with a countable set of exceptions
|
||
|
||
- 153 `Icon(` calls; 18 pass `contentDescription = null`. Some of those are
|
||
correct (a decorative icon beside its own label should be null), but they have
|
||
not been triaged.
|
||
- 45 `IconButton` and 5 `FilledIconButton` — these enforce 48dp themselves.
|
||
- 33 bare `Modifier.clickable`, which does not. Two are text-sized:
|
||
`ArticleCard.kt:143` makes an author name clickable, and `LinkPreview.kt:122`
|
||
a `Text` with 2dp vertical padding. Both are around 20dp tall.
|
||
- `Clickable.kt` — a vendored ACINQ helper, still in package
|
||
`com.machankura.compose.ui.composable.widgets.buttons` — defaults to
|
||
`internalPadding = PaddingValues(0.dp)` and `RectangleShape`, so every use
|
||
starts below the minimum.
|
||
- Zero uses of `minimumInteractiveComponentSize()`.
|
||
|
||
91 `TextAlign.Center` and 128 `Alignment.CenterHorizontally`. Centring is the
|
||
default posture of this UI. The grids-and-spacing page asks the opposite for
|
||
lists and content: *"leading elements like thumbnails, avatars, or icons should
|
||
always be aligned"*, and the rulers section builds hierarchy from a shared
|
||
leading edge. Centred body text also loses the 40–60 character line the
|
||
breakpoints page asks for, because there is no ruler to hold it to.
|
||
|
||
Bidirectionality is, unexpectedly, in decent shape: 23 uses of
|
||
`padding(horizontal =`/`vertical =`, 6 of `start`/`end`, and no `left`/`right`
|
||
anywhere.
|
||
|
||
### Text cannot be translated
|
||
|
||
Two `stringResource` calls. 334 literal strings inside composables — 247
|
||
`text = "…"` and 87 `Text("…")`. `strings.xml` exists but holds Phoenix wallet
|
||
strings inherited from the fork, under `app_name` = "Machankura".
|
||
|
||
Capitalisation is title case throughout — roughly 45 distinct strings including
|
||
`"Edit Profile"`, `"Create Profile"`, `"New Chat"`, `"Sign In"`, `"Leave Group"`,
|
||
`"Key Package Management"`, `"Publish New Key Package"`. The style guide is
|
||
unambiguous: *"All text, including titles, headings, labels, menu items,
|
||
navigation components, app bars, and buttons should use sentence-style
|
||
capitalization."*
|
||
|
||
The app's top bar reads `"Torch"` while the window title reads `"Mantra"` and
|
||
`app_name` reads `"Machankura"`. Three names for one product.
|
||
|
||
### There is no feedback surface, and empty states are bare
|
||
|
||
Zero `Snackbar`, zero `SnackbarHost`, zero `rememberSnackbarHostState` — across
|
||
26 `Scaffold`s. There is one `AlertDialog` in the whole app. Every transient
|
||
outcome — a message sent, an invite failing, a key package published — has
|
||
nowhere to be reported.
|
||
|
||
The error state is `Text("Something went wrong")`, duplicated at 16 sites, and
|
||
`Text("No events were found")` at 5. **None of the 16 offers a retry.** No icon,
|
||
no explanation, no action. The content design foundation asks for the opposite:
|
||
*"emphasize the results of the user's potential action"*, *"tell users what will
|
||
happen … and how they can undo it"*.
|
||
|
||
Interaction states are the library's defaults, which is mostly correct — ripple,
|
||
hover and focus come free with `Button`, `Card`, `ListItem`. But the 33 bare
|
||
`.clickable` sites and `Clickable.kt` opt out of the shape and padding that make
|
||
a state layer legible, and nothing anywhere handles keyboard focus explicitly,
|
||
which the desktop target needs.
|
||
|
||
### One layout, three platforms
|
||
|
||
Two `BoxWithConstraints` in the entire tree, both inside
|
||
`ChatMessageListViewModel.kt`. No `WindowSizeClass`, no
|
||
`currentWindowAdaptiveInfo`, no `NavigationSuiteScaffold`, no
|
||
`ListDetailPaneScaffold`, no `NavigationBar`, no `NavigationRail`. The desktop
|
||
entry point says so itself:
|
||
|
||
```kotlin
|
||
// The layouts have only ever been exercised at phone widths. This is a starting size
|
||
// that does not immediately misrepresent them, not a considered desktop layout.
|
||
state = rememberWindowState(width = 480.dp, height = 900.dp),
|
||
```
|
||
|
||
Every one of the components needed is present in the pinned material3 —
|
||
`NavigationRailKt`, `ShortNavigationBarKt`, `WideNavigationRail`,
|
||
`AppBarRowKt`/`AppBarColumnKt`, `FloatingToolbarKt`, `ButtonGroupKt`,
|
||
`SplitButtonKt`, `LoadingIndicatorKt`, `MaterialShapesKt`, `MotionSchemeKt`,
|
||
`MaterialExpressiveTheme`. What is missing is the adaptive layer
|
||
(`material3-adaptive`), which is not declared in `libs.versions.toml`.
|
||
|
||
Insets are thin but not absent: `enableEdgeToEdge()` is called in
|
||
`MainActivity.kt:22`, and `Scaffold` consumes `ScaffoldDefaults.contentWindowInsets`
|
||
by default, so most screens are covered. Nine explicit inset uses exist —
|
||
`imePadding` appears once, in `SovereignWalletStartupScreen.kt`, though nine
|
||
screens and a dialog carry text fields.
|
||
|
||
### Nothing moves
|
||
|
||
One animation API in use, `animateScrollToPage`, twice. No `AnimatedVisibility`,
|
||
no `AnimatedContent`, no `Crossfade`, no `MotionScheme`, and no
|
||
`enterTransition`/`exitTransition` on any of the 43 navigation routes. Every
|
||
state change in the app is a hard cut.
|
||
|
||
### Summary
|
||
|
||
| area | state | phase |
|
||
|---|---|---|
|
||
| Theme completeness | 12 roles unset, 4 schemes unreachable, no shapes/motion | 1 |
|
||
| Colour at call sites | 7 pairings under threshold, one at 1.00:1 | 1, 3 |
|
||
| Spacing | 527 literals, no role recorded | 2 |
|
||
| Typography | 90/240 uses on `label*`, 2/30 roles emphasized | 1 |
|
||
| Targets & labels | 33 unguarded `.clickable`, 18 untriaged nulls | 3 |
|
||
| Content | 334 literals, title case throughout | 4 |
|
||
| Feedback | 0 snackbars, 16 duplicated error states, 0 retries | 5 |
|
||
| Adaptive | 1 layout, 5 breakpoints | 6 |
|
||
| Motion | 1 API, 0 transitions | 7 |
|
||
|
||
## The phases
|
||
|
||
Each phase lands on its own and leaves the app shippable. The order is chosen so
|
||
that each phase makes the next one mechanical rather than judgemental: tokens
|
||
before the call sites that consume them, the accessibility floor before the
|
||
adaptive work that would otherwise double the surface to fix, guard rails last
|
||
so they lock in real state rather than aspiration.
|
||
|
||
---
|
||
|
||
### Phase 0 — a baseline that can be re-measured
|
||
|
||
**Why first.** Every count in this document was produced by hand. If they cannot
|
||
be regenerated, the phases below have no acceptance criteria — only opinions.
|
||
|
||
**Built.**
|
||
|
||
1. **`docs/scripts/m3-audit.sh`** regenerates every count in "Where this app
|
||
stands": the dp histogram split three ways, the typography role
|
||
distribution, hardcoded colour sites, `.clickable` sites,
|
||
`contentDescription = null`, string literals, snackbars, adaptive APIs. Each
|
||
number that a phase is meant to move carries a **budget** at the top of the
|
||
file, and `--check` exits 1 when one is exceeded. Budgets ratchet down in the
|
||
same commit that earns the reduction; Phase 8 wires `--check` into CI, at
|
||
which point raising one is the thing a reviewer looks for.
|
||
|
||
Two counts it reports separately, because conflating them would overstate the
|
||
problem: the twelve `*Fixed*` roles that fall through to Material baseline
|
||
lavender, and `surfaceTint`, which is also unassigned but defaults to
|
||
`primary` and is therefore correct. The `.dp` histogram likewise splits
|
||
dimensions (a hairline border, an avatar) out of the off-scale count.
|
||
|
||
2. **`ColorSchemeContrastTest`** in `commonTest` — WCAG relative luminance from
|
||
first principles, no Compose runtime, four assertions over all six schemes:
|
||
every content role on its container at 4.5:1, `onSurface` on each of the
|
||
seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on
|
||
at 3:1, and `primary`/`error` containers against `surface` at 3:1. 4 tests,
|
||
green.
|
||
|
||
It walks the real `ColorScheme` objects, which is why `Theme.kt`'s six
|
||
schemes moved from `private` to `internal`: rebuilding them in the test from
|
||
`Color.kt` would assert the palette and miss the wiring, and a role pointed at
|
||
its neighbour's value is exactly the slip that reads fine in review.
|
||
|
||
Verified to bite. Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496`
|
||
— a plausible "soften the secondary text" edit — fails it with
|
||
`light: onSurfaceVariant on surfaceVariant is 2.29:1`.
|
||
|
||
**Deliberately not asserted:** monotonicity across the contrast ladder. The
|
||
obvious invariant — high-contrast beats medium beats default, for every pair —
|
||
is false, and correctly so: ten pairs move the other way because a high-contrast
|
||
scheme darkens `surfaceContainerHighest` to separate it from `surface`, trading
|
||
ratio against `onSurface` for the separation that matters. The floor is the
|
||
invariant; the ladder is not.
|
||
|
||
**Risk:** none to the app. The only production change is a visibility keyword.
|
||
|
||
---
|
||
|
||
### Phase 1 — one theme, complete
|
||
|
||
**Why here.** Six of the nine problem areas are call sites reaching past a theme
|
||
that has nothing to offer them. Fill the theme and most later phases become
|
||
find-and-replace.
|
||
|
||
**Work.**
|
||
|
||
1. **Regenerate the scheme with all 49 roles.** Feed the existing source colours
|
||
through Material Theme Builder and take the full export — the twelve
|
||
`*Fixed*` roles and `surfaceTint` included. This removes the latent lavender.
|
||
Keep the existing hex values for the roles already defined so nothing shifts
|
||
visually; this phase adds, it does not restyle.
|
||
|
||
2. **Reach the contrast schemes.** *Built.* `TorchTheme` takes a `ThemeContrast`
|
||
defaulted from a new `platformThemeContrast()` expect/actual, and the six-way
|
||
selection table lives in common code as `appColorScheme`. The old
|
||
`themeColorScheme` expect took four arguments and did both jobs; it splits into
|
||
`platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow
|
||
question, so the scheme table is in one place rather than three.
|
||
|
||
Android reads `UiModeManager.getContrast()` (API 34+) and registers a
|
||
`ContrastChangeListener`, because a contrast change does not restart the
|
||
activity or arrive as a `Configuration` update — without the listener the new
|
||
setting would wait for the next cold start, which is the case the setting
|
||
exists for. iOS reads `UIAccessibilityDarkerSystemColorsEnabled` and observes
|
||
`…StatusDidChangeNotification`; it is a boolean, so iOS never reports Medium.
|
||
Desktop reads Windows' `win.highContrast.on` AWT desktop property and answers
|
||
Standard on linux and macos, which is honest rather than complete — see
|
||
"What this plan does not cover".
|
||
|
||
Verified on an API 36 emulator with dynamic colour off: the `onPrimaryContainer`
|
||
pixel of the "Skip for now" label reads `#848484` → `#A7A7A7` → `#D0D0D0` as the
|
||
setting moves, the three declared values exactly, **without the app restarting**.
|
||
|
||
3. **Give the theme its other three slots.** *Built.* The expressive-vs-standard
|
||
question was put to the product owner on 2026-09-08 and answered
|
||
**expressive**, so `TorchTheme` calls `MaterialExpressiveTheme` with all four
|
||
slots passed explicitly — colour, `MotionScheme.expressive()`, `MantraShapes`,
|
||
`AuxTypography`. Explicitly, because `MaterialExpressiveTheme` would otherwise
|
||
default them to `expressiveLightColorScheme()` and friends, which is Material's
|
||
palette rather than this app's: the same class of accident as the twelve
|
||
unassigned fixed roles.
|
||
|
||
No visual change on the screens checked. The "Invite a Friend" button measures
|
||
byte-identical before and after — same fill, same 357×… box — because the
|
||
expressive default for a `Button` at default size matches the baseline in this
|
||
version. What expressive buys is elsewhere: `LocalUsingExpressiveTheme`, the
|
||
three increased shape steps, the fifteen `…Emphasized` type roles, and the
|
||
components later phases need (button groups, split buttons, floating toolbars,
|
||
wide navigation rails, `LoadingIndicator`).
|
||
|
||
`MantraShapes` is baseline `Shapes()`, and `Shape.kt` records why: the corners
|
||
hand-written across the tree already land on the scale — 4dp ×3 = `extraSmall`,
|
||
12dp ×11 = `medium`, 16dp ×2 = `large`, and one 30dp that drifted two units off
|
||
`extraLarge`. Nothing needs restyling; seventeen literals need migrating, later.
|
||
|
||
4. **Move and fill typography.** `Type.kt` moves from `com.example.ui.theme` to
|
||
`press.mantra.compose.ui.theme`. It stays baseline-derived, but it becomes a
|
||
real file with a stated font stack and a comment recording which roles carry
|
||
what: `display*`/`headline*` for screen identity, `title*` for section and
|
||
card headers, `body*` for prose, `label*` for component text only.
|
||
|
||
5. **Brand colours become roles.** `BluePill` and `RedPill` leave `Color.kt` as
|
||
raw values and enter the scheme as extended colour families with their own
|
||
`container`/`on`/`onContainer`. `CreateProfileScreen.kt:311` and `:329` then
|
||
stop pairing them with `Color.White`/`Color.DarkGray` by eye.
|
||
|
||
6. **Wrap the desktop gate.** *Built.* `Main.kt` moves `TorchTheme` outside the
|
||
`unlocked` branch, so `PassphraseGate` — the first screen a desktop user sees —
|
||
stops composing under the default `MaterialTheme`. `MantraApp` keeps its own
|
||
`TorchTheme`, so the unlocked branch is wrapped twice: android and ios enter
|
||
through `MantraApp` and would lose the theme entirely if it moved out, and a
|
||
second application of the same values costs one `CompositionLocalProvider`.
|
||
|
||
`Type.kt` also left `com.example.ui.theme` for `press.mantra.compose.ui.theme`,
|
||
which removes one of the three package namespaces the UI was living across.
|
||
|
||
**Done when** every role in `ColorScheme` is explicitly assigned in both
|
||
schemes; the contrast test still passes and now covers six schemes; `Type.kt`
|
||
lives under `press.mantra`; no composable in the tree renders under a default
|
||
`MaterialTheme`.
|
||
|
||
**Risk:** medium. Adding the fixed roles cannot regress anything (nothing reads
|
||
them), but switching to `MaterialExpressiveTheme` changes default component
|
||
shapes and sizes app-wide. Land it as its own commit so it can be reverted
|
||
alone.
|
||
|
||
---
|
||
|
||
### Phase 2 — spacing becomes a token
|
||
|
||
**Why here.** Phase 6 has to adapt spacing per breakpoint. It cannot adapt 527 literals.
|
||
|
||
**Built.** Three commits.
|
||
|
||
1. **The scale**, `Spacing.kt` — M3's eighteen stops on a composition local, with eight
|
||
semantic names over them (`screenMargin`, `containerPadding`, `compactPadding`,
|
||
`relatedGap`, `itemGap`, `sectionGap`, `emphasisGap`, `targetGap`) split along the
|
||
spec's padding / gap / margin distinction, with exactly one margin. A `data class`
|
||
rather than constants so that a wider instance at a larger breakpoint moves every
|
||
value, including the semantic ones — asserted, because a semantic name holding a
|
||
literal would stay behind and the layout would half-adapt.
|
||
|
||
Reached through `MaterialTheme.spacing.containerPadding`, matching
|
||
`MaterialTheme.colorScheme.primary`. `LocalSpacing.current` would have to be read into
|
||
a local first and so could not appear inline in a `Modifier` chain; over 500 call sites
|
||
that difference is what decides whether the scale gets used.
|
||
|
||
2. **The 89 off-grid values**, to their nearest stop — 5→4, 15→16, 30→32, 50→48. Largest
|
||
move 2dp. Plus the one hand-written corner that was off the *shape* scale, 30dp against
|
||
`extraLarge`'s 28, which is the drift a scale exists to stop.
|
||
|
||
3. **The remaining 353**, shape-aware: `padding + 8dp → compactPadding`,
|
||
`padding + 16dp → containerPadding`, `gap + 4dp → relatedGap`, `gap + 8dp → itemGap`,
|
||
everything else to the raw stop. 38 of 353 take a semantic name; the rest do not,
|
||
because assigning one needs somebody to have read what the container is, and a name
|
||
asserting a meaning the code lacks is worse than a stop asserting none.
|
||
|
||
**The audit was measuring the wrong thing, and this is where it showed.** It split
|
||
literals by *value* against an exemption list, and the split is not a property of the
|
||
value: 16dp is a spacing stop and a plausible icon size, and 50dp was a `Spacer` height in
|
||
52 places and a divider width in one. `docs/scripts/m3-spacing-positions.py` classifies by
|
||
**call shape** instead — brace-matching `padding(…)`, `PaddingValues(…)`,
|
||
`Arrangement.spacedBy(…)`, and a `.height()`/`.width()` whose enclosing call is `Spacer(`
|
||
— and `docs/scripts/m3-migrate-spacing.py` rewrites using the same parse, so the audit and
|
||
the migration agree by construction.
|
||
|
||
One value is exempt and says so at the site, through an inline `// m3-spacing-exempt:
|
||
<reason>` comment the classifier honours: a 128dp spacer reserving room to scroll the last
|
||
wallet clear of the window. Exemptions belong at the call site rather than in a list of
|
||
numbers in the tool.
|
||
|
||
**Done:** spacing positions 527 → **0**, 431 token reads, one reasoned exemption.
|
||
Verified on-device that nothing moved — the landing screen differs in 47 of 162,000
|
||
sampled pixels, 0.03%, all of them the status bar clock.
|
||
|
||
**Left for later:** 76 `.dp` in dimension positions — avatar sizes at 35/55/70/75dp, icon
|
||
sizes at 18/22dp. Sizing is a per-component question, and the plan puts component specs
|
||
after the adaptive phase. They are reported rather than exempted so the number stays
|
||
visible.
|
||
|
||
---
|
||
|
||
### Phase 3 — the accessibility floor
|
||
|
||
**Why here.** These are defects, not polish, and one of them hides the app's most
|
||
important rows. It goes before the adaptive work so the fixes are made once rather than
|
||
per breakpoint.
|
||
|
||
**Built.** Four commits.
|
||
|
||
1. **Nine contrast failures**, all of them colour reached for at the call site rather
|
||
than derived from what it sits on. The worst was `ProposalListScreen`: a `ListItem`
|
||
inside a `Card` with only the container overridden, so the headline took
|
||
`ListTokens.ItemLabelTextColor` (`onSurface`) against `primaryContainer` — both
|
||
`#1B1B1B` in the light scheme, **1.00:1**, on exactly the proposals awaiting your
|
||
signature. Four of five colours on that card were under the floor.
|
||
|
||
Also `HomeScreen`'s top bar at 1.22:1 (override removed), five
|
||
`onSurfaceVariant.copy(alpha = …)` sites, the LIVE badge's hand-mixed red, and the
|
||
avatar picker's selection tint. Three colours stay hardcoded behind a
|
||
`// m3-color-exempt:` marker with a reason at the site: a QR code's modules, a
|
||
control over an arbitrary photograph, a spinner over a blurhash.
|
||
|
||
2. **Every `.clickable` chain got `minimumInteractiveComponentSize()`** — a no-op above
|
||
48dp, so applying it everywhere makes the rule checkable rather than measurable. Three
|
||
of the nineteen were text-sized. `Clickable.kt` got it built in and left
|
||
`com.machankura`.
|
||
|
||
3. **Eighteen `contentDescription = null` became a decision.** Fifteen say `Decorative`
|
||
— same null, but recording that somebody looked. Three carried state the text did not
|
||
repeat and got real descriptions, including `ProposalListScreen`'s stage icon, which
|
||
the previous commit had made the *only* cue for a failed proposal on the highlighted
|
||
card.
|
||
|
||
4. **Eight text-field screens lift above the keyboard.** `Scaffold`'s
|
||
`contentWindowInsets` is `systemBars` and excludes the ime, so a Scaffold alone does
|
||
nothing about it. `ChatRoomMessagingScreen` is deliberately excluded — its composer
|
||
already reserves the bottom inset by hand, and combining the two needs a device.
|
||
|
||
5. **Initial focus** on the npub dialog's field and the desktop passphrase field — M3's
|
||
flow rule that a dialog takes focus on open, and that every screen defines one. Those
|
||
are the two places where a single field *is* the screen; auto-focusing elsewhere would
|
||
pop the keyboard over content somebody wants to read first.
|
||
|
||
**Font scale verified, not assumed.** A static pass found no fixed-height container
|
||
holding text — all 23 fixed vertical dimensions are icons, images and progress
|
||
indicators. Then at 200% text size on an API 36 emulator: onboarding, the message list
|
||
and a chat room all reflow without clipping, system messages wrapping to two lines with
|
||
their timestamps and chevrons still aligned.
|
||
|
||
**Left undone, and why.** Full keyboard traversal on desktop — tab order across the four
|
||
`ModalBottomSheet`s and the `AlertDialog`, and focus returning to what opened them — is
|
||
not verified. Compose restores focus on dismissal by default, so the gap is evidence
|
||
rather than known breakage, and checking it means driving the desktop build by keyboard.
|
||
The avatar picker's selected state is `secondaryContainer` at 1.65:1 against the surface,
|
||
which M3 accepts only because its own selected states carry a second cue; this grid has
|
||
neither an outline nor a checkmark, and adding one is component work.
|
||
|
||
---
|
||
|
||
### Phase 4 — text the system can translate
|
||
|
||
**Why here.** It is the last phase that touches every file, and doing it after phase 2
|
||
means one pass over each file instead of two. It must precede phase 6: RTL is a breakpoint
|
||
concern too, and there is no point testing a mirrored layout against 334 English literals.
|
||
|
||
**Built.** Three commits.
|
||
|
||
1. **Sentence case, 100 occurrences across 60 strings.** Two passes, and the second is
|
||
the instructive one: the first pattern required every word after the first to be
|
||
capitalised, so anything with an article survived — "Invite a Friend" was still on the
|
||
app's first screen after the audit reported zero — and it read one line at a time, so a
|
||
`Text(` whose literal sat on the next line was invisible.
|
||
|
||
Sample data is deliberately left in title case: "Steve Biko", "To Kill a Mockingbird".
|
||
Those are a person and a book.
|
||
|
||
2. **251 plain strings into the catalogue**, 315 call sites. The extractor took four
|
||
attempts and each failure is recorded in `docs/scripts/m3-extract-strings.py`: a bare
|
||
`text = "…"` is not a Compose string (it rewrote a data class), a regex over quote
|
||
pairs is not a Kotlin lexer (it lifted `"chunk"` out of `"${if (n == 1) "chunk" …}"`),
|
||
and a fragment of a `+` concatenation is not a translatable unit.
|
||
|
||
3. **40 interpolated strings as format strings**, 49 call sites — `${expr}` to `%1$s`,
|
||
passed as arguments.
|
||
|
||
**Compose Resources is not aapt, and a test caught that before a device did.** The first
|
||
extraction escaped apostrophes as `\'` and doubled `%`, which is what android's resource
|
||
compiler requires. Compose Resources does neither: `getString` returned `Don\'t sign`,
|
||
backslash included, across 30-odd strings. Escape handling is *partial* rather than
|
||
absent — `\n` **is** processed — so there is no family rule to lean on.
|
||
`StringCatalogueJvmTest` asserts each escape it depends on.
|
||
|
||
**Done:** literals in composables 334 → 39, `stringResource` 0 → 424, title case 0. Plus
|
||
1101 inherited phoenix strings deleted (nothing referenced them), the product settled on
|
||
one name, and the two destructive actions now state their consequences — read out of the
|
||
repository rather than guessed, because "Delete group" with no qualifier invites the
|
||
belief that the messages are gone from the relays, which is the opposite of true.
|
||
|
||
**Left for later, and why it is not a script's job.** 83 literals that are terms of a `+`
|
||
concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what
|
||
the whole sentence is, and several of them are pluralisations —
|
||
`(if (n == 2) "event" else "events")` — which want a real plural resource rather than a
|
||
format argument. `m3-extract-formatted.py --remaining` lists them.
|
||
|
||
`UserAgent.APP_NAME` still says "Torch". It goes on the wire to relay operators, so it is
|
||
a network identity question rather than a content one.
|
||
|
||
---
|
||
|
||
### Phase 5 — every screen has four states
|
||
|
||
**Why here.** It needs the tokens from phase 2 and the strings from phase 4, and it
|
||
produces the components phase 6 will lay out.
|
||
|
||
**Built.** Two commits.
|
||
|
||
1. **`ErrorState` and `EmptyState`** replace 21 hand-copied blocks — 16 saying "Something
|
||
went wrong", five saying "No events were found", **none of the sixteen with a retry**.
|
||
`EmptyState`'s message is required with no default, because that one sentence was shown
|
||
for five different absences and a shared default would have preserved exactly that.
|
||
|
||
2. **A snackbar host**, where there had been none across 43 `Scaffold`s. On a composition
|
||
local rather than a parameter, because a view model coroutine reporting an outcome sits
|
||
several composables below the `Scaffold` that owns the host. It throws rather than
|
||
defaulting to a detached state: a default would make `notify(…)` a silent no-op on any
|
||
screen that forgot the host, which is the failure the file exists to end. Wired to
|
||
`publishNewKeyPackage` and `rotateKeyPackage`, both of which were fire-and-forget, and
|
||
verified on a device.
|
||
|
||
3. **`LoadingDataIndicator` draws `LoadingIndicator`** — the expressive component for an
|
||
indeterminate wait — instead of a `CircularProgressIndicator` hardcoded to 80dp in the
|
||
brand gold, which read as a warning rather than as a wait. One wrapper, 41 call sites.
|
||
|
||
4. **The profile screen's hierarchy.** Seven actions in one list, five of them
|
||
`TextButton`s and two filled `Button`s — M3's highest emphasis, meant for one action per
|
||
screen. One of the two was **Sign out**: the most prominent control on the screen given
|
||
to its most destructive action. Sharing is now `FilledTonalButton`; signing out is a
|
||
`TextButton` in the error colour, matching how leaving and deleting a group are already
|
||
treated elsewhere in this app.
|
||
|
||
**The plan was wrong about disabled FABs.** It said five screens should pass `enabled` and
|
||
let the component apply the 38% state layer. No `FloatingActionButton` overload in
|
||
material3 1.10 takes `enabled` — the spec's position is that an unavailable FAB should not
|
||
appear at all — so hand-computing the colours is the only way to show one, and the existing
|
||
code already pairs it with `Modifier.semantics { disabled() }` so a screen reader does not
|
||
announce a button it is happy to press. Left alone.
|
||
|
||
**Left for a person.** Eight more screens have two or more filled buttons competing:
|
||
LandingScreen's "Sign in" beside "Create profile", SocialPreconditionScreen's "Invite a
|
||
friend" beside "View invites", and six others. Which of a pair is primary is a product
|
||
decision about what the screen is for, not something to infer from the source, and getting
|
||
it wrong quietly weights a choice the user is supposed to make freely.
|
||
|
||
---
|
||
|
||
### Phase 6 — layouts that survive a wide window
|
||
|
||
**Why here.** It is the largest phase and the only one that cannot be done
|
||
mechanically. Everything above reduces its surface: tokenised spacing can be
|
||
swapped per breakpoint, and the states from Phase 5 are what fills a second
|
||
pane.
|
||
|
||
**Built.** Six commits, in the order the dependencies fall rather than the order
|
||
listed above: the view model split first because the pane work needs it, the
|
||
measure before the panes because it decides what a pane holds, navigation and
|
||
panes last because both were product decisions.
|
||
|
||
1. **The dependency question, settled.** `material3-adaptive` publishes
|
||
multiplatform under `org.jetbrains.compose.material3.adaptive`, with android,
|
||
desktop and ios variants — the ios ones carry `ios_arm64` and
|
||
`ios_simulator_arm64` attributes despite their `uikit*` artifact names, so the
|
||
targets declared on a mac resolve. Version **1.2.0**, not the newer
|
||
1.3.0-beta02, because it is what the pinned material3 itself resolves:
|
||
`material3-adaptive-navigation-suite:1.10.0-alpha05` names `adaptive:1.2.0` in
|
||
its pom, and 1.3.0 would pull window-core 1.5.0 in beside the 1.4.0 the pinned
|
||
material3 compiled against. Nothing is lost by staying — 1.2.0 already computes
|
||
the large and extra-large breakpoints through `supportLargeAndXLargeWidth`.
|
||
|
||
So the library scaffolds were available. Step 3 uses one and step 4 does not;
|
||
see below.
|
||
|
||
2. **`Breakpoint`**, the five-value enum with `ofWidth` as a pure function so the
|
||
thresholds are assertable without a Compose runtime. `TorchTheme` classifies
|
||
once and provides `LocalBreakpoint`, so no two screens can disagree about the
|
||
window they are both in.
|
||
|
||
It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`,
|
||
because the latter also computes a `Posture` from the platform's fold state —
|
||
on android, `WindowInfoTracker` and an activity. This call sits in `TorchTheme`,
|
||
which wraps all 51 `@Preview` bodies in the tree, and a preview context is not
|
||
an activity.
|
||
|
||
**Spacing adapts, and exactly one value moves.** M3 publishes a margin per
|
||
breakpoint — 16dp compact, 24dp wider — and publishes nothing else that varies
|
||
with window width. The scale itself is absolute: `space200` is 16dp on a phone
|
||
and 16dp on a desktop, and what adapts is which token a job reaches for. So
|
||
`screenMargin` goes 16 → 24 at medium and holds; `containerPadding`, `itemGap`
|
||
and the rest do not move. A test asserts the non-movement, because "make it
|
||
breathe on a big screen" is the edit a reviewer waves through.
|
||
|
||
A test found a real defect while being written: `ofWidth` threw below 0dp, and
|
||
a desktop window reports a zero size for the frame before its first layout pass.
|
||
|
||
3. **The chat transcript left the view model.** `ChatMessageListViewModel` was
|
||
1,113 lines, of which 380 were a `@Composable` member holding a `LazyColumn`, a
|
||
`DropdownMenu` and both of the app's only two `BoxWithConstraints`. It is now
|
||
356 lines of state, and `ChatTranscript.kt` is 779 of layout. The move is
|
||
verbatim — the three helper composables are byte-identical, and the transcript
|
||
differs by its signature and fourteen references that had been resolving
|
||
against the enclosing class.
|
||
|
||
4. **A readable measure on all 49 screen roots.** `readableContentWidth()` is
|
||
`bodyLarge`'s size through the current density, times half an em per character,
|
||
times sixty: 480dp at the default text size. Derived rather than written down,
|
||
because a hardcoded `480.dp` holds thirty characters at 200% text scale —
|
||
silently, since the text still fits. Only the ceiling is enforced: a 400dp
|
||
window less its margins holds about 46 characters, and no cap can add any.
|
||
|
||
The *column* is centred; the text is not. Those are opposite things, and a
|
||
centred column still has one straight leading edge for every row, avatar and
|
||
icon to align to.
|
||
|
||
Of the 91 `TextAlign.Center` uses, **84 are right** and were left. Centring is
|
||
correct for a block that is the only thing on a screen — an empty state, a
|
||
loading message, an onboarding status screen, a placeholder, a hero. Seven
|
||
were wrong and shared one shape: text in a column *beside a full-width
|
||
element*, so there was a leading edge and it was being ignored — four helper
|
||
lines under `fillMaxWidth()` text fields, one above three full-width cards, and
|
||
a confirmation list where "Name" and the name below it each floated at their
|
||
own width.
|
||
|
||
5. **A navigation component, and an IA decision to make one possible.** The app
|
||
had none: 43 screens reached by route, and one home screen whose app bar
|
||
carried the only two peer surfaces. With a single top-level destination a
|
||
navigation bar would hold one item and be strictly worse than the app bar it
|
||
replaced, so the question — promote search and profile to peer destinations, or
|
||
record the finding and wait — was put to the product owner on 2026-09-08 and
|
||
answered **promote**. `HomeScreen`'s app bar now carries a title and nothing
|
||
else: two routes to one destination is what M3's "swap only functionally
|
||
equivalent components" caution is about.
|
||
|
||
Compact takes a navigation bar, medium and expanded a collapsed rail, large and
|
||
extra-large an expanded rail. `NavigationSuiteScaffoldDefaults.navigationSuiteType`
|
||
is not used, and the difference is the last row — it stops at the collapsed rail,
|
||
because it classifies with the three-value window size class rather than the five
|
||
breakpoints. `NavigationSuiteType.None` on every other screen.
|
||
|
||
Two things the wiring needed. `ActiveProfileRoute` is addressed by metadata event
|
||
id rather than by public key, and only the home screen ever had one, so the nav
|
||
host observes it and the profile item is *disabled* until it arrives rather than
|
||
absent. And the item click pops to `HomeRoute`, not to the graph's start
|
||
destination: the android docs give the second shape and it would be wrong here,
|
||
because this graph starts at `LoadingRoute` and onboarding clears the stack on
|
||
its way to home.
|
||
|
||
6. **The chat list beside the conversation, from expanded up.** Below that it is
|
||
one pane, and that is the spec rather than caution: the breakpoints page says no
|
||
two dense panes in a medium window, and `calculatePaneScaffoldDirective` says the
|
||
same in code — `maxHorizontalPartitions = 1` for compact and medium alike.
|
||
|
||
`ListDetailPaneScaffold` was available and was **not** used. It earns its API
|
||
surface — a navigator, a destination history, an `AnimatedPane` per pane, three
|
||
experimental opt-ins — by owning the single-pane case as well, showing the detail
|
||
*instead of* the list on a phone. This app cannot hand it that:
|
||
`ChatRoomMessagingRoute` is navigated to from eleven places, so the conversation
|
||
stays a pushed destination whatever the window is doing, and a scaffold
|
||
permanently in its two-pane state would be a `Row` with more words. Its *numbers*
|
||
are taken instead — 360dp of list at expanded, 412dp from large up, 24dp between
|
||
— so a hand-built pair measures the same as the scaffold would.
|
||
|
||
The floating action button moves into the list pane when there are two, because
|
||
the `Scaffold`'s slot is the bottom-right of the window, which with two panes is
|
||
on top of the transcript's send button.
|
||
|
||
7. **The desktop window opens at 1100×800** — inside the expanded breakpoint, the
|
||
narrowest window M3 recommends two panes in — with a 400×600 minimum it never
|
||
had. The comment apologising for the old 480dp size is gone because it has
|
||
stopped being true.
|
||
|
||
**Verified by measuring compositions, not by reading code.** This phase added
|
||
`compose.desktop.uiTestJUnit4` to `jvmTest`, pinned to the same 1.11.1 as the rest
|
||
of Compose Multiplatform, and `runDesktopComposeUiTest(width = …)` gives a window
|
||
that genuinely is that many pixels across at density 1. Thirteen tests run at the
|
||
five widths the phase names:
|
||
|
||
- the measure caps at 480dp and centres at 1400, and is a no-op at 400. Swapping
|
||
the last two modifiers in `readableContent()` reports `Actual width is 1400.0.dp,
|
||
expected 480.0.dp` — the "centred but never capped" failure, which no phone-width
|
||
preview would show;
|
||
- `currentBreakpoint()` answers Compact / Medium / Expanded / Large / ExtraLarge at
|
||
400 / 700 / 1000 / 1400 / 1800, with the screen margin following. A version
|
||
measuring the parent's constraints rather than the window would answer `Compact`
|
||
everywhere and pass every unit test in the suite;
|
||
- the navigation component appears on the three top-level destinations, disappears
|
||
under a pushed route, and marks the right item selected;
|
||
- the chat panes split at 1000 and 1400 and do not at 400 or 700, with the list
|
||
pane exactly 360dp and 412dp.
|
||
|
||
**One test could not be written, and the reason is recorded at the site.** A click
|
||
handler that navigates trips navigation-compose's own main-thread assertion under
|
||
`runDesktopComposeUiTest` — reproducible in twenty lines containing no app code, a
|
||
`NavHost`, two routes and a `TextButton`. What an item's `onClick` builds is
|
||
asserted where it is a pure function instead.
|
||
|
||
**The audit grew a floor.** Every other budget in `m3-audit.sh` is a ceiling that
|
||
ratchets down; the adaptive work is the one thing in this document that a later
|
||
edit removes rather than adds — a screen that stops reading the breakpoint still
|
||
compiles and still renders — so `--check` now also fails when the adaptive API and
|
||
navigation component counts drop *below* their floors.
|
||
|
||
**Left for a person, and for a later commit.**
|
||
|
||
Proposals → signing and artifacts → chapters are the two remaining list-detail
|
||
families the plan names. Chat was done first and alone, deliberately: it is the
|
||
one the spec is most careful about, and it is the one whose eleven entry points
|
||
made the shape of the answer clear. The other two are the same shape with
|
||
different content.
|
||
|
||
"Every screen renders correctly at five widths" is verified structurally rather
|
||
than screen by screen — the measure is applied at all 49 roots and asserted at
|
||
those widths, and every screen compiles under it. What a person still has to look
|
||
at is whether a 480dp column of a *particular* screen reads well, which is a
|
||
judgement no assertion makes.
|
||
|
||
**Risk:** high, and realised in one place — the navigation bar changes what every
|
||
screen's app bar offers. It is its own commit and reverts alone.
|
||
|
||
---
|
||
|
||
### Phase 7 — motion
|
||
|
||
**Why last of the build phases.** Motion describes relationships between
|
||
layouts. Animating the current layouts and then changing them in Phase 6 is
|
||
work done twice.
|
||
|
||
**Built.** Two commits.
|
||
|
||
1. **Navigation transitions on all 43 routes at once**, from the theme's
|
||
`MotionScheme` rather than from a literal. The plan expected a hard cut and
|
||
found something else: navigation-compose's default on android and desktop is
|
||
`fadeIn(tween(700))` / `fadeOut(tween(700))`, written into the library's own
|
||
internals. Both halves are worth changing — 700ms is roughly three times M3's
|
||
duration for a full-screen change, and a literal inside a dependency is not a
|
||
decision this app made.
|
||
|
||
The shape is M3's shared axis: the arriving screen slides in from the trailing
|
||
edge while the leaving one slides out toward the leading edge, both fading, and
|
||
going back mirrors it so the direction of travel is legible.
|
||
`slideIntoContainer` is layout-direction aware, so an RTL locale gets the
|
||
mirror for free.
|
||
|
||
**The plan named an API an app cannot reach.** `MotionSchemeKeyTokens` is
|
||
`internal` to material3, so the tokens are not addressable by name from outside.
|
||
`MaterialTheme.motionScheme` is the public surface and offers the same six
|
||
specs; two private helpers name which of them this app uses for what — spatial
|
||
for the slide, effects for the fade, which is the distinction the scheme draws.
|
||
|
||
2. **`ScreenStateTransition` on 20 screens**, M3's fade-through between a screen's
|
||
loading, error, empty and loaded states. The outgoing state fades out, the
|
||
incoming one fades in and grows the last 8% into place, with `SizeTransform`
|
||
off so a tall loaded state does not stretch a short spinner on its way in.
|
||
|
||
**The content key is the state's class, not the state**, and that is the half
|
||
that is easy to get wrong and impossible to see. Keyed on the value, a screen
|
||
re-runs the whole fade every time its loaded data changes — a message arriving,
|
||
a list growing by one — so it flickers whenever anything happens, and every
|
||
screenshot looks perfect.
|
||
|
||
Not applied to 15 other `when`s, by a mechanical rule: `AnimatedContent` is a
|
||
layout node, so it can only wrap a `when` that is a composable's whole body.
|
||
Where the `when` sits inside a `Column` whose branches use `Modifier.weight` —
|
||
the sign-in and create-profile flows, frost signing, the proposal list, the two
|
||
feed detail widgets, the four render helpers still on view models — wrapping it
|
||
would take those branches out of `ColumnScope`.
|
||
|
||
**Reduced motion, in the shape phase 1 established.** `platformReducedMotion()` is
|
||
an expect/actual beside `platformThemeContrast()`, observed rather than read once.
|
||
It does not mean *no* transition: the screen still fades and what goes is the
|
||
movement, which is what M3 and WCAG 2.3.3 are both about.
|
||
|
||
- **Android has no "reduce motion" switch.** It has **Remove animations**, which
|
||
sets the animation duration scales to zero — and the platform applies that scale
|
||
to `ValueAnimator` and **not to Compose**, which runs on its own clock and
|
||
ignores it entirely. An app that draws its own transitions has to read the
|
||
setting itself. A `ContentObserver` on `ANIMATOR_DURATION_SCALE` catches changes
|
||
without a restart.
|
||
- **iOS** is the one platform where it is a single documented call,
|
||
`UIAccessibilityIsReduceMotionEnabled`, with the same notification shape as the
|
||
darker-system-colours one already observed there.
|
||
- **Desktop answers `false`**, and says at the site why that is honest rather than
|
||
a stub: Windows, macos and the freedesktop desktops each have the setting and
|
||
none of the three reaches AWT. The same wall `platformThemeContrast` hits on
|
||
linux and macos, and the same eventual answer — a preference with the platform
|
||
as its default.
|
||
|
||
**Verified by holding the clock still**, because neither claim can be asserted on
|
||
a value. `EnterTransition` has no public shape to inspect, so "this one slides and
|
||
that one does not" is measured: navigate, advance a third of the way, and read
|
||
where the arriving screen is — 54dp from home when sliding, already there when
|
||
reduced. And a crossfade is told from a cut by the one frame in which *both*
|
||
states are composed.
|
||
|
||
**A test found a design flaw rather than a bug.** The reduced case read 54dp of
|
||
slide at first, because the test provided `LocalReducedMotion` around `TorchTheme`
|
||
and the theme overwrote it. The fix is not in the test: `reducedMotion` is now a
|
||
`TorchTheme` parameter defaulted to the platform, exactly as `contrast` is,
|
||
because a value nothing can override is a value nothing can test — and because the
|
||
desktop actual is a hardcoded `false` that a settings screen will need to override
|
||
anyway.
|
||
|
||
**Left undone.** The container transform between a list item and its detail
|
||
screen, which the plan names as the transition carrying the most meaning. It is
|
||
`SharedTransitionLayout` work and it wants the pane split to settle first: on a
|
||
wide window the detail is already beside the list, so there is no container to
|
||
transform, and the animation only applies below the expanded breakpoint. Doing it
|
||
now would mean writing it twice.
|
||
|
||
**Risk:** low. Visible, easily tuned, easily reverted.
|
||
|
||
---
|
||
|
||
### Phase 8 — guard rails
|
||
|
||
**Why last.** These lock in what the previous phases achieved. Written earlier,
|
||
they would only fail.
|
||
|
||
**Work.**
|
||
|
||
1. **CI runs the Phase 0 audit** and fails on regression: no new `.dp` literals
|
||
outside the theme, no new hardcoded `Color`, no new string literal in a
|
||
composable, no new bare `.clickable`.
|
||
2. **The contrast test covers every scheme and every call-site pairing**, and is
|
||
part of the normal test run. It is the only one of these that catches a bug a
|
||
human reviewer reliably misses.
|
||
3. **Preview coverage per screen**: light, dark, high-contrast, 200% font scale,
|
||
compact and expanded. There are already 51 previews to build on.
|
||
4. **A short conventions note in `CLAUDE.md`** — spacing comes from the scale,
|
||
colour from the role, text from resources, targets are 48dp — so the rules
|
||
are visible at the point of writing new code rather than at review.
|
||
|
||
**Done when** the audit and the contrast test both run in CI, and a change
|
||
violating any of the four rules fails.
|
||
|
||
**Risk:** none to the app; some friction for contributors, which is the point.
|
||
|
||
## Order and dependencies
|
||
|
||
| phase | depends on | touches | reversible alone |
|
||
|---|---|---|---|
|
||
| 0 — baseline | — | new files only | n/a |
|
||
| 1 — theme | 0 | `ui/theme/`, 2 entry points | yes |
|
||
| 2 — spacing | 1 | ~every file | yes, mechanically |
|
||
| 3 — a11y floor | 1 | ~25 sites | yes |
|
||
| 4 — content | 2 | ~every file | yes, mechanically |
|
||
| 5 — states | 2, 4 | 26 scaffolds, 21 sites | yes |
|
||
| 6 — adaptive | 2, 3, 5 | navigation, ~15 screens | no — plan per family |
|
||
| 7 — motion | 1, 5, 6 | navigation, state switches | yes |
|
||
| 8 — guard rails | all | CI, `CLAUDE.md` | yes |
|
||
|
||
Phases 1–5 can be worked in parallel by different people if 1 lands first;
|
||
6 cannot start until 3 and 5 are done, or the same screens get touched twice.
|
||
|
||
## What this plan does not cover
|
||
|
||
- **Which M3 is the target.** Phase 1 asks the expressive-vs-standard question
|
||
and this document does not answer it. The 66 existing
|
||
`ExperimentalMaterial3ExpressiveApi` opt-ins and the pinned alpha both point
|
||
expressive, but it is a product decision with a visible outcome, and it should
|
||
be made deliberately rather than inherited from an import.
|
||
- **Whether dynamic colour should keep overriding the brand.** `TorchTheme` defaults
|
||
`dynamicColor = true`, and on Android 12+ that wins unconditionally — so on
|
||
essentially every current Android device, none of the six schemes below is used
|
||
and the app renders in whatever the user's wallpaper produced. Verified on an API
|
||
36 emulator: the app paints Material lavender until dynamic colour is switched
|
||
off, at which point the black-and-gold brand appears. M3's customization
|
||
foundation treats this as a developer choice, and applying it selectively — a
|
||
profile screen, say — is the usual answer for an app with a brand. Deciding it is
|
||
a product call, not a conformance one, and it is why phase 1's contrast work is
|
||
reachable today only on Android below 12, on iOS and on desktop. On Android 14+
|
||
with dynamic colour on, the platform honours contrast itself.
|
||
- **Whether the monochrome palette is right.** The scheme's `primary` is pure
|
||
black in light and near-white in dark, with the entire tertiary family a copy
|
||
of primary. That is a defensible choice for this product and it passes
|
||
contrast. It is also why `HomeScreen`'s app bar override collapsed to 1.22:1 —
|
||
a monochrome scheme has no slack. This plan fixes the call sites; it does not
|
||
propose a repalette.
|
||
- **Component-level specs.** The foundations are the scope. The per-component
|
||
pages — button sizes, card variants, list item densities — are a second pass,
|
||
best taken after Phase 6 settles which components are used where.
|
||
- **iOS.** The ios targets only build on a mac (see `jvm-target.md`), so nothing
|
||
here has been verified on the platform whose HIG asks for 44dp targets rather
|
||
than 48dp. M3 notes the difference; this plan assumes 48dp everywhere.
|
||
- **The three package namespaces.** `press.mantra`, `com.example.ui.theme` and
|
||
`com.machankura.compose` all hold live UI code. Phase 1 moves `Type.kt` and
|
||
Phase 3 moves `Clickable.kt` because both are in the way; the rest of the
|
||
`com.machankura` tree — NFC widgets among it — is out of scope.
|