Files
mantra-kmp/docs/scripts/m3-audit.sh

288 lines
14 KiB
Bash
Raw Normal View History

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
#!/usr/bin/env bash
#
# Material Design 3 conformance audit.
#
# Regenerates every count quoted in docs/material-design-conformance.md. The plan
# in that document has acceptance criteria per phase; this is what checks them.
#
# Usage:
# docs/scripts/m3-audit.sh report, always exit 0
# docs/scripts/m3-audit.sh --check report, exit 1 if any budget is exceeded
#
# The budgets at the top are the state of the tree at the phase named beside each
# one. They ratchet down as phases land: lower the number in the same commit that
# earns it, never raise one. Phase 8 wires --check into CI, at which point raising
# a budget is what a reviewer looks for.
set -uo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1
UI=composeApp/src/commonMain/kotlin/press/mantra/compose/ui
THEME="$UI/theme"
# ---------------------------------------------------------------------------
# Budgets. "-1" means not yet budgeted -- reported, but never fails --check.
# ---------------------------------------------------------------------------
fix: derive every call-site colour from its container, ending nine contrast failures Phase 3, first step, of docs/material-design-conformance.md. The generated palette was already sound -- every `onX`-on-`X` pair in all six schemes clears 4.5:1 -- and every failure in the app came from a colour reached for at the call site instead of derived from what it sits on. **The worst one made the app's most important rows invisible.** `ProposalListScreen` put a `ListItem` inside a `Card` and overrode only the card's container: Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) { ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), `cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, so `LocalContentColor` inside the card was correct. `ListItem` does not read `LocalContentColor`. Its headline comes from `ListTokens.ItemLabelTextColor`, which is `onSurface`, and in the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. Measured on that card: headline (onSurface) 1.00:1 invisible leading icon (primary) 1.22:1 supporting (onSurfaceVariant) 1.84:1 "could not be read" (error) 2.67:1 onPrimaryContainer 4.61:1 the only one that worked Four of five below the floor, and the card is applied to exactly `proposal.awaitsYou` -- the proposals waiting on your signature. Dark was fine throughout, because there `primaryContainer` is black, so this only ever showed in the light scheme. The card's colours are now computed once and everything inside derives from `cardColors.contentColor`: the six `ListItemColors` slots, the leading icon tint, the "Review" label, and the unreadable-count line. `primaryContainer` is kept as the highlight so this stays a fix rather than a restyle -- `secondaryContainer`, the brand gold, would read more like "this needs you", and that is a design call recorded in a comment rather than taken here. On the highlighted card the failure state loses its red, because `error` is 2.67:1 there. The signal survives in the icon and in the sentence "could not be read", which is the more robust cue anyway and the only one available to somebody who cannot distinguish the red. **`HomeScreen`'s top bar lost its override entirely.** `containerColor = primaryContainer` with `titleContentColor = primary` is `#000000` on `#1B1B1B`: **1.22:1**, a black title on a near-black bar. `TopAppBarDefaults` gives `surface`/`onSurface` and needed no help. **Three of the four `alpha = 0.5f` sites were not text, which changes what they failed.** The audit called them caption text; they are `CircularProgressIndicator` colours, so the threshold is 3:1 rather than 4.5:1. At 2.49:1 they fail either way, but the plan said the wrong thing and is corrected. The one that really is text -- `ArticleCard`'s published-at timestamp at `alpha = 0.7f`, 3.96:1 -- is the fourth. All five now use `onSurfaceVariant` at full opacity, 7.25:1, which is the role for secondary text and needed no alpha to become one. **The LIVE badge was a hand-mixed red.** `Color(0xFFE53935)` with a white label is 4.23:1, under the floor for `labelSmall`. `error`/`onError` is the role for a red that has to be read and is 6.46:1. **The avatar picker used a content colour as a background.** `onSurface` at 50% composited to a mid grey 2.49:1 from the unselected cells beside it -- so which emoji was selected was close to unreadable. Now `secondaryContainer`, M3's role for a selected item. Worth being straight about the limit: that role is 1.65:1 against the surface in this palette, which M3 accepts because its own selected states carry a second cue, an outline or a checkmark. This grid has neither. Adding one is component work, and the comment and the plan both say so rather than leaving it looking finished. **Three colours stay hardcoded, and each says why at the site.** A new `// m3-color-exempt: <reason>` marker, matching the spacing convention from phase 2, and the audit honours it: - `QRCodeView` -- a QR code is read by a camera. Scanners need maximum luminance contrast between the modules and their background, and under dynamic colour `onSurface`/`surface` could be two mid tones and unscannable. - `FullScreenImageViewer`'s close button -- it floats over an arbitrary photograph, so no role is safe behind it. A translucent scrim with white on it is M3's own full-screen media treatment and the only pairing that holds over both a white sky and a black one. - `LoadingAsyncImage`'s spinner, but only when a blurhash placeholder is behind it. With no placeholder the surface is known and the role is used. Exemptions belong at the call site: the reason travels with the code and a reviewer sees it in the diff that adds it, rather than in a list of file names in the audit script. **Two colours were tokenised without moving a pixel.** `Color.Black` on the blank route's `Surface` and on the image viewer's backdrop are both `scrim`, which is `#000000` in every one of this app's six schemes. Same bytes, and the value now travels with the theme. **A new assertion for the case the others structurally cannot catch.** A translucent container has no contrast ratio of its own -- it has one only once composited -- so `ColorSchemeContrastTest` grows an eleventh test that composites the two remaining tinted containers over `surface` and measures the result, in all six schemes, naming the call site in the failure. The pairings this commit *fixed* are not restated: once the proposal card derives its colours, the pair it produces is `onPrimaryContainer` on `primaryContainer`, which the first assertion already walks. **Audit budget for hardcoded colours ratcheted 9 -> 0**, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, up from 942/594/348. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:45:01 +02:00
BUDGET_HARDCODED_COLOR=0 # phase 3: reached 2026-09-08
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
BUDGET_SPACING_LITERALS=0 # phase 2: reached 2026-09-08
fix: give every tappable element a real target, and every icon a decided description Phase 3, second step, of docs/material-design-conformance.md. Two accessibility rules the tree had no way to hold: M3's 48x48dp touch target and 44x44dp pointer target, and its requirement that a decorative visual be *annotated* as decorative rather than merely left undescribed. **Nineteen `.clickable` chains had no minimum size, and three were text-sized.** `ArticleCard` and `LiveStreamCardContent` each make an author's name tappable -- a `labelMedium`, around 16dp tall -- and `LinkPreview` does the same to a `bodyLarge` url with 2dp of vertical padding. The other sixteen are cards, rows and full-screen boxes that are already far larger. `minimumInteractiveComponentSize()` is applied to all nineteen rather than to the three, because it is a no-op on anything already 48dp and that makes the rule checkable by a script instead of by measuring. Worth being precise about what it does, since the modifier is easy to describe wrongly: it reserves 48x48dp of **layout**, not of touch handling -- touch expansion happens at the input layer regardless. Layout is what keeps adjacent targets from overlapping, what satisfies M3's 8dp separation, and what a mouse pointer on the desktop build actually has to land on. **`Clickable.kt` had it built in and moved house.** The vendored ACINQ helper defaults to `RectangleShape` and `PaddingValues(0.dp)`, so a `Clickable` is exactly as big as its content -- and its call sites wrap a 20dp emoji and a row of wallet text. It now applies the modifier unconditionally, before `.padding(internalPadding)`, since a size modifier after it would re-impose the smaller constraint. It also stopped declaring `package com.machankura.compose.ui.composable.widgets.buttons` while living under `press/mantra/`. That is the second of the three package namespaces the UI was spread across; `Type.kt` was the first. **Eighteen `contentDescription = null` were indistinguishable from eighteen oversights.** `null` is the *correct* API -- M3 asks that decorative visuals be "annotated as decorative in order to hide them in code", and null is how that annotation is spelled in Compose. The problem is that it reads identically whether somebody decided or never looked. So `Decorative` is introduced -- a `String?` that is null -- and fifteen sites now say `contentDescription = Decorative`. Same bytes, same behaviour, and the difference between a decision and a gap is now visible in the source and countable by the audit. Each of the fifteen has adjacent text saying what the icon says: a lock beside "Private to Ada", a check beside "The group has a shared key.", an icon inside a button whose label is right there. **Three were not decorative and now carry their state.** - `DkgRitualScreen`'s participant list -- a filled or empty circle beside each member. The name says who; only the icon says whether they have contributed. Now "Contributed" / "Not yet contributed". - `DkgRitualScreen`'s round header -- the title says which round and the count says how far along; only the icon says whether it finished. Now "Complete" / "In progress". - `ProposalListScreen`'s leading icon, which is the one this commit could not have left alone: the previous commit took the red away from the failure state on the highlighted card, because `error` is 2.67:1 there. The shape is now the only cue a sighted user gets and the description is the only cue anyone else gets. Now "Awaiting your signature" / "Signed" / "Failed" / "Waiting on others". Descriptions follow M3's rule -- name the purpose, not the picture, and never the role. "Contributed", not "green check", and never "Contributed icon", since the role is added automatically and a screen reader would say it twice. **Two new checks, replacing one that was asking the wrong question.** `docs/scripts/m3-touch-targets.py` finds `.clickable` chains with no minimum size, including chains broken across two lines. The audit used to count `.clickable` outright, which is not a defect count: a clickable `Card` is fine and a clickable `Text` is not, and only the modifier tells them apart. The audit also now separates `contentDescription = null` (untriaged, budget 0) from `Decorative` (decided, reported at 15). Both budgets ratcheted to 0, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged -- these are layout and semantics properties, and this repo has no Compose UI test infrastructure to assert them against a running composition. What stands in for it is the two scripts, which check the property that *can* be checked statically: that the modifier and the decision are present at every site. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:52:15 +02:00
BUDGET_BARE_CLICKABLE=0 # phase 3: reached 2026-09-08
BUDGET_NULL_DESCRIPTION=0 # phase 3: reached 2026-09-08
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
BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
BUDGET_TITLE_CASE=0 # phase 4: reached 2026-09-08
fix: assign every ColorScheme role, so no component can fall back to Material lavender Phase 1, step 1 of docs/material-design-conformance.md. `Theme.kt` assigned 36 of the 49 roles `androidx.compose.material3.ColorScheme` declares. The other thirteen took `lightColorScheme()`/`darkColorScheme()` defaults, and for twelve of them that default is the Material baseline palette: `primaryFixed` -> `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> **#EADDFF**. Lavender, in an app whose primary is `#000000`, in both themes, in all six schemes. Nothing in the tree reads a fixed role today, which is why nobody has seen it. That also means it could not have been found by looking at the app -- it springs the first time an expressive component reaches for one, and it will look like a rendering bug rather than a missing assignment. **The tones were computed, not chosen.** M3 defines the family by tone: `xFixed` = tone 90, `xFixedDim` = 80, `onXFixed` = 10, `onXFixedVariant` = 30, and ColorLightTokens and ColorDarkTokens carry identical values for all twelve -- theme-independence is what "fixed" means. Tone is CIE L*, so for a chroma-0 palette a tone is exactly the sRGB grey at that L*, and inverting L* -> Y -> sRGB reproduces this palette's own greys **to the byte**: tone 0 #000000 primaryLight tone 10 #1B1B1B primaryContainerLight, onSurfaceLight tone 20 #303030 onPrimaryDark, inverseSurfaceLight tone 40 #5E5E5E inversePrimaryDark tone 80 #C6C6C6 primaryDark, inversePrimaryLight tone 90 #E2E2E2 onSurfaceDark, surfaceContainerHighestLight tone 95 #F1F1F1 inverseOnSurfaceLight tone 100 #FFFFFF onPrimaryLight Eight independent hits. The primary and tertiary palettes are the standard M3 neutral tonal palette at chroma 0, so their fixed families are derived rather than invented. **The secondary palette is gold at Lab hue 87.5 degrees, and its dark half is maximum in-gamut chroma at that hue.** Generating tones off that ramp regenerates `onSecondaryDark` (#3D2F00, tone 20) and `secondaryLight` (#745B00, tone 40) byte for byte, which is what licenses using it for tones 10 (#241A00) and 30 (#584400). Its tones 90 and 80 are **reused rather than regenerated**. The palette already ships #FFDE82 at tone 90 (as `secondaryDark`) and the brand gold #EFBF04 at tone 80 (as `secondaryContainer`, identical in light and dark -- someone hand-set it, no generator emits that). Regenerating would have produced #FFDF99 and #F1C100: a second gold two units from the one already on screen, indistinguishable in isolation and wrong beside it. A near-duplicate brand colour is worse than none. **Sanity check on the whole derivation.** The four ratios these families produce land within 0.1 of M3's own baseline fixed family -- onFixed on Fixed 13.30 (baseline 13.32) onFixedVariant on Fixed 7.17 (baseline 7.23) onFixed on FixedDim 10.08 (baseline 10.08) onFixedVariant on Dim 5.44 (baseline 5.47) -- because tone, not hue, sets the ratio. Two palettes with nothing in common landing on the same four numbers is the check that the tone mapping is right. **Containers hold across the contrast setting; content darkens.** That is the move `Color.kt` already makes everywhere else -- `onSurfaceLight` goes #1B1B1B -> #111111 -> #000000 while `surfaceLight` stays #F9F9F9 through all three -- so the fixed family follows it: content tones 10/30, then 5/20, then 0/10. The weakest pair ladders 5.44 -> 7.73 -> 10.08. Shifting the containers instead would have moved the brand-visible half for a setting that is about legibility. **`surfaceTint` is the thirteenth, and it was never a defect.** Its default is `primary`, which is correct: `surfaceColorAtElevation` composites it over `surface` at 2-8% alpha, so an elevated light surface darkens toward primary and an elevated dark one lightens -- M3's own behaviour, and this app sets no elevations anywhere, so nothing reads it. It is assigned explicitly anyway, with that reasoning in a comment, so that "every role is assigned" is a property a reader can check by looking rather than by knowing which omissions were deliberate. m3-audit.sh reports the two kinds apart for the same reason. **Three new assertions, and the two that matter cannot be satisfied by accident.** `ColorSchemeContrastTest` grows from 4 to 7: - both content roles on both fixed containers at 4.5:1, across all six schemes; - the fixed roles are the same colour in light and dark, which is the definition and would otherwise only fail on a screen that puts one beside a themed surface; - no role is left at the Material baseline palette -- the twelve baseline hex values read out of `PaletteTokens.kt` and asserted absent. Verified by deleting `primaryFixed = primaryFixed,` from `lightScheme` alone: two tests fail, naming the role and printing back `Color(0.917, 0.866, 1.0)`. Reverted. **Audit budget ratcheted 12 -> 0**, dated in the file. Per the header's contract that is the only direction a budget moves, and the commit that lowers it is the one that earns it. **Tests.** 920 pass, 583 jvm over 70 classes and 337 android over 42, up from 914/580/337 -- three new assertions counted once per target. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. No visual change: every role that had a value keeps it, and the thirteen that gain one were rendering baseline defaults nothing reads yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:59:34 +02:00
BUDGET_UNSET_COLOR_ROLES=0 # phase 1: reached 2026-09-07
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
docs: record what phase 6 built, and give the audit a floor to defend it The plan's phase 6 becomes a record rather than a proposal, in the shape the earlier phases took: what was built, what was decided and why, what a person still has to look at. Two decisions in it were the product owner's rather than the code's -- promoting search and profile to navigation destinations, and doing chat alone rather than all three list-detail families -- and both are named as such with the date. **The audit learns two things.** It counted `NavigationBar(`, `NavigationRail(` and friends, and reported **zero** for an app that had just grown a navigation bar: `NavigationSuiteScaffold` is what chooses between them per breakpoint, and the concrete component never appears in the source. It now counts the scaffold and its items. And it grew a `floor()` beside `report()`. Every other budget in the file is a ceiling that ratchets down as a phase lands, which is the right shape for literals, hardcoded colours and untriaged nulls -- things a careless edit *adds*. The adaptive work is the opposite: a screen that stops reading the breakpoint still compiles and still renders, and the count goes down. So `--check` now also fails when the adaptive API count drops below 12 or the navigation component count below 2. **Two `contentDescription = null` that the audit caught in this phase's own work** -- the navigation item's icon and the new-chat button's -- now say `Decorative`. Same null, and the same convention phase 3 established: recording that somebody looked is the whole point, and a budget of zero only holds if new code obeys it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:04:00 +02:00
# A floor rather than a ceiling: --check fails when the count drops *below* it. The
# adaptive work is the one thing in this document that a later edit removes rather
# than adds -- a screen that stops reading the breakpoint still compiles and still
# renders -- so the budget that protects it has to point the other way.
FLOOR_ADAPTIVE_APIS=12 # phase 6: reached 2026-09-08
FLOOR_NAVIGATION_COMPONENTS=2 # phase 6: reached 2026-09-08
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
fail_count=0
hdr() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
note() { printf ' %s\n' "$1"; }
docs: record what phase 6 built, and give the audit a floor to defend it The plan's phase 6 becomes a record rather than a proposal, in the shape the earlier phases took: what was built, what was decided and why, what a person still has to look at. Two decisions in it were the product owner's rather than the code's -- promoting search and profile to navigation destinations, and doing chat alone rather than all three list-detail families -- and both are named as such with the date. **The audit learns two things.** It counted `NavigationBar(`, `NavigationRail(` and friends, and reported **zero** for an app that had just grown a navigation bar: `NavigationSuiteScaffold` is what chooses between them per breakpoint, and the concrete component never appears in the source. It now counts the scaffold and its items. And it grew a `floor()` beside `report()`. Every other budget in the file is a ceiling that ratchets down as a phase lands, which is the right shape for literals, hardcoded colours and untriaged nulls -- things a careless edit *adds*. The adaptive work is the opposite: a screen that stops reading the breakpoint still compiles and still renders, and the count goes down. So `--check` now also fails when the adaptive API count drops below 12 or the navigation component count below 2. **Two `contentDescription = null` that the audit caught in this phase's own work** -- the navigation item's icon and the new-chat button's -- now say `Decorative`. Same null, and the same convention phase 3 established: recording that somebody looked is the whole point, and a budget of zero only holds if new code obeys it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:04:00 +02:00
# floor <label> <value> <minimum>
# The mirror of report(), for counts a phase drives *up*. Used by the adaptive
# section, where the regression to catch is a screen quietly losing its breakpoint.
floor() {
local label=$1 value=$2 minimum=$3
if (( value < minimum )); then
printf ' %-42s %6s \033[31mbelow floor %s\033[0m\n' "$label" "$value" "$minimum"
fail_count=$((fail_count + 1))
else
printf ' %-42s %6s (floor %s)\n' "$label" "$value" "$minimum"
fi
}
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
# report <label> <value> <budget>
report() {
local label=$1 value=$2 budget=$3
if [[ $budget == "-1" ]]; then
printf ' %-42s %6s (no budget)\n' "$label" "$value"
elif (( value > budget )); then
printf ' %-42s %6s \033[31mover budget %s\033[0m\n' "$label" "$value" "$budget"
fail_count=$((fail_count + 1))
else
printf ' %-42s %6s (budget %s)\n' "$label" "$value" "$budget"
fi
}
fix: promote the two pill colours to extended roles, fixing both contrast failures Phase 1, step 5 of docs/material-design-conformance.md. `BluePill` and `RedPill` were raw `Color` values in `Color.kt`, paired at the call site with `Color.White` and `Color.DarkGray` by eye. Both pairings were below the 4.5:1 floor, and one of them was not the colour it looked like. **This step could not leave the pixels alone, and it is the only one so far that changes them.** `Color.DarkGray` on `BluePill` measures **2.90:1**. `RedPill` was `Color(230, 32, 32, 191)` -- the four-Int constructor, whose last argument is alpha, so it is `#E62020` at 0.749. Opaque, white on it is 4.57:1 and passes; composited over the surface as it actually renders, it is **3.50:1** and does not. Any correct version of these two buttons is a visible change, so "adds, does not restyle" does not apply here and the plan already said the call sites would move in this step. **What M3 asks for here is an extended colour**, not a literal: a brand colour promoted to a full role family -- `color` / `onColor` / `colorContainer` / `onColorContainer` -- so that contrast is a property of the family rather than a decision repeated at each use. `ColorFamily` was already declared in `Theme.kt`, unused, alongside an `unspecified_scheme`; Material Theme Builder emits both, and this is what they are for. **Derived by the same rule as the gold palette**, which the fixed-roles commit established and verified: maximum in-gamut chroma at the source colour's Lab hue, sampled at M3's role tones. BluePill's hue is 277.0 and RedPill's is 36.3. role light dark blue light red light color tone 40 tone 80 #0060AB #C00012 onColor tone 100 tone 20 #FFFFFF #FFFFFF colorContainer tone 90 tone 30 #D7E2FF #FFDAD3 onColorContainer tone 10 tone 90 #001C39 #390C00 The buttons take `color`/`onColor`: 6.46:1 for the red pill and 6.44:1 for the blue, from 2.90 and 3.50. **A side effect worth having.** At tone 40 the two pills are the same lightness, so they now read as a matched pair. Before, `#E62020` sat beside `#5D8DD6` -- a saturated red next to a soft periwinkle -- and the blue looked like the lesser option. On a screen whose whole content is "commit, or wipe and leave", weighting one choice by accident is a defect of its own. **They travel on a composition local, not on `isSystemInDarkTheme()`.** `ColorScheme` has no slot for extended colours, so `LocalExtendedColors` is provided by `TorchTheme` from the same `darkTheme` it chooses the scheme with. Reading `isSystemInDarkTheme()` at the call site would have been one line shorter and subtly wrong: it ignores a caller who passed `darkTheme` explicitly, so a preview forcing dark would show light pills. The local defaults to the light families rather than to `unspecified_scheme` -- nothing composes outside `TorchTheme` today, and an invisible button is a worse way to discover that than a light-themed one. **No medium- or high-contrast variants, deliberately.** The entire surface is two buttons on one screen, and the light family's weakest pair is 6.44:1 -- clear of the floor by more than the contrast schemes would add. 32 more values for that would be out of proportion, and the comment in `Color.kt` says so rather than leaving the omission to be read as an oversight. **`QRCodeView` lost its constructor default.** `QRCodeBackgroundPainter` defaulted `backgroundColor` to `BluePill` -- a colour picked outside the theme for a surface that is almost never seen, since at the default `padding = 0.dp` the logo painter covers the rect it fills. The default is gone and the one call site passes it, so the choice is visible rather than buried. **Two new assertions, one of which is about the constructor.** `ColorSchemeContrastTest` grows to 9. The first checks both pairs of every extended family at 4.5:1. The second checks that every extended role is **opaque**, because `RedPill`'s alpha is what made the first assertion insufficient: a translucent container has no ratio of its own -- it has one only once composited -- so a contrast test would have measured a colour the user never sees. That is the bug this commit fixes, and it would have passed a naive contrast test. **The audit stopped counting its own commentary.** Fixing these call sites left a comment *explaining* what `Color.White`/`Color.DarkGray` had been, and `m3-audit.sh` counted it as a hardcoded colour -- so the file stayed in the report after being fixed. The script now drops comment lines before counting. Budget ratcheted 11 -> 9: the two real sites, plus the false positive the filter removes. **Tests.** 930 pass, 588 jvm over 71 classes and 342 android over 43, up from 926/586/340. `:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build, `m3-audit.sh --check` exits 0. The nine remaining hardcoded colours are phase 3's, and are listed by the audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:23:31 +02:00
# Lines that are comments rather than code. Without this a note *about* a hardcoded
# colour counts as one -- which happened the first time a call site was fixed and the
# commit explained what it had replaced.
NOT_A_COMMENT='^[^:]*:[[:space:]]*(//|\*|/\*)'
fix: derive every call-site colour from its container, ending nine contrast failures Phase 3, first step, of docs/material-design-conformance.md. The generated palette was already sound -- every `onX`-on-`X` pair in all six schemes clears 4.5:1 -- and every failure in the app came from a colour reached for at the call site instead of derived from what it sits on. **The worst one made the app's most important rows invisible.** `ProposalListScreen` put a `ListItem` inside a `Card` and overrode only the card's container: Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) { ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), `cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, so `LocalContentColor` inside the card was correct. `ListItem` does not read `LocalContentColor`. Its headline comes from `ListTokens.ItemLabelTextColor`, which is `onSurface`, and in the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. Measured on that card: headline (onSurface) 1.00:1 invisible leading icon (primary) 1.22:1 supporting (onSurfaceVariant) 1.84:1 "could not be read" (error) 2.67:1 onPrimaryContainer 4.61:1 the only one that worked Four of five below the floor, and the card is applied to exactly `proposal.awaitsYou` -- the proposals waiting on your signature. Dark was fine throughout, because there `primaryContainer` is black, so this only ever showed in the light scheme. The card's colours are now computed once and everything inside derives from `cardColors.contentColor`: the six `ListItemColors` slots, the leading icon tint, the "Review" label, and the unreadable-count line. `primaryContainer` is kept as the highlight so this stays a fix rather than a restyle -- `secondaryContainer`, the brand gold, would read more like "this needs you", and that is a design call recorded in a comment rather than taken here. On the highlighted card the failure state loses its red, because `error` is 2.67:1 there. The signal survives in the icon and in the sentence "could not be read", which is the more robust cue anyway and the only one available to somebody who cannot distinguish the red. **`HomeScreen`'s top bar lost its override entirely.** `containerColor = primaryContainer` with `titleContentColor = primary` is `#000000` on `#1B1B1B`: **1.22:1**, a black title on a near-black bar. `TopAppBarDefaults` gives `surface`/`onSurface` and needed no help. **Three of the four `alpha = 0.5f` sites were not text, which changes what they failed.** The audit called them caption text; they are `CircularProgressIndicator` colours, so the threshold is 3:1 rather than 4.5:1. At 2.49:1 they fail either way, but the plan said the wrong thing and is corrected. The one that really is text -- `ArticleCard`'s published-at timestamp at `alpha = 0.7f`, 3.96:1 -- is the fourth. All five now use `onSurfaceVariant` at full opacity, 7.25:1, which is the role for secondary text and needed no alpha to become one. **The LIVE badge was a hand-mixed red.** `Color(0xFFE53935)` with a white label is 4.23:1, under the floor for `labelSmall`. `error`/`onError` is the role for a red that has to be read and is 6.46:1. **The avatar picker used a content colour as a background.** `onSurface` at 50% composited to a mid grey 2.49:1 from the unselected cells beside it -- so which emoji was selected was close to unreadable. Now `secondaryContainer`, M3's role for a selected item. Worth being straight about the limit: that role is 1.65:1 against the surface in this palette, which M3 accepts because its own selected states carry a second cue, an outline or a checkmark. This grid has neither. Adding one is component work, and the comment and the plan both say so rather than leaving it looking finished. **Three colours stay hardcoded, and each says why at the site.** A new `// m3-color-exempt: <reason>` marker, matching the spacing convention from phase 2, and the audit honours it: - `QRCodeView` -- a QR code is read by a camera. Scanners need maximum luminance contrast between the modules and their background, and under dynamic colour `onSurface`/`surface` could be two mid tones and unscannable. - `FullScreenImageViewer`'s close button -- it floats over an arbitrary photograph, so no role is safe behind it. A translucent scrim with white on it is M3's own full-screen media treatment and the only pairing that holds over both a white sky and a black one. - `LoadingAsyncImage`'s spinner, but only when a blurhash placeholder is behind it. With no placeholder the surface is known and the role is used. Exemptions belong at the call site: the reason travels with the code and a reviewer sees it in the diff that adds it, rather than in a list of file names in the audit script. **Two colours were tokenised without moving a pixel.** `Color.Black` on the blank route's `Surface` and on the image viewer's backdrop are both `scrim`, which is `#000000` in every one of this app's six schemes. Same bytes, and the value now travels with the theme. **A new assertion for the case the others structurally cannot catch.** A translucent container has no contrast ratio of its own -- it has one only once composited -- so `ColorSchemeContrastTest` grows an eleventh test that composites the two remaining tinted containers over `surface` and measures the result, in all six schemes, naming the call site in the failure. The pairings this commit *fixed* are not restated: once the proposal card derives its colours, the pair it produces is `onPrimaryContainer` on `primaryContainer`, which the first assertion already walks. **Audit budget for hardcoded colours ratcheted 9 -> 0**, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, up from 942/594/348. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:45:01 +02:00
# A colour that genuinely cannot come from a role -- a QR code's modules, a control
# floating over an arbitrary photograph -- is marked at the site with
# `// m3-color-exempt: <reason>` on the lines above it, the same convention
# m3-spacing-positions.py uses. `grep -A` pulls the following lines in so the marker
# above a literal suppresses it; the reason travels with the code rather than living
# in a list of file names in this script.
hardcoded_colours() {
grep -rE -A 8 'm3-color-exempt' "$UI" --include=*.kt 2>/dev/null \
| grep -E 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' \
| sed 's/^\([^-:]*\)[-:]/\1:/' | sort -u > /tmp/.m3-exempt-lines.$$
grep -rE 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' \
"$UI" --include=*.kt 2>/dev/null \
| grep -v "^$THEME/" | grep -vE "$NOT_A_COMMENT" \
| grep -vxFf /tmp/.m3-exempt-lines.$$ 2>/dev/null
rm -f /tmp/.m3-exempt-lines.$$
}
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
# Count matches across the UI tree, optionally excluding the theme package.
# $1 pattern, $2 "exclude-theme" | "all"
count() {
local pattern=$1 scope=${2:-all}
if [[ $scope == exclude-theme ]]; then
grep -rE "$pattern" "$UI" --include=*.kt 2>/dev/null \
fix: promote the two pill colours to extended roles, fixing both contrast failures Phase 1, step 5 of docs/material-design-conformance.md. `BluePill` and `RedPill` were raw `Color` values in `Color.kt`, paired at the call site with `Color.White` and `Color.DarkGray` by eye. Both pairings were below the 4.5:1 floor, and one of them was not the colour it looked like. **This step could not leave the pixels alone, and it is the only one so far that changes them.** `Color.DarkGray` on `BluePill` measures **2.90:1**. `RedPill` was `Color(230, 32, 32, 191)` -- the four-Int constructor, whose last argument is alpha, so it is `#E62020` at 0.749. Opaque, white on it is 4.57:1 and passes; composited over the surface as it actually renders, it is **3.50:1** and does not. Any correct version of these two buttons is a visible change, so "adds, does not restyle" does not apply here and the plan already said the call sites would move in this step. **What M3 asks for here is an extended colour**, not a literal: a brand colour promoted to a full role family -- `color` / `onColor` / `colorContainer` / `onColorContainer` -- so that contrast is a property of the family rather than a decision repeated at each use. `ColorFamily` was already declared in `Theme.kt`, unused, alongside an `unspecified_scheme`; Material Theme Builder emits both, and this is what they are for. **Derived by the same rule as the gold palette**, which the fixed-roles commit established and verified: maximum in-gamut chroma at the source colour's Lab hue, sampled at M3's role tones. BluePill's hue is 277.0 and RedPill's is 36.3. role light dark blue light red light color tone 40 tone 80 #0060AB #C00012 onColor tone 100 tone 20 #FFFFFF #FFFFFF colorContainer tone 90 tone 30 #D7E2FF #FFDAD3 onColorContainer tone 10 tone 90 #001C39 #390C00 The buttons take `color`/`onColor`: 6.46:1 for the red pill and 6.44:1 for the blue, from 2.90 and 3.50. **A side effect worth having.** At tone 40 the two pills are the same lightness, so they now read as a matched pair. Before, `#E62020` sat beside `#5D8DD6` -- a saturated red next to a soft periwinkle -- and the blue looked like the lesser option. On a screen whose whole content is "commit, or wipe and leave", weighting one choice by accident is a defect of its own. **They travel on a composition local, not on `isSystemInDarkTheme()`.** `ColorScheme` has no slot for extended colours, so `LocalExtendedColors` is provided by `TorchTheme` from the same `darkTheme` it chooses the scheme with. Reading `isSystemInDarkTheme()` at the call site would have been one line shorter and subtly wrong: it ignores a caller who passed `darkTheme` explicitly, so a preview forcing dark would show light pills. The local defaults to the light families rather than to `unspecified_scheme` -- nothing composes outside `TorchTheme` today, and an invisible button is a worse way to discover that than a light-themed one. **No medium- or high-contrast variants, deliberately.** The entire surface is two buttons on one screen, and the light family's weakest pair is 6.44:1 -- clear of the floor by more than the contrast schemes would add. 32 more values for that would be out of proportion, and the comment in `Color.kt` says so rather than leaving the omission to be read as an oversight. **`QRCodeView` lost its constructor default.** `QRCodeBackgroundPainter` defaulted `backgroundColor` to `BluePill` -- a colour picked outside the theme for a surface that is almost never seen, since at the default `padding = 0.dp` the logo painter covers the rect it fills. The default is gone and the one call site passes it, so the choice is visible rather than buried. **Two new assertions, one of which is about the constructor.** `ColorSchemeContrastTest` grows to 9. The first checks both pairs of every extended family at 4.5:1. The second checks that every extended role is **opaque**, because `RedPill`'s alpha is what made the first assertion insufficient: a translucent container has no ratio of its own -- it has one only once composited -- so a contrast test would have measured a colour the user never sees. That is the bug this commit fixes, and it would have passed a naive contrast test. **The audit stopped counting its own commentary.** Fixing these call sites left a comment *explaining* what `Color.White`/`Color.DarkGray` had been, and `m3-audit.sh` counted it as a hardcoded colour -- so the file stayed in the report after being fixed. The script now drops comment lines before counting. Budget ratcheted 11 -> 9: the two real sites, plus the false positive the filter removes. **Tests.** 930 pass, 588 jvm over 71 classes and 342 android over 43, up from 926/586/340. `:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build, `m3-audit.sh --check` exits 0. The nine remaining hardcoded colours are phase 3's, and are listed by the audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:23:31 +02:00
| grep -v "^$THEME/" | grep -vE "$NOT_A_COMMENT" | wc -l | tr -d ' '
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
else
fix: promote the two pill colours to extended roles, fixing both contrast failures Phase 1, step 5 of docs/material-design-conformance.md. `BluePill` and `RedPill` were raw `Color` values in `Color.kt`, paired at the call site with `Color.White` and `Color.DarkGray` by eye. Both pairings were below the 4.5:1 floor, and one of them was not the colour it looked like. **This step could not leave the pixels alone, and it is the only one so far that changes them.** `Color.DarkGray` on `BluePill` measures **2.90:1**. `RedPill` was `Color(230, 32, 32, 191)` -- the four-Int constructor, whose last argument is alpha, so it is `#E62020` at 0.749. Opaque, white on it is 4.57:1 and passes; composited over the surface as it actually renders, it is **3.50:1** and does not. Any correct version of these two buttons is a visible change, so "adds, does not restyle" does not apply here and the plan already said the call sites would move in this step. **What M3 asks for here is an extended colour**, not a literal: a brand colour promoted to a full role family -- `color` / `onColor` / `colorContainer` / `onColorContainer` -- so that contrast is a property of the family rather than a decision repeated at each use. `ColorFamily` was already declared in `Theme.kt`, unused, alongside an `unspecified_scheme`; Material Theme Builder emits both, and this is what they are for. **Derived by the same rule as the gold palette**, which the fixed-roles commit established and verified: maximum in-gamut chroma at the source colour's Lab hue, sampled at M3's role tones. BluePill's hue is 277.0 and RedPill's is 36.3. role light dark blue light red light color tone 40 tone 80 #0060AB #C00012 onColor tone 100 tone 20 #FFFFFF #FFFFFF colorContainer tone 90 tone 30 #D7E2FF #FFDAD3 onColorContainer tone 10 tone 90 #001C39 #390C00 The buttons take `color`/`onColor`: 6.46:1 for the red pill and 6.44:1 for the blue, from 2.90 and 3.50. **A side effect worth having.** At tone 40 the two pills are the same lightness, so they now read as a matched pair. Before, `#E62020` sat beside `#5D8DD6` -- a saturated red next to a soft periwinkle -- and the blue looked like the lesser option. On a screen whose whole content is "commit, or wipe and leave", weighting one choice by accident is a defect of its own. **They travel on a composition local, not on `isSystemInDarkTheme()`.** `ColorScheme` has no slot for extended colours, so `LocalExtendedColors` is provided by `TorchTheme` from the same `darkTheme` it chooses the scheme with. Reading `isSystemInDarkTheme()` at the call site would have been one line shorter and subtly wrong: it ignores a caller who passed `darkTheme` explicitly, so a preview forcing dark would show light pills. The local defaults to the light families rather than to `unspecified_scheme` -- nothing composes outside `TorchTheme` today, and an invisible button is a worse way to discover that than a light-themed one. **No medium- or high-contrast variants, deliberately.** The entire surface is two buttons on one screen, and the light family's weakest pair is 6.44:1 -- clear of the floor by more than the contrast schemes would add. 32 more values for that would be out of proportion, and the comment in `Color.kt` says so rather than leaving the omission to be read as an oversight. **`QRCodeView` lost its constructor default.** `QRCodeBackgroundPainter` defaulted `backgroundColor` to `BluePill` -- a colour picked outside the theme for a surface that is almost never seen, since at the default `padding = 0.dp` the logo painter covers the rect it fills. The default is gone and the one call site passes it, so the choice is visible rather than buried. **Two new assertions, one of which is about the constructor.** `ColorSchemeContrastTest` grows to 9. The first checks both pairs of every extended family at 4.5:1. The second checks that every extended role is **opaque**, because `RedPill`'s alpha is what made the first assertion insufficient: a translucent container has no ratio of its own -- it has one only once composited -- so a contrast test would have measured a colour the user never sees. That is the bug this commit fixes, and it would have passed a naive contrast test. **The audit stopped counting its own commentary.** Fixing these call sites left a comment *explaining* what `Color.White`/`Color.DarkGray` had been, and `m3-audit.sh` counted it as a hardcoded colour -- so the file stayed in the report after being fixed. The script now drops comment lines before counting. Budget ratcheted 11 -> 9: the two real sites, plus the false positive the filter removes. **Tests.** 930 pass, 588 jvm over 71 classes and 342 android over 43, up from 926/586/340. `:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build, `m3-audit.sh --check` exits 0. The nine remaining hardcoded colours are phase 3's, and are listed by the audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:23:31 +02:00
grep -rE "$pattern" "$UI" --include=*.kt 2>/dev/null \
| grep -vE "$NOT_A_COMMENT" | wc -l | tr -d ' '
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
fi
}
printf '\033[1mMaterial Design 3 conformance audit\033[0m\n'
printf 'tree: %s\n' "$(git rev-parse --short HEAD 2>/dev/null || echo 'not a git checkout')"
printf 'over: %s\n' "$UI"
# ---------------------------------------------------------------------------
hdr 'Colour (phases 1, 3)'
# Roles ColorScheme declares that Theme.kt never assigns. An unassigned role
# falls through to the Material baseline palette -- lavender, in a monochrome
# app -- so this is a defect count, not a style count.
declared=$(grep -oE '^\s{4}[a-zA-Z]+ = ' "$THEME/Theme.kt" 2>/dev/null \
| tr -d ' =' | sort -u)
# The 49 roles of androidx.compose.material3.ColorScheme, as of material3
# 1.10.0-alpha05. Hardcoded because the artifact is not on this script's path.
all_roles="primary onPrimary primaryContainer onPrimaryContainer inversePrimary
secondary onSecondary secondaryContainer onSecondaryContainer
tertiary onTertiary tertiaryContainer onTertiaryContainer
background onBackground surface onSurface surfaceVariant onSurfaceVariant
surfaceTint inverseSurface inverseOnSurface error onError errorContainer
onErrorContainer outline outlineVariant scrim surfaceBright surfaceDim
surfaceContainer surfaceContainerHigh surfaceContainerHighest
surfaceContainerLow surfaceContainerLowest
primaryFixed primaryFixedDim onPrimaryFixed onPrimaryFixedVariant
secondaryFixed secondaryFixedDim onSecondaryFixed onSecondaryFixedVariant
tertiaryFixed tertiaryFixedDim onTertiaryFixed onTertiaryFixedVariant"
# A role left unassigned takes lightColorScheme()'s default. For the twelve
# *Fixed* roles that default is ColorLightTokens.PrimaryFixed and friends --
# PaletteTokens.Primary90, #EADDFF -- so a monochrome app renders Material
# baseline lavender. For surfaceTint the default is `primary`, which is right.
# Only the first kind is a defect, so they are counted apart.
unset_baseline=0; unset_derived=0
baseline_list=""; derived_list=""
for role in $all_roles; do
echo "$declared" | grep -qx "$role" && continue
case $role in
*Fixed|*FixedDim|*FixedVariant)
unset_baseline=$((unset_baseline + 1)); baseline_list="$baseline_list $role" ;;
*)
unset_derived=$((unset_derived + 1)); derived_list="$derived_list $role" ;;
esac
done
report 'roles falling to the baseline palette' "$unset_baseline" "$BUDGET_UNSET_COLOR_ROLES"
[[ -n $baseline_list ]] && note "lavender:$baseline_list"
[[ -n $derived_list ]] && note "derived (not a defect):$derived_list"
fix: derive every call-site colour from its container, ending nine contrast failures Phase 3, first step, of docs/material-design-conformance.md. The generated palette was already sound -- every `onX`-on-`X` pair in all six schemes clears 4.5:1 -- and every failure in the app came from a colour reached for at the call site instead of derived from what it sits on. **The worst one made the app's most important rows invisible.** `ProposalListScreen` put a `ListItem` inside a `Card` and overrode only the card's container: Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) { ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), `cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, so `LocalContentColor` inside the card was correct. `ListItem` does not read `LocalContentColor`. Its headline comes from `ListTokens.ItemLabelTextColor`, which is `onSurface`, and in the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. Measured on that card: headline (onSurface) 1.00:1 invisible leading icon (primary) 1.22:1 supporting (onSurfaceVariant) 1.84:1 "could not be read" (error) 2.67:1 onPrimaryContainer 4.61:1 the only one that worked Four of five below the floor, and the card is applied to exactly `proposal.awaitsYou` -- the proposals waiting on your signature. Dark was fine throughout, because there `primaryContainer` is black, so this only ever showed in the light scheme. The card's colours are now computed once and everything inside derives from `cardColors.contentColor`: the six `ListItemColors` slots, the leading icon tint, the "Review" label, and the unreadable-count line. `primaryContainer` is kept as the highlight so this stays a fix rather than a restyle -- `secondaryContainer`, the brand gold, would read more like "this needs you", and that is a design call recorded in a comment rather than taken here. On the highlighted card the failure state loses its red, because `error` is 2.67:1 there. The signal survives in the icon and in the sentence "could not be read", which is the more robust cue anyway and the only one available to somebody who cannot distinguish the red. **`HomeScreen`'s top bar lost its override entirely.** `containerColor = primaryContainer` with `titleContentColor = primary` is `#000000` on `#1B1B1B`: **1.22:1**, a black title on a near-black bar. `TopAppBarDefaults` gives `surface`/`onSurface` and needed no help. **Three of the four `alpha = 0.5f` sites were not text, which changes what they failed.** The audit called them caption text; they are `CircularProgressIndicator` colours, so the threshold is 3:1 rather than 4.5:1. At 2.49:1 they fail either way, but the plan said the wrong thing and is corrected. The one that really is text -- `ArticleCard`'s published-at timestamp at `alpha = 0.7f`, 3.96:1 -- is the fourth. All five now use `onSurfaceVariant` at full opacity, 7.25:1, which is the role for secondary text and needed no alpha to become one. **The LIVE badge was a hand-mixed red.** `Color(0xFFE53935)` with a white label is 4.23:1, under the floor for `labelSmall`. `error`/`onError` is the role for a red that has to be read and is 6.46:1. **The avatar picker used a content colour as a background.** `onSurface` at 50% composited to a mid grey 2.49:1 from the unselected cells beside it -- so which emoji was selected was close to unreadable. Now `secondaryContainer`, M3's role for a selected item. Worth being straight about the limit: that role is 1.65:1 against the surface in this palette, which M3 accepts because its own selected states carry a second cue, an outline or a checkmark. This grid has neither. Adding one is component work, and the comment and the plan both say so rather than leaving it looking finished. **Three colours stay hardcoded, and each says why at the site.** A new `// m3-color-exempt: <reason>` marker, matching the spacing convention from phase 2, and the audit honours it: - `QRCodeView` -- a QR code is read by a camera. Scanners need maximum luminance contrast between the modules and their background, and under dynamic colour `onSurface`/`surface` could be two mid tones and unscannable. - `FullScreenImageViewer`'s close button -- it floats over an arbitrary photograph, so no role is safe behind it. A translucent scrim with white on it is M3's own full-screen media treatment and the only pairing that holds over both a white sky and a black one. - `LoadingAsyncImage`'s spinner, but only when a blurhash placeholder is behind it. With no placeholder the surface is known and the role is used. Exemptions belong at the call site: the reason travels with the code and a reviewer sees it in the diff that adds it, rather than in a list of file names in the audit script. **Two colours were tokenised without moving a pixel.** `Color.Black` on the blank route's `Surface` and on the image viewer's backdrop are both `scrim`, which is `#000000` in every one of this app's six schemes. Same bytes, and the value now travels with the theme. **A new assertion for the case the others structurally cannot catch.** A translucent container has no contrast ratio of its own -- it has one only once composited -- so `ColorSchemeContrastTest` grows an eleventh test that composites the two remaining tinted containers over `surface` and measures the result, in all six schemes, naming the call site in the failure. The pairings this commit *fixed* are not restated: once the proposal card derives its colours, the pair it produces is `onPrimaryContainer` on `primaryContainer`, which the first assertion already walks. **Audit budget for hardcoded colours ratcheted 9 -> 0**, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, up from 942/594/348. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:45:01 +02:00
hardcoded=$(hardcoded_colours | wc -l | tr -d ' ')
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
report 'hardcoded Color outside theme/' "$hardcoded" "$BUDGET_HARDCODED_COLOR"
fix: derive every call-site colour from its container, ending nine contrast failures Phase 3, first step, of docs/material-design-conformance.md. The generated palette was already sound -- every `onX`-on-`X` pair in all six schemes clears 4.5:1 -- and every failure in the app came from a colour reached for at the call site instead of derived from what it sits on. **The worst one made the app's most important rows invisible.** `ProposalListScreen` put a `ListItem` inside a `Card` and overrode only the card's container: Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) { ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), `cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, so `LocalContentColor` inside the card was correct. `ListItem` does not read `LocalContentColor`. Its headline comes from `ListTokens.ItemLabelTextColor`, which is `onSurface`, and in the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. Measured on that card: headline (onSurface) 1.00:1 invisible leading icon (primary) 1.22:1 supporting (onSurfaceVariant) 1.84:1 "could not be read" (error) 2.67:1 onPrimaryContainer 4.61:1 the only one that worked Four of five below the floor, and the card is applied to exactly `proposal.awaitsYou` -- the proposals waiting on your signature. Dark was fine throughout, because there `primaryContainer` is black, so this only ever showed in the light scheme. The card's colours are now computed once and everything inside derives from `cardColors.contentColor`: the six `ListItemColors` slots, the leading icon tint, the "Review" label, and the unreadable-count line. `primaryContainer` is kept as the highlight so this stays a fix rather than a restyle -- `secondaryContainer`, the brand gold, would read more like "this needs you", and that is a design call recorded in a comment rather than taken here. On the highlighted card the failure state loses its red, because `error` is 2.67:1 there. The signal survives in the icon and in the sentence "could not be read", which is the more robust cue anyway and the only one available to somebody who cannot distinguish the red. **`HomeScreen`'s top bar lost its override entirely.** `containerColor = primaryContainer` with `titleContentColor = primary` is `#000000` on `#1B1B1B`: **1.22:1**, a black title on a near-black bar. `TopAppBarDefaults` gives `surface`/`onSurface` and needed no help. **Three of the four `alpha = 0.5f` sites were not text, which changes what they failed.** The audit called them caption text; they are `CircularProgressIndicator` colours, so the threshold is 3:1 rather than 4.5:1. At 2.49:1 they fail either way, but the plan said the wrong thing and is corrected. The one that really is text -- `ArticleCard`'s published-at timestamp at `alpha = 0.7f`, 3.96:1 -- is the fourth. All five now use `onSurfaceVariant` at full opacity, 7.25:1, which is the role for secondary text and needed no alpha to become one. **The LIVE badge was a hand-mixed red.** `Color(0xFFE53935)` with a white label is 4.23:1, under the floor for `labelSmall`. `error`/`onError` is the role for a red that has to be read and is 6.46:1. **The avatar picker used a content colour as a background.** `onSurface` at 50% composited to a mid grey 2.49:1 from the unselected cells beside it -- so which emoji was selected was close to unreadable. Now `secondaryContainer`, M3's role for a selected item. Worth being straight about the limit: that role is 1.65:1 against the surface in this palette, which M3 accepts because its own selected states carry a second cue, an outline or a checkmark. This grid has neither. Adding one is component work, and the comment and the plan both say so rather than leaving it looking finished. **Three colours stay hardcoded, and each says why at the site.** A new `// m3-color-exempt: <reason>` marker, matching the spacing convention from phase 2, and the audit honours it: - `QRCodeView` -- a QR code is read by a camera. Scanners need maximum luminance contrast between the modules and their background, and under dynamic colour `onSurface`/`surface` could be two mid tones and unscannable. - `FullScreenImageViewer`'s close button -- it floats over an arbitrary photograph, so no role is safe behind it. A translucent scrim with white on it is M3's own full-screen media treatment and the only pairing that holds over both a white sky and a black one. - `LoadingAsyncImage`'s spinner, but only when a blurhash placeholder is behind it. With no placeholder the surface is known and the role is used. Exemptions belong at the call site: the reason travels with the code and a reviewer sees it in the diff that adds it, rather than in a list of file names in the audit script. **Two colours were tokenised without moving a pixel.** `Color.Black` on the blank route's `Surface` and on the image viewer's backdrop are both `scrim`, which is `#000000` in every one of this app's six schemes. Same bytes, and the value now travels with the theme. **A new assertion for the case the others structurally cannot catch.** A translucent container has no contrast ratio of its own -- it has one only once composited -- so `ColorSchemeContrastTest` grows an eleventh test that composites the two remaining tinted containers over `surface` and measures the result, in all six schemes, naming the call site in the failure. The pairings this commit *fixed* are not restated: once the proposal card derives its colours, the pair it produces is `onPrimaryContainer` on `primaryContainer`, which the first assertion already walks. **Audit budget for hardcoded colours ratcheted 9 -> 0**, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, up from 942/594/348. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:45:01 +02:00
[[ $hardcoded -gt 0 ]] && hardcoded_colours | cut -d: -f1 | sort -u | sed "s|$UI/| |"
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
alpha=$(count '\.copy\(alpha')
report 'colours derived with .copy(alpha =)' "$alpha" -1
# ---------------------------------------------------------------------------
hdr 'Spacing (phase 2)'
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
# 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'
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
# ---------------------------------------------------------------------------
hdr 'Typography (phase 1)'
typo_total=$(count 'MaterialTheme\.typography\.')
note "MaterialTheme.typography reads: $typo_total"
grep -rhoE 'MaterialTheme\.typography\.[a-zA-Z]+' "$UI" --include=*.kt 2>/dev/null \
| sed 's/.*typography\.//' | sort | uniq -c | sort -rn \
| awk '{printf " %-26s %s\n", $2, $1}'
label_uses=$(grep -rhoE 'MaterialTheme\.typography\.label[A-Za-z]*' "$UI" --include=*.kt 2>/dev/null | wc -l | tr -d ' ')
note "of which label* roles: $label_uses"
fontsize=$(count 'fontSize = [0-9]')
note "hardcoded fontSize: $fontsize"
# ---------------------------------------------------------------------------
hdr 'Targets and labels (phase 3)'
fix: give every tappable element a real target, and every icon a decided description Phase 3, second step, of docs/material-design-conformance.md. Two accessibility rules the tree had no way to hold: M3's 48x48dp touch target and 44x44dp pointer target, and its requirement that a decorative visual be *annotated* as decorative rather than merely left undescribed. **Nineteen `.clickable` chains had no minimum size, and three were text-sized.** `ArticleCard` and `LiveStreamCardContent` each make an author's name tappable -- a `labelMedium`, around 16dp tall -- and `LinkPreview` does the same to a `bodyLarge` url with 2dp of vertical padding. The other sixteen are cards, rows and full-screen boxes that are already far larger. `minimumInteractiveComponentSize()` is applied to all nineteen rather than to the three, because it is a no-op on anything already 48dp and that makes the rule checkable by a script instead of by measuring. Worth being precise about what it does, since the modifier is easy to describe wrongly: it reserves 48x48dp of **layout**, not of touch handling -- touch expansion happens at the input layer regardless. Layout is what keeps adjacent targets from overlapping, what satisfies M3's 8dp separation, and what a mouse pointer on the desktop build actually has to land on. **`Clickable.kt` had it built in and moved house.** The vendored ACINQ helper defaults to `RectangleShape` and `PaddingValues(0.dp)`, so a `Clickable` is exactly as big as its content -- and its call sites wrap a 20dp emoji and a row of wallet text. It now applies the modifier unconditionally, before `.padding(internalPadding)`, since a size modifier after it would re-impose the smaller constraint. It also stopped declaring `package com.machankura.compose.ui.composable.widgets.buttons` while living under `press/mantra/`. That is the second of the three package namespaces the UI was spread across; `Type.kt` was the first. **Eighteen `contentDescription = null` were indistinguishable from eighteen oversights.** `null` is the *correct* API -- M3 asks that decorative visuals be "annotated as decorative in order to hide them in code", and null is how that annotation is spelled in Compose. The problem is that it reads identically whether somebody decided or never looked. So `Decorative` is introduced -- a `String?` that is null -- and fifteen sites now say `contentDescription = Decorative`. Same bytes, same behaviour, and the difference between a decision and a gap is now visible in the source and countable by the audit. Each of the fifteen has adjacent text saying what the icon says: a lock beside "Private to Ada", a check beside "The group has a shared key.", an icon inside a button whose label is right there. **Three were not decorative and now carry their state.** - `DkgRitualScreen`'s participant list -- a filled or empty circle beside each member. The name says who; only the icon says whether they have contributed. Now "Contributed" / "Not yet contributed". - `DkgRitualScreen`'s round header -- the title says which round and the count says how far along; only the icon says whether it finished. Now "Complete" / "In progress". - `ProposalListScreen`'s leading icon, which is the one this commit could not have left alone: the previous commit took the red away from the failure state on the highlighted card, because `error` is 2.67:1 there. The shape is now the only cue a sighted user gets and the description is the only cue anyone else gets. Now "Awaiting your signature" / "Signed" / "Failed" / "Waiting on others". Descriptions follow M3's rule -- name the purpose, not the picture, and never the role. "Contributed", not "green check", and never "Contributed icon", since the role is added automatically and a screen reader would say it twice. **Two new checks, replacing one that was asking the wrong question.** `docs/scripts/m3-touch-targets.py` finds `.clickable` chains with no minimum size, including chains broken across two lines. The audit used to count `.clickable` outright, which is not a defect count: a clickable `Card` is fine and a clickable `Text` is not, and only the modifier tells them apart. The audit also now separates `contentDescription = null` (untriaged, budget 0) from `Decorative` (decided, reported at 15). Both budgets ratcheted to 0, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged -- these are layout and semantics properties, and this repo has no Compose UI test infrastructure to assert them against a running composition. What stands in for it is the two scripts, which check the property that *can* be checked statically: that the modifier and the decision are present at every site. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:52:15 +02:00
# Counting `.clickable` was never the question -- a clickable Card is fine and a
# clickable Text is not, and only the minimum-size modifier tells them apart.
targets_left=$(python3 docs/scripts/m3-touch-targets.py | grep -oE '[0-9]+$')
report 'clickable chains with no minimum target' "$targets_left" "$BUDGET_BARE_CLICKABLE"
note 'run docs/scripts/m3-touch-targets.py --list to see them'
# `null` and `Decorative` compile to the same thing; the difference is that one of them
# is a decision. Untriaged icons are the count that matters.
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
null_desc=$(count 'contentDescription = null')
fix: give every tappable element a real target, and every icon a decided description Phase 3, second step, of docs/material-design-conformance.md. Two accessibility rules the tree had no way to hold: M3's 48x48dp touch target and 44x44dp pointer target, and its requirement that a decorative visual be *annotated* as decorative rather than merely left undescribed. **Nineteen `.clickable` chains had no minimum size, and three were text-sized.** `ArticleCard` and `LiveStreamCardContent` each make an author's name tappable -- a `labelMedium`, around 16dp tall -- and `LinkPreview` does the same to a `bodyLarge` url with 2dp of vertical padding. The other sixteen are cards, rows and full-screen boxes that are already far larger. `minimumInteractiveComponentSize()` is applied to all nineteen rather than to the three, because it is a no-op on anything already 48dp and that makes the rule checkable by a script instead of by measuring. Worth being precise about what it does, since the modifier is easy to describe wrongly: it reserves 48x48dp of **layout**, not of touch handling -- touch expansion happens at the input layer regardless. Layout is what keeps adjacent targets from overlapping, what satisfies M3's 8dp separation, and what a mouse pointer on the desktop build actually has to land on. **`Clickable.kt` had it built in and moved house.** The vendored ACINQ helper defaults to `RectangleShape` and `PaddingValues(0.dp)`, so a `Clickable` is exactly as big as its content -- and its call sites wrap a 20dp emoji and a row of wallet text. It now applies the modifier unconditionally, before `.padding(internalPadding)`, since a size modifier after it would re-impose the smaller constraint. It also stopped declaring `package com.machankura.compose.ui.composable.widgets.buttons` while living under `press/mantra/`. That is the second of the three package namespaces the UI was spread across; `Type.kt` was the first. **Eighteen `contentDescription = null` were indistinguishable from eighteen oversights.** `null` is the *correct* API -- M3 asks that decorative visuals be "annotated as decorative in order to hide them in code", and null is how that annotation is spelled in Compose. The problem is that it reads identically whether somebody decided or never looked. So `Decorative` is introduced -- a `String?` that is null -- and fifteen sites now say `contentDescription = Decorative`. Same bytes, same behaviour, and the difference between a decision and a gap is now visible in the source and countable by the audit. Each of the fifteen has adjacent text saying what the icon says: a lock beside "Private to Ada", a check beside "The group has a shared key.", an icon inside a button whose label is right there. **Three were not decorative and now carry their state.** - `DkgRitualScreen`'s participant list -- a filled or empty circle beside each member. The name says who; only the icon says whether they have contributed. Now "Contributed" / "Not yet contributed". - `DkgRitualScreen`'s round header -- the title says which round and the count says how far along; only the icon says whether it finished. Now "Complete" / "In progress". - `ProposalListScreen`'s leading icon, which is the one this commit could not have left alone: the previous commit took the red away from the failure state on the highlighted card, because `error` is 2.67:1 there. The shape is now the only cue a sighted user gets and the description is the only cue anyone else gets. Now "Awaiting your signature" / "Signed" / "Failed" / "Waiting on others". Descriptions follow M3's rule -- name the purpose, not the picture, and never the role. "Contributed", not "green check", and never "Contributed icon", since the role is added automatically and a screen reader would say it twice. **Two new checks, replacing one that was asking the wrong question.** `docs/scripts/m3-touch-targets.py` finds `.clickable` chains with no minimum size, including chains broken across two lines. The audit used to count `.clickable` outright, which is not a defect count: a clickable `Card` is fine and a clickable `Text` is not, and only the modifier tells them apart. The audit also now separates `contentDescription = null` (untriaged, budget 0) from `Decorative` (decided, reported at 15). Both budgets ratcheted to 0, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged -- these are layout and semantics properties, and this repo has no Compose UI test infrastructure to assert them against a running composition. What stands in for it is the two scripts, which check the property that *can* be checked statically: that the modifier and the decision are present at every site. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:52:15 +02:00
report 'contentDescription = null (untriaged)' "$null_desc" "$BUDGET_NULL_DESCRIPTION"
note "marked Decorative: $(count 'contentDescription = Decorative')"
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
icons=$(count 'Icon\(')
note "Icon( call sites: $icons"
min_size=$(count 'minimumInteractiveComponentSize')
note "minimumInteractiveComponentSize: $min_size"
centred=$(count 'TextAlign\.Center')
note "TextAlign.Center: $centred"
# ---------------------------------------------------------------------------
hdr 'Content (phase 4)'
# A file whose strings are sample text rather than UI text -- ThemeGallery, whose whole
# job is to render colour pairings and whose words are chosen to be words -- marks itself
# once at the top with `// m3-string-exempt: <reason>`. Per file rather than per line,
# because the exemption is a property of what the file is for, and eight markers down one
# gallery would say less than one at the top of it.
#
# The same shape as `m3-color-exempt` and `m3-spacing-exempt`: the reason travels with the
# code rather than living in a list of file names here.
sample_text_files() {
grep -rlE 'm3-string-exempt' "$UI" --include=*.kt 2>/dev/null
}
string_literals() {
local exempt
exempt=$(sample_text_files | sed 's|^|^|' )
if [[ -z $exempt ]]; then
grep -rE 'text = "|Text\("' "$UI" --include=*.kt 2>/dev/null | grep -vE "$NOT_A_COMMENT"
else
grep -rE 'text = "|Text\("' "$UI" --include=*.kt 2>/dev/null | grep -vE "$NOT_A_COMMENT" \
| grep -vE "$(sample_text_files | paste -sd'|' -)"
fi
}
literals=$(string_literals | wc -l | tr -d ' ')
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
report 'string literals in composables' "$literals" "$BUDGET_STRING_LITERALS"
note "sample-text files exempt: $(sample_text_files | wc -l | tr -d ' ')"
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
res=$(count 'stringResource|Res\.string')
note "stringResource / Res.string: $res"
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
# Delegated, because the grep version was wrong twice: it required every word after the
# first to be capitalised (missing "Invite a Friend") and it read one line at a time
# (missing a `Text(` whose literal was on the next). It also counted sample data --
# "Steve Biko" is title case because that is how a name is written.
title_case=$(python3 docs/scripts/m3-title-case.py | grep -oE '[0-9]+$')
report 'Title Case in UI strings' "$title_case" "$BUDGET_TITLE_CASE"
note 'run docs/scripts/m3-title-case.py --list to see them'
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
# ---------------------------------------------------------------------------
hdr 'States and feedback (phase 5)'
scaffolds=$(count '(^|[^A-Za-z])Scaffold\(')
snackbars=$(count 'Snackbar|SnackbarHost')
note "Scaffold( call sites: $scaffolds"
note "Snackbar / SnackbarHost: $snackbars"
went_wrong=$(count '"Something went wrong"')
note '"Something went wrong" sites: '"$went_wrong"
for c in FilledTonalButton OutlinedButton ElevatedButton Button TextButton; do
n=$(grep -rhoE "\b$c\(" "$UI" --include=*.kt 2>/dev/null | wc -l | tr -d ' ')
note "$(printf '%-38s' "$c:")$n"
done
# ---------------------------------------------------------------------------
hdr 'Adaptive and motion (phases 6, 7)'
docs: record what phase 6 built, and give the audit a floor to defend it The plan's phase 6 becomes a record rather than a proposal, in the shape the earlier phases took: what was built, what was decided and why, what a person still has to look at. Two decisions in it were the product owner's rather than the code's -- promoting search and profile to navigation destinations, and doing chat alone rather than all three list-detail families -- and both are named as such with the date. **The audit learns two things.** It counted `NavigationBar(`, `NavigationRail(` and friends, and reported **zero** for an app that had just grown a navigation bar: `NavigationSuiteScaffold` is what chooses between them per breakpoint, and the concrete component never appears in the source. It now counts the scaffold and its items. And it grew a `floor()` beside `report()`. Every other budget in the file is a ceiling that ratchets down as a phase lands, which is the right shape for literals, hardcoded colours and untriaged nulls -- things a careless edit *adds*. The adaptive work is the opposite: a screen that stops reading the breakpoint still compiles and still renders, and the count goes down. So `--check` now also fails when the adaptive API count drops below 12 or the navigation component count below 2. **Two `contentDescription = null` that the audit caught in this phase's own work** -- the navigation item's icon and the new-chat button's -- now say `Decorative`. Same null, and the same convention phase 3 established: recording that somebody looked is the whole point, and a budget of zero only holds if new code obeys it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:04:00 +02:00
adaptive=$(count 'WindowSizeClass|currentWindowAdaptiveInfo|currentWindowDpSize|NavigationSuiteScaffold|ListDetailPaneScaffold|SupportingPaneScaffold|BoxWithConstraints|MaterialTheme\.breakpoint|listPaneWidthFor')
floor "adaptive APIs in use" "$adaptive" "$FLOOR_ADAPTIVE_APIS"
# NavigationSuiteScaffold rather than the components themselves: it is what phase 6
# uses, and it chooses between ShortNavigationBar, WideNavigationRail collapsed and
# WideNavigationRail expanded per breakpoint. Counting only the concrete components
# reported zero for an app that had just grown a navigation bar.
nav=$(count 'NavigationBar\(|NavigationRail\(|WideNavigationRail\(|ShortNavigationBar\(|NavigationSuiteScaffold\(|NavigationSuiteItem\(')
floor "navigation components" "$nav" "$FLOOR_NAVIGATION_COMPONENTS"
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
motion=$(count 'AnimatedVisibility|AnimatedContent|Crossfade|MotionScheme|updateTransition')
note "motion APIs in use: $motion"
transitions=$(count 'enterTransition|exitTransition|popEnterTransition')
note "navigation transitions: $transitions"
# ---------------------------------------------------------------------------
printf '\n'
if [[ ${1:-} == --check ]]; then
if (( fail_count > 0 )); then
printf '\033[31m%s budget(s) exceeded.\033[0m See docs/material-design-conformance.md.\n' "$fail_count"
exit 1
fi
printf '\033[32mAll budgets met.\033[0m\n'
fi
exit 0