GUIDE — List surfaces & card anatomy#
Status: law — v1, 2026-08-03 Applies to: every list in the product — Transfers, Series, Sessions, Stream, Search, Requests, Connections, and the console equivalents Parent: GUIDE.interface-principles.md Build spec: the shared list shell is specified in relay-app/docs/DESIGN-NOTE.list-views-refresh.md (epic #449). This document is the design law that shell implements.
Lists are where people spend their time. They are also where the governing principle gets violated most often, because a list row is where designers are most tempted to show everything.
Card anatomy#
A row answers three questions, in this order, and nothing else at rest:
Governed by DEC-025. The card head is already law — see
../briefs/BRIEF.code-presence.md(display laws L1–L4) andrelay-app/docs/PLAN.code-presence-experience.md. This section states how those laws compose at phone width; where they differ, DEC-025 wins.
Shared (has a Code) Unshared (no Code — head closes up)
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ ● ABC123 │ │ ● Title, fully legible │
│ Title, fully legible │ │ 7h ago │
│ 7h ago · EXPIRED │ │ tag tag +3 [Share]│
│ tag tag +3 │ └───────────────────────────────┘
└───────────────────────────────┘| Slot | Carries | Rules |
|---|---|---|
| Code (head) | The Code when the object has one | Mono, letter-spaced, --accent-text — the brightest element in the card (L2). When absent the slot closes up; the layout never leaves a hole and nothing is promoted into it (L3). |
| Title | The object's label | Same slot, same size, same weight, same colour in both states (L3). Its job is recall, not identity. Fully legible at two lines — never truncated to a stub. |
| Status dot | Unread / needs-attention | One dot, one meaning. Not a second badge system. |
| Meta line | Relative ticking time · exceptional state only | One line. Honest time — exact on press. |
| Tags | Up to 2 + +n | Inline, never wrapping to a third line. |
| Trailing | On unshared objects, the share action — --accent-fill + --on-accent, the one teal moment for that state (L2). Otherwise one affordance, or none. | Everything else lives in the row's sheet or the detail view. |
The two failure modes, named:
- Truncating the title to a stub.
Firm…/launc…is not a title — two rows rendered that way are indistinguishable. This is a legibility failure, and it is independent of rank: the title keeps its rank, and it must still be readable. DEC-025's title floor (§12 Q13) says titles must be reliably present before any surface leans on them. - Reaching for the title when the Code is absent. L3 exists precisely because "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 and cannot carry a verb surface. The head closes up instead.
The badge rule#
Render the exception, not the default.
ACTIVE · PRIVATE · PERSONAL on every card is not information — it's the norm rendered as data, and it costs the title its space (EXP-005). Show a state badge only when the state deviates from what the user should assume:
| Show | Don't show |
|---|---|
EXPIRED, REVOKED, SPENT | ACTIVE |
ORG, BROADCAST when scope is unusual | PRIVATE, PERSONAL |
DUE, EXPIRES <n> when near | a distant expiry |
If a badge appears on more than about three quarters of rows, it is chrome. Move it to the detail view or delete it.
Absence is addressable, not annotated (DEC-025 L4). The strongest form of this rule: don't stamp "never shared" — or its cousins — on every row. Make the set selectable as a filter, so absence 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're tempted to add a badge to express a state that's true of many rows, add a filter instead.
No title is a content problem, not a layout one#
An untitled object must never fall back to a raw internal ID. 8D9F34E2E7CA tells the user nothing and isn't even the Code they'd share.
The real fix is upstream — DEC-025's title floor (§12 Q13): titles must be reliably present before any surface leans on them. Derive one at creation rather than papering over the gap at render time. Where a row must still cope, fall back in this order:
- The context note / synopsis
- The first meaningful line of content
- A minimal honest label — last resort only, and never
N/A, an empty string, or an internal ID
A placeholder is a symptom. If rows are hitting step 3 with any regularity, that's a finding against the title floor, not a card-design problem.
The list shell#
Every list uses the same anatomy. Divergence needs a written reason.
Above the list — at phone width, two rows maximum (see GUIDE.mobile-composition.md):
- Control row: search · scope ·
Filter ▾(with active count) - Result count — always present. "111 relays" is orientation; a list without a count makes the user guess whether they're seeing everything.
The list itself:
- Pagination or windowing is mandatory. No list renders its full set. A silent cap is worse than pagination — Sessions capping at 100 with no indicator (per the list-views inventory) is a correctness bug dressed as a layout choice.
- Whole row is the tap target.
- Row actions live in a sheet or swipe, and are the same set in the same order across every list.
- Sort and density are view options behind the Filter control, not a permanent toolbar.
Filtering#
Filters are combinable and stateful, never a single-select tab strip.
The failure to avoid: a tab row mixing unrelated axes — direction (Sent/Received), status (Pending/Delivered), and personal state (Unread/Due/Starred) on one strip, single-select, so "Received + Unread + tag:mobile" is unexpressible.
Rules:
- One axis per control. Direction, status, and personal state are three controls, not eight tabs.
- Filters combine, and the combination is visible — active filters show as removable chips below the control row, or as a count on the Filter button.
- Filter state is in the URL where the surface has one, so a filtered view is shareable and survives a refresh.
- Empty results from filters are a distinct state from an empty collection — and always offer clear filters. See
GUIDE.states-and-language.md. - Tag selection above ~6 tags is search inside a sheet, not chips.
Density and scale#
Design every list against three data volumes, and check all three:
| Volume | What it tests |
|---|---|
| 0 | The empty state — is there a first action? |
| 3 | The demo case — does it look composed, not sparse? |
| 500+ | The real case — does it scroll, filter, and find? |
A list that only looks right at three rows is a mockup, not a design. Most recorded friction shows up only at the third volume.
Selection and bulk action#
Multi-select exists only where handling many objects at once is a real behaviour. Where it does:
- Entering selection mode is explicit (long-press on touch, checkbox on pointer) and reversible.
- The action bar appears at the bottom on a phone, showing the count and the actions.
- Bulk destructive actions get undo, not a confirm dialog (rule 5).
- Exiting selection restores the previous scroll position.
Checklist#
- Title unclipped at two lines; identity composed first
- Only exceptional states badged
- Untitled objects fall back to content, never a raw ID
- Result count present
- Pagination or windowing — no full-set render, no silent cap
- Filters combinable, one axis per control, state visible and in the URL
- Whole row tappable; row actions identical across lists
- Checked at 0, 3, and 500+ items
- Filtered-empty distinguished from truly-empty
Framework note#
Card anatomy, the badge rule, the filter model, and the three-volume check are generic to any object list — framework-candidate for Launchpad Phase 2. Relay-specific: the Code slot and the claim/handoff state vocabulary.