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>
This commit is contained in:
Kgothatso Ngako
2026-09-08 00:26:05 +02:00
parent 47bdb6f976
commit 73d99f9a41
3 changed files with 238 additions and 1 deletions

View File

@@ -0,0 +1,90 @@
package press.mantra.compose.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* M3's spacing scale, and the semantic names layered over it.
*
* `MaterialTheme` has no spacing slot, so this rides a composition local beside it, the
* same way [ExtendedColors] does. [TorchTheme] provides it.
*
* **The scale** is M3's own, an 8dp system where `space100 = 8dp`, including the sub-8
* nested units Material defines because its components need them. Numbers are from
* m3.material.io/m3/pages/spacing/tokens, read September 2026; see
* docs/material-design-conformance.md for the full table.
*
* **Why a `data class` and not constants.** Nothing scales today, but two things are
* coming that need to: spacing adapts across breakpoints, and M3 has a density setting
* for data-heavy views. Both are a matter of providing a different [Spacing] instance
* rather than of touching a call site, and that is only true if the values arrive through
* the local. A file of top-level `val`s would read the same and adapt to nothing.
*/
@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,
) {
// -----------------------------------------------------------------------
// Semantic names
// -----------------------------------------------------------------------
//
// `space125` at a call site is no more readable than `10.dp` -- it says the size and
// not the job. These say the job, and they are what call sites should reach for; the
// raw scale is for the cases none of them fits.
//
// The distinction M3 draws, and the reason the names are split this way:
//
// padding space inside an element, between its edge and its content
// gap space between elements in a row, column or grid
// margin space outside an element, between it and its parent or the screen
//
// The spec is explicit that margins are a last resort -- "define padding and gaps on
// the parent container", "avoid defining margins on child elements as they usually
// aren't uniform, and require more tokens" -- so there is exactly one margin here,
// for the screen edge, and everything else is padding or a gap.
/** Screen edge to content. The one margin; everything inside a screen is padding or a gap. */
val screenMargin: Dp get() = space200
/** Inside a card, dialog, sheet or list row: container edge to its content. */
val containerPadding: Dp get() = space200
/** Inside a compact container -- a chip, a badge, a dense row. */
val compactPadding: Dp get() = space100
/** Between two elements that belong to the same thought: a label and its value. */
val relatedGap: Dp get() = space50
/** The default gap between items in a list or column. */
val itemGap: Dp get() = space100
/** Between one group of content and the next within a screen. */
val sectionGap: Dp get() = space300
/** Around a lone element that needs to stand apart -- an empty state, a hero action. */
val emphasisGap: Dp get() = space500
/** Between adjacent touch targets, which M3 asks to be at least 8dp apart. */
val targetGap: Dp get() = space100
}
val LocalSpacing = staticCompositionLocalOf { Spacing() }

View File

@@ -497,7 +497,13 @@ fun TorchTheme(
val colorScheme = dynamicColorScheme(darkTheme, dynamicColor)
?: appColorScheme(darkTheme, contrast)
CompositionLocalProvider(LocalExtendedColors provides extendedColorsFor(darkTheme)) {
CompositionLocalProvider(
LocalExtendedColors provides extendedColorsFor(darkTheme),
// Nothing scales it yet. It rides the theme now so that the breakpoint phase can
// provide a wider instance without touching a call site -- which is the whole
// reason for tokenising spacing rather than leaving it in literals.
LocalSpacing provides Spacing(),
) {
MaterialExpressiveTheme(
colorScheme = colorScheme,
// Every animation in the app should come from here rather than from a literal