RelayCTX Creative briefsBRIEF.ui-component-kit.md
RelayCTX Creative · Document

BRIEF — Mobile component kit (phase 1)

2,872 words · 13 min View raw Source History

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.

RealityConsequence
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.

CanonDecidesWhere
DEC-025 — Code presenceThe 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 rowbriefs/BRIEF.code-presence.md · relay-app/docs/PLAN.code-presence-experience.md · concept concepts/code-presence-v1.html
SPEC.app-componentsCross-surface component/behaviour decisions — the slide-up sheet mechanic, the Approve/Deny contract, segmented control, tools panel, bellSPEC.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


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 specifiedSPEC.app-components.md, "Slide-up sheet". Implement that contract; don't invent a second one:

Adding to that contract for this kit:

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 ▾]

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:

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               │      └───────────────────────────────┘
└───────────────────────────────┘
SlotRule
Code (head)Mono, letter-spaced, --accent-textthe 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).
TitleSame 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 dotOne dot, one meaning (unread / needs attention). Not a second badge system.
Meta lineRelative ticking time (exact on press) · exceptional state only
TagsMax 2 inline + +n. Never wraps to a third line.
TrailingOn 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:

ShowNever show
EXPIRED REVOKED SPENTACTIVE
ORG BROADCAST when scope is unusualPRIVATE, PERSONAL
DUE / EXPIRES <n> when neara 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:

KindSays
Never had anywhat this is for + the first action
Filtered to nothingwhat's hidden + Clear filters
All doneacknowledge 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

#ShipWhy here
1SheetThree other components need it. Nothing lands cleanly before it exists.
2Rail off-canvasCheapest large win — gives every screen back a quarter of its width. Already scoped as RCTX-543.
3ObjectCardHighest legibility gain per line of code; independent of the filter work.
4ControlRow + FilterSheetThe biggest change; wants Sheet settled first.
5StateBlockPorts 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


6. Out of scope


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.

SeriesCarries
relay-ui-overhaulThe v3.5 overhaul loop: kickoff brief FEY7GG, Graph design hand-backs PDTQJB / 8M6BR9 / 8DZ5JG, Graph build spec KFVM2X, scaffolding spec VRU8V7
relay-ui-overhaul-sessionsSession handoffs for that work
relay-design-systemDesign-system canon relays
relay-app-mobile, mobile-concept-design, mobile-orientationMobile 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

WhatWhere
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 decisionsSPEC.app-components.md — sheet mechanic, Approve/Deny, segmented control, bell, menu
Canonical concept markupconcepts/app-mobile-v2.html · concepts/app-relay-agent.html
Interface lawrelay-creative/guidelines/ — principles · mobile · lists · states · harness
Why these findings existrelay-app/docs/AUDIT.experience-findings.md (EXP-003…008)
Program charterrelay-app/docs/PLAN.experience-program.md
Shell spec of recordrelay-app/docs/DESIGN-NOTE.list-views-refresh.md
Design materialrelay-app/DESIGN.md + packages/ui/tokens.css (tokens win)
Vocabulary../_vocab/_VOCAB.md — Code, never PIN; Stream, never Pulse
Screens inventoryrelay-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.