Files
mantra-kmp/CLAUDE.md
Kgothatso Ngako 9946f98ca8 docs: write the UI conventions down where they are needed
Phase 8, item four. This repository had no `CLAUDE.md` at all, so eight phases of
decisions lived only in `docs/material-design-conformance.md` -- a 980-line
account that is the right place to explain *why* and the wrong place to look
while writing a screen.

Seven rules, each with the shape to copy, the shape not to, and the budget the
audit holds it to: spacing from the scale, colour from a role, text from the
catalogue in sentence case, 48dp targets with decided icon descriptions, four
screen states with a transition between them, layout that reads the window rather
than the composable, motion from the scheme.

Where a rule has a trap that has already caught somebody, the trap is named rather
than the rule restated: `.copy(alpha = …)` on a content role is how nine contrast
failures got in; Compose Resources unescapes `\n` but not `\'`; `AnimatedContent`
is a layout node and takes a `when`'s branches out of `ColumnScope`;
`MotionSchemeKeyTokens` is internal and cannot be reached from an app;
`rememberNotifier` and `stringResource` are composable and an `onClick` is not.

Every API named in it was checked to exist, and the notifier example was corrected
after the first draft got its signature wrong -- it takes the caller's scope, so
that a message shown as a screen navigates away is not cancelled with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:21:18 +02:00

7.6 KiB

Conventions for UI code

Eight phases of work brought this app's 43 screens onto Material Design 3; the full account, with the numbers and the reasoning, is in docs/material-design-conformance.md. What follows is the short version — the rules a new screen has to follow, in the place they are needed, which is while it is being written rather than while it is being reviewed.

Most of these are enforced. ./gradlew check runs docs/scripts/m3-audit.sh --check, which fails on a budget that has been exceeded or a floor that has been undercut. Where a rule below has a number beside it, that number is the budget.

Spacing comes from the scale — never a .dp literal

Modifier.padding(MaterialTheme.spacing.containerPadding)   // yes
Modifier.padding(16.dp)                                    // no

M3's eighteen stops live on Spacing, with eight semantic names over them — screenMargin, containerPadding, compactPadding, relatedGap, itemGap, sectionGap, emphasisGap, targetGap, paneGap. Reach for a semantic name first and a raw stop (space125) only when none of them says the job.

The semantic names are what adapt: screenMargin widens from 16dp to 24dp at the medium breakpoint without a call site changing. That is the whole reason the scale exists rather than a file of constants.

Budget: 0 dp literals in spacing positions. A .dp in a dimension position — an avatar's size, a hairline border — is fine and is counted separately.

Colour comes from a role — never a Color(0x…)

MaterialTheme.colorScheme.onSurfaceVariant                    // yes
MaterialTheme.extendedColors.bluePill.onContainer             // yes, for the brand pair
Color(0xFF888888)                                             // no
onSurfaceVariant.copy(alpha = 0.5f)                           // almost never

Six schemes are declared — light and dark, each with medium and high contrast variants — and the platform's contrast setting selects between them. A colour written at a call site belongs to none of them and will be wrong in five.

.copy(alpha = …) on a content role is how nine contrast failures got in: an alpha over an unknown background has no ratio until it is composited, and the composite is usually under 4.5:1. The exception M3 states is the 38% disabled state.

Where a colour genuinely cannot come from a role — a QR code's modules, a control over an arbitrary photograph — mark it at the site:

// m3-color-exempt: the modules of a QR code have to be pure black on pure white

Budget: 0 hardcoded colours outside ui/theme/. ColorSchemeContrastTest measures every pair in all six schemes; it runs in :composeApp:jvmTest.

Text comes from the catalogue, in sentence case

Text(stringResource(Res.string.publish_new_key_package))   // yes
Text("Publish New Key Package")                            // no, twice over

Strings live in composeApp/src/commonMain/composeResources/values/strings.xml. Interpolation is a format argument (%1$s), not a "${…}".

Capitalisation is sentence case everywhere — titles, headings, labels, menu items, buttons — which is M3's rule and not a preference. Proper nouns keep their capitals.

Compose Resources is not aapt: it does not unescape \' and does not collapse %%, though it does process \n. StringCatalogueJvmTest asserts each escape the app depends on; add to it rather than assuming a family rule.

Budget: 0 title-case strings. Literals in composables are reported without a budget — 39 remain, all of them terms of a + concatenation.

Every target is 48dp, and every icon has a decided description

Modifier.clickable {  }.minimumInteractiveComponentSize()   // yes
Icon(Icons.Default.Search, contentDescription = "Search")     // yes
Icon(Icons.Default.Add, contentDescription = Decorative)      // yes, when the label is beside it
Icon(Icons.Default.Add, contentDescription = null)            // no — say which

IconButton and FilledIconButton enforce 48dp themselves; a bare Modifier.clickable does not, and three of the app's nineteen were text-sized before this rule.

Decorative is the same null the compiler sees, and it records that somebody looked. An icon carrying state the surrounding text does not repeat needs a real description.

Budgets: 0 unguarded .clickable, 0 untriaged contentDescription = null.

A screen has four states, and says so

Loading, empty, error, loaded. ErrorState, EmptyState and LoadingDataIndicator are the shared ones; ErrorState takes an onRetry, and passing null is a decision rather than a default. EmptyState's message is required, because one shared default is how five different absences all came to say "No events were found".

Report outcomes through the snackbar host:

val notify = rememberNotifier(rememberCoroutineScope())
val published = stringResource(Res.string.key_package_published)   // read outside the handler

onClick = { viewModel.publish { notify(published) } }

Both rememberNotifier and stringResource are composable and an onClick lambda is not, so read them above the handler. The notifier takes the caller's scope on purpose: "saved" is usually shown as the screen navigates away, and a message launched in the departing composable's scope would be cancelled with it.

Wrap the state when so the change is a transition rather than a cut:

ScreenStateTransition(viewModel.uiState) { uiState ->
    when (val state = uiState) {  }
}

It only works where the when is the composable's whole body — AnimatedContent is a layout node, so wrapping one inside a Column takes its branches out of ColumnScope.

Layout adapts to the window, not to the composable

Modifier.padding(innerPadding).readableContent()   // on every screen's content root
MaterialTheme.breakpoint.isAtLeast(Breakpoint.Expanded)

readableContent() holds content to sixty characters of bodyLarge — derived from the type scale, so it follows the reader's text size — and centres the column, not the text. Centring text loses the leading edge that rows, avatars and icons align to; centre a block only when it is the only thing on the screen.

Two panes from Breakpoint.Expanded up and never below, which is M3's rule for dense content and also what calculatePaneScaffoldDirective does. listPaneWidthFor gives the width.

Read the window through MaterialTheme.breakpoint, not the local constraints. A pane 300dp wide inside a 1400dp window is still in a large layout.

Floors: at least 12 adaptive API uses, at least 2 navigation components. These regress by being removed, so the audit checks them from below.

Motion comes from the scheme

MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()   // things that move
MaterialTheme.motionScheme.defaultEffectsSpec<Float>()       // things that fade
tween(300)                                                   // no

MotionSchemeKeyTokens is internal to material3 and cannot be reached from here; MaterialTheme.motionScheme is the public surface. Honour MaterialTheme.reducedMotion — it means drop the movement, not the transition.

Checking your work

./gradlew :composeApp:m3Audit
./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest

docs/scripts/ also holds the tools each phase was done with — m3-spacing-positions.py, m3-touch-targets.py, m3-title-case.py and the two string extractors — each of which takes --list to show the sites rather than the count.