Files
mantra-kmp/docs/scripts/m3-migrate-spacing.py
Kgothatso Ngako f7a732d68a refactor: move the 89 off-grid spacing values onto the M3 scale
Phase 2, second step, of docs/material-design-conformance.md. 77 of the 89 literals that
were off M3's spacing scale sat in spacing positions and now read
`MaterialTheme.spacing.spaceNNN`; the remaining 12 are dimensions and are out of scope.
One drifted corner moved onto the shape scale.

**The mapping, and why each is the nearest stop rather than the nicest number.**

     5.dp  x10  -> space50   (4dp)   padding and gaps in dense rows
    15.dp  x14  -> space200  (16dp)  card and dialog padding, two gaps
    30.dp   x1  -> space400  (32dp)  the spacer under LoadingDataIndicator's spinner
    50.dp  x52  -> space600  (48dp)  the spacer above an empty or error message

Nearest-stop throughout, so the largest move is 2dp and most are 1. `5.dp` is equidistant
between `space50` and `space75`; it goes to 4dp because `spacedBy(4.dp)` is already the
idiom elsewhere in the tree and a scale with two answers for the same input is not one.

The 52 at 48dp are the same three lines copied into 16 files -- a `Spacer` pushing
"Something went wrong" down the screen. Phase 5 retires them into a shared empty-state
composable; migrating them first means that composable inherits a token rather than
another literal.

**One shape, and it is the argument for having a scale at all.**
`RoundedCornerShape(30.dp)` in `TextNoteEventDetail` was the only hand-written corner off
the M3 scale, at 30dp against `extraLarge`'s 28. Two units: invisible beside any single
other card, and exactly the drift that happens when the value is a literal. It is now
`MaterialTheme.shapes.extraLarge`, the first call site for the scale `Shape.kt` documented.

**Rewritten by a script that reads call shapes, not values, and it is checked in.**
`docs/scripts/m3-migrate-spacing.py` brace-matches three call shapes -- `padding(...)`/
`PaddingValues(...)`, `Arrangement.spacedBy(...)`, and a `.height()`/`.width()` whose
enclosing call is `Spacer(` -- and rewrites only literals that fall inside one. A
`.size(18.dp)` icon, a non-Spacer `.height()`, a `RoundedCornerShape` or a `BorderStroke`
can never be caught, which a regex over `\\d+\\.dp` would have done to all of them. It
inserts the two imports where they are missing and skips comment lines. Dry run by default.

**The audit was measuring the wrong thing, and this is where that showed.** It split
literals by value against a hardcoded `DIMENSION_EXEMPT` list -- and the split is not a
property of the value. `16.dp` is a spacing stop *and* a plausible icon size. `50.dp` was a
`Spacer` height in 52 places and a divider width in one, and no list of numbers separates
those. `docs/scripts/m3-spacing-positions.py` replaces it with the same brace-matching
parse the migration uses, so the audit and the migration agree by construction; the audit
now reports **353 spacing literals** left and 76 dimensions out of scope, and the exemption
table is gone.

That reframes phase 2's acceptance criterion into something checkable: spacing positions to
zero, dimensions untouched. The script exits 1 while any spacing literal remains.

**What is left off-scale, and why none of it is a defect.** Twelve dimensions: avatar sizes
at 35, 55, 70 and 75dp, icon sizes at 18 and 22dp, and a 50dp divider width. Avatar and
icon sizing is a component-spec question rather than a spacing one -- M3 gives icons 18/20/
24/40/48 and says nothing about avatars -- and the plan puts per-component specs after the
adaptive phase. They are reported rather than exempted so the number stays visible.

**Tests.** 942 pass, 594 jvm over 72 classes and 348 android over 44, unchanged --
this commit adds no assertions, and the ones it could add (`SpacingScaleTest`) landed with
the scale. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0.
Pixels move by at most 2dp, in 30 files.

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

150 lines
5.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Rewrite .dp literals that sit in spacing positions onto MaterialTheme.spacing.
Checked in because phase 2 runs it twice and the breakpoint phase will want it again.
Usage:
m3-migrate-spacing.py '{"5":"space50","15":"space200"}' # dry run
m3-migrate-spacing.py '{"5":"space50","15":"space200"}' --apply
Only three call shapes are touched, and each is matched with the literal in place so a
dimension can never be caught by accident:
padding(...) any of the overloads, including named start/end/top/bottom
Arrangement.spacedBy(N) horizontal or vertical
Spacer height/width a .height()/.width() whose enclosing call is Spacer(
Everything else -- .size(), a non-Spacer .height(), RoundedCornerShape, BorderStroke --
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
DRY = '--apply' not in sys.argv
def token(value):
return MAPPING.get(value)
def spacer_spans(text):
"""Character ranges covered by a Spacer( ... ) call, brace-matched."""
spans = []
for m in re.finditer(r'\bSpacer\s*\(', text):
depth, i = 0, m.end() - 1
while i < len(text):
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
if depth == 0:
spans.append((m.start(), i))
break
i += 1
return spans
def in_spans(pos, spans):
return any(a <= pos <= b for a, b in spans)
def padding_spans(text):
spans = []
for m in re.finditer(r'\.?\bpadding\s*\(|\bPaddingValues\s*\(', text):
depth, i = 0, m.end() - 1
while i < len(text):
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
if depth == 0:
spans.append((m.start(), i))
break
i += 1
return spans
def spacedby_spans(text):
spans = []
for m in re.finditer(r'\bspacedBy\s*\(', text):
depth, i = 0, m.end() - 1
while i < len(text):
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
if depth == 0:
spans.append((m.start(), i))
break
i += 1
return spans
def process(path):
text = io.open(path, encoding='utf-8').read()
original = text
changed = []
for _ in range(60): # spans shift after each edit; recompute
pads = padding_spans(text)
gaps = spacedby_spans(text)
spacers = spacer_spans(text)
# a .height()/.width() literal counts only inside a Spacer(
hw = [(m.start(1), m.end(1), m.group(1))
for m in re.finditer(r'\.(?:height|width)\s*\(\s*(\d+\.dp)\s*\)', text)
if in_spans(m.start(), spacers)]
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 edit is None:
break
a, b, tok, lit, ctx = edit
text = text[:a] + f'MaterialTheme.spacing.{tok}' + text[b:]
changed.append((lit, tok, ctx))
if text != original:
if 'import androidx.compose.material3.MaterialTheme' not in text:
# insert alphabetically among the material3 imports, else after the last import
lines = text.split('\n')
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
for i, l in enumerate(lines):
if l.startswith('import androidx.compose.material3.') and l > 'import androidx.compose.material3.MaterialTheme':
idx = i - 1
break
lines.insert(idx + 1, 'import androidx.compose.material3.MaterialTheme')
text = '\n'.join(lines)
if 'import press.mantra.compose.ui.theme.spacing' not in text and \
not path.endswith('theme/Spacing.kt'):
lines = text.split('\n')
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
lines.insert(idx + 1, 'import press.mantra.compose.ui.theme.spacing')
text = '\n'.join(lines)
if not DRY:
io.open(path, 'w', encoding='utf-8').write(text)
return changed
if __name__ == '__main__':
import json
MAPPING.update(json.loads(sys.argv[1]))
total = 0
for root, _, files in os.walk(UI):
for f in sorted(files):
if not f.endswith('.kt'):
continue
p = os.path.join(root, f)
ch = process(p)
if ch:
print(f'{p.replace(UI + "/", "")}')
for lit, tok, ctx in ch:
print(f' {lit:>7s} -> {tok:18s} {ctx}')
total += len(ch)
print(f'\n{total} literal(s) {"would be " if DRY else ""}rewritten')