# BRIEF — Mobile component kit (phase 1)

**Status:** work order — v1, 2026-08-03
**For:** the team building UI components in `relay-app`
**Target:** `web/` (Vite/TS SPA) · `packages/ui/` (CSS) — native app follows, shared design not shared code
**Law:** [`../guidelines/`](../guidelines/) — cite rule numbers in review
**Tracker:** RCTX-1088 · RCTX-1089 · RCTX-1090, under epic RCTX-1083

---

## 1. The ask, in one paragraph

Relay's phone surfaces fail because each screen composes itself. Fixing screens one at a time
re-litigates the same decisions every time. **Build five shared primitives that make the guidelines
the path of least resistance**, then port screens onto them. Transfers is the first port because it
is the heaviest surface and the one we have evidence for.

**Do not start by redesigning Transfers.** Start by building the kit that makes Transfers cheap.

---

## 2. Read this before you design anything

The codebase constrains what "component" means, and getting this wrong wastes a week.

| Reality | Consequence |
|---|---|
| **Web SPA is vanilla TypeScript. No framework, no JSX, no component library.** DOM is built by hand from `innerHTML` string templates. | A "component" here = a **CSS class set in `packages/ui/`** + a **render/behaviour function in `web/src/components/`**. Do not propose React. Do not add a UI dependency. |
| `packages/ui/` is **CSS-only** — tokens and class styles, no JS. | Styling goes there. Behaviour goes in `web/src/components/`. Keep the split clean. |
| **`onclick=` bridge exists** — HTML attributes can't reference ES module exports, so `globals.ts` assigns functions to `window`. | New components use `addEventListener`, **not** `onclick=`. Don't extend `globals.ts`. This is the direction the codebase is already moving. |
| **No automated tests.** `npm run typecheck` is the correctness gate. | Type the component APIs properly — types are the only safety net you get. Run `npm run typecheck` and `npm run lint` before every push. |
| Mobile app is **Expo/React Native**, separate code. | Design decisions transfer; code does not. Where a rule is shared, write it into the guidelines so both surfaces read the same source. |

### Canon you must not contradict

Two documents already decide things this kit touches. Read both before you design; where they
disagree with this brief, **they win** and this brief gets corrected.

| Canon | Decides | Where |
|---|---|---|
| **DEC-025 — Code presence** | The card head. Four display laws: **L1** shared objects are marked, unshared are quiet · **L2** one teal moment per object (the Code when shared, the share action when not) · **L3** **the title never changes rank** — same slot, size, weight, colour in both states, nothing promoted to fill the gap · **L4** absence is a **filter**, not a badge on every row | `briefs/BRIEF.code-presence.md` · `relay-app/docs/PLAN.code-presence-experience.md` · concept `concepts/code-presence-v1.html` |
| **SPEC.app-components** | Cross-surface component/behaviour decisions — the slide-up sheet mechanic, the Approve/Deny contract, segmented control, tools panel, bell | [`SPEC.app-components.md`](SPEC.app-components.md) |

**The trap L3 exists to prevent is the one this kit is most likely to walk into:** *"a designer sees
empty space where the Code was and reaches for the nearest text field."* Title-as-hero on codeless
objects was proposed and **rejected** — the title is the most generic field in the schema, every
competing tool has one, and promoting it teaches that the hero slot is a container for whatever's
available rather than a claim about identity. **The head closes up instead.**

This does **not** soften the finding behind RCTX-1089. A title truncated to `Firm…` is a
*legibility* failure, and legibility is independent of rank — the title keeps its rank and must
still be readable. DEC-025's **title floor** (§12 Q13) says the same thing from the other side.

### What already exists — extend, don't duplicate

- **`packages/ui/list-shell.css`** — an `ls-*` kit is already in place: `ls-facets`, `ls-search`,
  `ls-seg`, `ls-facet-btn`, `ls-chip`, `ls-pop` / `ls-opt` (popover + options), `ls-tagset` /
  `ls-tagpick`. **Build on these names.** A second parallel system is the failure mode.
- **`packages/ui/card.css`**, `badge.css`, `button.css`, `form.css`, `list.css` — existing surfaces.
- **`web/src/components/`** — currently `busy.ts` and `dialog.ts`. This is where the new modules go;
  the directory pattern is established and nearly empty, which is your opportunity.
- **`packages/ui/tokens.css`** — canonical values. **Tokens win over any spec, including this
  brief.** Never hardcode a colour or a radius.

---

## 3. The five components

Build in this order. Each one unblocks the next.

### 3.1 `Sheet` — the container everything secondary lives in

**Job:** replace modals and popovers on phone widths. Filters, row actions, edit forms, pickers,
confirmations all land here. Build this first — three of the other four depend on it.

**Anatomy:** grabber · optional title row · content (scrolls) · action row pinned to the bottom.

**The mechanic is already specified** — `SPEC.app-components.md`, "Slide-up sheet". Implement that
contract; don't invent a second one:

- **Transform-only animation (`translateY`) — never `height` or `max-height`.** Named in the spec
  specifically to avoid layout thrash.
- Collapses on: a second trigger press · backdrop tap · explicit close · navigating elsewhere.
- **Never a route change.** Opening a sheet must not lose the user's place.
- Canonical markup/CSS to copy from: `concepts/app-mobile-v2.html` (`.tools-sheet`, `.menu-sheet`)
  and `concepts/app-relay-agent.html` (`.agentwin-tools`).

**Adding to that contract for this kit:**
- Draggable, and dismissible by drag-down and Esc as well
- Sized to content, expandable to a taller detent; never full-screen by default
- Actions sit at the **bottom** of the sheet — the reach rule applies inside the sheet too
- Focus trapped while open; focus returns to the trigger on close
- Never stack more than one deep — a second sheet means the first was the wrong container
- Respects `prefers-reduced-motion` (no slide, just present)

**Related canon to reuse rather than rebuild:** the **Approve/Deny pattern** is already a behaviour
contract — Approve is primary and single-tap; Deny gets a **5s undo toast** before it finalizes;
never symmetric-weight buttons. That is the house pattern for rule 5 (recoverable) and every
destructive action in this kit should follow its timing. The **segmented control** (`.segbar`) also
already exists and is reused verbatim across concepts.

**API sketch:**
```ts
openSheet({
  title?: string,
  content: HTMLElement | string,
  actions?: { label: string, kind: 'primary'|'ghost'|'danger', onSelect: () => void }[],
  onDismiss?: () => void,
}): SheetHandle   // .close(), .update(content)
```

**Acceptance:** opens over any screen without layout shift · dismisses three ways · focus returns
correctly · no page scroll behind it · works at 390px with the keyboard open.

---

### 3.2 `ControlRow` + `FilterSheet` — content-first list controls

**Job:** collapse the five stacked control layers into one row plus a sheet. This is RCTX-1088 and
the single biggest visible win.

**The row** (sticky, one line): `[search] [scope] [Filter ▾]`
- Filter button shows the **active count** — `Filter · 2` — so filter state is never hidden
- Result count sits directly under the row (`111 relays`) — orientation, not chrome
- **Two rows maximum above content, including this one.** That's the budget.

**The sheet** carries everything else: status · direction · unread · due · starred · labels · tags ·
sort. Reuse the existing `ls-seg`, `ls-chip`, `ls-opt`, `ls-tagpick` styles inside it.

**Rules:**
- **One axis per control.** Direction, status, and personal state are three controls — never one
  eight-way tab strip. Filters must combine: *Received + Unread + tag:mobile* has to be expressible.
- **Tags above ~6 options get a search field**, not a chip wall. We have 115 tags; a wrapping chip
  row is not a filter surface at any width.
- Active filters render as removable chips under the row, or as the count on the button — pick one
  and apply it everywhere.
- **Filter state goes in the URL** so a filtered view is shareable and survives refresh.
- Applies immediately on selection; the sheet does not need an Apply button.

**Acceptance:** three or more list items visible above the fold at 390px · every filter combination
expressible · state survives refresh · zero horizontal scroll.

---

### 3.3 `ObjectCard` — identity-first list row

**Job:** RCTX-1089. The card is where the governing principle gets violated most, so this is the
one to get exactly right.

**Composed under DEC-025.** Two states, and the difference is *shape*, not colour:

```
Shared (has a Code)                    Unshared (no Code — head closes up)
┌───────────────────────────────┐      ┌───────────────────────────────┐
│ ●  ABC123        <- teal, mono│      │ ●  Title, fully legible       │
│    Title, fully legible       │      │    7h ago                     │
│    7h ago · EXPIRED           │      │    tag  tag  +3      [ Share ]│  <- teal, filled
│    tag  tag  +3               │      └───────────────────────────────┘
└───────────────────────────────┘
```

| Slot | Rule |
|---|---|
| **Code (head)** | Mono, letter-spaced, `--accent-text` — **the brightest element in the card** when present (L2). When absent the slot **closes up**: no empty bracket, no em-dash, no placeholder glyph, and **nothing promoted into it** (L3). |
| **Title** | **Same slot, size, weight and colour in both states.** Job is *recall*, not identity — do not promote it, do not restyle it when the Code is gone. Two lines available and **fully legible** — never a stub. |
| **Status dot** | One dot, one meaning (unread / needs attention). Not a second badge system. |
| **Meta line** | Relative ticking time (exact on press) · **exceptional state only** |
| **Tags** | Max 2 inline + `+n`. Never wraps to a third line. |
| **Trailing** | On an unshared object this is the **share action** — `--accent-fill` + `--on-accent`, the one teal moment for that state (L2). Otherwise one affordance, or none. |

**One teal moment per object.** Never two. If the Code is present it is the accent; if it isn't, the
share action is. A card showing both a teal Code and a teal button is wrong.

**The badge rule — render the exception, not the default:**

| Show | Never show |
|---|---|
| `EXPIRED` `REVOKED` `SPENT` | `ACTIVE` |
| `ORG` `BROADCAST` when scope is unusual | `PRIVATE`, `PERSONAL` |
| `DUE` / `EXPIRES <n>` when near | a distant expiry |

*Test:* if a badge appears on more than ~75% of rows, it is chrome — delete it or move it to detail.

**DEC-025 L4 gives the stronger form:** absence is **addressable, not annotated**. Don't stamp a
state that's true of most rows — make it a **selectable filter**, so it reads as a population
(*"41 of 68 never left this workspace"*) instead of a mark repeated 41 times that each individually
looks like missing data. When you want to express a common state, reach for the FilterSheet, not a
badge.

**Untitled objects** — the real fix is upstream: DEC-025's **title floor** (§12 Q13) requires titles
be reliably present before surfaces lean on them. Where a row must still cope, fall back
1. context note / synopsis → 2. first meaningful line → 3. a minimal honest label. **Never** a raw
internal ID, `N/A`, or an empty string. If rows hit step 3 regularly, file it against the title
floor rather than absorbing it in the card.

**Also:** whole card is the tap target (≥44px) · no content clips past the card edge at 390px
(that's an open bug, EXP-006).

---

### 3.4 `StateBlock` — empty, error, loading

**Job:** RCTX-1090's copy half, and the end of dead ends everywhere.

**Three empties, never collapsed into one:**

| Kind | Says |
|---|---|
| Never had any | what this is for + the first action |
| Filtered to nothing | what's hidden + **Clear filters** |
| All done | acknowledge it — don't imply something is missing |

**Has-content guard is mandatory.** A user with 50 streams must never see *"Create your first
stream"*. If intro copy can render over a non-empty collection, that's a defect.

**Errors** say what to do, not what failed, and carry the retry control inline. Never blame the
user. Error codes go in small type for support, not in the message.

**Loading** is a skeleton in the shape of what's coming — not a spinner, never a full-screen block
for a partial update.

**Format:** one line of what's true · one line of why · one action. Two sentences and a button.

---

### 3.5 `Rail` — off-canvas at phone width

**Job:** RCTX-543, already filed. Reclaims ~25% of the viewport — the docked icon rail is why
titles have no room in the first place.

Off-canvas overlay below the phone breakpoint; docked rail above it. Same nav module, one
responsive behaviour — **not** a separate bottom-tab bar. Trap focus while open, close on
backdrop tap and Esc, and return focus to the trigger.

---

## 4. Build order

| # | Ship | Why here |
|---|---|---|
| 1 | **Sheet** | Three other components need it. Nothing lands cleanly before it exists. |
| 2 | **Rail off-canvas** | Cheapest large win — gives every screen back a quarter of its width. Already scoped as RCTX-543. |
| 3 | **ObjectCard** | Highest legibility gain per line of code; independent of the filter work. |
| 4 | **ControlRow + FilterSheet** | The biggest change; wants Sheet settled first. |
| 5 | **StateBlock** | Ports cleanly once lists have a shell to sit in. |

Then **port Transfers** onto the full kit, and only then the other list surfaces (Series, Sessions,
Stream, Search).

---

## 5. Definition of done — per component

- [ ] CSS in `packages/ui/`, behaviour in `web/src/components/`, split clean
- [ ] Extends the existing `ls-*` / `card.css` vocabulary, no parallel system
- [ ] `addEventListener` only — no new `onclick=`, no additions to `globals.ts`
- [ ] All values from `tokens.css` — no hardcoded colour, radius, or spacing
- [ ] Works at **390px** with no horizontal page scroll
- [ ] Targets ≥44px, ≥8px apart; whole-row tap where applicable
- [ ] Keyboard operable, focus visible, focus returned on close
- [ ] `prefers-reduced-motion` honoured
- [ ] Checked at **0, 3, and 500+** items — a component that only looks right at three rows is a mockup
- [ ] `npm run typecheck` and `npm run lint` clean
- [ ] `CHANGELOG.md` line in the same PR

---

## 6. Out of scope

- **The v3.5 shell redesign** (*UI Refresh — Web App*, epic #449) — this kit is the phone-width
  behaviour of that shell. If #450 lands first, build on it rather than against it.
- **Full-cache render / windowing** — EXP-002, tracked separately under RCTX-1086. This kit does not
  fix scroll cost, and shouldn't try.
- **The native app** — same design, different code. Later phase.
- **Console** — tandem batch, its own project.

---

## 7. How this gets reviewed

**Name the rule.** "Fails rule 3 — title truncated to four characters" is actionable; "feels
cluttered" is not. The seven rules live in
[`../guidelines/GUIDE.interface-principles.md`](../guidelines/GUIDE.interface-principles.md), and
the same numbers are used by triage in the findings register and by the Linear issues. One shared
language across design, build, and tracker.

Anything ambiguous goes back to the guidelines — and if the guidelines don't answer it, that's a
gap to fix there, in the same PR, not a decision to make locally and forget.

---

## 7b. The design→build loop runs on Relay

This program already has a handoff channel — use it rather than starting a new one.

| Series | Carries |
|---|---|
| **`relay-ui-overhaul`** | The v3.5 overhaul loop: kickoff brief `FEY7GG`, Graph design hand-backs `PDTQJB` / `8M6BR9` / `8DZ5JG`, Graph build spec `KFVM2X`, scaffolding spec `VRU8V7` |
| **`relay-ui-overhaul-sessions`** | Session handoffs for that work |
| **`relay-design-system`** | Design-system canon relays |
| **`relay-app-mobile`**, **`mobile-concept-design`**, **`mobile-orientation`** | Mobile concept + orientation handoffs |

**The loop:** prototype per component → relayed under `relay-ui-overhaul` → ported into the repo →
staged in `relay-creative` → implemented. Each component in §3 should come back as a claimable
handoff with its prototype, not as a comment thread.

**Streams to post progress into:** `Relay Experience` (`pc_9b8b6183e706` — findings and quality),
`Mobile Experience` (`pc_f947b8bdd086` — the mobile product track), `Relay Product Development`
(`pc_78b145fbb371` — build status).

## 8. Where everything is

| What | Where |
|---|---|
| **Code-presence law (DEC-025)** | `briefs/BRIEF.code-presence.md` · `relay-app/docs/PLAN.code-presence-experience.md` · concept `concepts/code-presence-v1.html` · MCP tier `mcp/code-presence-display.html` |
| **Cross-surface component decisions** | [`SPEC.app-components.md`](SPEC.app-components.md) — sheet mechanic, Approve/Deny, segmented control, bell, menu |
| Canonical concept markup | `concepts/app-mobile-v2.html` · `concepts/app-relay-agent.html` |
| Interface law | `relay-creative/guidelines/` — principles · mobile · lists · states · harness |
| Why these findings exist | `relay-app/docs/AUDIT.experience-findings.md` (EXP-003…008) |
| Program charter | `relay-app/docs/PLAN.experience-program.md` |
| Shell spec of record | `relay-app/docs/DESIGN-NOTE.list-views-refresh.md` |
| Design material | `relay-app/DESIGN.md` + `packages/ui/tokens.css` (**tokens win**) |
| Vocabulary | [`../_vocab/_VOCAB.md`](../_vocab/_VOCAB.md) — Code, never PIN; Stream, never Pulse |
| Screens inventory | `relay-app/REFERENCE.screens.md` |

---

## 9. Framework note

Sheet, ControlRow/FilterSheet, ObjectCard and StateBlock are **generic object-surface primitives** —
nothing about them is Relay-specific except the Code slot and the claim/handoff state vocabulary.
They are `framework-candidate` for **Launchpad Phase 2 — Frontend Core**, which extracts the generic
shell from `relay-app`. Build them so the Relay-specific parts are visibly separable: keep Code
rendering and state vocabulary at the edges, not baked through the middle.
