PR: #571: feat(app): the Record register — saveable views, filter bar, side-peek [Story-2295] by @pramaan-board-bot[bot]
View PR →

Story-2295: the Record register — saveable views (B, EPIC-2270)

The Record is one dataset. What changes is how counsel looks at it — and that way of looking is a thing you can name, save, and come back to. The GitHub-Projects model, worded for lawyers.

The idea in one line

A view = columns + grouping + row order + filters, saved under a name. Narrowing the register is not a mode you fall out of; it is a draft of a view, and the register offers to keep it.

The uncomfortable thing this ticket found

There were two registers, and the features this Story asked for were already built — in the wrong one.

documents/registershared/register
Wired to a routeYes — /matters/[id]/recordNo — stories only
DataReal API + RSC bootstrapfixtures.ts
Saved viewsServer-persisted (document_saved_views)—
Filter bar / peek / arriving strip / panMissingAll present

The prototype's own header said it: "Prototype gate: mock data only … live wiring is founder-gated." It had zero importers. Its filter bar rendered its placeholder as a <span> — it could never be typed into. This is the exact failure AGENTS.md names: done at the component boundary, never at the system boundary. Fixtures lie in your favour.

So: port, don't rebuild. The live tree owns the load-bearing engine (server persistence, i18n, bundles, view-state). The prototype owned UI shells. The shells moved onto the engine, and the prototype was deleted (33 files) — the founder-gated door opens the moment its parts ship live. Old and new do not coexist.

Two conflicts, resolved against the live contract

ConflictFrame saidShipped
Side tokens 3 (Client's / Opponent's / Other) All four — the backend enum has ours | opposing | court | others, and a court order is not "Other". The frame's three predate the fourth.
Arrival stages Received → Scanned → Read → On the record All four, but each pinned to a real pipeline signal. No stage is animated on a timer. A progress bar that lies is worse than none.

Rule applied: the frame loses to the live contract.

The engine (pure, unit-tested)

lib/documents/register/filters.ts — the filter bar's algebra. One box, no query language.

Why same-kind must OR. Two values of one scalar field can never both be true of a row. Had same-kind chips ANDed, Side: Ours + Side: Opposing would match nothing and the register would go permanently blank. There is a test named for exactly this.

Persistence: no backend change

The saved-view contract already carried an opaque filters jsonb (it was holding the slice value and the hierarchy toggle). The filter bar's query + chips ride the same door.

viewStateToFilters(view) → { hierarchy, sliceValue?, query?, chips? }

So "a view = columns + grouping + order + filters" is true end-to-end, persisted server-side, per matter, shared with the team — and the Zustand fallback the brief allowed for was never needed. A malformed chip from an older client is dropped, not fatal: a saved view must always open.

What the default view now is

The surfaces

SurfaceNote
Filter bar + Narrow-byA real input, with typeahead, chips, "N of M", / hint. Narrow-by shows only while already filtering — a quiet register gets no extra chrome.
Header9 documents · 14.2 MB + the amber N need attention chip. Attention = needs_review | unreadable | failed only. A still-processing row is not attention, it is patience; a chip that always shows is a chip nobody reads.
Arriving stripOptimistic upload rows above the settled record. A row leaves the strip the moment its documentId appears in the corpus — the only honest definition of "on the record".
Side peekSlides over the table on space. Shows what the row actually carries; the frame's summary/provenance/notes do not exist on the corpus contract, so it links to the permalink (Story-2294) where that dossier already lives — rather than rendering invented placeholders.
Horizontal panTable exceeds its container; select + format + Document name stay pinned. Losing which document a row is while reading its far-right columns is the failure this prevents.
Row hover clusterPreview · copy permalink · download · details. Stays mounted and fades in, so the row never reflows under the pointer.
Keyboardj/k · space · enter/o · / · v · esc. Suppressed inside inputs, so typing into the filter box does not page the register underneath the caret.
A privileged row never peeks. A preview is a disclosure. Non-counsel gets a panel that opens and refuses — not a silent no-op, and not a preview.

Why arrivals are a context, not the Zustand store

The Import drawer and the register are siblings under the matter shell; the drawer owns the upload queue. The in-flight items cross through a React context the shell hosts (matter-arrivals.tsx).

Deliberately not HR-23 Zustand: in-flight uploads are ephemeral and must never persist. A rehydrated "arriving" row after a reload would be a ghost document that never lands. The store is for client state worth keeping; this is client state worth forgetting.

Bundle grouping still works inside views

The Story-2281 family collapse (parent_document_id) is untouched: the bundle filter still runs before the engine, so group summaries and slice counts describe what counsel is actually looking at. The filter bar was inserted at the same point in the pipeline, for the same reason.

Verification

pnpm lint (0 errors) · pnpm test 1151 passed · pnpm build · pnpm storybook:check · pnpm check:routes (38 surfaces fresh). i18n parity en/hi verified programmatically. No colour literals in the diff.
Not walked live by the agent. The live sign-in needs Clerk Development credentials this agent does not hold, and this repo has no test-auth persona path. The following flows are verified in Storybook and by unit test, but have not been exercised against matter 15 with real documents: the grouped-by-side first paint, save/switch of a view carrying filters (persistence across reload), the peek on a real row, and the arriving strip during a real upload. These are listed in the PR for the merge walk. Storybook proves the component renders; only the live flow proves the feature works.

Files changed

68 file(s), +2598 / -2820 lines

Path Role + -
messages/en.jsonConfig+55-2
messages/hi.jsonConfig+55-2
src/components/documents/register/presentation.tsComponent+22-0
src/components/documents/register/register-arriving-strip.stories.tsxComponent+104-0
src/components/documents/register/register-arriving-strip.tsxComponent+152-0
src/components/documents/register/register-document-peek.stories.tsxComponent+76-0
src/components/documents/register/register-document-peek.tsxComponent+221-0
src/components/documents/register/register-document-row.tsxComponent+97-16
src/components/documents/register/register-filter-bar.stories.tsxComponent+130-0
src/components/documents/register/register-filter-bar.tsxComponent+288-0
src/components/documents/register/register-header.stories.tsxComponent+36-0
src/components/documents/register/register-header.tsxComponent+60-0
src/components/documents/register/register-table.tsxComponent+55-12
src/components/documents/register/register-view.tsxComponent+146-23
src/components/documents/register/saved-views-register.tsxComponent+48-11
src/components/documents/register/use-register-cursor.tsComponent+54-0
src/components/documents/register/use-register-keyboard.tsComponent+104-0
src/components/matters/import-into-matter-sheet-v2.tsxComponent+7-1
src/components/matters/live-matter-documents-page.tsxComponent+24-7
src/components/matters/matter-arrivals.tsxComponent+98-0
src/components/matters/matter-intake-drawer.tsxComponent+32-2
src/components/matters/matter-shell-chrome.tsxComponent+10-4
src/components/matters/use-matter-import-v2.tsComponent+1-0
src/components/shared/register/arriving-row.stories.tsxComponent+0-53
src/components/shared/register/arriving-row.tsxComponent+0-100
src/components/shared/register/badges.stories.tsxComponent+0-67
src/components/shared/register/badges.tsxComponent+0-137
src/components/shared/register/copy.tsComponent+0-175
src/components/shared/register/document-peek.stories.tsxComponent+0-52
src/components/shared/register/document-peek.tsxComponent+0-163
src/components/shared/register/filter-bar.stories.tsxComponent+0-58
src/components/shared/register/filter-bar.tsxComponent+0-162
src/components/shared/register/fixtures.tsComponent+0-216
src/components/shared/register/grid.tsComponent+0-11
src/components/shared/register/grouping.tsComponent+0-25
src/components/shared/register/modified-view-bar.stories.tsxComponent+0-42
src/components/shared/register/modified-view-bar.tsxComponent+0-72
src/components/shared/register/presentation.tsComponent+0-83
src/components/shared/register/record-empty-state.stories.tsxComponent+0-18
src/components/shared/register/record-empty-state.tsxComponent+0-26
src/components/shared/register/register-group-header.tsxComponent+0-50
src/components/shared/register/register-header.tsxComponent+0-32
src/components/shared/register/register-row.stories.tsxComponent+0-50
src/components/shared/register/register-row.tsxComponent+0-166
src/components/shared/register/register-table.stories.tsxComponent+0-23
src/components/shared/register/register-table.tsxComponent+0-123
src/components/shared/register/register-view.stories.tsxComponent+0-109
src/components/shared/register/register-view.tsxComponent+0-182
src/components/shared/register/tag-browse-rail.stories.tsxComponent+0-20
src/components/shared/register/tag-browse-rail.tsxComponent+0-114
src/components/shared/register/types.tsComponent+0-90
src/components/shared/register/use-register-keyboard.tsComponent+0-77
src/components/shared/register/view-menu.stories.tsxComponent+0-23
src/components/shared/register/view-menu.tsxComponent+0-117
src/components/shared/register/view-tabs.stories.tsxComponent+0-23
src/components/shared/register/view-tabs.tsxComponent+0-66
src/lib/documents/register/arrivals.tsLibrary+86-0
src/lib/documents/register/filters.test.tsLibrary+219-0
src/lib/documents/register/filters.tsLibrary+202-0
src/lib/documents/register/index.tsLibrary+23-0
src/lib/documents/register/saved-view.test.tsLibrary+46-0
src/lib/documents/register/saved-view.tsLibrary+36-2
src/lib/documents/register/types.tsLibrary+10-0
src/lib/documents/register/view-edits.test.tsLibrary+3-2
src/lib/documents/register/view-edits.tsLibrary+36-0
src/lib/documents/register/view-mutations.test.tsLibrary+2-0
src/lib/documents/register/view-state.test.tsLibrary+6-2
src/lib/documents/register/view-state.tsLibrary+54-9