One record unfolded — as a page, and as the peek that opens beside the list it came from.
Page-class laws·docs/detail.md·196 lines
Kit src/components/detail/Living example · Deals
How every detail page — the screen one row of a data view opens into — is built, and the peek it opens into first. The kit is src/components/detail/ (+ PageHeader's eyebrow slot and the dashboard's StatTile for figures); the living examples are the deal detail (/labs/apps/crm/deals/[id]), the filing review (/labs/card-list/financial-statements/[id]), and the tasks board's peek (/labs/apps/tasks/board). Same contract as data-view.md: the kit enforces what it can with defaults, this file records the rest.
01
A detail page answers three questions in fixed order: which record is this (identity band), how is it doing (figure strip), everything else (feature cards + fact rail). The two pages that predated this kit had already agreed on that order and then drifted on every particular — one used a breadcrumb, one a bare arrow; one rebuilt the header band by hand, one drew a plain row; two figure-tile recipes, two fact-row styles. The kit makes the agreement structural.
02
In yPulse, opening a record defaults to a side panel. Works and Matrix are the references: the operator keeps the list in place while inspecting one record, checking its modules, making a small edit, and moving to the next. This profile takes precedence over the compact triage peek rules below; it does not change the yBlocks examples' existing navigation.
| Intent | Default surface |
|---|---|
| Inspect a record, its metrics, history or configuration | Modular detail panel beside the list |
| Make a small edit or perform a record action | The same panel, with visible pending, success and failure feedback |
| Edit for a sustained session, use a large canvas or complete a complex multi-step workflow | A dedicated workspace when the task needs it |
Sheet is valid here. On narrow screens, use a full-width modal detail surface with an accessible title, a visible close control and correct focus handling.usePeek replaces the query entry when opening, switching and closing; browser Back is not a panel-close control. Closing is explicit (close button or Escape).The identity, status, action scope, figure reconciliation and mutation feedback laws below still apply. The 22rem width, mandatory page link and content restrictions below apply specifically to the compact triage peek.
03
The page is not the only way to open a record, and the second way is not a smaller page — it answers a different question.
| Question | Surface | Costs | |
|---|---|---|---|
| Page | "Tell me everything about this record." | /list/[id] | The list: its scroll, its filter, the column the row sat in. |
| Peek | "Is this the one?" | DetailPeek beside the list | Nothing. |
Triage asks the second question once per row, and paying a navigation for it destroys the very thing being triaged. So a list-shaped view opens the peek, the peek links to the page, and the cut between them is width: the moment a reading needs room or completeness — a full event log, a sub-task table, a dependency list as rows, a figure strip — it belongs to the page. A peek that kept growing would end up being the record, and then the list beside it is permanently half-width for no gain.
Not every view wants one. Peek where the loop is scan → judge → next (list, table, board, my-work); go straight to the page where a click means trace this back (a calendar cell, a feed entry, an aggregate).
<PeekLayout peek={selected ? <TaskPeek … /> : null}>
<DataViewShell>…the list…</DataViewShell>
</PeekLayout>| Region | Component | Rule |
|---|---|---|
| Split | PeekLayout | Wraps the whole DataViewShell — two sibling cards, not a column inside one. Forced, not stylistic: the shell's overflow-hidden makes it a scrollport, and a sticky panel inside it resolves against the card instead of the viewport. peek={null} when nothing is selected; never hold the space. |
| Panel | DetailPeek | Sticky column on lg+ under --app-header-height + --page-band-height; a right-hand drawer with a scrim below it. One DOM node, one mount. |
| Selection | href (required) | Every peek links to its page. A peek with no way through is a detail page that lost its content. |
| Queue | pager slot | Prev / next over the view's own ordering, derived from the same predicate the list renders — never a second hand-written order. |
| Blocks | PeekSection + FactRow | Dashed hairlines, mono labels — FactCard's grammar without the card, since a card inside a 22rem column is a surface inside a surface. |
| Action | footer slot | One action, the one triage takes, writing through the same mutation the list does (same toast, same Undo). |
usePeek, never useState — deep-link safety (below) is not waived because the surface is small. replace, not push: a triage queue must not bury the page the user arrived from under twenty history entries.Sheet is wrong here — it is a modal dialog, and modality is the one property this surface must not have.04
<DetailHeader
backHref="/labs/apps/crm/deals" backLabel="Deals"
title={deal.name}
status={<StageChip … />}
meta={deal.company}
chips={<><MetaChip … /><MetaChip … /></>}
pager={<DetailPager prevHref=… nextHref=… position="3 / 12" />}
actions={<><Button variant="outline">…</Button><Button>…</Button></>}
/>
<PageContainer>
<DetailBody rail={<FactCard title="Details"><FactRow …/></FactCard>}>
<FigureStrip>
<StatTile label="VALUE" value={…} />
…
</FigureStrip>
…feature cards…
</DetailBody>
</PageContainer>| Region | Component | Rule |
|---|---|---|
| Identity band | DetailHeader | It is a PageHeader (breadcrumb in the eyebrow slot) — never a hand-built band or a plain row in the body. Title + status chip + one meta line; record actions right, primary action right-most. |
| Return path | breadcrumb (in DetailHeader) | A breadcrumb, not a bare arrow: it names where "back" goes and survives deep links, where history is empty and an arrow is a dead end. |
| Sibling nav | DetailPager | Prev / next within the record's own list, so working a queue never bounces through the list page. Ends render disabled, not hidden. |
| Fact chips | MetaChip | icon · label · value chips on the band's toolbar row — the record's ambient facts (currency, confidence, owner). |
| Figure strip | FigureStrip + StatTile | Up to four headline readings, derived from the same data the cards below render — the reconciliation rule. Same tile as the dashboards; tones mean the same thing everywhere. |
| Body | DetailBody | Fluid main column + fixed 320px fact rail (rail slot); rail collapses below lg. No rail-worthy facts → omit the slot, never fake a second grid. |
| Fact rail | FactCard + FactRow | Label · value rows, dashed hairline dividers. Values keep their own rendering (avatar, mono date, badge). |
| Feature cards | standard Card | One feature, one card — timeline, tabbed statements, contact; the cross-class law applies unchanged. A history or activity card renders the timeline kit (src/components/timeline/), never a hand-drawn rail. |
05
status slot), not to a corner of some card. When an action changes the state (Mark won, Approve), the chip is where the change must show.notFound() for unknown ids.sonner, since the changed chip alone can be missed.06
<list>/[id] beside its list; generateStaticParams over the dataset, notFound() on a miss.DetailHeader — breadcrumb to the list, status chip, meta line, MetaChips, DetailPager over the same ordered dataset the list renders.FigureStrip of StatTiles first, then one standard Card per feature; facts into the rail as FactCards.07
border-b bg-card + container) instead of DetailHeader — that's a second PageHeader.<h1> inside the body repeating the record name.rounded-xl border bg-card p-*) — that's StatTile.Sheet, or any surface that must be dismissed before the next row can be clicked.