# GUIDE — Mobile composition

**Status:** law — v1, 2026-08-03
**Applies to:** the web SPA at phone widths **and** the native app. Mobile-first means both — most
people meet Relay in a browser before they install anything.
**Parent:** [`GUIDE.interface-principles.md`](GUIDE.interface-principles.md)

Design at **390px first**. Not "check it on mobile after" — compose for the phone, then let the
layout earn extra space on wider screens.

---

## The space budget

A phone gives you roughly 390–430px wide and about 700px tall, minus browser chrome. Treat it as a
budget you spend, and know what each decision costs.

| Consumer | Budget | Rule |
|---|---|---|
| Horizontal navigation | **0px** | The rail is off-canvas at phone width. A docked icon rail costs ~25% of the viewport and is unscannable without labels. |
| Vertical chrome above content | **≤ 2 rows** | One control row + optional context line. Everything else goes in a sheet. |
| Content | **everything else** | If content is getting less than ~70% of the vertical space above the fold, the composition is wrong. |

**Measured failure (EXP-003/004):** a docked rail took ~100px of ~430px, and five stacked control
layers pushed the list down until roughly one card was visible. Neither element was individually
unreasonable. The composition was.

---

## Controls: on demand, not on arrival

> A filter is useless until you know what you're filtering.

Controls do not get the position content should have. The pattern:

**One control row**, sticky at the top:
`[search] [scope] [Filter ▾]`

**One sheet** behind the Filter button, carrying everything else — status, direction, unread, due,
starred, labels, tags, sort. Filters that are active show as a count on the button (`Filter · 2`),
so state is never hidden.

**One result line** — `111 relays` — visible under the control row. That's orientation, not chrome.

### Rules

- **No chip walls.** More than ~6 options is a sheet with search, not a wrapping row of chips.
  115 tags is a search problem at every width.
- **One view mode.** Density and layout toggles are desktop controls. Pick the right one for a
  phone and ship that.
- **Never stack toolbars.** If two control rows are both needed above the content, one of them
  belongs in the sheet.

---

## Reach

The bottom third is where hands are. Compose accordingly.

- **Primary action** lives in the bottom third — a bottom action bar on detail views, a FAB or
  sticky bar on lists that create things.
- **Sticky primaries** stay reachable while a long page scrolls. A detail page that requires a
  scroll back to the top to act has failed rule 1.
- **Destructive actions are not adjacent to primary ones.** Separation is the cheapest protection
  against the fat-finger case.
- **The top bar carries orientation and escape only** — title, back, overflow.

---

## Sheets, not modals

**Bottom sheets** are the default container for anything secondary: filters, actions, edit forms,
pickers, confirmations that need more than a line.

| Rule | Why |
|---|---|
| Draggable, and dismissible by drag and by backdrop tap | A container the user can't dismiss by instinct reads as a trap |
| Sized to content, expandable | A half-empty full-screen sheet wastes the reveal |
| Actions inside the sheet sit at its bottom | Same reach rule applies inside the sheet |
| Never stack sheets more than one deep | A second sheet means the first one was the wrong container |

Full-screen modals are for one case only: a flow with its own multi-step lifecycle (send, claim
with preview). Even then it gets a visible, labelled exit — never only a corner X.

---

## Touch and input

- **Minimum target 44×44px**, with the *whole card* tappable on list rows — not just the title.
- **Spacing between targets ≥ 8px.** Adjacent taps that do different things need daylight.
- **Correct keyboard per field** — numeric for numbers, no autocapitalise on Codes, `autocomplete`
  set honestly.
- **Codes paste cleanly.** Strip whitespace and case on paste; never make someone retype a Code
  because it arrived with a space.
- **Inputs stay visible when the keyboard opens.** Test with the keyboard up, always.
- **No zoom-on-focus** — inputs at ≥16px so iOS doesn't zoom.
- **Respect safe-area insets.** Bottom bars clear the home indicator.

---

## Layout behaviour

- **One column.** Multi-column layouts collapse to a single column with a stated priority order,
  not a squeeze.
- **No horizontal overflow, ever.** The page body never scrolls sideways. Wide content — tables,
  code, diagrams — scrolls inside its own container with a visible edge cue. (EXP-006 was content
  clipped past the card edge; that's this rule.)
- **Tables become cards** at phone width, or become a scrollable region with a frozen first
  column. They do not become tiny tables.
- **Detail panes become pages.** A side panel at desktop width is a navigated route on a phone,
  with real back behaviour.

---

## Copy at 390px

Copy is written *for* the column, not trimmed *to* it. See
[`GUIDE.states-and-language.md`](GUIDE.states-and-language.md).

- Titles: assume two lines, never rely on more.
- Buttons: one or two words. A button that wraps is a button that's wrong.
- Empty and error states: two sentences and an action. Long explanations belong behind a link.

---

## The checklist

Before a phone screen ships:

- [ ] Rail off-canvas — content gets full width
- [ ] ≤ 2 rows of chrome above the content
- [ ] Three or more items visible above the fold on a list
- [ ] Primary action in the bottom third and sticky if the page scrolls
- [ ] Titles unclipped at two lines
- [ ] No horizontal page scroll at 390px
- [ ] Every target ≥ 44px, whole-card tap on rows
- [ ] Tested with the keyboard open
- [ ] Tested with reduced-motion on
- [ ] Tested with a *real* data volume, not three seed rows

---

## Framework note

Everything here except the Code paste rule is generic to any phone-width object surface —
`framework-candidate` for Launchpad Phase 2 Frontend Core.
