docs: measure the UI against the M3 foundations, and phase the work that follows
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>
This commit is contained in:
@@ -16,6 +16,7 @@ silent, or a decision that looked arbitrary and was not.
|
||||
| [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list |
|
||||
| [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover |
|
||||
| [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it |
|
||||
| [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme |
|
||||
|
||||
Start with the ceremony if you are new to this area; the Marmot notes all assume it.
|
||||
Read the skipped-keys note before debugging any "the other device never got it"
|
||||
@@ -27,3 +28,6 @@ chronicle note is a phased plan that has not been built, and reads as the
|
||||
membership note's unanswered half: what a member who joins late can be given,
|
||||
and the one thing they cannot. The
|
||||
jvm-target note is unrelated to all of them: it is a build and packaging story.
|
||||
The Material Design note is a phased plan that has not been built, and is the
|
||||
only one about what the app looks like rather than what it does; read the
|
||||
jvm-target note first if you want to know why its adaptive-layout phase exists.
|
||||
|
||||
797
docs/material-design-conformance.md
Normal file
797
docs/material-design-conformance.md
Normal file
@@ -0,0 +1,797 @@
|
||||
# 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 | 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:
|
||||
|
||||
```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
|
||||
|
||||
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 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 | 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.**
|
||||
|
||||
1. `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, `.clickable` sites, `contentDescription = null`
|
||||
count, string literal count, snackbar count, adaptive API count.
|
||||
2. `composeApp/src/commonTest/.../ui/theme/ColorSchemeContrastTest.kt` — a pure
|
||||
computation over the six declared schemes, no Compose runtime needed:
|
||||
|
||||
```kotlin
|
||||
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.
|
||||
3. 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.**
|
||||
|
||||
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.** `TorchTheme` grows a contrast parameter and
|
||||
the platform actuals report it — Android from `UiModeManager.getContrast()`
|
||||
(API 34+, a float where `0f`/`0.33f`/`0.66f` map onto the three schemes),
|
||||
falling back to the default scheme on the app's minSdk of 26; iOS from
|
||||
`UIAccessibilityDarkerSystemColorsEnabled`; desktop from a preference. The
|
||||
four schemes already written stop being dead code.
|
||||
|
||||
3. **Give `MaterialTheme` its other three slots.** `Shapes`, `Typography` and a
|
||||
`MotionScheme` are all parameters of the overload the app already calls:
|
||||
|
||||
```kotlin
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
motionScheme = MotionScheme.expressive(), // or .standard()
|
||||
shapes = MantraShapes,
|
||||
typography = MantraTypography,
|
||||
content = content,
|
||||
)
|
||||
```
|
||||
|
||||
Decide `MaterialExpressiveTheme` vs `MaterialTheme` here and once. The app
|
||||
already opts into `ExperimentalMaterial3ExpressiveApi` in 66 places, so the
|
||||
expressive default is the honest choice; it is also what makes the
|
||||
`…Emphasized` type roles and the increased shape sizes meaningful.
|
||||
|
||||
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.** `Main.kt` moves `TorchTheme` outside the
|
||||
`unlocked` branch so `PassphraseGate` composes 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.**
|
||||
|
||||
1. **A spacing scale, named as the spec names it.** `MaterialTheme` has no
|
||||
spacing slot, so this is a `CompositionLocal`:
|
||||
|
||||
```kotlin
|
||||
@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 class` so Phase 6 can supply a wider instance at larger breakpoints
|
||||
without touching a call site.
|
||||
|
||||
2. **A semantic layer over it**, because `space125` at a call site is no more
|
||||
readable than `10.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.
|
||||
|
||||
3. **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.dp` and `20.dp` en masse.
|
||||
|
||||
4. **Retire the 49 spacer idioms** into the empty-state composable Phase 5
|
||||
builds. They disappear rather than being migrated.
|
||||
|
||||
5. Adopt the parent-container rule while passing through: prefer
|
||||
`Arrangement.spacedBy` on the parent (119 uses already) and padding on the
|
||||
container over per-child padding. The 119 existing `spacedBy` calls 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.**
|
||||
|
||||
1. **Fix the seven measured pairings.** Take them in the order of the table.
|
||||
`ProposalListScreen.kt:228` needs the `ListItem` colours derived from the
|
||||
card's container, not left at `onSurface`:
|
||||
|
||||
```kotlin
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = contentColorFor(cardContainer),
|
||||
supportingColor = contentColorFor(cardContainer),
|
||||
leadingIconColor = contentColorFor(cardContainer),
|
||||
)
|
||||
```
|
||||
|
||||
`HomeScreen.kt:113` should drop its `topAppBarColors` override entirely —
|
||||
the default is `surface`/`onSurface` and is correct. The four
|
||||
`onSurfaceVariant.copy(alpha = 0.5f)` sites should use `onSurfaceVariant` at
|
||||
full opacity, which is already the role for secondary text.
|
||||
|
||||
2. **Extend the contrast test to call sites.** Every non-default
|
||||
`containerColor`/`contentColor` pairing in the tree gets a row in a table the
|
||||
test walks. This is what stops the class of bug rather than the instance —
|
||||
the `ListItem`-inside-`Card` case is invisible to a reviewer and obvious to
|
||||
an assertion.
|
||||
|
||||
3. **Guarantee the 48dp minimum.** The 33 bare `.clickable` sites either become
|
||||
a real component (`ArticleCard.kt:143`'s author name is a `TextButton`) or
|
||||
gain `Modifier.minimumInteractiveComponentSize()`. `Clickable.kt` gets it
|
||||
built in, moves into `press.mantra`, and its `RectangleShape` default is
|
||||
reconsidered so state layers read.
|
||||
|
||||
4. **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").
|
||||
|
||||
5. **Survive a large font scale.** Test every screen at 200% text size. The
|
||||
pattern to look for is a fixed `height` on a container of text; the 49
|
||||
`height(50.dp)` spacers are safe, but `Modifier.height(…)` around a `Text`
|
||||
is not.
|
||||
|
||||
6. **Keyboard flow for the desktop target.** Initial focus per screen, focus
|
||||
into and back out of the four `ModalBottomSheet`s and the `AlertDialog`, and
|
||||
`Tab` order 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.
|
||||
|
||||
7. `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.**
|
||||
|
||||
1. **Externalise all 334 strings** into `composeResources/values/strings.xml`,
|
||||
organised by screen. Clear out the inherited Phoenix wallet strings that
|
||||
nothing references, and settle `app_name`.
|
||||
2. **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.
|
||||
3. **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.
|
||||
4. **Alt text for meaningful images** — profile avatars, QR codes, artifact
|
||||
images — following the Phase 3 triage rule.
|
||||
5. **Spell out abbreviations** in user-facing text. Protocol terms that are
|
||||
genuinely the domain (npub, NIP-05, relay) stay; incidental shortenings go.
|
||||
6. Verify RTL by mirroring: the codebase has no `left`/`right` modifiers, 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.**
|
||||
|
||||
1. **A `SnackbarHost` on every `Scaffold`**, and a single place to send messages
|
||||
to it. 26 scaffolds, zero hosts, is why there is nowhere to report an invite
|
||||
failing.
|
||||
2. **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 of `Text("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.
|
||||
3. **Loading gets the M3 component.** `LoadingDataIndicator` hardcodes an 80dp
|
||||
`CircularProgressIndicator` in `colorScheme.secondary`. The pinned material3
|
||||
ships `LoadingIndicator`, which is the expressive equivalent and themed.
|
||||
4. **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`).
|
||||
Passing `enabled` and letting the component apply the 38% state layer is both
|
||||
less code and the specified behaviour.
|
||||
5. **Give buttons a hierarchy.** 31 `Button` and 27 `TextButton`, and nothing in
|
||||
between — no `FilledTonalButton`, `OutlinedButton` or `ElevatedButton`
|
||||
anywhere. 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.**
|
||||
|
||||
1. **Declare the adaptive dependency.** `material3-adaptive` is not in
|
||||
`libs.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 over
|
||||
`BoxWithConstraints`.
|
||||
|
||||
2. **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.
|
||||
|
||||
3. **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.
|
||||
|
||||
4. **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.
|
||||
|
||||
5. **Hold text to 40–60 characters** by giving content a max width rather than
|
||||
letting it stretch, and revisit the 91 `TextAlign.Center` uses — start
|
||||
alignment is what gives the rulers something to align to.
|
||||
|
||||
6. **Give the desktop entry a real window size** and delete the comment that
|
||||
apologises for the current one.
|
||||
|
||||
7. Split the four composables out of `ChatMessageListViewModel.kt` — a 1,000+
|
||||
line view model holding UI is where the only two `BoxWithConstraints` in 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.**
|
||||
|
||||
1. **Use the `MotionScheme` wired in Phase 1.** Every spec comes from
|
||||
`MotionSchemeKeyTokens` rather than a literal `tween`, so the whole app's
|
||||
feel is one decision.
|
||||
2. **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.
|
||||
3. **State transitions.** `AnimatedContent` between the loading, empty, error
|
||||
and loaded states that Phase 5 standardises — currently a hard cut in every
|
||||
case.
|
||||
4. **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.**
|
||||
|
||||
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 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.
|
||||
Reference in New Issue
Block a user