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
|
|
|
# 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.
|
|
|
|
|
|
docs: record what phase 8 built, and what is left for a person across all nine
The plan's last phase becomes a record, and the document gains a closing status:
every count the audit was written to move, from the state in "Where this app
stands" to what `m3-audit.sh` reports today, and a gathered list of what a person
still has to look at — the eight screens with competing filled buttons, the two
list-detail families the pane work did not reach, the container transform, desktop
keyboard traversal, and the avatar picker's selected state.
**The audit caught the previous commit.** `ThemeGallery` added eight string
literals in composables, taking the count 39 -> 47, which is exactly the drift the
budget exists to notice. They are sample text — the words are chosen to be words,
so that colour pairings can be looked at — and putting them in the catalogue would
add eight entries no screen shows and a translator would have to be told to
ignore.
So the audit grows a third exemption marker beside `m3-color-exempt` and
`m3-spacing-exempt`: `m3-string-exempt`, per file rather than per line, because
the exemption is a property of what the file is for and eight markers down one
gallery would say less than one at the top of it. Back to 39, and the report now
says how many files are exempt so the mechanism cannot be used quietly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:26:56 +02:00
|
|
|
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
|
|
|
|
|
```
|
|
|
|
|
|
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
|
|
|
**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.
|
|
|
|
|
|
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
|
|
|
## 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.
|