From 9946f98ca8e0debdf9ef7426dafd0aa4644b3c71 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 08:21:18 +0200 Subject: [PATCH] docs: write the UI conventions down where they are needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..28cea88f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# 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](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 + +```kotlin +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…)` + +```kotlin +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: + +```kotlin +// 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 + +```kotlin +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 + +```kotlin +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: + +```kotlin +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: + +```kotlin +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 + +```kotlin +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 + +```kotlin +MaterialTheme.motionScheme.defaultSpatialSpec() // things that move +MaterialTheme.motionScheme.defaultEffectsSpec() // 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 + +```bash +./gradlew :composeApp:m3Audit +``` + +```bash +./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.