Files
mantra-kmp/docs/scripts/m3-audit.sh
Kgothatso Ngako 47bdb6f976 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

248 lines
11 KiB
Bash
Executable File

#!/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=9 # phase 3 drives to 0 outside theme/
BUDGET_DP_LITERALS=-1 # phase 2 drives to ~0 outside theme/
BUDGET_OFF_SCALE_DP=-1 # phase 2 drives to 0
BUDGET_BARE_CLICKABLE=33 # phase 3 drives to 0
BUDGET_NULL_DESCRIPTION=18 # phase 3 triages each one
BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10
BUDGET_TITLE_CASE=-1 # phase 4 drives to 0
BUDGET_UNSET_COLOR_ROLES=0 # phase 1: reached 2026-09-07
# The M3 spacing scale: docs/material-design-conformance.md, "The numbers".
# space0..space900. Anything outside this set is off-scale.
ON_SCALE=(0 2 4 6 8 10 12 14 16 20 24 32 36 40 48 56 64 72)
# Dimensions rather than spacing -- an avatar, an image height, a hairline
# border. These are exempt from the off-scale count; keep the list short and
# justify additions in the commit that makes them.
DIMENSION_EXEMPT=(1 80 128 180 200 500)
fail_count=0
hdr() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
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
}
# 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:]]*(//|\*|/\*)'
# 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/" | grep -vE "$NOT_A_COMMENT" | wc -l | tr -d ' '
else
grep -rE "$pattern" "$UI" --include=*.kt 2>/dev/null \
| grep -vE "$NOT_A_COMMENT" | 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"
# No -n, so the line is path:content and NOT_A_COMMENT's single-colon prefix matches.
[[ $hardcoded -gt 0 ]] && grep -rE 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' \
"$UI" --include=*.kt | grep -v "^$THEME/" | grep -vE "$NOT_A_COMMENT" \
| cut -d: -f1 | sort -u | 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