Agent docs

6 · Tables

The kit's richest component and the usage grammar around it.

The kit's richest component and the usage grammar around it. Kit source: packages/ui/src/table (layered: presentational Table, independent feature hooks, orchestrated DataTable). Evidence registers under docs/evidence/20260807-0230-speedway-design-language/ in this repo.

Audience: an agent (or person) building or reviewing a table.

Travels with: 07 (its toolbar), 04 (skeleton and the three empty states), 05 (the card it bleeds inside), 10 (the peek or modal a row opens), 09 (work surface), 02 (row actions). Request-shaped bundle: AP-10.

§A Principles and mental models#

1. Pick the right layer.

  • Table (Layer A): draws exactly the rows given. Sort is affordance-only. For short, capped lists (a job's max-30 runs, an org's members: "auto layout, not table-fixed: a members table is small").
  • DataTable (Layer B): filter → search → sort → paginate pipeline (fixed order, so search never narrows just the current page), auto selection column, rowMenu, default toolbar, pager. "Turn features on by naming them in features; anything you leave out is off."
  • Raw <table>: legitimate only for dense inline-EDITABLE form grids (field editors, sample previews), never for browsable lists.

2. Row click means exactly one thing per table. Navigate (jobs list → job detail), or toggle expansion (when renderRowDetail exists, the name link becomes the way in), or quiet-pick (clickSelect tint). Never two. With checkbox selection outside the model, pass clickSelect={false}: "two selection states on one table diverge."

3. Tint priority is fixed. ?hl= highlight (bg-primary/10, scrolls into view once per key) > expanded (bg-primary/5) > click-picked (bg-primary/5, "open and picked read as one state", owner 2026-07-29) > hover. The click tint resets on page-flip, "the page number is the cheapest correct invalidation." ?hl=<id> is the app-wide "create lands you back on the list without losing the thing you just made" convention.

4. Columns encode meaning, via factories. col.mono (ids/SKUs), col.number (right-aligned tabular), col.date, col.status (StatusPill from a mapper), col.actions. Column tip explains non-obvious columns. Cell helpers are explicit, "a value only becomes a link/date/code because you asked, never by accident." Rich cells: name + mono sub-line, FileChip + +N overflow, clickable counts (muted 0), slim progress bar + stage caption, consolidated ranked status badges. An entity in a cell (a person, team, file, stage) is an identity row, never plain text: it stays on one line, a long title truncates (an identifier cuts its middle), the second line rides a two-line tooltip, and a blank value says N/A. Use col.entity.

5. Width discipline: scroll, never overlap, shed for peeks. Name what each column holds and write no widths. A column's shape gives it a floor, a cap and a wrapping rule: ids and names never wrap, numbers, dates and status hug their content, and text truncates with its full value on hover. Spare width goes to the columns that can use it. When a table cannot fit its floors it sheds its lowest priority columns and says "+N columns" in the last header, or scrolls sideways with an edge shade; it never overlaps ("owner: never overlap"). table-fixed and a hand-added minWidth are retired for shaped columns. When a peek panel opens, shed the widest low-priority column instead of squeezing all. Expanded detail rows pin sticky left-0 to survive horizontal scroll.

6. Selection scope is explicit. Kit selection is page-scoped ("select all" = visible page). Whole-filter selection ("select all in filter") lives OUTSIDE the model with hand-placed SelectAllCell/SelectionCell + clickSelect={false} (the review queue). Row keys must be globally unique across the table's real domain (the job-qualified composite key lesson: item ids were only unique per job).

7. One implementation, reused. Shared list surfaces get ONE component reused everywhere they appear (JobsTable across Jobs page + every module tab; one buildPartColumns for both parts tables; one AttrDefsTable rendering page and panel densities via a variant prop, "a peek is a placement change, not a re-authoring"). Stated in doc comments so the surfaces "can't drift."

8. Header and pager rulings. Headers render as authored, Title Case, gray band (owner rounds 2026-07-10/16). Sort caret always trails the label. The sort button sizes to content so every header's affordance reads the same. Pager: windowed tokens keep fixed width at any depth. Cursor mode for keyset lists. Ctrl+Shift+arrows go to the pager inside an open modal first. copyCells puts the copy button beside the value, not at the column edge. It is opt-in per table rather than a default: a read-only data table should take it, editable grids should not (the value is already selectable there, and a copy button competes with the input). The sibling rule for generated content in overlays is 10 §A12, which draws the same data-plane line one layer up.

9. A sticky header needs an unbroken line to the thing that scrolls. position: sticky resolves against the nearest scroll container, so any wrapper between the header and the real scrollport captures it and pins it to a box that never moves. Two consequences, both measured rather than reasoned. The table takes overflow-x only when it declares minWidth or maxHeight, because overflow on one axis makes an element the scrollport on both; a table that needs to scroll sideways says so with minWidth. And the common idiom for clipping a full-bleed table's corners, overflow-hidden on the wrapper, silently kills the sticky header: use overflow-clip, which clips identically and does not create a scroll container. With maxHeight the table owns its scrollport and top-0 is right; without it the page is the scrollport, and since the shell insets its content the header cancels that inset through --shell-inset.

§B Presets#

SituationPreset
Browsable listDataTable + FilterBar toolbar + pager (25/50/100); the kit bleeds by default in a card, bleed={false} keeps the inset
Capped sub-listplain Table, maxHeight cap, scrolled not paged
Editable gridraw table-sm, native controls, cell-level skeletons
List + detailexpandable rows (one open at a time) or Eye-icon → modal
Bulk work surfaceout-of-model filter-wide selection, clickSelect off, warn-tint in-flight rows
Post-create returnnavigate with ?hl=<id>
In-modal tabledensity compact, max-h-[48dvh] scroll, column cap + footnote
Read-only data tablecopyCells on; skip it on editable grids

§C Use cases through Speedway#

  • JobsTable: navigate-or-expand duality, ProgressCell only when expandable, StagePill otherwise. minWidth 1436/1536. Export toast warns on truncation.
  • Parts tables: ?hl=, copyCells, active-row tint correlating with the open preview dialog, rowMenu only on the standalone page.
  • Review queue: 2000-row server window ("a 100-row window made the queue look mysteriously short", owner 2026-07-25), fixed px widths on every column, detailIndicator off (the Fix caret owns it).
  • Log viewer: NOT a table. Scan-mode custom markup with level badges and client-side tail search (run.tsx:512-537). Debug page: CodeBlocks + Tabs, no tables.
  • Audit log (review/log.tsx:633-718): IS a table, a full DataTable + FilterBar, distinct from the scan-mode Log viewer above; toolbar detail in 07 §C.
  • Admin: team/teams on DataTable with the house full-bleed trio. Diagnostic screens (scrape-requests, usage limits) on raw tables, flagged as the bounded rough tier.
@versable-git/ui · reference, canon, and method, read in place