Files
mantra-kmp/CLAUDE.md

204 lines
8.8 KiB
Markdown
Raw Normal View History

# 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.
A file whose strings are sample text rather than UI text — a gallery of colour pairings,
say — marks itself once at the top:
```kotlin
// m3-string-exempt: these words are sample text for looking at colour pairings
```
**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`.
fix(ui): a back button on every pushed screen, and one spelling of it Eighteen screens drew a back arrow in their app bar's leading slot and twenty-two pushed screens did not. On Android the system back stood in for it and on iOS the edge swipe, but the desktop target has neither, and a screen reached by `navigate(...)` with no way to pop it is a dead end there: seven group forms, six chat screens, five DKG screens, and four screens with no app bar at all. **One widget, `NavigateBackButton`, rather than a nineteenth inline copy.** The eighteen that had the button spelled it four ways -- `Icons.Filled.ArrowBack`, the auto-mirrored one, `ArrowBackIosNew`, and a `"Back"` literal against a `stringResource`. The widget decides twice: the icon is the auto-mirrored one, because "back" points at the leading edge and the leading edge is on the right in an RTL locale, which is what `Icons.Filled.ArrowBack` is deprecated for; and the description is the catalogue's, because it is text. All forty-one sites use it now, the eighteen converted mechanically with the imports they no longer need dropped. **Each screen takes `onNavigateBack` and the host passes `popBackStack()`.** Hoisted rather than read from a controller in the screen, so every one stays previewable and testable without a nav host, which is how the twenty-two were fixed without a nav host in a single test. **Four screens had no bar to put it in, and got one.** `ChatRoomCreationScreen` is "New chat", after the button that opens it. `CreateProfileScreen` is "Create profile", and the two body headlines that repeated the name are gone, which is the shape `SignInScreen` beside it on the landing page already has. `WriteNewNoteScreen` is "New note" whether it is a reply, a quote or neither, one new string. The "coming soon" placeholder's bar is titled with the name of what was tapped. `AddMemberToChatRoomConfirmationScreen`'s bar had been commented out; it is back, titled "Invite new member" since the body already names who and which room. `NostrEventDetailScreen`'s repost and unknown-kind branches were the last two placeholders without a bar. **Two screens are reached two ways, and only the caller knows which.** Their callback is nullable, and null draws no button. `ChatRoomMessagingScreen` is a destination on a phone and the home screen's detail pane in an expanded window; in the pane the room list is beside the transcript and a button that popped would pop the home screen, so the pane passes null. `ImplementationPendingScreen` is pushed from "learn more" and "edit profile" and is also where the navigation observer lands with `popUpTo(0)` on an error; the host reads `previousBackStackEntry`, remembered at first composition because the departing screen reads it again after the stack has moved on. The DKG approval screens reuse their existing `onDone`, which "Not now" already called -- the bar makes the same leave reachable from the loading and error states, which had no other way off. **Left alone on purpose.** The three top-level destinations have the navigation bar, which phase 6 put there instead of app-bar icons. The onboarding and loading screens cleared the stack to get where they are and have nothing under them. `SearchScreen` and `SearchResultScreen` keep their `ArrowBackIosNew`: it is a search bar's collapse control in a `leadingIcon` slot, not a navigation icon. `NavigateBackButtonJvmTest` covers the two conditional screens by the description a screen reader would announce: present and popping when pushed, absent in the pane and at the root. The rule is recorded in CLAUDE.md beside the others a new screen has to follow. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (938 tests, 4 new) and docs/scripts/m3-audit.sh --check, all budgets met. Replayed onto Mantra by docs/curated-to-mantra.md: CuratedSuggestionListScreen.kt: taken as the original merge fedbe724 left it, this being the branch join; AcceptCuratedSuggestionScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts; BroadcastGroupSignedEventScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@0634487e120975156f5d3720e2d6ef9f82e38cf1
2026-09-12 13:55:42 +02:00
## A pushed screen has a way back
```kotlin
TopAppBar(
title = { Text(stringResource(Res.string.add_post)) },
navigationIcon = { NavigateBackButton(onNavigateBack) }, // yes
)
TopAppBar(title = { … }) // no, on a pushed screen
```
A screen reached by `navController.navigate(...)` takes an `onNavigateBack: () -> Unit`
and puts `NavigateBackButton` in its app bar's leading slot; the host passes
`popBackStack()`. Android has a system back and iOS an edge swipe, but the desktop target
has neither, and a pushed screen with no button there is a dead end. Twenty-two were.
The button is *only* for pushed screens. The three top-level destinations have the
navigation bar instead, and a screen that cleared the stack to get there -- onboarding,
the loading gate -- has nothing under it to pop to. Where one composable is reached both
ways, the callback is nullable and the caller decides: `ChatRoomMessagingScreen` is a
destination on a phone and the home screen's detail pane in a wide window, and in the
pane the list beside it is the way back.
## 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.