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>
This commit is contained in:
Kgothatso Ngako
2026-09-08 00:32:27 +02:00
parent 73d99f9a41
commit f7a732d68a
34 changed files with 397 additions and 122 deletions

View File

@@ -25,23 +25,13 @@ 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_DP_LITERALS=-1 # phase 2 drives to ~0 outside theme/
BUDGET_OFF_SCALE_DP=-1 # phase 2 drives to 0
BUDGET_SPACING_LITERALS=353 # phase 2 drives to 0
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
BUDGET_TITLE_CASE=-1 # phase 4 drives to 0
BUDGET_UNSET_COLOR_ROLES=0 # phase 1: reached 2026-09-07
# The M3 spacing scale: docs/material-design-conformance.md, "The numbers".
# space0..space900. Anything outside this set is off-scale.
ON_SCALE=(0 2 4 6 8 10 12 14 16 20 24 32 36 40 48 56 64 72)
# Dimensions rather than spacing -- an avatar, an image height, a hairline
# border. These are exempt from the off-scale count; keep the list short and
# justify additions in the commit that makes them.
DIMENSION_EXEMPT=(1 80 128 180 200 500)
fail_count=0
hdr() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
@@ -136,40 +126,18 @@ report 'colours derived with .copy(alpha =)' "$alpha" -1
# ---------------------------------------------------------------------------
hdr 'Spacing (phase 2)'
dp_all=$(grep -rhoE '\b[0-9]+\.dp' "$UI" --include=*.kt 2>/dev/null \
| grep -v "^$THEME/" | wc -l | tr -d ' ')
dp_outside_theme=$(grep -rhoE '\b[0-9]+\.dp' \
$(grep -rl '\.dp' "$UI" --include=*.kt 2>/dev/null | grep -v "^$THEME/") \
2>/dev/null | wc -l | tr -d ' ')
report '.dp literals outside theme/' "$dp_outside_theme" "$BUDGET_DP_LITERALS"
# Classified by call shape rather than by value, which is the only thing that says
# whether a given literal is spacing or a dimension: 16.dp is a spacing stop and also a
# plausible icon size, and 50.dp was a Spacer height in 53 places and a divider width in
# one. m3-spacing-positions.py does the parse; it exits 1 while any spacing literal is
# left, which is phase 2's acceptance criterion.
spacing_report=$(python3 docs/scripts/m3-spacing-positions.py)
spacing_left=$(echo "$spacing_report" | awk '/spacing positions/ {print $NF}')
dimensions=$(echo "$spacing_report" | grep 'dimension positions' | grep -oE '[0-9]+')
report 'dp literals in spacing positions' "$spacing_left" "$BUDGET_SPACING_LITERALS"
note "in dimension positions (out of scope): $dimensions"
note 'run docs/scripts/m3-spacing-positions.py --list to see them'
# Split the histogram into on-scale, exempt dimensions, and off-scale.
declare -A hist
while read -r n; do
hist[$n]=$(( ${hist[$n]:-0} + 1 ))
done < <(grep -rhoE '\b[0-9]+\.dp' \
$(grep -rl '\.dp' "$UI" --include=*.kt 2>/dev/null | grep -v "^$THEME/") \
2>/dev/null | sed 's/\.dp//')
on_scale_total=0; off_scale_total=0; exempt_total=0; off_scale_detail=""
for n in "${!hist[@]}"; do
c=${hist[$n]}
if printf '%s\n' "${ON_SCALE[@]}" | grep -qx "$n"; then
on_scale_total=$((on_scale_total + c))
elif printf '%s\n' "${DIMENSION_EXEMPT[@]}" | grep -qx "$n"; then
exempt_total=$((exempt_total + c))
else
off_scale_total=$((off_scale_total + c))
off_scale_detail="$off_scale_detail ${n}dp:${c}"
fi
done
note "on the M3 scale: $on_scale_total"
note "exempt dimensions: $exempt_total"
report 'off the M3 spacing scale' "$off_scale_total" "$BUDGET_OFF_SCALE_DP"
[[ -n $off_scale_detail ]] && note "off-scale:$off_scale_detail"
spacer_idiom=$(count 'height\(50\.dp\)')
note "Spacer(height(50.dp)) idiom: $spacer_idiom"
# ---------------------------------------------------------------------------
hdr 'Typography (phase 1)'