← All posts

A pattern library for list, detail and ledger

Three archetypes cover every page the dashboard has. Here is what each one is for, how to tell a Ledger from a List, the anatomy of each, and the rule that new pages compose them instead of hand-rolling a layout.

Vocion Teamvocion-v2.74.0

Status: shipped. vocion-core #339 is running. Merged onto the release branch release/2026-09-15-rail (7db4f7f6) and deployed to a deployment we operate; migrations 0094–0099 are applied. It landed on main on 2026-09-16 as v2.84.0; the user-facing half of it is One list, and a Search you can read in Vocion 2.88.

A dashboard accumulates pages faster than it accumulates opinions about pages. Ours had eight bordered panels on one screen and four bordered cards on another, each hand-rolled, each slightly different about where a score goes and what a state chip looks like.

The claim in this change is narrow and testable: three archetypes cover every page the dashboard has, and every page it is likely to get.

Which archetype

You are showing…UseReference page
Many records of one kind, each a door to its own page, filtered by lane or categoryListthe personalization queue
One record — its facts, its evidence, and often a decision to take on itDetaila lead
What the system did over time: every assessment with scores, verdict, provenance and the human follow-up — read, rarely acted onLedgerdiscovery

The disambiguation rule is the useful part:

A Ledger is a List whose rows are richer and read-mostly. If a row has a score, a verdict and a provenance footer, it is a Ledger. If a row is a door to a Detail page, it is a List.

List

ListPage is the frame: title, description, at most one primary action, children. ListToolbar takes five independent optional groups — lanes with counts, search, sort, direction, and multi-select category chips — all controlled. ListRow is a 44px row with a title, a subline, right-aligned columns, an always-visible state chip and hover actions.

The columns are the quiet win:

export const COLUMN = {
  score:  'w-32',   // "speculative 0.42"
  number: 'w-16',
  date:   'w-24',
  status: 'w-28',
  chip:   'w-24',
} as const;

A score on one page now sits where a score on another page sits. That is not aesthetics; it is the difference between scanning a list and reading it.

The state layer is pure and URL-backed:

export type ListState = { tab: string; q: string; sort: string; dir: SortDirection; chips: string[] };
export type ListStateConfig = {
  defaults: ListState;
  tabs?: readonly string[];     // an unknown tab in the URL falls back to the default
  sorts?: readonly string[];
  chips?: readonly string[];    // unknown chips are dropped
  prefix?: string;              // two lists on one page: `ledger.tab`, `ledger.q`, …
};

Two rules make it behave: defaults are never written, so a list at rest has a clean URL; and unknown values fall back rather than error, so a stale link a colleague sent last month still opens the list. useListUrlState writes through window.history.replaceState, which Next folds into its router, rather than calling the router itself:

// listUrlState.ts
window.history.replaceState(
  window.history.state, '',
  `${window.location.pathname}${next}${window.location.hash}`,
);

So no page needs the app router mounted just to filter a list — which is also why the stories and browser tests can render it.

The personalization queue as a List. Lanes with counts, category chips that clear to "All · n", hairlines instead of boxes. Bordered panels on this page: three before, zero after.

Detail

DetailPage takes crumbs, a title, a subtitle, a meta row, actions, an aside, children and a sticky bar. It sets the document title to "Record · Section" and puts it back on unmount, which is the smallest possible respect for a reader with fourteen tabs open:

// DetailPage.tsx
const prev = document.title;
document.title = section ? `${title} · ${section}` : title;
return () => { document.title = prev; };

The responsive rule is the part worth stealing:

<div
  data-pattern="detail-columns"
  className="@container grid gap-x-10 gap-y-2 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,18rem)]"
>
  <div className="min-w-0">{props.children}</div>
  {props.aside}
</div>

A container query, not a media query. The right column drops under the content when the page's own column gets narrow — which is what happens when a persistent conversation rail opens beside it. A viewport media query would keep a two-column layout in a 700px-wide page on a 1440px screen, and it would look exactly as bad as that sounds.

The supporting parts are small and specific: DetailMeta middot-joins a row and skips falsy items without leaving a gap; StatusDot is a 6px dot plus a word, never a colour alone; ConfidenceMeter shows a value, an optional second reading, and puts the rationale in a tooltip. Its cut points are imported from the domain rather than invented for display — the pattern borrows the thresholds the system actually uses.

A lead page rebuilt on the Detail archetype. Crumbs, one H1, a meta row, then sections separated by hairlines, with the decision in a sticky bar that never scrolls away.

The same page before the migration. The same facts in eight bordered panels, with the decision somewhere below the fold. Bordered panels: eight before, zero after.

Ledger

LedgerGroup is a day header with a count; LedgerEntry is a title, a time, a verdict badge, a routing state, score chips, a two-line clamped summary, a match reason, a mono provenance line, and what a person did about it.

Colour has three meanings and they are written down: ink means the system decided nothing needs a person, green means the system produced something, amber means a person is being asked. An unrecognised verdict renders neutral, as written, rather than being swallowed.

Score chips never let colour replace the number, and the comparison is pinned to the one the router uses:

export function scoreVerdict(value: number, threshold: number | null | undefined): ScoreVerdict {
  if (threshold == null || Number.isNaN(threshold)) {
    return 'none';
  }
  return value >= threshold ? 'pass' : 'fail';   // ">=", the router's own inequality
}

A chip that said "pass" where the router said "fail" would be a worse bug than no chip at all.

No screenshot of the Ledger here: the only capture we had was shot against a seeded fixture whose sample meeting name would have read as a customer's, and a post about a pattern library is not worth loosening the rule that every published frame comes from clean fixture data. The anatomy diagram from docs/design/patterns.md shows the entry in full — with the fixture line replaced by a placeholder for the same reason:

LedgerGroup ────────────────────────────────────────────────────────────
  MON, SEP 14  3
  ───────────────────────────────────────────────────── (hairline)
  LedgerEntry
  <meeting title>  Sep 14, 10:00 AM                routed  [generate]
  Matched hubspot-deal · deals:1201
  discovery 0.95 ▮▮▮▮▯   proposal-ready 0.88 ▮▮▮▮▯   thresholds 0.8 / 0.75
  Two lines of the model's reasoning, clamped, with a "more" toggle…
  claude-haiku-4-5#discovery-v1 · revops-lead · run #4412 · transcript 9f3c… · ws a81f…
                                                     review: pending →
  ─────────────────────────────────────────────────────

The Ledger reuses ListToolbar and useListUrlState rather than growing its own filtering — the reference implementation's import line is the evidence:

// features/discovery/DiscoveryLedger.tsx
import {
  LedgerEntry, LedgerGroup, ListEmpty, ListToolbar,
  ProvenanceLine, ScoreChip, useListUrlState, VerdictBadge,
} from '@/components/patterns';

The library paying for itself inside itself is the first evidence that it is the right shape. Bordered cards on the discovery page: four before, zero after.

Do and don't

The migration checklist ends with a step that is also a metric: delete every leftover bordered wrapper, then count the boxes before and after. The after count is the number of pages, not the number of panels.

How the rule is enforced

Honestly: by prose, in two places, and not by tooling.

/**
 * `components/patterns` — the three dashboard archetypes. New dashboard
 * pages compose these; nobody hand-rolls a list, a detail or a ledger layout.
 * Read `docs/design/patterns.md` first.
 */

The same sentence is in the repository's CLAUDE.md under Conventions. There is no ESLint rule and no no-restricted-imports entry. Since most of this repository is worked by coding agents that read CLAUDE.md before they touch a file, that context file is doing more enforcement than a lint rule would — but it is worth being clear that it is a convention, not a gate.

Every pattern component renders a data-pattern attribute — Ledger.tsx alone emits ledger-group, ledger-entry, score-chip, verdict-badge and provenance, the score chip also carrying data-verdict — which is the hook a real gate would use later.

What is tested

Twenty-four unit tests, all pure logic, no rendering, in three files:

FileTestsCovers
components/patterns/listUrlState.test.ts12clean URL reads defaults, every param parsed with and without ?, fallback on unaccepted values, prefix namespacing, nothing written for the default state, foreign params left alone, chips sorted and comma-joined, round-trip, toggleChip order, flipDirection
components/patterns/evidence.test.ts7fact/inference vocabulary buckets, an unknown kind shown as written, a missing kind treated as a fact, citation shortening
components/patterns/scoreChip.test.ts5>= at the threshold passes, below fails, no threshold is none, two-decimal clamped formatting, clamped meter

Run them with npx vitest run src/components/patterns.

The browser test project (*.test.tsx) did not run in this environment, so the three existing component tests were kept passing by construction — every asserted string, role and href preserved through the migrations. Do not read this as "the migrations are covered by passing component tests". They are covered by unchanged assertions and a run that still needs doing: npx vitest run --project ui src/features/personalization.

Where this lives in the repo

Read off release/2026-09-15-rail at 7db4f7f6, the commit that shipped; the pattern-library commit inside it is 044239d2. #339 is merging to main individually behind it.

Related: The Product Design Manifesto is in the repo and The review page, rebuilt.