refactor: take the last 353 spacing literals onto the scale, and reach zero

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>
This commit is contained in:
Kgothatso Ngako
2026-09-08 00:36:59 +02:00
parent f7a732d68a
commit 8cd0f3f44d
75 changed files with 432 additions and 348 deletions

View File

@@ -25,7 +25,7 @@ THEME="$UI/theme"
# Budgets. "-1" means not yet budgeted -- reported, but never fails --check.
# ---------------------------------------------------------------------------
BUDGET_HARDCODED_COLOR=9 # phase 3 drives to 0 outside theme/
BUDGET_SPACING_LITERALS=353 # phase 2 drives to 0
BUDGET_SPACING_LITERALS=0 # phase 2: reached 2026-09-08
BUDGET_BARE_CLICKABLE=33 # phase 3 drives to 0
BUDGET_NULL_DESCRIPTION=18 # phase 3 triages each one
BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10

View File

@@ -20,11 +20,15 @@ is a dimension and is left alone.
import re, sys, io, os
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
MAPPING = {} # filled by the caller
# {"padding": {"8": "compactPadding"}, "gap": {...}, "spacer": {...}, "*": {...}}
# A shape-specific entry wins over "*". Shape matters 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.
MAPPING = {}
DRY = '--apply' not in sys.argv
def token(value):
return MAPPING.get(value)
def token(value, shape):
return MAPPING.get(shape, {}).get(value) or MAPPING.get('*', {}).get(value)
def spacer_spans(text):
"""Character ranges covered by a Spacer( ... ) call, brace-matched."""
@@ -92,18 +96,24 @@ def process(path):
edit = None
for m in re.finditer(r'\b(\d+)\.dp\b', text):
lit = m.group(0)
tok = token(m.group(1))
if tok is None:
continue
pos = m.start()
line_start = text.rfind('\n', 0, pos) + 1
line = text[line_start:text.find('\n', pos)]
if re.match(r'\s*(//|\*|/\*)', line): # a comment
continue
if in_spans(pos, pads) or in_spans(pos, gaps) or \
any(a <= pos < b for a, b, _ in hw):
edit = (m.start(), m.end(), tok, lit, line.strip()[:80])
break
if in_spans(pos, pads):
shape = 'padding'
elif in_spans(pos, gaps):
shape = 'gap'
elif any(a <= pos < b for a, b, _ in hw):
shape = 'spacer'
else:
continue
tok = token(m.group(1), shape)
if tok is None:
continue
edit = (m.start(), m.end(), tok, lit, line.strip()[:80])
break
if edit is None:
break
a, b, tok, lit, ctx = edit

View File

@@ -61,6 +61,22 @@ def classify(path):
line = text[line_start:text.find('\n', pos)]
if re.match(r'\s*(//|\*|/\*)', line):
continue
# An explicit, reasoned opt-out. A value that is genuinely not a step in the
# spacing rhythm -- room reserved below a list, say -- is marked at the site
# rather than exempted by value in this file, so the reason travels with it
# and a reviewer sees it in the diff.
# Walk back over the unbroken run of comment lines directly above.
exempt = False
prev = text[:line_start].rstrip('\n').split('\n')
for candidate in reversed(prev[-8:]):
stripped = candidate.strip()
if not stripped.startswith('//'):
break
if 'm3-spacing-exempt' in stripped:
exempt = True
break
if exempt:
continue
entry = (path, text[:pos].count('\n') + 1, m.group(0), line.strip()[:90])
if inside(pos, pads) or inside(pos, gaps) or any(a <= pos < b for a, b in hw):
spacing.append(entry)