Files
mantra-kmp/docs/material-design-conformance.md

831 lines
40 KiB
Markdown
Raw Normal View History

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>
2026-09-07 23:44:32 +02:00
# 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 | 600839dp | 1 recommended, 2 possible | collapsed navigation rail |
| Expanded | 8401199dp | 2 recommended | collapsed or standard expanded rail |
| Large | 12001599dp | 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 4060
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
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
527 `.dp` literals. 419 land on a defined spacing stop, 19 are dimensions rather
than spacing (a hairline border, an avatar, an image height), and 89 are off-scale:
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>
2026-09-07 23:44:32 +02:00
```
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
5dp × 10 15dp × 14 18dp × 1 22dp × 1 30dp × 2
35dp × 3 50dp × 53 55dp × 2 70dp × 2 75dp × 1
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>
2026-09-07 23:44:32 +02:00
```
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
The off-scale values are the smaller half of the problem. `10.dp` (132 uses) and
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>
2026-09-07 23:44:32 +02:00
`20.dp` (115) — the two dominant values — *are* on the scale, as `space125` and
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
`space250`, so a sweep for off-grid numbers would change almost nothing. What is
wrong is that **nothing records which of padding, gap or margin any of these
is**, so there is no way to adapt them per breakpoint or density later, and no
way to tell a deliberate 15dp from a typo.
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>
2026-09-07 23:44:32 +02:00
`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
```
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
`label*` roles are 92 of 240 uses. Labels are for component text — buttons,
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>
2026-09-07 23:44:32 +02:00
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 4060 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 |
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
| Spacing | 527 literals, no role recorded | 2 |
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>
2026-09-07 23:44:32 +02:00
| 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.
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
**Built.**
1. **`docs/scripts/m3-audit.sh`** regenerates every count in "Where this app
stands": the dp histogram split three ways, the typography role
distribution, hardcoded colour sites, `.clickable` sites,
`contentDescription = null`, string literals, snackbars, adaptive APIs. Each
number that a phase is meant to move carries a **budget** at the top of the
file, and `--check` exits 1 when one is exceeded. Budgets ratchet down in the
same commit that earns the reduction; Phase 8 wires `--check` into CI, at
which point raising one is the thing a reviewer looks for.
Two counts it reports separately, because conflating them would overstate the
problem: the twelve `*Fixed*` roles that fall through to Material baseline
lavender, and `surfaceTint`, which is also unassigned but defaults to
`primary` and is therefore correct. The `.dp` histogram likewise splits
dimensions (a hairline border, an avatar) out of the off-scale count.
2. **`ColorSchemeContrastTest`** in `commonTest` — WCAG relative luminance from
first principles, no Compose runtime, four assertions over all six schemes:
every content role on its container at 4.5:1, `onSurface` on each of the
seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on
at 3:1, and `primary`/`error` containers against `surface` at 3:1. 4 tests,
green.
It walks the real `ColorScheme` objects, which is why `Theme.kt`'s six
schemes moved from `private` to `internal`: rebuilding them in the test from
`Color.kt` would assert the palette and miss the wiring, and a role pointed at
its neighbour's value is exactly the slip that reads fine in review.
Verified to bite. Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496`
— a plausible "soften the secondary text" edit — fails it with
`light: onSurfaceVariant on surfaceVariant is 2.29:1`.
**Deliberately not asserted:** monotonicity across the contrast ladder. The
obvious invariant — high-contrast beats medium beats default, for every pair —
is false, and correctly so: ten pairs move the other way because a high-contrast
scheme darkens `surfaceContainerHighest` to separate it from `surface`, trading
ratio against `onSurface` for the separation that matters. The floor is the
invariant; the ladder is not.
**Risk:** none to the app. The only production change is a visibility keyword.
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>
2026-09-07 23:44:32 +02:00
---
### 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.
feat: honour the platform's contrast setting, reaching four schemes that were dead code Phase 1, step 2 of docs/material-design-conformance.md. `Color.kt` has carried medium- and high-contrast variants of both themes since it was generated -- 156 colour values, wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and never selected. `TorchTheme` chose between `darkScheme` and `lightScheme` and nothing else, so a user who turned contrast up in Accessibility settings got no change at all. M3's accessibility foundation leads with *honour individuals*: "supporting varying preferences and choices that allow individuals to address how their changing conditions, individual knowledge, and varying needs are met." The work to do that was already done and disconnected. **The expect/actual boundary moved, because it was in the wrong place.** `themeColorScheme` took four arguments and did two unrelated jobs -- decide the contrast-free light/dark scheme, and decide whether to prefer a wallpaper palette. Adding contrast to it would have meant passing six schemes across the boundary and repeating the selection table in three actuals. It splits instead into `platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow platform question, with the six-way table as a plain function `appColorScheme(darkTheme, contrast)` in common code. `dynamicColorScheme` returns null rather than falling back internally so the fallback stays in one place. **Android reads the setting and listens for changes.** `UiModeManager.getContrast()` is API 34; the app's minSdk is 26, so below that the answer is Standard. The float is snapped to the nearest of the platform's three documented positions rather than matched exactly, so a future finer-grained slider degrades to the closest scheme this app has instead of falling back to Standard. The `ContrastChangeListener` is the part that is easy to leave out and matters most. A contrast change does not restart the activity and does not arrive as a `Configuration` update, so without it the new setting would take effect on the next cold start -- which is precisely the case the setting exists for. `context.mainExecutor` rather than `ContextCompat.getMainExecutor`: it needs API 28, this branch is already gated on 34, and composeApp does not declare androidx.core -- it only arrives transitively through activity-compose, which is not a dependency to lean on. **iOS observes the notification for the same reason** -- `UIAccessibilityDarkerSystemColorsEnabled` plus `UIAccessibilityDarkerSystemColorsStatusDidChangeNotification`. It is a boolean, not a slider, so iOS reports High or Standard and never Medium. **Desktop is honest rather than complete.** Windows publishes high contrast as the `win.highContrast.on` AWT desktop property and fires a property change when it is toggled, so that path is real and live. macos "Increase contrast" and the linux desktop equivalents do not reach AWT, and reading them means a native call per platform, so on those two the answer is Standard and the file says so. This is the right place for a user-overridable preference later; a desktop app cannot always see what the desktop was told. **Verified on an emulator, at the pixel.** API 36, dynamic colour temporarily switched off (see below for why that is necessary), sampling the `onPrimaryContainer` pixel of the "Skip for now" label as `settings put secure contrast_level` moved: standard (0.0) #848484 onPrimaryContainerLight medium (0.5) #A7A7A7 onPrimaryContainerLightMediumContrast high (1.0) #D0D0D0 onPrimaryContainerLightHighContrast The three declared values exactly, and **the app was not restarted between them** -- only the setting changed, four seconds apart. That is the listener working end to end. The probe that switched dynamic colour off is reverted in this commit; the emulator's contrast_level is back at 0.0. **A finding that came out of the verification, and is not fixed here.** `TorchTheme` defaults `dynamicColor = true`, and on Android 12+ dynamic colour wins unconditionally -- so on essentially every current Android device **none of the six schemes is used at all** and the app renders in whatever the user's wallpaper produced. The first screenshot of this session shows the onboarding screen in Material lavender; switching dynamic colour off reveals the black-and-gold brand for the first time. Nobody on a modern Android has been seeing this app's palette. That is a product decision, not a conformance one, so it is recorded in the plan's "What this plan does not cover" rather than changed. It does bound what this commit buys: on Android 14+ with dynamic colour on, contrast is honoured by the platform anyway (the `system_*` resources shift with it, confirmed on the same emulator -- buttons went slate-blue to near-black navy). What this commit reaches is Android below 12, Android 12-13, iOS, and desktop. **Three new assertions.** `AppColorSchemeSelectionTest` covers the table itself, because its failure mode is silent and specific: a scheme wired to the wrong cell still renders a complete, plausible UI, and somebody who turns contrast up and gets the medium scheme back cannot tell it apart from a high-contrast scheme that is not very high. It asserts each of the six cells by identity, that all six are distinct objects (a copy-paste leaving two cells on the same scheme would pass the first test only if it also mislabelled one), and that `onSurface` on `surface` never *falls* as contrast rises -- the one direction that must hold, and deliberately not the full monotonicity assertion that ColorSchemeContrastTest explains is false. **Not compiled: the iOS actual.** The ios targets are declared only on macos (see docs/jvm-target.md), so `Theme.ios.kt` is written against the UIKit and Foundation bindings rather than checked by a compiler. Its file comment says so. The android and jvm actuals of the same two functions are compiled, and the android one is verified on a device. **Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, up from 920/583/337. `:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build, `m3-audit.sh --check` exits 0. The 54 existing `TorchTheme { }` call sites are untouched -- the new parameter is defaulted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:10:06 +02:00
2. **Reach the contrast schemes.** *Built.* `TorchTheme` takes a `ThemeContrast`
defaulted from a new `platformThemeContrast()` expect/actual, and the six-way
selection table lives in common code as `appColorScheme`. The old
`themeColorScheme` expect took four arguments and did both jobs; it splits into
`platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow
question, so the scheme table is in one place rather than three.
Android reads `UiModeManager.getContrast()` (API 34+) and registers a
`ContrastChangeListener`, because a contrast change does not restart the
activity or arrive as a `Configuration` update — without the listener the new
setting would wait for the next cold start, which is the case the setting
exists for. iOS reads `UIAccessibilityDarkerSystemColorsEnabled` and observes
`…StatusDidChangeNotification`; it is a boolean, so iOS never reports Medium.
Desktop reads Windows' `win.highContrast.on` AWT desktop property and answers
Standard on linux and macos, which is honest rather than complete — see
"What this plan does not cover".
Verified on an API 36 emulator with dynamic colour off: the `onPrimaryContainer`
pixel of the "Skip for now" label reads `#848484``#A7A7A7``#D0D0D0` as the
setting moves, the three declared values exactly, **without the app restarting**.
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>
2026-09-07 23:44:32 +02:00
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
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
**Why here.** Phase 6 has to adapt spacing per breakpoint. It cannot adapt 527
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>
2026-09-07 23:44:32 +02:00
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.
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
3. **Migrate, in the order the audit reports.** The 89 off-scale values are the
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>
2026-09-07 23:44:32 +02:00
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 34 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 4060 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 15 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.
feat: honour the platform's contrast setting, reaching four schemes that were dead code Phase 1, step 2 of docs/material-design-conformance.md. `Color.kt` has carried medium- and high-contrast variants of both themes since it was generated -- 156 colour values, wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and never selected. `TorchTheme` chose between `darkScheme` and `lightScheme` and nothing else, so a user who turned contrast up in Accessibility settings got no change at all. M3's accessibility foundation leads with *honour individuals*: "supporting varying preferences and choices that allow individuals to address how their changing conditions, individual knowledge, and varying needs are met." The work to do that was already done and disconnected. **The expect/actual boundary moved, because it was in the wrong place.** `themeColorScheme` took four arguments and did two unrelated jobs -- decide the contrast-free light/dark scheme, and decide whether to prefer a wallpaper palette. Adding contrast to it would have meant passing six schemes across the boundary and repeating the selection table in three actuals. It splits instead into `platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow platform question, with the six-way table as a plain function `appColorScheme(darkTheme, contrast)` in common code. `dynamicColorScheme` returns null rather than falling back internally so the fallback stays in one place. **Android reads the setting and listens for changes.** `UiModeManager.getContrast()` is API 34; the app's minSdk is 26, so below that the answer is Standard. The float is snapped to the nearest of the platform's three documented positions rather than matched exactly, so a future finer-grained slider degrades to the closest scheme this app has instead of falling back to Standard. The `ContrastChangeListener` is the part that is easy to leave out and matters most. A contrast change does not restart the activity and does not arrive as a `Configuration` update, so without it the new setting would take effect on the next cold start -- which is precisely the case the setting exists for. `context.mainExecutor` rather than `ContextCompat.getMainExecutor`: it needs API 28, this branch is already gated on 34, and composeApp does not declare androidx.core -- it only arrives transitively through activity-compose, which is not a dependency to lean on. **iOS observes the notification for the same reason** -- `UIAccessibilityDarkerSystemColorsEnabled` plus `UIAccessibilityDarkerSystemColorsStatusDidChangeNotification`. It is a boolean, not a slider, so iOS reports High or Standard and never Medium. **Desktop is honest rather than complete.** Windows publishes high contrast as the `win.highContrast.on` AWT desktop property and fires a property change when it is toggled, so that path is real and live. macos "Increase contrast" and the linux desktop equivalents do not reach AWT, and reading them means a native call per platform, so on those two the answer is Standard and the file says so. This is the right place for a user-overridable preference later; a desktop app cannot always see what the desktop was told. **Verified on an emulator, at the pixel.** API 36, dynamic colour temporarily switched off (see below for why that is necessary), sampling the `onPrimaryContainer` pixel of the "Skip for now" label as `settings put secure contrast_level` moved: standard (0.0) #848484 onPrimaryContainerLight medium (0.5) #A7A7A7 onPrimaryContainerLightMediumContrast high (1.0) #D0D0D0 onPrimaryContainerLightHighContrast The three declared values exactly, and **the app was not restarted between them** -- only the setting changed, four seconds apart. That is the listener working end to end. The probe that switched dynamic colour off is reverted in this commit; the emulator's contrast_level is back at 0.0. **A finding that came out of the verification, and is not fixed here.** `TorchTheme` defaults `dynamicColor = true`, and on Android 12+ dynamic colour wins unconditionally -- so on essentially every current Android device **none of the six schemes is used at all** and the app renders in whatever the user's wallpaper produced. The first screenshot of this session shows the onboarding screen in Material lavender; switching dynamic colour off reveals the black-and-gold brand for the first time. Nobody on a modern Android has been seeing this app's palette. That is a product decision, not a conformance one, so it is recorded in the plan's "What this plan does not cover" rather than changed. It does bound what this commit buys: on Android 14+ with dynamic colour on, contrast is honoured by the platform anyway (the `system_*` resources shift with it, confirmed on the same emulator -- buttons went slate-blue to near-black navy). What this commit reaches is Android below 12, Android 12-13, iOS, and desktop. **Three new assertions.** `AppColorSchemeSelectionTest` covers the table itself, because its failure mode is silent and specific: a scheme wired to the wrong cell still renders a complete, plausible UI, and somebody who turns contrast up and gets the medium scheme back cannot tell it apart from a high-contrast scheme that is not very high. It asserts each of the six cells by identity, that all six are distinct objects (a copy-paste leaving two cells on the same scheme would pass the first test only if it also mislabelled one), and that `onSurface` on `surface` never *falls* as contrast rises -- the one direction that must hold, and deliberately not the full monotonicity assertion that ColorSchemeContrastTest explains is false. **Not compiled: the iOS actual.** The ios targets are declared only on macos (see docs/jvm-target.md), so `Theme.ios.kt` is written against the UIKit and Foundation bindings rather than checked by a compiler. Its file comment says so. The android and jvm actuals of the same two functions are compiled, and the android one is verified on a device. **Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, up from 920/583/337. `:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build, `m3-audit.sh --check` exits 0. The 54 existing `TorchTheme { }` call sites are untouched -- the new parameter is defaulted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:10:06 +02:00
- **Whether dynamic colour should keep overriding the brand.** `TorchTheme` defaults
`dynamicColor = true`, and on Android 12+ that wins unconditionally — so on
essentially every current Android device, none of the six schemes below is used
and the app renders in whatever the user's wallpaper produced. Verified on an API
36 emulator: the app paints Material lavender until dynamic colour is switched
off, at which point the black-and-gold brand appears. M3's customization
foundation treats this as a developer choice, and applying it selectively — a
profile screen, say — is the usual answer for an app with a brand. Deciding it is
a product call, not a conformance one, and it is why phase 1's contrast work is
reachable today only on Android below 12, on iOS and on desktop. On Android 14+
with dynamic colour on, the platform honours contrast itself.
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>
2026-09-07 23:44:32 +02:00
- **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.