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>
This commit is contained in:
Kgothatso Ngako
2026-09-08 08:21:18 +02:00
parent 9f09f48133
commit 9946f98ca8

174
CLAUDE.md Normal file
View File

@@ -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<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
```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.