Files
mantra-kmp/docs/scripts/m3-audit.sh
Kgothatso Ngako 2b0ce8d73b test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test
Phase 0 of docs/material-design-conformance.md. Every count in that document was
produced by hand, which makes the eight phases after it opinions rather than work
with acceptance criteria. This is the harness that turns them back into numbers.

**`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.**
Plain invocation reports; `--check` exits 1 when a budget at the top of the file is
exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare
`.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the
contract written into the header is that they ratchet **down**, in the same commit
that earns the reduction, and are never raised. Counts a phase has not reached yet
are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which
point a raised budget is the diff a reviewer is looking for.

Verified both directions: `--check` exits 0 on the clean tree, and appending a
single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget.

**Two counts are reported apart from each other on purpose.** Thirteen ColorScheme
roles are never assigned in Theme.kt, and reporting that as one number would
overstate it. Twelve are the `*Fixed*` family, which default to
`ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a
monochrome app renders Material baseline lavender the moment anything reads one.
The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a
defect. The script labels the first group "lavender" and the second "not a defect".

The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the
M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an
image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by
counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines.
`DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in
the commit that lengthens it.

**`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.**
Four assertions over all six declared schemes: every content role on its container at
4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against
every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1.
WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4
exponent, not a gamma-2.2 approximation, because the approximation moves borderline
pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1.

`Theme.kt`'s six schemes went from `private val` to `internal val` so the test can
see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s
public values -- keeps production visibility untouched and was rejected: it would
assert the palette and miss the wiring, and the wiring is the half that fails
silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character
slip, compiles, and reads fine in review. A comment above the first scheme says this,
so the keyword is not quietly widened back.

**Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to
`#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the
build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is
2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged.

**Monotonicity across the contrast ladder is deliberately not asserted.** The obvious
invariant -- high-contrast beats medium beats default for every pair -- looks right
and is false. Ten pairs move the other way, and correctly: in the light high-contrast
scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which
drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation
that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19
from default to medium for the same kind of reason. Asserting the ladder would have
meant either a red test or nine exemptions; the floor is the real invariant and every
one of those values is comfortably above it. The test's doc comment records this so
the next reader does not add the assertion.

**Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads
1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline
sits in the same range and the role is a decorative divider, so `outline` is what
gets the 3:1 assertion. The seven call-site pairings that are genuinely below
threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding
them now would mean checking in a red test.

**Doc reconciled to the script rather than the other way round.** Three hand counts
were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp`
literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*`
typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`,
which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten
from a plan into what was built, including what was decided against.

**Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from
906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per
target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid`
builds. No app behaviour changes: the only production edit in this commit is
`private` -> `internal` on six vals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00

11 KiB
Executable File