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

240 lines
11 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.
# ---------------------------------------------------------------------------
BUDGET_HARDCODED_COLOR=11 # phase 3 drives to 0 outside theme/
BUDGET_DP_LITERALS=-1 # phase 2 drives to ~0 outside theme/
BUDGET_OFF_SCALE_DP=-1 # phase 2 drives to 0
BUDGET_BARE_CLICKABLE=33 # phase 3 drives to 0
BUDGET_NULL_DESCRIPTION=18 # phase 3 triages each one
BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10
BUDGET_TITLE_CASE=-1 # phase 4 drives to 0
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
# The M3 spacing scale: docs/material-design-conformance.md, "The numbers".
# space0..space900. Anything outside this set is off-scale.
ON_SCALE=(0 2 4 6 8 10 12 14 16 20 24 32 36 40 48 56 64 72)
# Dimensions rather than spacing -- an avatar, an image height, a hairline
# border. These are exempt from the off-scale count; keep the list short and
# justify additions in the commit that makes them.
DIMENSION_EXEMPT=(1 80 128 180 200 500)
fail_count=0
hdr() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
note() { printf ' %s\n' "$1"; }
# 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
}
# 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 \
| grep -v "^$THEME/" | wc -l | tr -d ' '
else
grep -rE "$pattern" "$UI" --include=*.kt 2>/dev/null | wc -l | tr -d ' '
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"
hardcoded=$(count 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' exclude-theme)
report 'hardcoded Color outside theme/' "$hardcoded" "$BUDGET_HARDCODED_COLOR"
[[ $hardcoded -gt 0 ]] && grep -rEln 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' \
"$UI" --include=*.kt | grep -v "^$THEME/" | sed "s|$UI/| |"
alpha=$(count '\.copy\(alpha')
report 'colours derived with .copy(alpha =)' "$alpha" -1
# ---------------------------------------------------------------------------
hdr 'Spacing (phase 2)'
dp_all=$(grep -rhoE '\b[0-9]+\.dp' "$UI" --include=*.kt 2>/dev/null \
| grep -v "^$THEME/" | wc -l | tr -d ' ')
dp_outside_theme=$(grep -rhoE '\b[0-9]+\.dp' \
$(grep -rl '\.dp' "$UI" --include=*.kt 2>/dev/null | grep -v "^$THEME/") \
2>/dev/null | wc -l | tr -d ' ')
report '.dp literals outside theme/' "$dp_outside_theme" "$BUDGET_DP_LITERALS"
# Split the histogram into on-scale, exempt dimensions, and off-scale.
declare -A hist
while read -r n; do
hist[$n]=$(( ${hist[$n]:-0} + 1 ))
done < <(grep -rhoE '\b[0-9]+\.dp' \
$(grep -rl '\.dp' "$UI" --include=*.kt 2>/dev/null | grep -v "^$THEME/") \
2>/dev/null | sed 's/\.dp//')
on_scale_total=0; off_scale_total=0; exempt_total=0; off_scale_detail=""
for n in "${!hist[@]}"; do
c=${hist[$n]}
if printf '%s\n' "${ON_SCALE[@]}" | grep -qx "$n"; then
on_scale_total=$((on_scale_total + c))
elif printf '%s\n' "${DIMENSION_EXEMPT[@]}" | grep -qx "$n"; then
exempt_total=$((exempt_total + c))
else
off_scale_total=$((off_scale_total + c))
off_scale_detail="$off_scale_detail ${n}dp:${c}"
fi
done
note "on the M3 scale: $on_scale_total"
note "exempt dimensions: $exempt_total"
report 'off the M3 spacing scale' "$off_scale_total" "$BUDGET_OFF_SCALE_DP"
[[ -n $off_scale_detail ]] && note "off-scale:$off_scale_detail"
spacer_idiom=$(count 'height\(50\.dp\)')
note "Spacer(height(50.dp)) idiom: $spacer_idiom"
# ---------------------------------------------------------------------------
hdr 'Typography (phase 1)'
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)'
clickable=$(count '\.clickable')
report 'bare Modifier.clickable' "$clickable" "$BUDGET_BARE_CLICKABLE"
null_desc=$(count 'contentDescription = null')
report 'contentDescription = null' "$null_desc" "$BUDGET_NULL_DESCRIPTION"
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)'
literals=$(( $(count 'text = "') + $(count 'Text\("') ))
report 'string literals in composables' "$literals" "$BUDGET_STRING_LITERALS"
res=$(count 'stringResource|Res\.string')
note "stringResource / Res.string: $res"
title_case=$(grep -rhoE '"[A-Z][a-z]+( [A-Z][a-z]+)+"' "$UI" --include=*.kt 2>/dev/null | sort -u | wc -l | tr -d ' ')
report 'distinct Title Case strings' "$title_case" "$BUDGET_TITLE_CASE"
note 'includes preview sample data (person names); phase 4 triages'
# ---------------------------------------------------------------------------
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)'
adaptive=$(count 'WindowSizeClass|currentWindowAdaptiveInfo|NavigationSuiteScaffold|ListDetailPaneScaffold|SupportingPaneScaffold|BoxWithConstraints')
note "adaptive APIs in use: $adaptive"
nav=$(count 'NavigationBar\(|NavigationRail\(|WideNavigationRail\(|ShortNavigationBar\(')
note "navigation components: $nav"
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