Files
mantra-kmp/docs/scripts/m3-spacing-positions.py

111 lines
3.9 KiB
Python
Raw Normal View History

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
#!/usr/bin/env python3
"""Count .dp literals by whether they sit in a spacing position or a dimension one.
m3-audit.sh used to split them by value, exempting a list of numbers that "looked like"
dimensions -- which is wrong twice over. 16.dp is a spacing stop *and* a plausible icon
size, and 50.dp was a Spacer height in 53 places and a divider width in one. Only the call
shape says which, so this reads the shape.
spacing padding(...), PaddingValues(...), Arrangement.spacedBy(...), and a
.height()/.width() whose enclosing call is Spacer(
dimension .size(), a non-Spacer .height()/.width(), RoundedCornerShape(),
BorderStroke(), a `size =` argument
Spacing positions are what phase 2 drives to zero; dimensions are out of its scope and are
reported so the number is visible rather than exempted.
Usage: m3-spacing-positions.py [--list]
"""
import os, re, sys
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
THEME = os.path.join(UI, 'theme')
def _spans(text, opener):
"""Brace-matched character ranges of every call matching `opener`."""
spans = []
for m in re.finditer(opener, 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 classify(path):
text = open(path, encoding='utf-8').read()
pads = _spans(text, r'\.?\bpadding\s*\(|\bPaddingValues\s*\(')
gaps = _spans(text, r'\bspacedBy\s*\(')
spacers = _spans(text, r'\bSpacer\s*\(')
def inside(pos, spans):
return any(a <= pos <= b for a, b in spans)
hw = [
(m.start(1), m.end(1))
for m in re.finditer(r'\.(?:height|width)\s*\(\s*(\d+\.dp)\s*\)', text)
if inside(m.start(), spacers)
]
spacing, dimension = [], []
for m in re.finditer(r'\b(\d+)\.dp\b', text):
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):
continue
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>
2026-09-08 00:36:59 +02:00
# 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
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
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)
else:
dimension.append(entry)
return spacing, dimension
def main():
spacing, dimension = [], []
for root, _, files in os.walk(UI):
if root.startswith(THEME):
continue
for f in sorted(files):
if f.endswith('.kt'):
s, d = classify(os.path.join(root, f))
spacing += s
dimension += d
print(f' {"dp literals in spacing positions":42s} {len(spacing):6d}')
print(f' {"dp literals in dimension positions":42s} {len(dimension):6d} (out of scope)')
if '--list' in sys.argv:
for path, line, lit, ctx in spacing:
print(f' {path.replace(UI + "/", "")}:{line} {lit:>7s} {ctx}')
return 1 if spacing else 0
if __name__ == '__main__':
sys.exit(main())