# 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`](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`](../briefs/BRIEF.code-presence.md) (display laws L1–L4) and
> `relay-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:**

1. **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.
2. **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:

1. The context note / synopsis
2. The first meaningful line of content
3. 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`](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`](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.
