A living document. It records the calibrations, the rhythm and the agreements about the interface, and is updated whenever something is settled.
Material icons from androidx.compose.material.icons.outlined.* share a nominal size — a 24×24 grid — but the drawn part differs from one to the next. Give them all a plain Modifier.size(24.dp)and some will look big and heavy (Bolt, Delete) while others look tiny (Contacts, VpnKey). They need calibrating by hand.
new_size = 24 × 52 / measured_px, clipped to [18..28].52 px is the reference painted glyph, Fingerprint; everything is measured against it as the unit.
| Icon | Size | Why |
|---|---|---|
| Add, Apps, ArrowBack, Close | 28dp | thin lines, a glyph of 36–41 px |
| AutoMode, Bolt, DeleteOutline, Edit, List, Photo, QrCode, Tune, DriveFileRenameOutline | 26dp | medium density, a glyph of about 47 px |
| LocationOn | 24dp | the map pin — do not touch, it is a reference |
| LocationOff | 26dp | the crossed-out pin — do not touch |
| ContentCopy, MyLocation, PeopleAlt, Wifi, ExpandMore, ExpandLess | 22dp | wide or tall, and visually heavy |
| Contacts, VpnKey | 20dp | overloaded with internal detail |
Used everywhere Bolt used to be — the Automation button on a key card, the section icon in settings. Size 26 dp, the same as Bolt.
IconsDebugSheet.kt — the map iconSizeByName. The debug screen is opened from SettingsScreen with the "UI icons (debug)" button: a FlowRow of calibrated icons on top and a list of names, sizes and contexts below.
We use the standard Material 3 roles, deliberately limited to four:
| Role | Size / line height | Where it is used |
|---|---|---|
titleLarge SemiBold | 22sp/28sp | The bottom sheet title ("For the guest · <object>") |
titleMedium SemiBold | 16sp/24sp | Rule headings ("Geolocation", "Wi-Fi", "Interval", "Activation") and FieldSection labels ("Coordinates", "Radius", "Mode", "Saved networks") |
bodyMedium | 14sp/20sp | Radio button captions, the text inside pill buttons, segment labels |
bodySmall muted | 12sp/16sp | Statuses and hints ("36540 m to the point", "74 m · maximum, the guest may lower it", hint texts). Colour: onSurfaceVariant |
We agreed to work in multiples of 2 / 4 / 8 / 10 / 14 / 16 / 20. No feast or famine: identical elements get identical spacing.
spacedBy(12.dp). Cards are 12 dp apart.padding(horizontal=20.dp).padding(top=16.dp).verticalArrangement = spacedBy(16.dp) between the major blocks.spacedBy(2.dp). Only 2 dp between the bold label and the status or hint beneath it, so they read as a title-and-caption pair.Spacer(10.dp). The same in Geo, Wi-Fi connect and the Wi-Fi list.A Material 3 slider occupies about 48 dp of layout because of the minimum touch target, of which the visible track is only about 4 dp in the middle, with roughly 22 dp of empty touch area above and below. Hence:
Modifier.offset(y=-16.dp) to pull the thumb up to the text.Modifier.sliderTrim() (a helper in GuestAutomationEditor.kt), which reports a height of 22 dp to the parent instead of 48 dp. That way the card's padding(16.dp) gives the same gap from thumb to bottom edge as on cards that end with a pill button.Every rule — Geolocation, Wi-Fi, Time, Authorisation, Confirmation — and the Activation block takes one of three levels. The card's icon circle takes the matching colour when the switch is on.
| Key | Label | Colour | Description |
|---|---|---|---|
own |
The guest decides | #3BA55D (green) | The guest picks the setting themselves |
suggest |
Preset | #E5B409 (yellow) | The value is set, and the guest may change it |
force |
Locked | #CC3322 (red) | The value is set and the guest cannot change it |
With the switch off the circle turns grey (#9AA0A6), whatever level is selected.
A single 38 dp landing slot throughout the app:
CircleShape, with the background set to the level colour.contentAlignment = Alignment.Center.Wrap them in Box(Modifier.size(38.dp), contentAlignment=Center) { RadioButton(...) } so that the centre of the radio circle lines up with the centre of the coloured circle above. Without the wrapper the radio drifts horizontally, because RadioButton renders with padding of its own.
Between a radio button and its text: Spacer(14.dp). Between radio rows in a group: Column(spacedBy(4.dp)).
The minimum touch target is disabled: CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) — otherwise each radio row swells to 48 dp and breaks the rhythm.
A standard Material 3 switch, to the right of the card title. It turns the whole rule off, and the level colour is then not shown — the circle goes grey.
A custom control for switching modes inside a rule — currently only in Wi-Fi: "By a specific network" / "By the networks around". Two items in a Row, each with weight(1f):
colorScheme.surface, RoundedCornerShape(10.dp), padding 4dp.primaryContainer, RoundedCornerShape(8.dp), with SemiBold text.An outline, an icon and text, fully rounded. Used for secondary actions — "Use the current position", "Take a snapshot of nearby networks". When disabled, the outline and text drop to alpha=0.4.
See section 3 on offset(y=-16.dp) and sliderTrim(). A slider is always preceded by a StatusLine carrying status text (bodySmall, muted) — "74 m · maximum, the guest may lower it".
Opened by tapping the card's title row — the whole Row except the switch. It appears directly under the header, across the full width of the card's content. For that:
Box with onGloballyPositioned, and we measure its width in dp.DropdownMenuItem with the modifier Modifier.width(anchorWidthDp).The dropdown items breathe rather than being flat rows: a colour marker on the left, the label in bold, the description beneath in small muted type. They align vertically on the first line, the label.
Unlike the ordinary coloured circles of the rules, Activation's header circle pulses.
drawBehind + Stroke(width=6dp).LinearEasing. Not FastOutSlowInEasing — otherwise the cycle restart shows a visible puff.sin(π·p) × 0.6: at p=0 and p=1 alpha is 0, peaking in the middle. The restart is invisible.A single sentence in an interface string takes no full stop. Full stops appear only when there are two sentences or more, to separate them. This covers every description, hint, status, tooltip, heading and standalone phrase.
In slider statuses in yellow (Preset) mode we state the direction of freedom plainly:
In red (Locked): "74 m · exactly" / "5 of 7 · exactly".
"The guest decides / Preset / Locked" have stuck; we do not change them.
If a rule has an off state, express it with a switch rather than a radio option like "Not required / No / Off". Otherwise the user has two ways to turn the same thing off, which is confusing.
Examples:
In the Coordinates block the copy and delete icons sit on the right level with both lines (the label and the status). Implemented as Row { Column(weight=1f) { label; status }; CopyIcon; DeleteIcon }. The icons are centred vertically against the pair as a whole.
The body expands only when:
Animation: expandVertically(tween(220)) + fadeIn(tween(220)) on opening, shrinkVertically(tween(180)) + fadeOut(tween(120)) on closing.
On receiving a guest key — only if the app is on screen and the key is new rather than a repeat. Otherwise we save it silently.
sin(π·p) × 0.6. Any simplification ruins the look.Last updated: 18 April 2026 · document version 2.07