A plan, not a change: what m3.material.io/foundations asks for as of its May 2026
revision, what these 43 screens actually do, and eight phases ordered so that each
one makes the next mechanical rather than judgemental.
**The spec was read, not remembered.** m3.material.io is a client-rendered SPA --
WebFetch returns an empty `<main>` and the tab URLs 404 on direct navigation -- so
the numbers here came out of a real browser session clicking through the tab
controls. That mattered: the May 2026 revision renamed window size classes to
**breakpoints** and there are now five of them rather than three (compact / medium
/ expanded / large / extra-large, at 600 / 840 / 1200 / 1600dp), renamed responsive
design to adaptive design, and published the spacing system as tokens on an 8dp
scale where `space100 = 8dp`. Writing this from memory of older M3 would have
produced a plan against a vocabulary the current spec no longer uses.
**The palette is fine; the call sites are not.** Every `onX`-on-`X` pair in all six
declared schemes clears 4.5:1, the tightest being `onPrimaryContainer` on
`primaryContainer` at 4.61:1 light and 4.56:1 dark. So the generated scheme is not
the problem and this plan does not propose a repalette. What fails is colour
decided locally, seven pairings of it, and the worst is not visible to a reviewer:
Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) {
ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent),
`cardColors(containerColor = ...)` does derive `contentColor = contentColorFor(...)`,
so `LocalContentColor` inside the card is correct. But `ListItem` does not read
`LocalContentColor` -- its headline comes from `ListTokens.ItemLabelTextColor`,
which is `onSurface` -- and the call site overrides only `containerColor`. In the
light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. That is
**1.00:1**, and it is applied exactly to `proposal.awaitsYou`, so the proposals
waiting on your signature are the ones rendered invisible. `HomeScreen`'s
`titleContentColor = primary` on `containerColor = primaryContainer` is the same
mistake at 1.22:1. Ratios were computed rather than eyeballed; the script is in the
Phase 0 deliverable.
**Twelve colour roles fall through to Material baseline lavender.** `Color.kt`
never assigns `primaryFixed`, `primaryFixedDim`, `onPrimaryFixed`,
`onPrimaryFixedVariant` or the secondary/tertiary equivalents, so
`lightColorScheme()` defaults them to `ColorLightTokens.PrimaryFixed` ->
`PaletteTokens.Primary90` -> `#EADDFF`. Nothing reads them today, which is why it
has never been noticed; the trap springs the first time an expressive component
does. Read out of the pinned `material3-desktop-1.10.0-alpha05-sources.jar` rather
than assumed.
**Four of the six declared schemes are unreachable.** The medium- and high-contrast
variants are written out in full in `Color.kt` -- 78 colour values -- wired into
`lightColorScheme`/`darkColorScheme` in `Theme.kt`, and then never selected:
`TorchTheme` chooses between `darkScheme` and `lightScheme` only. The work to
honour a platform contrast setting is already done and disconnected.
**10dp and 20dp are not the problem they look like.** They are the two dominant
spacing values (132 and 115 uses) and both are *on* the M3 scale, as `space125` and
`space250`. The plan says so rather than proposing a sweep that would change
nothing. What is wrong is that none of the 520 `.dp` literals records whether it is
padding, a gap or a margin -- the three categories the spec gives different rules
to -- so nothing can be adapted per breakpoint later. About 101 are off-scale
(50dp x 53, 15dp x 14, 5dp x 10 and so on), and `Modifier.height(50.dp)` appears 49
times as the same copied spacer above the same copied error message.
**Findings that were measured and then dropped.** `outlineVariant` reads 1.61:1
against surface and `secondaryContainer` 1.65:1, both of which look alarming and
neither of which is a defect: M3's own baseline sits in the same range, and the 3:1
rule the spec gives is for clustered interactive containers, not dividers or tonal
surfaces. `onSurface.copy(alpha = 0.38f)` is the specified disabled opacity and the
spec exempts disabled states from contrast entirely. Reporting these would have
padded the count and cost the reader trust in the rest.
**The rest of the audit, in counts.** 334 string literals in composables against 2
`stringResource` calls, with title case throughout ("Edit Profile", "New Chat") where
the style guide asks for sentence case. Zero `Snackbar` across 26 `Scaffold`s. 16
copies of `Text("Something went wrong")`, none of which offers a retry. 90 of 240
typography reads on `label*` roles, which are for component text, while `display*`
and `headline*` carry 9 uses between them across 43 screens. 33 bare
`Modifier.clickable` with no minimum target, two of them text-height. Two
`BoxWithConstraints` and no window-size handling at all, on a project with a desktop
target whose own entry point already says so in a comment.
**Eight phases, ordered by what each unblocks.** 0 baseline harness, 1 theme,
2 spacing tokens, 3 accessibility floor, 4 content, 5 states and feedback,
6 adaptive layout, 7 motion, 8 guard rails. Tokens come before the call sites that
consume them; the accessibility floor comes before the adaptive work that would
otherwise double the surface to fix; guard rails come last so they lock in real
state rather than aspiration. Phase 6 is the only one that cannot be done
mechanically and the only one marked not reversible alone.
**What it deliberately does not decide.** Whether the target is
`MaterialExpressiveTheme` or `MaterialTheme` -- the pinned material3 ships the full
expressive set and the code already opts into `ExperimentalMaterial3ExpressiveApi`
in 66 places, but it changes default component shapes and sizes app-wide, so it is a
product call and Phase 1 raises it rather than answering it. Also out of scope:
whether the monochrome palette is right, the per-component specs, iOS (which only
builds on a mac, and whose HIG asks 44dp where M3 asks 48dp), and the three package
namespaces the UI currently lives across.
No code changes. `docs/README.md` gains the row and the closing paragraph's note on
how this one relates to the others.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
38 KiB
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:
-
Twelve roles are never set.
primaryFixed,primaryFixedDim,onPrimaryFixed,onPrimaryFixedVariantand the secondary/tertiary equivalents are absent from both schemes, so they fall through toColorLightTokens.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. -
The medium and high contrast schemes are dead code. All four are declared
private val;TorchThemeonly ever selectsdarkSchemeorlightScheme. 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. -
AuxTypographyisTypography()— the baseline, in packagecom.example.ui.theme, in a file otherwise unused. No shapes and no motion scheme are passed toMaterialThemeat all.
Two entry points render outside the theme:
composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt:126—PassphraseGatesits in theelsebranch besideMantraApp, so it composes under the defaultMaterialTheme. ItsMaterialTheme.colorScheme.errorandtypography.headlineSmallare baseline M3, not this app's. It is the first screen a desktop user sees.Profile.kt:183and 50 other@Previewbodies wrap inTorchThemeby 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 |
2.49:1 | 4.5: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:
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
520 .dp literals. Roughly 419 land on a defined spacing stop and about 101 do
not:
1dp × 12 5dp × 10 15dp × 14 18dp × 1 22dp × 1
30dp × 2 35dp × 3 50dp × 53 55dp × 2 70dp × 2 75dp × 1
Two things are true at once here, and the second matters more. The 1dp values
are hairline borders and dividers, which is fine. But 10.dp (132 uses) and
20.dp (115) — the two dominant values — are on the scale, as space125 and
space250. So this is not mostly an off-grid problem. It 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 90 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 passcontentDescription = null. Some of those are correct (a decorative icon beside its own label should be null), but they have not been triaged. - 45
IconButtonand 5FilledIconButton— these enforce 48dp themselves. - 33 bare
Modifier.clickable, which does not. Two are text-sized:ArticleCard.kt:143makes an author name clickable, andLinkPreview.kt:122aTextwith 2dp vertical padding. Both are around 20dp tall. Clickable.kt— a vendored ACINQ helper, still in packagecom.machankura.compose.ui.composable.widgets.buttons— defaults tointernalPadding = PaddingValues(0.dp)andRectangleShape, 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 Scaffolds. 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:
// 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 | 520 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.
Work.
-
docs/scripts/m3-audit.sh, checked in, emitting the tables above: dp histogram split by on/off the spacing scale, typography role distribution, hardcoded colour sites,.clickablesites,contentDescription = nullcount, string literal count, snackbar count, adaptive API count. -
composeApp/src/commonTest/.../ui/theme/ColorSchemeContrastTest.kt— a pure computation over the six declared schemes, no Compose runtime needed:private fun ratio(a: Color, b: Color): Double { … } // WCAG relative luminance @Test fun everyOnRolePairsAtFourPointFive() { schemes.forEach { (name, scheme) -> pairs.forEach { (bg, fg) -> val r = ratio(bg(scheme), fg(scheme)) assertTrue(r >= 4.5, "$name: ${fg.name} on ${bg.name} is $r") } } }It passes today. It exists so Phase 1 cannot regress it, and so Phase 3 has somewhere to add the call-site pairings.
-
Baseline the numbers into
docs/alongside this file, dated.
Done when the script runs from a clean checkout and its output matches the
tables in "Where this app stands", and :composeApp:jvmTest runs the contrast
test green.
Risk: none. Nothing in the app changes.
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.
-
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 andsurfaceTintincluded. 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. -
Reach the contrast schemes.
TorchThemegrows a contrast parameter and the platform actuals report it — Android fromUiModeManager.getContrast()(API 34+, a float where0f/0.33f/0.66fmap onto the three schemes), falling back to the default scheme on the app's minSdk of 26; iOS fromUIAccessibilityDarkerSystemColorsEnabled; desktop from a preference. The four schemes already written stop being dead code. -
Give
MaterialThemeits other three slots.Shapes,Typographyand aMotionSchemeare all parameters of the overload the app already calls:MaterialTheme( colorScheme = colorScheme, motionScheme = MotionScheme.expressive(), // or .standard() shapes = MantraShapes, typography = MantraTypography, content = content, )Decide
MaterialExpressiveThemevsMaterialThemehere and once. The app already opts intoExperimentalMaterial3ExpressiveApiin 66 places, so the expressive default is the honest choice; it is also what makes the…Emphasizedtype roles and the increased shape sizes meaningful. -
Move and fill typography.
Type.ktmoves fromcom.example.ui.themetopress.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. -
Brand colours become roles.
BluePillandRedPillleaveColor.ktas raw values and enter the scheme as extended colour families with their owncontainer/on/onContainer.CreateProfileScreen.kt:311and:329then stop pairing them withColor.White/Color.DarkGrayby eye. -
Wrap the desktop gate.
Main.ktmovesTorchThemeoutside theunlockedbranch soPassphraseGatecomposes inside it.
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 520 literals.
Work.
-
A spacing scale, named as the spec names it.
MaterialThemehas no spacing slot, so this is aCompositionLocal:@Immutable data class Spacing( val space0: Dp = 0.dp, val space25: Dp = 2.dp, val space50: Dp = 4.dp, val space75: Dp = 6.dp, val space100: Dp = 8.dp, val space125: Dp = 10.dp, val space150: Dp = 12.dp, val space175: Dp = 14.dp, val space200: Dp = 16.dp, val space250: Dp = 20.dp, val space300: Dp = 24.dp, val space400: Dp = 32.dp, val space450: Dp = 36.dp, val space500: Dp = 40.dp, val space600: Dp = 48.dp, val space700: Dp = 56.dp, val space800: Dp = 64.dp, val space900: Dp = 72.dp, ) val LocalSpacing = staticCompositionLocalOf { Spacing() }Defaulted to the M3 values so the migration is a rename, not a restyle. It is a
data classso Phase 6 can supply a wider instance at larger breakpoints without touching a call site. -
A semantic layer over it, because
space125at a call site is no more readable than10.dp. Screen margin, list gap, card padding, section gap — named for what they are, each pointing at a stop. This is the layer that lets the audit distinguish padding from gap from margin, which the raw scale cannot. -
Migrate, in the order the audit reports. The 101 off-scale values are the interesting ones and go first: each is either a typo (round to the nearest stop) or deliberate (say why, in a comment, and pick the nearest stop anyway). Then
10.dpand20.dpen masse. -
Retire the 49 spacer idioms into the empty-state composable Phase 5 builds. They disappear rather than being migrated.
-
Adopt the parent-container rule while passing through: prefer
Arrangement.spacedByon the parent (119 uses already) and padding on the container over per-child padding. The 119 existingspacedBycalls suggest this is already the instinct.
Done when the audit script reports zero .dp literals outside
ui/theme/Spacing.kt and a short allowlist of genuine dimensions (avatar sizes,
image heights, hairline borders).
Risk: low but wide — it touches nearly every file. Best done as one mechanical commit per directory with previews checked between.
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.
Work.
-
Fix the seven measured pairings. Take them in the order of the table.
ProposalListScreen.kt:228needs theListItemcolours derived from the card's container, not left atonSurface:ListItemDefaults.colors( containerColor = Color.Transparent, headlineColor = contentColorFor(cardContainer), supportingColor = contentColorFor(cardContainer), leadingIconColor = contentColorFor(cardContainer), )HomeScreen.kt:113should drop itstopAppBarColorsoverride entirely — the default issurface/onSurfaceand is correct. The fouronSurfaceVariant.copy(alpha = 0.5f)sites should useonSurfaceVariantat full opacity, which is already the role for secondary text. -
Extend the contrast test to call sites. Every non-default
containerColor/contentColorpairing in the tree gets a row in a table the test walks. This is what stops the class of bug rather than the instance — theListItem-inside-Cardcase is invisible to a reviewer and obvious to an assertion. -
Guarantee the 48dp minimum. The 33 bare
.clickablesites either become a real component (ArticleCard.kt:143's author name is aTextButton) or gainModifier.minimumInteractiveComponentSize().Clickable.ktgets it built in, moves intopress.mantra, and itsRectangleShapedefault is reconsidered so state layers read. -
Triage the 18 null content descriptions. Each is either genuinely decorative — and then says so with
Modifier.clearAndSetSemantics {}or a comment — or gets a label. The spec's rule for the labels themselves: name the purpose, not the picture, and never include the role ("Search", not "magnifying glass", never "Search button"). -
Survive a large font scale. Test every screen at 200% text size. The pattern to look for is a fixed
heighton a container of text; the 49height(50.dp)spacers are safe, butModifier.height(…)around aTextis not. -
Keyboard flow for the desktop target. Initial focus per screen, focus into and back out of the four
ModalBottomSheets and theAlertDialog, andTaborder verified on the nine screens with text fields. The foundations page is explicit that when a dialog opens, focus moves into it, and when it closes, focus returns to what opened it. -
imePadding()on all nine text-field screens and the npub dialog, not one.
Done when the extended contrast test passes over both scheme families; the
audit reports zero unguarded .clickable; every Icon either has a description
or a recorded reason; and every screen is legible at 200% text scale.
Risk: low. Each fix is local and independently verifiable.
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.
Work.
- Externalise all 334 strings into
composeResources/values/strings.xml, organised by screen. Clear out the inherited Phoenix wallet strings that nothing references, and settleapp_name. - Sentence case, everywhere. "Edit profile", "Create profile", "New chat", "Sign in", "Leave group", "Key package management", "Publish new key package". Roughly 45 strings. Product names stay capitalised — which requires settling on one: Torch, Mantra or Machankura.
- Rewrite the destructive confirmations to state consequences plainly. "Delete group" and "Leave group" currently offer a label and nothing else; the style guide wants the outcome and whether it can be undone.
- Alt text for meaningful images — profile avatars, QR codes, artifact images — following the Phase 3 triage rule.
- Spell out abbreviations in user-facing text. Protocol terms that are genuinely the domain (npub, NIP-05, relay) stay; incidental shortenings go.
- Verify RTL by mirroring: the codebase has no
left/rightmodifiers, so this should be confirmation rather than repair.
Done when the audit reports fewer than ten string literals in composables (test data and previews), and no user-facing string uses title case.
Risk: low, high volume. Sentence-casing is the part most likely to draw disagreement — settle the product name first, in one decision.
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.
Work.
- A
SnackbarHoston everyScaffold, and a single place to send messages to it. 26 scaffolds, zero hosts, is why there is nowhere to report an invite failing. - One empty-state composable, one error-state composable. Icon, message,
and — for errors — a retry action. This replaces 16 copies of
Text("Something went wrong")and 5 ofText("No events were found"), and absorbs the 49 spacer idioms. None of the 16 sites offers a retry today; each one is a dead end for the user. - Loading gets the M3 component.
LoadingDataIndicatorhardcodes an 80dpCircularProgressIndicatorincolorScheme.secondary. The pinned material3 shipsLoadingIndicator, which is the expressive equivalent and themed. - Audit the disabled states. Five screens compute a FAB container colour
by hand from a
can…flag (AddArtifactScreen.kt:156,AddChapterScreen.kt:149,AddDialectScreen.kt:128,TranslateChunkScreen.kt:131,AddTranslationArtifactVersionScreen.kt:155). Passingenabledand letting the component apply the 38% state layer is both less code and the specified behaviour. - Give buttons a hierarchy. 31
Buttonand 27TextButton, and nothing in between — noFilledTonalButton,OutlinedButtonorElevatedButtonanywhere. Every screen therefore reads as either maximum or minimum emphasis. Assign one filled button per screen and demote the rest.
Done when every Scaffold has a host, every list has an empty state, every
error offers a retry, and no screen computes a disabled colour by hand.
Risk: low. Additive.
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.
Work.
-
Declare the adaptive dependency.
material3-adaptiveis not inlibs.versions.toml. Confirm which artifact publishes multiplatform for the pinned Compose Multiplatform 1.11.1 before planning around it — the desktop target makes this a real question, not a formality, and the answer decides whether steps 3–4 use the library scaffolds or a hand-rolled equivalent overBoxWithConstraints. -
Introduce the breakpoints — compact / medium / expanded / large / extra-large, at 600 / 840 / 1200 / 1600dp. Wire the spacing scale from Phase 2 to widen with them.
-
Swap navigation. Today there is no navigation component at all; screens are reached by route. Compact gets a navigation bar, medium and expanded a collapsed rail, large and extra-large an expanded rail. The spec's caution applies: swap only functionally equivalent components.
-
Two panes where the content is list-and-detail. The obvious candidates are chat rooms → messages, proposals → proposal detail, and artifacts → chapters. Chat is the one to do first and the one to be careful with: a message list is high-density content, and the breakpoints page says not to put two dense panes in a medium window.
-
Hold text to 40–60 characters by giving content a max width rather than letting it stretch, and revisit the 91
TextAlign.Centeruses — start alignment is what gives the rulers something to align to. -
Give the desktop entry a real window size and delete the comment that apologises for the current one.
-
Split the four composables out of
ChatMessageListViewModel.kt— a 1,000+ line view model holding UI is where the only twoBoxWithConstraintsin the app ended up, and it will not survive a pane split.
Done when every screen renders correctly at 400dp, 700dp, 1000dp, 1400dp and 1800dp; navigation swaps at the right thresholds; and the desktop build opens at a size that reflects a considered layout.
Risk: high. This is the phase that changes what the app looks like. Take it screen family by screen family — chat first, then proposals, then translation — and keep each behind its own commit.
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.
Work.
- Use the
MotionSchemewired in Phase 1. Every spec comes fromMotionSchemeKeyTokensrather than a literaltween, so the whole app's feel is one decision. - Navigation transitions. All 43 routes use the default; the container transform between a list item and its detail screen is the one that carries the most meaning, and pairs naturally with the pane work from Phase 6.
- State transitions.
AnimatedContentbetween the loading, empty, error and loaded states that Phase 5 standardises — currently a hard cut in every case. - Respect the reduced-motion preference on every platform, and hold to the spec's own caution that the dragged state is deliberately low-emphasis.
Done when no state change in the app is an unannounced cut, and every animation spec comes from the scheme.
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.
- CI runs the Phase 0 audit and fails on regression: no new
.dpliterals outside the theme, no new hardcodedColor, no new string literal in a composable, no new bare.clickable. - 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.
- Preview coverage per screen: light, dark, high-contrast, 200% font scale, compact and expanded. There are already 51 previews to build on.
- 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
ExperimentalMaterial3ExpressiveApiopt-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 the monochrome palette is right. The scheme's
primaryis 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 whyHomeScreen'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.themeandcom.machankura.composeall hold live UI code. Phase 1 movesType.ktand Phase 3 movesClickable.ktbecause both are in the way; the rest of thecom.machankuratree — NFC widgets among it — is out of scope.