Phase 2, final step, of docs/material-design-conformance.md. Every `.dp` literal in a
spacing position in the UI tree is now a token. 431 reads of `MaterialTheme.spacing.*`, one
reasoned exemption, and `m3-spacing-positions.py` exits 0.
**Shape decides the token, not just the value.** The migration script grew a per-shape
mapping because the same number means different things in different positions: 8dp of
padding is `compactPadding`, 8dp of gap is `itemGap`, and 8dp under a `Spacer` is neither
of those and stays `space100`. Where the pair determines the meaning the semantic name is
used, and nowhere else:
padding + 8dp -> compactPadding 10 sites
padding + 16dp -> containerPadding 10
gap + 4dp -> relatedGap 8
gap + 8dp -> itemGap 10
That is 38 of 353. The rest take the raw stop, and deliberately: assigning a semantic name
needs somebody to have read what the container *is*, and a name that asserts a meaning the
code does not have is worse than a stop that asserts none. `screenMargin` in particular is
unassignable mechanically -- it is 16dp of padding, exactly like `containerPadding` -- so
it has no call sites yet and gets them when someone reads the screens.
**Two spacers were standing in for zero.** `WriteNewNoteScreen` renders
`Spacer(Modifier.height(1.dp))` twice, in the `LazyColumn` item that shows a reply preview
when there is one. There is nothing to show and the item still has to render something;
1dp was the placeholder. Now `space0`, with a comment, because a 1dp gap that nobody
intended is the kind of thing that gets copied.
**One value is exempt, and says so at the site.** `SovereignWalletStartupScreen`'s
`Spacer(Modifier.height(128.dp))` is room to scroll the last wallet clear of the bottom of
the window -- reserved space, not a step in the rhythm. The scale tops out at `space900`
(72dp) and rounding to it would put the row back under the edge.
Rather than exempt it in the script by value, the classifier now honours an inline
`// m3-spacing-exempt: <reason>` comment on the lines directly above. Exemptions belong at
the call site: the reason travels with the code, a reviewer sees it in the diff that adds
it, and the tool stops accumulating a list of numbers that mean nothing on their own -- the
mistake the first version of this audit made with `DIMENSION_EXEMPT`.
**Where the tokens landed.** `space125` (10dp) 128 times and `space250` (20dp) 107 -- the
two values that already dominated the tree, now named. `space600` (48dp) 52 times, which is
the empty-state spacer from the previous commit. The long tail is 2, 4, 6, 12, 14, 16, 24,
32, 40 and 64dp.
**Verified that nothing moved.** The landing screen was captured on emulator-5554 before
and after and compared pixel by pixel on a 4px grid: **47 differing samples out of
162,000, 0.03%**, and they are the status bar clock. The sweep is a rename.
**Budget ratcheted 353 -> 0**, dated in the file. Phase 8 wires `--check` into CI, at which
point a new `.dp` in a `padding()` fails the build.
**Tests.** 942 pass, 594 jvm over 72 classes and 348 android over 44, unchanged --
`SpacingScaleTest` already asserts the scale, and there is nothing to assert about a
call site having been renamed that the compiler does not.
`:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs,
`m3-audit.sh --check` exits 0. 75 files, 432 insertions, 348 deletions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mantra docs
Notes on the parts of this app whose behaviour is not recoverable by reading the code alone — where the reasoning lives in a protocol, a failure mode that is silent, or a decision that looked arbitrary and was not.
| document | covers |
|---|---|
| shared-key-ceremony.md | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
| shared-key-derivation.md | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
| frost-batch-signing.md | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
| marmot-membership.md | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
| member-chronicle.md | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
| marmot-direct-messages.md | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it |
| mls-skipped-keys.md | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app |
| long-running-sync.md | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list |
| dead-code.md | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover |
| jvm-target.md | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it |
| material-design-conformance.md | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme |
Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" report — it is silent, and it looks like every other kind of delivery failure. The sync note stands alone, and the dead-code inventory reads as a follow-up to it. The batch-signing note is a phased plan that has been built: read it after the derivation note, whose one rule is the same one it is built around. The member chronicle note is a phased plan that has not been built, and reads as the membership note's unanswered half: what a member who joins late can be given, and the one thing they cannot. The jvm-target note is unrelated to all of them: it is a build and packaging story. The Material Design note is a phased plan that has not been built, and is the only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists.