Agent docs

10 · A browsable list

You were asked for a list, a table, a queue, an index, a "page showing all the X".

You were asked for a list, a table, a queue, an index, a "page showing all the X". This is what that request brings along.

Audience: an agent (or person) asked to build a list, table, queue, or index page.

Read the bundle first. It is the completeness contract: every row is something the request implies whether or not anyone said it. A build that skips a row without hitting its "deviate when" is incomplete, and incompleteness in this class of surface is the single most corrected thing in both apps.

Traits involved: 06 tables, 07 toolbars and filters, 04 loading and states, 05 cards and surfaces, 09 page composition, 02 buttons, 10 overlays.

The bundle#

#Comes alongDefaultLawDeviate when
1Page archetypework surface: full width, no rail, viewport-bound09 §A1a short capped sub-list inside a reading page stays a reading page
2Viewport boundingopt-in and named, so the table scrolls inside its card rather than the page09 §A3never implicit; if you do not name it you did not do it
3Composition orderfixed: title, then toolbar, then content09 §A4no
4Table layerDataTable with features named on06 §A1raw <table> ONLY for dense inline-editable form grids, never for a browsable list
5Card containerthe list-page card is a flex column that owns its scroll05 §A5no
6Horizontal insetexactly one owner per framed container, table goes full-bleed inside it05 §A4no
7Sticky headerneeds an unbroken line to the thing that actually scrolls06 §A9no; a broken chain is why a sticky header silently stops sticking
8Row click meaningexactly one thing per table: navigate XOR expand XOR pick06 §A2no; two selection models on one table is a named bug class
9Tint priorityfixed order: highlight, expanded, click-picked, hover06 §A3no
10Columnsbuilt via col.* factories; tip on any non-obvious header06 §A4no, but see the adoption note
11Width disciplinescroll, never overlap; shed columns when a peek opens06 §A5no
12Selection scopeexplicit, never inferred06 §A6no
13Toolbarone anatomy: search, counted facets, count and Refresh row07 §A1a picker list may use a plain Input instead
14Facetsnever bare labels; each carries its live count07 §A2no
15Filter stateURL params through @versable-git/qsync (batches each tick's writes into one navigation; the react-router entry additionally owns replace + preventScrollReset, the history entry replaces by construction)07 §A3never for a modal-local list
16Selection visibilityevery selection a clear-button would clear is individually visible07 §A4no; a count-only trigger hides its selection and needs chips beside it
17Chips placementapplied-selection chips gather AFTER all controls, never inline between them07 §A4no
18Count linehonest and situational: "N of M" only while filtered, else the plain count07 §A5no
19Search controlFilterBar's model-bound search for a browsable catalog, a plain small Input for a short picker list; debounced via the kit's useDebounce at its default (the primitive owns the number)07 §A6no
19bSearch escalationwhen local search runs over a partial window, offer a trailing link to search the full set on the server07 §A5a fully-loaded list has no partial window, so no escalation
20Sibling mirroringa sibling list's toolbar is the spec for this one07 §A8no; divergence between sibling lists is a defect, not a choice
21Boot skeletonthe page's own layout, routed, not a generic loader04 §A10no
22Skeleton fidelitypreserves the box, and renders anything known at build time as itself04 §A2, 04 §A3no
23Empty statesthree-way branch: truly empty, filtered to nothing, still loading04 §A7no; one generic empty state fails this row
24Empty state tonecarries meaning, and may carry an action04 §A8, 04 §A9no
25Mutation feedbacksettles into a toast, owned by a component that survives the success02 §A6, 10 §A8no
26Disabled actionsa button that cannot act is hidden or explains itself, never silently disabled02 §A4no
27Post-create return?hl=<id> returns you to the list without losing what you made06 §A3SPEEDWAY ONLY today, see divergence note below

Two cross-app divergences, verified 2026-08-13#

Rows 15 and 27 are the two places this bundle was checked against both apps rather than assumed. They came out differently, and the difference matters to anyone adopting the recipe in a third app.

Row 15 holds in both, but only half of it. 07 §A3 states one clause with two parts, replace: true, preventScrollReset: true. They have different adoption.

replace: true is genuinely cross-app. Walmart's are at walmart-mvp/frontend/src/pages/ErrorManagement.tsx:524,538,1038,1050, and speedway writes it in four files. So the back button deliberately does NOT undo a filter in either app, and a QA heuristic that flags that as a bug is wrong.

preventScrollReset: true is not cross-app. Walmart has none at all, and speedway carries it in one file. So the second half of the clause is an adoption gap in both apps rather than settled practice, and a third app should treat it as a decision to make rather than a convention to inherit.

Row 10 is law with almost no adoption. 06 §A4 states columns are built via col.* factories, and exactly one file in either app does it: walmart's Jobs.tsx, twice, at :536 and :548. Everywhere else both apps hand-build column objects with inline render functions, including for the shapes the factories exist to cover. The rule is still the rule and the factories are real, so build to it. You are joining a convention with one prior caller rather than following a beaten path, and that a grep suggesting wider use is probably catching speedway's unrelated Firestore col helper.

Row 13's kit component has no product callers. The kit ships a real FilterBar at packages/ui/src/table/filter-bar.tsx, fully implemented; its one first-party call site is DataTable's own default toolbar (data-table.tsx:174-176), which no product reaches, because every shipped features block configures only sort, search and pagination and the page-level lists pass their own toolbar. Playground demos do exercise it (demo/review, demo/modules/[slug], the data-table gallery). Speedway's FilterBar is its own app-local component typed against the kit's model, and walmart's equivalent is FacetToolbar. So a third app choosing a toolbar today is choosing between a kit component proven only in demos and two divergent app-local precedents, and the docs should not pretend that is a settled question.

Recorded here rather than quietly averaged because the first draft of this very section certified the whole clause after checking only replace, two paragraphs above where it correctly caught the same error in ?hl=. Checking one half of a two-part rule and generalising is the failure this section exists to prevent, and it is evidently easy to make while writing the warning against it.

Row 27 does not hold in walmart. The canon calls ?hl= "the app-wide convention" at 06-tables.md:16, but walmart has no ?hl= handling anywhere in frontend/src. The convention is speedway's. Until walmart adopts it or the canon scopes the claim, treat row 27 as a speedway default rather than a law, and expect a third app to need the decision made explicitly.

Worked example: walmart Jobs#

walmart-mvp/frontend/src/pages/Jobs.tsx, at commit d8d9ea7.

A jobs list with expandable rows. It is the better of the two examples for studying rows 4, 8, 13 and 21 together, because its expanded row forced the row-click question to be answered explicitly.

Bundle rowHow this surface does itWhere
4, table layerDataTable driven by useDataTableJobs.tsx:705
8, row clickexpand only; the stepper and parts table live in the expanded row, and only one row opens at a timeJobs.tsx:682, :506
13, toolbarFacetToolbar with an extra slot for the review chip and a trailing slot for searchJobs.tsx:910, :946
21, boot skeletonTableSkeleton fed the REAL live columns, with a ToolbarSkeleton whose facet count matches the real toolbarJobs.tsx:894
23, empty statesa real three-way branch, PageError with a Retry, then TableSkeleton while null, then EmptyState when loaded and emptyJobs.tsx:888-900

The skeleton line is the one worth copying. TableSkeleton<JobListEntry> takes columns={liveColumns}, the same array the loaded table renders, so the skeleton cannot drift from the page. That is 04 §A3 made structural instead of remembered. (The kit's Table carries the same guarantee as a built-in mode, skeleton at table.types.ts:96-98, shipped since before 0.0.1; 12-primitives-and-rules.md records walmart's component as a hand-copy of it, so copy the principle, not the component.)

Do-nots#

Mined from the correction corpora of both apps. Every one of these was built, shipped, and then caught by a human looking at the screen. They are ordered by how easily they survive a code review, because the ones at the bottom are invisible in a diff and only show up in a browser.

Do not let a row's state have two sources of truth. An expanded row and a selected row tracked independently will disagree, and the disagreement shows as a row that looks open while behaving closed. One state, one owner. Row 8 exists to prevent this and it is the most-repeated table defect in the corpus.

Do not render a caught fetch failure as the empty state. "Nothing here yet" and "we could not load this" are opposite facts. Row 23 branches three ways for this reason, and a silent catch collapses two of the branches into the wrong one.

Do not batch nothing. Two setSearchParams calls in one tick lose a write: each derives from the not-yet-navigated location, and the last navigation wins wholesale. The probe that shipped @versable-git/qsync proved this holds for the FUNCTIONAL updater form too on react-router 7 (observed on 7.18.2: packages/qsync/src/probe.react-router.test.tsx), so no setter form is safe on its own. Through the qsync adapter the rule is structural: a tick's writes coalesce into one navigation. A hand-rolled site keeps this as law: one atomic update per tick. It cost a real debugging session before it had a name.

Do not let a nav badge count go stale. A sidebar count that refreshes on route change but not on the mutation that changed it will contradict the list the user is looking at. Invalidate on the write, not on the navigation.

Do not size columns by habit. A column gets the width its content needs. A count column does not need the width of a name column, and the space it wastes comes out of the column that needed it.

Do not make a clickable cell look like text. If a cell navigates, it says so before the click: an underline on hover, and a tooltip naming the destination. Silence plus a cursor change is not an affordance.

Do not separate a table header from its body by weight alone. Bold text on the same background is not a header row. It needs its own fill.

Do not mismatch the type scale of controls that sit in one row. Sibling controls at different sizes read as different importance, which is a claim you probably did not mean to make. This one has independent evidence in both apps.

Do not hand-write skeleton headers. A skeleton that re-authors its column labels while the page renders from live columns will drift, and the drift is invisible until someone puts the two on screen together. Feed the skeleton the same column array. Row 21 and 22.

Do not put chips inline beside their own facet. They push every later control sideways, so the row of triggers stops lining up as filters apply. Row 17, fixed at commit 5fa032c, and note that the fix had to reach both the shared toolbar and the one hand-built row, not just the shared one.

@versable-git/ui · reference, canon, and method, read in place