2b0ce8d73ba2bfeed3c97de7059e97de77a9a1eb
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
1c02b25a07 |
docs: measure the UI against the M3 foundations, and phase the work that follows
A plan, not a change: what m3.material.io/foundations asks for as of its May 2026
revision, what these 43 screens actually do, and eight phases ordered so that each
one makes the next mechanical rather than judgemental.
**The spec was read, not remembered.** m3.material.io is a client-rendered SPA --
WebFetch returns an empty `<main>` and the tab URLs 404 on direct navigation -- so
the numbers here came out of a real browser session clicking through the tab
controls. That mattered: the May 2026 revision renamed window size classes to
**breakpoints** and there are now five of them rather than three (compact / medium
/ expanded / large / extra-large, at 600 / 840 / 1200 / 1600dp), renamed responsive
design to adaptive design, and published the spacing system as tokens on an 8dp
scale where `space100 = 8dp`. Writing this from memory of older M3 would have
produced a plan against a vocabulary the current spec no longer uses.
**The palette is fine; the call sites are not.** Every `onX`-on-`X` pair in all six
declared schemes clears 4.5:1, the tightest being `onPrimaryContainer` on
`primaryContainer` at 4.61:1 light and 4.56:1 dark. So the generated scheme is not
the problem and this plan does not propose a repalette. What fails is colour
decided locally, seven pairings of it, and the worst is not visible to a reviewer:
Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) {
ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent),
`cardColors(containerColor = ...)` does derive `contentColor = contentColorFor(...)`,
so `LocalContentColor` inside the card is correct. But `ListItem` does not read
`LocalContentColor` -- its headline comes from `ListTokens.ItemLabelTextColor`,
which is `onSurface` -- and the call site overrides only `containerColor`. In the
light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. That is
**1.00:1**, and it is applied exactly to `proposal.awaitsYou`, so the proposals
waiting on your signature are the ones rendered invisible. `HomeScreen`'s
`titleContentColor = primary` on `containerColor = primaryContainer` is the same
mistake at 1.22:1. Ratios were computed rather than eyeballed; the script is in the
Phase 0 deliverable.
**Twelve colour roles fall through to Material baseline lavender.** `Color.kt`
never assigns `primaryFixed`, `primaryFixedDim`, `onPrimaryFixed`,
`onPrimaryFixedVariant` or the secondary/tertiary equivalents, so
`lightColorScheme()` defaults them to `ColorLightTokens.PrimaryFixed` ->
`PaletteTokens.Primary90` -> `#EADDFF`. Nothing reads them today, which is why it
has never been noticed; the trap springs the first time an expressive component
does. Read out of the pinned `material3-desktop-1.10.0-alpha05-sources.jar` rather
than assumed.
**Four of the six declared schemes are unreachable.** The medium- and high-contrast
variants are written out in full in `Color.kt` -- 78 colour values -- wired into
`lightColorScheme`/`darkColorScheme` in `Theme.kt`, and then never selected:
`TorchTheme` chooses between `darkScheme` and `lightScheme` only. The work to
honour a platform contrast setting is already done and disconnected.
**10dp and 20dp are not the problem they look like.** They are the two dominant
spacing values (132 and 115 uses) and both are *on* the M3 scale, as `space125` and
`space250`. The plan says so rather than proposing a sweep that would change
nothing. What is wrong is that none of the 520 `.dp` literals records whether it is
padding, a gap or a margin -- the three categories the spec gives different rules
to -- so nothing can be adapted per breakpoint later. About 101 are off-scale
(50dp x 53, 15dp x 14, 5dp x 10 and so on), and `Modifier.height(50.dp)` appears 49
times as the same copied spacer above the same copied error message.
**Findings that were measured and then dropped.** `outlineVariant` reads 1.61:1
against surface and `secondaryContainer` 1.65:1, both of which look alarming and
neither of which is a defect: M3's own baseline sits in the same range, and the 3:1
rule the spec gives is for clustered interactive containers, not dividers or tonal
surfaces. `onSurface.copy(alpha = 0.38f)` is the specified disabled opacity and the
spec exempts disabled states from contrast entirely. Reporting these would have
padded the count and cost the reader trust in the rest.
**The rest of the audit, in counts.** 334 string literals in composables against 2
`stringResource` calls, with title case throughout ("Edit Profile", "New Chat") where
the style guide asks for sentence case. Zero `Snackbar` across 26 `Scaffold`s. 16
copies of `Text("Something went wrong")`, none of which offers a retry. 90 of 240
typography reads on `label*` roles, which are for component text, while `display*`
and `headline*` carry 9 uses between them across 43 screens. 33 bare
`Modifier.clickable` with no minimum target, two of them text-height. Two
`BoxWithConstraints` and no window-size handling at all, on a project with a desktop
target whose own entry point already says so in a comment.
**Eight phases, ordered by what each unblocks.** 0 baseline harness, 1 theme,
2 spacing tokens, 3 accessibility floor, 4 content, 5 states and feedback,
6 adaptive layout, 7 motion, 8 guard rails. Tokens come before the call sites that
consume them; the accessibility floor comes before the adaptive work that would
otherwise double the surface to fix; guard rails come last so they lock in real
state rather than aspiration. Phase 6 is the only one that cannot be done
mechanically and the only one marked not reversible alone.
**What it deliberately does not decide.** Whether the target is
`MaterialExpressiveTheme` or `MaterialTheme` -- the pinned material3 ships the full
expressive set and the code already opts into `ExperimentalMaterial3ExpressiveApi`
in 66 places, but it changes default component shapes and sizes app-wide, so it is a
product call and Phase 1 raises it rather than answering it. Also out of scope:
whether the monochrome palette is right, the per-component specs, iOS (which only
builds on a mac, and whose HIG asks 44dp where M3 asks 48dp), and the three package
namespaces the UI currently lives across.
No code changes. `docs/README.md` gains the row and the closing paragraph's note on
how this one relates to the others.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|