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:
174
CLAUDE.md
Normal file
174
CLAUDE.md
Normal 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.
|
||||
Reference in New Issue
Block a user