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>
111 lines
3.9 KiB
Python
Executable File
111 lines
3.9 KiB
Python
Executable File
#!/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
|
|
# 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)
|
|
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())
|