The Documents Register drawer — the whole truth behind one row
The register row (Story-1514) tells a litigator a document's
posture at a glance. Story-1515 answers the next question —
"open it, tell me everything" — without leaving the register.
Click a row and a right-anchored drawer slides in carrying the full
record: both names (the AI clean name over the original filename it can
never overwrite), the AI summary, the classified properties (document
type, derived filing type/state, tags, proceeding, pages · size ·
status), who uploaded it, and the processing-status
timeline read from document_status_events — the
document's custody trail. A download button presigns the original.
🛰️ Satellite — why a drawer, not a page
The stakes: a litigator triaging a 400-document matter cannot afford a full page navigation to inspect one file and lose her place in the register. The naive fix — a route per document — detonates on contact with the workflow: it unmounts the register, drops the slice/sort/scroll she built, and turns "check this one thing" into a round trip. WHY a drawer: it is an overlay on the same surface — the register stays mounted underneath, the row that opened it stays selected, and closing the drawer returns her to exactly where she was. The detail is additive, never a context switch.
And WHY the timeline matters specifically: in legal work
provenance is the product. "When did this become evidence, who
(or which agent) moved it there, and did anything fail?" is not metadata
trivia — it is the chain of custody. The timeline surfaces
document_status_events as a human-readable trail so that
truth is one click from any row.
✈️ Airplane — the shape of the change
One sustained analogy: the drawer is a case file jacket. The register is the cabinet; each row is a labelled tab. Pull a tab and the jacket opens to show the same facts in full — the cover (names + format), the index card (properties), the routing slip (custody timeline), and a copy slot (download). Nothing about the cabinet changes while a jacket is open.
| Layer | What it does | WHY here |
|---|---|---|
lib/documents/detail/ (pure) |
DocumentDetail view-model + toDocumentDetail / toStatusTimeline adapters + tests. |
The jacket's contents, decided with no React and no fetch — so the mapping is unit-testable and the leaf is dumb. |
components/documents/detail/ (leaves) |
DocumentStatusTimeline + DocumentDetailDrawer, Storybook-first. |
The jacket's look, verified in isolation (loading / error / empty / Mode-1 / failed) before any backend. |
…/document-detail-drawer-container.tsx |
Wires the generated hooks to the leaf. | The one place the live data seam lives — keeps the leaf Storybook-pure. |
lib/documents/gateway.ts + api/internal/documents/[docId]/… |
BFF proxy + assertContract at the trust boundary. |
The browser only talks to /api/internal/*; firm scoping + HR-21 contract parsing happen server-side. |
🔬 Microscopic — the seams that carry the truth
The view-model reuses the row, it does not re-derive it
DocumentDetail extends RegisterDocument. Every Mode-1 column
the table already mapped (names, format, size, status, flags) is reused
as-is; the drawer only layers on the two fields the table does not show —
the AI summary and the uploader. WHY extend rather than re-fetch: one
source of truth for what a document is, so the drawer and the
row can never disagree.
Mode discipline — nullable, never hidden
PRD v2: Mode-2 / AI fields stay nullable until EPIC-009 fills them. A still-processing document shows "processing…" for its name/summary and "—" for the unclassified properties rather than dropping the rows. WHY render the empty truth: the first real run is a brand-new firm with an unprocessed corpus — the drawer must read honestly there, not only when fixtures carry happy data.
The timeline reads document_status_events via src/gen
// gateway.ts — trust boundary (HR-21 / ADR-061)
const upstream = await gatewayResponse<unknown>(`/documents/${docId}/status-events`);
return assertContract(documentStatusEventListResponseSchema, upstream.data, { … });
// container — generated TanStack hook → /api/internal proxy → functions
const events = useAppListDocumentStatusEvents(docId, undefined, { query: { enabled: open } });
<DocumentStatusTimeline entries={toStatusTimeline(events.data)} … />
Firm tenancy is enforced upstream: gatewayResponse attaches
the caller's Clerk bearer and pramaan-functions scopes every row by firm
membership — a document outside the firm 404s, never another firm's
custody trail.
OpensOnRowClick
composition story proving the row-click → drawer wiring. The live path —
generated hooks → /api/internal/documents/[docId]/… proxy →
gateway with assertContract → pramaan-functions — is built
and compiles end-to-end; it goes hot when the register page mounts
RegisterTable with the DocumentDetailDrawerContainer.
One-sentence recall
Click a row → a jacket opens over the same cabinet showing the full record and the custody timeline, with Mode-2 blanks rendered as honest "processing…/—".
Acceptance criteria
- ✓ Opens on row click; full record (both names, AI summary, type, derived filing type/state, tags, proceeding, uploaded-by, pages/size, download).
- ✓ Status timeline from
document_status_events(FN-STATUSEVENTS viasrc/gen). - ✓ Storybook stories for each leaf component (story before live wiring).
- ✓ i18n: all copy via next-intl, keys in both
en.jsonandhi.json(parity verified). - ✓ Design tokens only — no inline hex/rgb/hsl; semantic tokens reused from the register.
- ✓ a11y: keyboard-operable sheet, focus-visible controls, ARIA on the timeline + status.
- ✓ Empty / zero states: empty timeline, loading, fetch error, Mode-1 processing.
- ✓ Tenancy: firm-scoped server-side;
assertContractat the trust boundary (HR-21).
Files changed
17 file(s), +1015 / -0 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +24 | -0 |
| messages/hi.json | Config | +24 | -0 |
| src/app/api/internal/documents/[docId]/download-url/route.ts | App route | +4 | -0 |
| src/app/api/internal/documents/[docId]/status-events/route.ts | App route | +4 | -0 |
| src/components/documents/detail/document-detail-drawer-container.tsx | Component | +74 | -0 |
| src/components/documents/detail/document-detail-drawer.stories.tsx | Component | +115 | -0 |
| src/components/documents/detail/document-detail-drawer.tsx | Component | +190 | -0 |
| src/components/documents/detail/document-status-timeline.stories.tsx | Component | +89 | -0 |
| src/components/documents/detail/document-status-timeline.tsx | Component | +114 | -0 |
| src/components/documents/detail/presentation.ts | Component | +24 | -0 |
| src/lib/documents/detail/adapter.test.ts | Library | +93 | -0 |
| src/lib/documents/detail/adapter.ts | Library | +65 | -0 |
| src/lib/documents/detail/index.ts | Library | +13 | -0 |
| src/lib/documents/detail/types.ts | Library | +54 | -0 |
| src/lib/documents/gateway.ts | Library | +65 | -0 |
| src/lib/documents/route-helper.ts | Library | +55 | -0 |
| src/lib/kubb-client.ts | Library | +8 | -0 |