Files
mantra-kmp/composeApp
Kgothatso Ngako 73d99f9a41 feat: put M3's spacing scale in the theme, with semantic names over it
Phase 2, first step, of docs/material-design-conformance.md. 527 `.dp` literals in the UI
tree and no record of what any of them is for. This is what they migrate onto; the sweep
that moves them is the next commit.

**The scale is M3's own**, transcribed from m3.material.io/m3/pages/spacing/tokens: an 8dp
system where `space100 = 8dp`, including the sub-8 nested units (2, 4, 6) and the
non-multiples (10, 14, 20, 36) that Material defines because its own components need them.
Eighteen stops.

Worth being precise about what the audit found, because it changes what this phase is for.
The two dominant values in the tree are `10.dp` (132 uses) and `20.dp` (115), and **both
are already on the scale** -- `space125` and `space250`. Only 89 of 527 are genuinely
off-grid. So this is not mostly a sweep for wrong numbers. It is that nothing records
whether a given `10.dp` is padding, a gap or a margin, which are three things the spec
gives different rules to, and none of them can be adapted per breakpoint or per density
while they are literals.

**A `data class` behind a composition local, not a file of constants.** Nothing scales it
today and `Spacing()` is provided unmodified. It is shaped this way because two things are
coming that need it: spacing adapts across breakpoints, and M3 has a density setting for
data-heavy views. Both become a matter of providing a different instance rather than
touching a call site -- but only if the values arrive through the local. Top-level `val`s
would read identically and adapt to nothing, which is the version of this that looks done
and is not.

**Eight semantic names, because `space125` is no more readable than `10.dp`.** It says the
size and not the job. `screenMargin`, `containerPadding`, `compactPadding`, `relatedGap`,
`itemGap`, `sectionGap`, `emphasisGap`, `targetGap` say the job, and they are what call
sites should reach for; the raw stops are for the cases none of them fits.

They are split along the distinction the spec draws -- padding is inside an element, a gap
is between elements in a container, a margin is outside one -- and there is exactly **one**
margin, for the screen edge. That is deliberate: "define padding and gaps on the parent
container", "avoid defining margins on child elements as they usually aren't uniform, and
require more tokens". A semantic layer with a margin per element would have re-created the
problem in better-sounding names.

**Six assertions, and three of them are about failure modes that are invisible in review.**

  - Every stop matches its published value. `space175 = 15.dp` would look entirely
    plausible in the source, compile, and put every call site one unit off the grid.
  - The token name predicts the value: the number after "space" is the value as a
    percentage of the 8dp base, so `space250` is 20dp. A stop that does not obey that is a
    stop nobody can predict from its name.
  - Every semantic name resolves to a stop that is actually on the scale. The layer stops
    being a scale the moment one of them is handed a literal, which is easy to do and
    invisible to review.
  - `targetGap` is at least 8dp, M3's minimum separation between adjacent touch targets --
    the one semantic name with an external floor, and the one phase 3 will apply between
    icon buttons.
  - A scaled instance moves the semantic names with it. This is what the data class is
    *for*: if a semantic name were a hardcoded `Dp` rather than a reference to a stop it
    would stay behind at a wider breakpoint and the layout would half-adapt, which is worse
    than not adapting.

**Tests.** 936 pass, 594 jvm over 72 classes and 348 android over 44, up from 930/588/342.
`:composeApp:compileDebugKotlinAndroid` builds. No call site changed, so no pixels moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:26:05 +02:00
..