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/ — 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 |
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— anls-*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/— currentlybusy.tsanddialog.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) — neverheightormax-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) andconcepts/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:
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
- 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 inweb/src/components/, split clean - Extends the existing
ls-*/card.cssvocabulary, no parallel system addEventListeneronly — no newonclick=, no additions toglobals.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-motionhonoured- Checked at 0, 3, and 500+ items — a component that only looks right at three rows is a mockup
npm run typecheckandnpm run lintcleanCHANGELOG.mdline 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, 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 — 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 — 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.