Data display
Data display
The data lane's shipped surfaces
A hidden column leaves the search, and keeps its sort.
Rows are built from the layout primitives and the Components.
The lane
Statuses come from .agents/slop/COMPONENTS.md.
Data display · 15 rows in .agents/slop/COMPONENTS.md · 15 shipped · 0 to build
| Component | Status | GPUix | Built on, and its limits |
|---|---|---|---|
| Tables | done | yes (measured) | @gpuixui/data ships Table, TableViewport, TableHeader, TableBody, TableRow, TableHeaderCell, TableCell, TableCaption, TableEmpty and TableSkeleton; 33 native behaviour cases mount the example. No <table> element: a column of div rows in one horizontal scroller. The fixed header is the measured sibling rule — the header sits outside the vertical row scroller and inside the same horizontal scroller, so a wheel over the rows moves only them and a sideways wheel moves both (data-grid-fixed-header; table-lifecycle measured a header moved into the row scroller painting at y = -36). The body's height is measured, not flexed: 0.11.0 sizes an overflow-y: scroll box from a definite height, and a flex-1 body inside the horizontal scroller grew to its content (8 880px for a 320px table in the package suite), so Table reads the viewport's and header's onLayout heights and hands the difference to the body. A frozen first column ships as FrozenTable: its own vertical scroller beside the grid's, mirrored both ways in onScrollChange → renderer.scrollTo with a re-entrancy guard (data-grid-frozen-column, frozen-column-lifecycle). The panes' headers take the taller of their two measured heights, the sideways pan is the data pane's alone, and a press on a frozen cell chooses the record for the whole grid; src/behaviour/frozen-table.test.tsx (5 cases) mounts the shipped example and measures the seam. The rows are stored data: rows is read through listOf, so the null a saved workspace holds — or the undefined an app's snapshot?.rows hands over before the snapshot arrives — fills no pane and the data pane paints its empty message instead of the first render throwing Cannot read properties of null (reading 'map'); the grid controller reads its own row list the same way: useTable and constructGrid hand the core listOf(rows), so an absent list paints 0 rows and the grid's empty state instead of the core's data access throwing Cannot read properties of null (reading 'length'), and a filter tree narrows that same list rather than the stored value; src/behaviour/stored-rows.test.tsx (14 cases) mounts both absences for the frozen grid, the controller and the tree, with a readable list as each one's own control. Both scrollers take the grid's `height` less the taller measured header, and a height the layout cannot paint is no height — the composition takes the pane's share and bounds them from the box it painted, the rule Table, VirtualTable, VirtualTree and TreeView already followed; measured in a 320-point pane, a NaN height painted both row scrollers at 0 against the 276 a readable height gives them (src/behaviour/paintable-height.test.tsx, 7 cases). Its two widths are read the same way: frozenWidth and contentWidth are computed as often as the height, and a width the layout cannot paint (NaN, an infinity, a negative) is no width — the frozen pane keeps the track its own cells declare and the data pane's column takes the pane it sits in — where the composition handed both to the layout raw; measured in a 1280-point frame with a 120-point frozen cell, frozenWidth={-180} painted no frozen pane at all (0) and gave the data pane the whole frame, and contentWidth={-420} painted the data pane's column at 0, so the scrolling header and rows had no box (src/behaviour/paintable-width.test.tsx, 3 cases). An empty set is the grid's, not the body's: a zero-row grid paints TableEmpty in the data pane — No rows when the caller names no empty, a string as that cell's own text — because a body with no child would paint the loading skeleton and a role-less message would sit outside the grid the tree describes. A blank empty reads as No rows too (emptyMessage, shared with VirtualTable): a default written empty ?? "No rows" only covers an absent prop, and a locale table's missing row answers "", which painted an empty row and cell with no words under them; src/behaviour/frozen-empty-a11y.test.tsx (3 cases) reads the tree and holds the one row and cell. A press stops at the first child that paints a fill, not at the innermost handler (press-routing-lifecycle: a filled text ran none, an unfilled one ran cell-click,row-click), so a composed cell control that must hand the press to its row declares pointerEvents: "none" — the rule Badge and Keycap follow; TableCell paints no fill of its own. The pointer's hover takes the same hit test (hover-routing-lifecycle): an unfilled label leaves the row its wash and its enter, a filled child takes both, and an inert child opts out while still running its own listener, so a decorative pill in a cell declares pointerEvents: "none". The empty state is a row holding one cell: TableEmpty used to be a role-less div inside the body's rowgroup, so the accessibility tree described a RowGroup with the message's text directly under it and no row or cell at all (measured: four nodes, no Row/Cell); it now writes role="row" and a role="cell" box that carries the recipe's centred column, so the paint does not move and the message sits inside the grid. A string child paints as the cell's own `<text>`, the rule TableCaption follows: a raw string in a box paints but produces no accessibility node, so empty="No matches" was silent. src/behaviour/table-empty-a11y.test.tsx decodes the rendered tree and holds both (both cases fail with the tree has no Cell node when the role-less box is restored) plus the string case (it reads no Label with the old box). A disabled row's state is behaviour, not ARIA: the runtime's aria-* set is closed and carries no aria-disabled (docs/gpuix-capabilities.md), so TableRow writes none — a disabled row installs no press handler and paints no pointer state, and the reason belongs in a cell's text. It used to write aria-disabled, which the runtime drops, so a reader heard an ordinary row while the component believed it had said something; src/table/aria.test.ts sweeps every shipped source file for an aria-* write outside the measured seven (restoring the attribute fails it naming table/table.tsx: aria-disabled), holds the row's docs to the fact and the consequence, and checks the capability record still publishes the set. The row's rule is derived from the fill it crosses: a row is a surface of its own (card at rest, selected chosen, accent under the pointer, the derived press wash while down), and the raw border role is under the 1.05:1 floor on 22 of the 90 shipped wash placements, so TableRow and TableHeader ask the token package's dividerOn(theme, fill) — the authored role where it already clears, otherwise border stepped toward borderStrong; table.test.tsx's derives the rule from the fill the row wears reads the base, hover and press rules off the window (measured: the raw #2A303B against the derived #2E3540 on the dark accent wash). A skeleton mounts the count it can lay out: rows/columns are often computed — a page size read from a config, a viewport divided by a row height — and Array.from({ length: Infinity }) throws RangeError: Invalid array length while NaN mounted no track at all, so a loading table either took the renderer down or painted nothing. trackCount reads a count the renderer cannot mount as the shipped 3 and bounds a finite one to SKELETON_TRACK_LIMIT (20) per side — a skeleton holds the shape of a screen, not a data set — while 0 and a negative still paint one track, so the placeholder keeps a row's shape when a caller computed none. table.test.tsx's mounts a count it can lay out, and a default for one it cannot mounts Infinity/NaN, 10 000, and 0/-4 and counts the Row/Cell nodes in the accessibility tree; direction measured: with the bare Math.max(1, count) the case fails on the RangeError. A cell's own `width` is a track the layout must be able to paint: TableHeaderCell/TableCell take the width a column paints, and the runtime drops NaN, the infinities and a negative (they reach native as null or paint nothing usable), so the cell sized from its own content and a column's header and cells stopped sharing a track — measured in a window, a 62px Name heading above a 204px body cell, the drift a key-value list was already fixed for. A width that cannot paint is now no width, the reading the prop already has when it is omitted: the cell shares the leftover track, the later columns line up too, and a finite width is still the caller's exactly. table.test.tsx's keeps a width that cannot paint on the column's one track mounts NaN/Infinity/-20 and holds the header, its cell and the next column to one track, while keeps the width a caller can paint pins the 160px negative control. A grid's saved state round-trips through one writer: gridConfig(state) is the inverse of readGridState, and useTable().save() composes it from the controller — the sorting, the query and the chosen ids — so a workspace saves a grid without re-deriving the wire shape. The order written is the one the grid runs: a saved sort naming a column this release dropped is not a sort the grid counts or writes back (retainSorting), because the row model cannot compare it while a header's "sort N of M" still would. The ids are the core's, not the retained view, so a chosen record a filter hides is saved and paints chosen again when the query widens; the range anchor is a gesture cursor and starts fresh after a restore. src/grid-core.test.ts holds the reader/writer law and behaviour/table-restore.test.tsx the window round trip. Its page's API tables are held to its types by src/docs-pages.test.ts. A colour role is read through the port that answers its absence: every painter reads themeColors(theme), so a theme a store handed back without the colors group — or with a role JSON wrote as null — paints no fill, no rule and no wash instead of throwing Cannot read properties of undefined (reading 'card') from the first row, while the words, the box and the press stay; src/paint-readers.test.ts scans every source under src/ for the raw group beside the numeric ones. The rows are stored data: useTable and constructGrid hand the core listOf(rows), so a saved workspace whose records have not arrived — or an app's snapshot?.rows — paints 0 rows and its own empty state instead of Cannot read properties of null (reading 'length') out of the build; behaviour/stored-rows.test.tsx mounts the absence beside a readable list. The grid pages: useTable({ pageSize }) runs the core's pagination row model, so the page is the last view over the kept set — a filter, a search or a new sort returns to the first page, a saved index past a narrowed set clamps to its last page instead of painting none, a page size change keeps the reader's top row, and the selection stays the kept set's rather than the page's (pagination/logic.test.ts, behaviour/table-pagination.test.tsx, behaviour/pagination-example.test.tsx). |
| Sortable tables | done | yes (measured) | sort/logic is the press cycle: the first press sorts ascending, a press on the same field flips it, a press on another field starts at its own initial direction. The order is the row model's — @tanstack/table-core via constructGrid, whose registered sortFn_basic compares numbers numerically and text by code unit and keeps ties in source order — so a grid cannot re-sort a second copy of the list. TableSortHeader is the control — the primitives' Button, so Enter-on-key-down, Space-on-key-up and a press all sort — and toggleSort/sortLabel are exported, with constructGrid the seam for an Effect or store-driven grid. table-select-sort measured the painted order changing (name-asc → name-desc, first row Item 199), and the package suite re-measures it on the shipped components. 0.11.0 has no `aria-sort`, so the state travels in the control's accessible name (Name, sorted descending) beside the arrow glyph. A sort is an order, not a field: a plain press replaces it and a Shift press appends the column as the next tie-break (or flips it where it already sits), so records the primary sort ties are ordered by the second — pressSortOrder/sortOrder/pressSort are the pure and controller halves, TableSortHeader's onSortPress reports the raw press with the runtime's modifiers, and the header's glyph and announced name carry the field's place (↓2, "sort 2 of 2") when the order has more than one. grid-core.test.ts holds the core walking both entries on a tied group, and table.test.tsx presses status then Shift-name in the real renderer and reads the tie-break out of the painted rows. A consumer that owns one sort keeps the onSort callback and never passes order. A restored state is read, not dereferenced: constructGrid is the seam a store-driven grid builds with, so its sorting, globalFilter and rowSelection are read through readGridState — a sorting list that is not a list is no sort, an entry that names no column is dropped, a query that is not text is no query, a chosen set keeps only the ids set to true, and a hiddenColumns list that is not a list of text ids hides nothing. Measured before the read: globalFilter: null threw Cannot read properties of null (reading 'trim') and sorting: null threw state.sorting is not iterable, so a grid that remembered its state could not reopen; grid-core.test.ts holds the read and the build. |
| Row selection | done (composed) | yes (measured) | TableRow selected paints the theme's selected wash and selectedForeground label; the chosen ids are @tanstack/table-core's rowSelection state, and selection/logic is the measured gesture rule set — a plain press replaces the set and anchors it, Cmd/Ctrl toggles and moves the anchor, Shift extends from the anchor and keeps it, retainSelection releases a record a narrower set hides, and an arrow step clamps rather than wraps. A chosen row still answers the pointer: its fill stays selected and the pointer moves it a step onto the accent wash (whose label the token contract measures on a chosen row), with pointerWash's derived press over the hover instead of the raw active role; a chosen row with no press handler paints no pointer state it cannot act on (table.test.tsx reads both states off a window). An unchosen interactive row answers the press too — the recipe tints hover and the button-down paints pointerWash(theme, "active", "hover"), the tokens contract's third transition (measured #333A46 in the dark theme) — because a row whose press looked exactly like its hover answered only half the pointer; table.test.tsx's answers a press on an unchosen row holds the declared wash, and the tokens suite holds the derivation over the 18 styles. useTable holds the state and measures a Shift range over the painted (filtered and sorted) order. The release is render-time retention, not an erase: chosen still holds the record, so a query that widens back to it paints it chosen again (table-filter-lifecycle measured 1 rows · 0 selected, then a whole-set query painting 60 rows · 2 selected with the same two records); a grid that needs a permanent clear calls clear() on the query change. The shipped example composes the roving tab stop and walks native focus with renderer.focusElement after the tabIndex commit — a call from the key handler moved nothing, measured, because the row was still outside the tab order — held by the package's keyboard case. The stop always exists: useTable().tabStop is the chosen record or the first painted row, because a stop that waited for a pointer press left a fresh table out of the Tab order entirely (measured through the renderer's own focusNext: Tab off the filter walked the column menu, the header controls and the per-row boxes and reached no row) and a filter that released the chosen record removed the stop a keyboard reader was standing on; table.test.tsx's keeps one row in the Tab order holds the fresh stop, the chosen stop and the stop after the filter releases it. A focused row answers Enter and Space — the activation a focusable div does not have — choosing the record the reader stands on with the press's modifier flags, so Shift+Enter still extends a range; table.test.tsx's chooses the row a keyboard reader is standing on presses both keys in a window. modifier-select-lifecycle measured the chord flags and the selected fill; virtualized-table measured a selection surviving the window. A press on a cell reaches the row only while the cell paints no fill — a filled child is where the hit test stops (press-routing-lifecycle), held by the package's blank-cell press case. The hover wash follows the same boundary (hover-routing-lifecycle): a decorative child opts out with pointerEvents: "none" so the chosen row still answers the pointer. A checkbox column ships: TableSelectionHeader is a tri-state select-all over the visible order (allSelected/someSelected) and TableSelectionCell toggles one record from a box that paints, so the box takes its own press and does not replace the set the row's press made (packages/data/src/selection/column.tsx; three behaviour cases mount the shipped example — a box press after a row press paints 2 selected, the header chooses all 60 and clears them, and a partial set reads mixed). The column is a fixed SELECTION_COLUMN_WIDTH track, and packages/data/docs/table-selection.mdx is its page. The box's painted target follows its row's fill: the target is the hit boundary a box press stops at, so it paints card at rest, selected in a chosen row and the row's accent wash under the pointer — a target fixed to card reads as a notch inside a chosen row — held by table.test.tsx's paints the box in the fill of the row it sits in. A blank select-all name reads `Select every row`: the label defaulted in the destructuring, so a missing i18n row's "" published an unnamed CheckBox while the documented words sat unused; surface-a11y.test.tsx holds the announced name. A row box's blank name reads `Select this row`: a record restored without its title interpolates Select and a saved label answers "", and the box's name is the only way a reader tells one row's box from another; the cell reads it through spokenText, held by the same suite's row-box case. The box's painted target is a role too: with a theme that lost the colour group the target paints nothing, so a box press also reaches its row (the measured control-unfilled route) — the row's press still chooses the record, and the box's own press still toggles it. |
| Column configuration | done (composed) | yes (measured) | @gpuixui/data ships the configuration as app state plus a menu: useColumns/ColumnState (order, visibility, widths), ColumnMenu (a visibility checkbox per column, a Move <column> submenu with Left/Right, and Reset) and the pure setColumnVisible/moveColumn/resizeColumn reducers for a controlled grid. The live table-columns-lifecycle probe measured the consequences on 0.11.0, and the grid owns them: hiding status takes its 80px out of the content (690 → 610), every later column shifts by exactly that width, the hidden column's header and cells paint nothing, the header and its cells keep one x, and the vertical offset and the selection survive; a press lands on the column that moved into the gap; a move changes the order and not the width, with the cells following the header. The last visible column cannot be hidden (the reducer refuses it and the menu disables the row). A move steps over a hidden column rather than spending the press on it. The renderer clamps its own offset when a hide shrinks the panned content (−250 → −90) and reports it through onScrollChange, so a grid needs no scrollTo after a column change — reading the old offset paints blank space past the last column. Pinned (frozen) columns are not an order flag: the runtime paints no position: "sticky", so a frozen column is the measured two-pane composition FrozenTable ships (data-grid-frozen-column, frozen-column-lifecycle) — the column menu deliberately offers no pin row, because a pin is a different layout rather than a column attribute. The page is packages/data/docs/column-configuration.mdx. ColumnMenu holds the component package's measured DropdownMenu; the package suite mounts the shipped example and measures the hide (cols=name,size, content=350), the exact 110-point shift and the move. The anchored menu in a scrolled header is measured too: the playground's header-menu-lifecycle (20 checks, 0.11.0) followed its anchor's wheel (−100 for both in one read), kept the panel at the trigger's x, returned the focus on Escape, hid a column from the first item, and still opened after the header shrank. The trigger paints 22px inside its cell, so anchor arithmetic reads the cell, not the trigger, and hiding a column before the anchor moves it while one after it does not. A blank menu name reads `Columns`: label/contentLabel defaulted in the destructuring, so a missing i18n row's "" left the trigger and its Menu surface unnamed; both read the blank through spokenText, held by surface-a11y.test.tsx's trigger-and-menu case. A column's blank label reads as its `id`: the visibility row is named by its children and the Move row said Move and stopped, so a saved configuration restored without a title left the one control that decides visibility unnamed; both rows read columnName, held by the same file's blank-label case. The configuration is restorable data: columnConfig(state) saves the order, the hidden ids and the widths, and restoreColumns(defs, stored) applies them to the definitions the current release ships — a dropped id is skipped, a new column follows the saved ones, a saved width is clamped to its column's bounds, and a value that is not a configuration reads as the shipped columns — so a saved set from an older build opens the table instead of taking it down; useColumns({ columns, stored }) reads it once at mount. Held by columns/logic.test.ts (a real save → store → restore round trip and the all-hidden set) and columns/docs.test.ts (the page, the seam and the hook). |
| Resizable columns | done (composed) | yes (measured) | TableColumnDivider is the control: a 6px strip inside the header cell it ends (position: "relative" on the cell, absolute on the strip), so the body's tracks need no spacer column. It listens to onMouseDown and onMouseMove, holds the press in a ref (a window delivers the press and every move before React commits the press's render), reads the width at each press, clamps to the column's own minWidth/maxWidth, steps 16px on the arrows, names the bounds on Home/End, and announces the width through aria-valuetext (0.11.0 has no aria-valuenow). Only the primary button arms the drag: the runtime delivers mouseDown for all three buttons, so the divider reads primaryButton and a right press (the context-menu gesture) or a middle one starts nothing — held by refuses a press that is not the primary button. The strip paints border at rest and primary under the pointer because GPUI's hit test stops at the first painted child: a transparent strip never receives the press. The headless table-column-resize dragged the divider +60px (160 → 220), clamped at the 120px minimum, and the header cell's painted width matched the readout — columns move with their header. The reader's drag is measured too — column-resize-lifecycle (29 checks, 0.11.0): a 6px divider keeps 8 of 8 moves past its own box and widens 160 → 220, the release ends the gesture, −150 clamps at 120, a second +30 measures from 150 rather than the first press, the widened grid pans to −20 with header and cells on one offset, right steps the focused divider to 166, and a move-only divider hears 1 of 8 while its column stays 90. The package behaviour suite mounts the shipped example and measures 260 → 320 with the header and cells on one width, a second gesture from 350, the 120/420 bounds, Home/End, the announce channel and a right/middle press that arms nothing. A move that holds no primary button ends the gesture: the runtime reports the held button on every move (pressedButton, 0 for the primary one), and a window the pointer left never hears the release — without the rule the strip stayed armed and the reader's next hover over it resized the column by the pointer's travel (measured in the window suite: a released-outside gesture then a 2px hover moved w=262 → 264), so table.test.tsx's lost-release case releases out of the window, hovers the strip and then drags again from the width on screen. The dock package's divider follows the same rule. A bound or a step the grid cannot read is its default: a width is often computed, and a non-finite minimum or maximum used to clamp the column — and contentWidth and every later offset — to NaN; widthBounds reads it as 80/640 and resizeWidthByKey reads a non-finite step as 16, held by columns/logic.test.ts. A width the divider cannot measure is announced and pressed from as the floor: aria-valuetext printed the raw number, so a computed NaN reached the one channel 0.11.0 has for a number as NaN pixels, and the press read NaN too, so a right drag grew nothing; readableWidth is the one reader now, and table.test.tsx holds both directions (expected 'NaN pixels' to be '80 pixels', expected 80 to be 120). The announcement, the drag and the arrows read one width, clamped to the column's bounds: the key path stepped from the raw prop while the strip announced and dragged from the clamped track, so a consumer that does not clamp — the rule the drag case already measures — handed over 700 at a 640 maximum and Left computed clamp(700 − 16) → 640, the number already shown: the column could be dragged narrower but its keyboard never moved it (and a 40 at an 80 minimum jumped to 80 in one press); resizeWidthByKey is now called with that track, held by table.test.tsx's steps from the width it announces, not the width it was handed (expected 640 to be 624 before the fix). A blank name reads `Resize column`: the divider is a focusable separator, and a locale table missing its row or a saved label answering "" left the one control that resizes a column nameless while its width stayed announced — measured, the tree held a Splitter with no label and one named " ". DEFAULT_RESIZE_LABEL is the reading, and a name that says *which* column is composed from columnName(column.label, column.id) — the header's and the menu's reading — so a column restored without a title is named by its id rather than Resize the column; the shipped example, the page and the README carry that composition. surface-a11y.test.tsx mounts an empty and a whitespace label and reads both names off the tree (direction measured: without the reading it reports expected [] to deeply equal [ 'Resize column', 'Resize column' ]), and columns/docs.test.ts fails on the pre-fix snippet (expected '…' to contain 'columnName(column.label, column.id)'). The same page documents the divider, its bounds and its keys. |
| Filtering controls | done | yes (measured) | TableFilterField is the primitives' Input above the rows, so the field keeps the platform text session while the set narrows; the query is @tanstack/table-core's global filter over the column accessors, registered as includesString and trimmed, so it matches literally and case-insensitively (*.ts matches nothing). useTable retains during render rather than in an effect, so the first frame after a query change already paints the cleared selection and the next press starts from it. The retention is not a write: the records are still held, so a query that widens back to them paints them chosen again (table-filter-lifecycle: 1 rows · 0 selected, then the whole set painting 60 rows · 2 selected); a grid that needs a permanent clear calls clear() on the query change. The live table-filter-lifecycle probe measured it on 0.11.0: a query narrows the painted rows (120 → 39 for 1, → 2 for 2) while the field keeps the text session, a selection the narrower set hides is cleared rather than kept invisibly (one the set still holds survives, including a clear), no match paints TableEmpty, a new set opens at the top read from scrollOffset, and an arrow step walks the filtered order. The package suite re-measures the narrowing, the release and the empty state on the shipped components. A hidden column leaves the search: useTable takes the controller's memoised hidden list and the core marks those columns enableGlobalFilter: false, so a query keeps no record by text no painted cell shows — while a sort by the hidden column still orders the records. src/grid-core.test.ts holds both halves and src/behaviour/table.test.tsx mounts the shipped example (hide status, modified narrows to 0 rows, restore and the same query paints 20 rows). A blank hint reads as `Filter…`: the placeholder is copy as often as a literal, and a destructuring default only covers an absent prop, so a locale table's missing row painted a field with no hint while the package documented one; visibleCopy reads a blank (or whitespace) one as DEFAULT_FILTER_PLACEHOLDER, the rule the toolbar's search follows, held by src/behaviour/filter-field.test.tsx (2 cases). |
| Data toolbars | done | yes (measured) | @gpuixui/data ships DataToolbar: a row of controls on the muted strip, with an optional search field at its head. A toolbar is a row; the runtime provides no toolbar host and needs none. The live inspector-lifecycle probe measured two actions on one row above the property rows they act on (Wide and Reset size share a y, both above the first row), each hit-tested by a press and each moving the object every view reads — the width 120 → 240 and back. The search field debounces through `@tanstack/pacer` (150 ms by default): a query over a long set is not worth a re-filter per keystroke, and the table's own filter field is deliberately not debounced (table-filter-lifecycle measured the set narrowing as the reader types). The debounce is a *reporting* delay — the field shows every keystroke at once. A pending query is dropped when the toolbar unmounts. A blank search label reads as Search — a locale table's missing row is absence, not an unnamed field — and surface-a11y.test.tsx holds the field's name. packages/data/src/behaviour/surfaces.test.tsx measures it in the real renderer: a typed query leaves the set whole while the wait runs and narrows it afterwards. A wait the toolbar cannot read is the default — debounceMs is often computed (a setting read from storage, a per-surface policy) and @tanstack/pacer hands its wait to setTimeout, which reads NaN and an infinity as an immediate timer (measured: TimeoutNaNWarning, duration set to 1 ms), so the query was reported in the next tick and the debounce existed only on paper; a finite non-negative wait is the caller's, 0 reports every edit at once, and a non-finite or negative one takes the documented 150 ms. surfaces.test.tsx's takes the documented wait when the delay cannot be read mounts debounceMs={NaN} and reads the reported query still unset 40 ms in, while reports every edit at once when the wait is zero pins the documented zero. A blank hint reads as `Search…`: the placeholder is copy as often as a literal, and placeholder ?? "Search…" only covers an absent prop, so a locale table's missing row painted no hint while the page printed the default; visibleCopy reads a blank one as the documented words, held by surfaces.test.tsx's blank-hint case. Its page's API tables are held to the types by src/surfaces/docs.test.ts, in both directions. |
| Virtualized lists | done (composed) | yes (measured) | @gpuixui/data ships VirtualList, the typed seam over the host element: a discriminated union mirrors the two modes, height gives the viewport a definite bound, onVisibleRange reports a { first, last } range instead of an untyped event, and ref forwards the instance a reveal needs. Children mode is wired by the shipped 2 000-row example and its four native behaviour cases (Row 1999 is retained but never painted; a wheel moves the window; the range arrives; scrollToItem reveals row 1 200). itemCount mode is exposed but its window arithmetic is the app's (phase 4b's useVirtualRows/VirtualTable). <virtual-list> published with both modes: children (all rows retained, only build/paint skipped) and `itemCount` + `estimatedItemHeight` + `windowStart` (only the app's rendered slice is retained — the mode a table with 100 000 rows needs). virtual-list-windowed measured the window mode on 0.11.0: the 60-row window is all that is retained, the estimate positions mounted-but-unpainted rows, onVisibleRange reports absolute indices yet lags the paint by one gesture (so the window must exceed viewport + overdraw), a press reports the row's absolute index, and scrollToItem(90000) paints that row at -90000 × 28 once the app moves its window. virtual-list-rows measured per-row heights: 24/48/72px rows stack at their own measured sizes, a far reveal lands its row at the viewport's top edge, and a row that grows while outside the window is not remeasured until it paints (so a reader is never jolted by a row they cannot see). virtual-list-window/virtualized-table measure the children form, and virtual-list-expand measures an expandable row (a painted row's growth re-measures on the next frame, the rows below shift, and the grown height joins the reveal arithmetic; the list paints whole rows, so the next row can drop out of the window until a scroll). The component owns no window arithmetic in the itemCount mode: the app moves windowStart first and scrolls after that commit, because the runtime cannot infer a window it was not given. @gpuixui/data's useVirtualRows/VirtualTable are that app: the window's arithmetic is @tanstack/virtual-core's, driven without a DOM. |
| Virtualized tables | done | yes (measured) | @gpuixui/data ships VirtualTable and VirtualTableBody: the table core's measured fixed header over a <virtual-list> in itemCount mode. The window is app state, and it follows the runtime's scroll anchor rather than the range the runtime reports — onVisibleRange lags the paint by one gesture (it read 20..27 while the paint had reached 40..46), so the body reads getListScrollTop and hands the offset and the measured body height to `@tanstack/virtual-core`, whose range becomes the list's windowStart. A window built from the reported range paints blank rows: measured, the window stayed at rows 0–17 while the paint was at 16–24 and nothing painted. The core is driven without a DOM (getScrollElement answers null, so it never registers its observers) and the rows are mounted with overdraw on each side, because the report's lag is one gesture wide. A reveal moves the window before the scroll: useVirtualRows moves the window to the row's own position in the same render and the body calls scrollToItem in the layout effect after that commit, because the runtime cannot build a row outside the window it was given. The body's height is the measured difference between the viewport and the header (0.11.0 sizes a scroller from a definite height). A set that shrinks under the window re-clamps in the same commit: a scroll offset belongs to the set it was measured against, so a filter that narrowed 1 000 rows to 127 while the window sat at row 900 left the core clamping the stale offset to the new content's end and mounting five rows in a nine-row viewport — half the table blank until the next wheel (virtual-table-lifecycle). useVirtualRows clamps the offset to count × rowHeight − viewport before the calculator sees it, so the tail fills the viewport and a record the narrowed set no longer holds leaves the tree (virtual-row-row-700 absent) while one it still holds stays painted. The package's behaviour suite mounts the shipped 1 000-row example and measures it: only a window paints (file-1000.ts is retained and never painted), the header keeps its painted y through a wheel, a chosen record survives the window leaving and returning, a filter narrows to the empty state, a sort reorders the whole set rather than the mounted window, and a filter applied under the window lands on the narrowed set's end with the viewport still full. A press on a painted cell stops at that fill rather than reaching the row (press-routing-lifecycle), the same rule the unvirtualized table owes. An empty set paints its message inside a `TableEmpty` row: VirtualTable used to pass the caller's empty node straight into the body's row group, so the accessibility tree described the message under a RowGroup with no row or cell — the role-less shape the plain table had already lost; src/behaviour/table-empty-a11y.test.tsx's windowed case fails with the tree has no Cell node when the wrap is removed. A caption's string child is painted as a `<text>`: the runtime reaches the accessibility tree only through text elements, so the documented <TableCaption>12 files</TableCaption> painted the line but produced no node at all — a reader heard the rows and never the count. TableCaption wraps a string or number in its own text element (a caller's node passes through), and src/behaviour/table-caption.test.tsx decodes the tree and requires the value under the table; 0.11.0 has no caption role, so it is a Label beside the row groups, not an associated caption. The caption prop of VirtualTable takes the same rule. `count={0}` is an empty set, not a load: with no empty node the table used to fall through to the body's no-children branch and paint the loading skeleton — three rows of placeholder bars, nine cells — so a caller who said there were no records was told the first page was still arriving. A blank empty reads as No rows too (emptyMessage, the reading FrozenTable shares): the default only covered an absent prop, so a locale table's missing row left the empty grid a message cell with no words (behaviour/virtual-table.test.tsx). It paints DEFAULT_EMPTY_MESSAGE (No rows) inside a TableEmpty row instead, and a string empty paints as that cell's own <text>; src/behaviour/table-empty-a11y.test.tsx reads the tree (the skeleton case counted 9 Cells where 1 was expected) and the announced label. The count is read once: rowCount makes a computed count the whole non-negative rows the table holds — a fraction of a row cannot exist, and a NaN/an infinity/a negative is 0 — and that one number drives the runtime list's itemCount, the window arithmetic and the empty state, so the list and its window cannot disagree. Before it, count={NaN} was not 0, so the table mounted a windowed body over no rows: a header above a blank body, with neither the empty message nor the skeleton (virtual-table.test.tsx's paints the empty set for a count it cannot read measured Name alone; it now reads No rows for NaN and -3), while mounts whole rows for a fractional count pins 2.5 to two painted rows. A row height is read once too: rowHeightOf makes a computed rowHeight — or the list's estimatedItemHeight — a finite height above zero, else DEFAULT_ROW_HEIGHT (37, what this package's own row paints: a text-sm line inside py-2 plus the row's rule), and the runtime's estimate, the scroll anchor, a reveal and a tree's cursor walks read that one number. Before it, rowHeight={NaN} reached all three: the window divided the viewport by one pixel (204 rows of a thousand in a 240px body), the anchor's index × NaN clamped the offset to 0, so the paint stalled at row 203 and the rest of the set was blank, and a reveal of row 900 painted the first window instead (src/behaviour/rowheight.test.tsx measures the retained window, the paint at the last row, the reveal and the tree's tab stop). An overdraw is read once as rows: @tanstack/virtual-core multiplies overdraw into both range edges, so a computed NaN reached new Array(NaN) and threw RangeError: Invalid array length — the table painted nothing at all. virtual/window.ts's overdrawRows reads a finite count floored and never negative (0 is no rows around the viewport) and a NaN/an infinity as DEFAULT_OVERDRAW (4), and VirtualList passes its host prop through the same function; virtual/window.test.ts holds the normaliser and virtual-table.test.tsx mounts overdraw={NaN} and reads the default window (before the fix the frame painted no rows). A table with no paintable height takes the pane it fills: VirtualTable fills its container (virtual/measured.ts's paneFill) and the body keeps reading the measured viewport frame, so height={NaN} in a 320px pane paints the same 8 rows as height={320} where it painted none; behaviour/paintable-height.test.tsx holds it beside the unbound tree. Its page's API tables are held to its types by src/docs-pages.test.ts. |
| Tree views | done | yes (measured) | @gpuixui/data ships TreeView and useExpandedRows over pure tree/logic. The flattening is data: a node paints only while its own key is open, a key is painted once (a caller's cycle or duplicated sibling cannot hang the walk), and expandedPath/expandTo open the path to a deep row for a reveal. The keyboard is the desktop tree's — Up/Down/Home/End move and clamp, Right opens a closed branch or steps into an open one, Left closes it or steps out (a root has no parent and keeps the cursor, so a reader standing on a later root is not moved to the first), Enter/Space flip — held by treeCursorMove; type-ahead matches the first row after the cursor (a repeated letter advances) through the published @gpuixui/components/typeahead seam, so the 500 ms window, the repeat rule, the trimmed case-insensitive match and the character a keystroke types are one rule shared with a list and a menu, and the tree keeps only the position-to-key step (src/tree/typeahead.test.ts refuses a second copy). A reveal focuses the row it lands on: tree.reveal(key) moves the cursor from the caller rather than the tree's key handler, so TreeView compares the committed cursor with the last row it focused and calls focusElement after the newly opened rows attach their instances (data-tree-lifecycle measured the revealed row painting while the focus stayed on the Reveal button before this; the first paint is the baseline, so mounting a tree does not steal focus; and a key that left the cursor where it was — Up on the first row, a press on the row already active — has no move to mark, so it cannot suppress the next reveal's retake). The measured rules are in the paint: a nested row insets 16px per level, the tree holds one tab stop and the arrows move real native focus after the tabIndex commit (focusElement from the key handler moved nothing — virtual-tree-lifecycle), and the chevron is an <svg> with pointerEvents: "none" so the row takes the press (press-routing-lifecycle). The hover wash takes the same hit test (hover-routing-lifecycle): an unfilled label leaves the row its fill, a filled child takes it, and an inert child puts the row back in the pointer's path while its own listener still runs — so a decorative pill in renderRow declares pointerEvents: "none". The row answers the pointer's press, not only its hover: an unchosen row tints hover, the cursor's row tints accent over its selected fill, and the button-down paint is pointerWash(theme, "active", …) over the wash it replaces — the raw active role can sit under the 1.05:1 floor beside the wash. src/tree/logic.test.ts (23 cases) holds the model and src/behaviour/tree.test.tsx (14 cases) mounts the shipped example: a press opens the folder it lands on, a child insets one step, the arrows move native focus with the cursor, Right opens and Left closes, the active row paints the selection pair, both rows' pointer states derive over the wash they replace, a typed prefix moves the cursor, a reveal opens the path and takes native focus off the press that asked for it — a caller-driven cursor re-takes the row on the next tick, because the runtime hands the pressed control the focus after the commit (data-tree-lifecycle measured the button keeping it; the package's reveal case simulates that landing) — and a reveal after a key that moved nothing still re-takes its row (takes the focus on a reveal after a key that moved nothing). A field in a row keeps its keys: a key is dispatched along the focus path (press-routing-lifecycle's field-cell route), so the tree answers only while one of its rows holds native focus — a field in renderRow keeps its space, Enter and characters, while the row itself still toggles its branch. tree.test.tsx's keeps a key typed in a row's field to that field holds both directions. A label is content: a labelOf that answers blank — a listing that could not read a name, a record restored without its title — reads as the row's key (treeName, the one reading the paint, the accessible name and the type-ahead match share), so a tree never paints a nameless row no prefix can reach; the type-ahead seam is handed treeName(labelOf(node), key), not the raw label, because the component's own table was the one place the reading stopped (measured: a two-row blank-label tree painted alpha/beta and typing b left the cursor on alpha, while treeCursorMove's pure typeAheadRow already read the key). behaviour/tree.test.tsx (14 cases) names a blank-labelled row by its key and reaches the second one by typing its first letter (reaches a blank-labelled row by typing the key it is named by; direction measured: expected 'blank-tree-row-beta' to be 'blank-tree-row-alpha' before the fix), and tree/logic.test.ts finds one by typing that key. A saved expansion is read, not iterated raw: initialExpanded is data — a workspace restores the folders left open — so expandedSet accepts a list or a Set of text keys and reads anything else (an object where JSON has no Set, a number, null) as no expansion instead of throwing TypeError: … is not iterable from a HashSet.fromIterable on the first render; src/tree/logic.test.ts holds the three reads and src/behaviour/tree.test.tsx mounts a tree whose saved set is an object (measured before: the window painted nothing). A stored hierarchy is read as lists too: roots and a node's childrenOf are data — JSON writes null (or drops the key) for a list a record never carried, so a leaf with no children list is the ordinary shape of a saved file tree — and flattenTree/branchKeys/expandedPath read the slot through listOf, so an absent list is no rows or a leaf instead of TypeError: Cannot read properties of null (reading 'length') from the first render; childrenOf names the absence in its type, and the window case measures a saved tree's leaves painting beside their branch (before the read: nothing painted). A tree's row list is stored data too: a workspace keeps its flattened rows and hands TreeView what the store gave back, so rows is read through listOf — an absent list paints the tree's frame and no rows, where the type-ahead's Arr.map(rows, …) used to throw Cannot read properties of null (reading 'map') in the first render and nothing painted at all; src/behaviour/stored-rows.test.tsx (14 cases) mounts the absence with a readable row as its own control. A theme without the density group still builds a row: the row's height defaults through the primitives' density reader (rowHeightOf(theme), tree/row.test.ts), which answers the library's row for a theme a store gave back without the group — the raw group read threw TypeError: Cannot read properties of undefined (reading 'rowHeight') from the first row. Limits: one active row (no multi-select tree), the plain tree does not virtualize itself — a tree of thousands composes VirtualTree, the same rows and keyboard over the runtime's itemCount window (page docs/virtual-tree.mdx) — and a tree whose rows grow sizes its viewport against the open height (virtual-list-expand). onVisibleRange does not fire for a programmatic reveal and reports indices computed against a spliced child list, so a virtualized tree reads the painted rows or the list anchor. Its page's API tables are held to its types by src/docs-pages.test.ts. A chevron whose tint is gone paints no glyph: the row's ink and both chevron tints come from themeColors, so a theme without the colour group keeps the slot's box and the label's alignment but paints no black chevron on a dark surface — the absence the primitives' stepper answers (src/paint-readers.test.ts). |
| Expandable rows | done | yes (measured) | @gpuixui/data ships TableExpandableRow and TableDetailRow: a row whose press opens a sibling detail row under itself, because 0.11.0 has no row span — the row is a horizontal box while a panel needs the content width and room to grow. The panel is one role="row" on the muted strip whose cell fills the content width, the row carries aria-expanded, and the expander track (32px, matched by a header cell) holds a chevron that paints but takes no press. The app owns the state through toggleExpanded (tree/logic), so a table and a tree agree on "open". Measured by virtual-list-expand: a painted row that grows re-measures on the next frame with no scroll, the rows below shift by the growth, and the height is remembered across the window round trip — so an expanding row belongs in the plain table or the list's children mode, never in the fixed-estimate itemCount window, and the viewport must leave room for the open panel. The row is the disclosure's control: it is a Tab stop (tabIndex 0, -1 when disabled) and Enter/Space toggle the panel, because a focusable div has no activation of its own — a press-only row left a keyboard reader no way to open a panel. The key is answered only while the row itself holds focus: a key is dispatched along the focus path, so a field inside the row's cells reaches the row's handler too (press-routing-lifecycle's field-cell) — a space typed into that field opened the panel until the row read the renderer's focused element, the rule ScrollArea follows. A disabled row answers neither the pointer nor the key: its -1 index is ordered before the zero stops rather than removed from the chain, so programmatic focus can still land on it. src/behaviour/expandable.test.tsx (7 cases) mounts the shipped example and a row whose cell holds a field: a press opens and closes, the panel spans the row with its cell filling it, the rows below move by exactly the panel's height, a press on the panel keeps the row open, a press on the chevron's edge opens the row, Tab from the example's toolbar reaches the first row where Space opens and Enter closes it, and a space typed in the field leaves the panel closed while Enter on the row opens it and a disabled row refuses Space. No height animation (the runtime has no transform); the panel paints on the frame the row opens. Its page's API tables are held to its types by src/docs-pages.test.ts. |
| Key-value displays | done | yes (measured) | @gpuixui/data ships KeyValue: a div + <text> list of labels and values that holds no state — the entries are the caller's, so a panel, a preview and a card read the same object without a second copy of it. The live inspector-lifecycle probe measured 30 property rows in one pane: every label cell holds one x, every value cell holds one x, and the values begin at the same place — so the list scans down its two columns rather than drifting row by row. The label column is fixed (labelWidth, 120 by default) and the value column fills the row; the width is a measurement of the words it holds, so it is set in style and not taken from a token. A computed width that cannot paint reads as the default (120 for the label; a valueWidth that cannot paint fills the row), because the runtime drops a malformed width and the column would otherwise become content-sized. packages/data/src/behaviour/surfaces.test.tsx measures it in the real renderer: every label is the same width at the same x, and every value begins at the same x — including a list given labelWidth={NaN} / valueWidth={Infinity}. The list is announced: it is a named List of ListItems and each label and primitive value is a Label node; before the fix the tree held only the Window while the inspector's words painted silently (surface-a11y.test.tsx, 7 cases across the surfaces). Its page's API tables are held to the types by src/surfaces/docs.test.ts, in both directions. A saved entry that names nothing paints no row: the entries are read through recordsOf, so a record a migration lost the key from is dropped while its neighbours paint; before the read it painted a row addressed kv-label-undefined with no identity, held by src/behaviour/surface-boundary.test.tsx (7 cases). A blank label reads as the key it is stored by: the entry's label is content — a saved object answers "" for a row whose name key is missing — and the list painted it straight into the fixed label column, so a value sat beside an empty cell naming nothing; propertyName is the one reading the label column and the property panel's field names share, held by surface-a11y.test.tsx's blank-label case. |
| Property panels | done | yes (measured) | @gpuixui/data ships PropertyPanel: the rows that describe one object over the primitives' Input for an editable row and the key-value list for the read-only ones. The panel owns no copy of the object — every row is the caller's and an edit is a callback, so a preview, a key-value list and a metric can all read the same object and every one of them repaints in the frame the edit lands. The shipped panel is measured in a window — property-panel-lifecycle (18 checks, 0.11.0) mounts two PropertyPanels over one object: the two editable rows paint their fields at the same 152-point x, 36 points apart; a labelWidth={200} panel puts its field at 232, exactly the 200 − 120 it adds over the default column; a read-only row paints no field; and typing Atlas then v2 keeps the platform text session on the field the press landed in while the caller's object repaints the whole record (name=Atlas note=v2). The composed inspector is the other half: the live inspector-lifecycle probe measured one object read by a preview, a key-value table and a metric, a toolbar action resizing all three views together. A row is read-only or editable as data — its kind is readonly or editable, not a flag — and the editable row carries the change it reports. packages/data/src/behaviour/surfaces.test.tsx measures the edit reaching the readout in the same frame in the real renderer. A window has one platform text session, so two editable fields share it: the focused one takes the keys. Its title is a level-3 heading and its rows are the key-value list's announced items (surface-a11y.test.tsx); a blank title paints no heading, never an unnamed one, and a row's blank label reads as its key (propertyName), so an editable row's field is never unnamed. Its page's API tables are held to the types by src/surfaces/docs.test.ts, in both directions. A saved row that names nothing paints no field: the rows are read through recordsOf, so a keyless record is dropped while its neighbours paint — before the read it painted a field addressed property-undefined, held by src/behaviour/surface-boundary.test.tsx (7 cases). |
| Statistic / metric cards | done | yes (measured) | @gpuixui/data ships StatCard: a label, a reading, an optional unit, an optional hint and an optional trend. The reading is derived, not stored twice — the card is handed the value it shows, so an edit that reaches the object reaches the card in the same frame. The live inspector-lifecycle probe measured the area card following a width change (9600 → 19200) in the same frame as the preview box, the size row and the readout, and packages/data/src/behaviour/surfaces.test.tsx measures it again in the real renderer (the card moves from 9600 to 19200 in one frame, with no timer). A trend is text on the card, not a mark: it paints statusTone(theme, tone).foreground, the role the tokens contract holds to the body floor on card and background, never the solid success/destructive fill (measured at the 3:1 mark floor, and under the text floor on a page — the base light success is 4.26:1 there). src/surfaces/stat-card.test.ts holds the painter and the 72-placement sweep over the 18 shipped, high-contrast, platform and authored styles. The reading is announced: the card is a Group named by the caller's aria-label, and its label, reading, unit and trend are Label nodes (surface-a11y.test.tsx); the value is not a live region, because the runtime has no aria-live. Its page's API tables are held to the types by src/surfaces/docs.test.ts, in both directions. A trend the theme cannot tint paints words alone: a lost colour group leaves statusTone(...).foreground and the flat mutedForeground undefined, so the direction is not painted rather than throwing from the card's first paint (src/paint-readers.test.ts holds the port). Blank copy paints no line: the label, unit, hint and trend text read visibleCopy, so a locale table's missing row or a saved record's empty field paints nothing rather than a whitespace run the runtime lays out as a full line (behaviour/surface-a11y.test.tsx). |
| Charts | done | yes (measured) | @gpuixui/data ships BarChart (vertical/horizontal, negative values, value labels with a custom formatter), StackedBar (with a percent mode), LineChart (linear/smooth/step, dots or icon dots, dashed, value labels), AreaChart (stacked, faded fill), PieChart/DonutChart (inside or outside slice labels with leader lines, a label formatter, an active slice and concentric stacked bands), RadarChart (polygon or circular grid at custom radii, grid fill, lines-only, a set radius, icon dots, an axis-label formatter), RadialChart (a track, guide rings, rounded caps and value labels), ComposedChart (bars and lines on one axis), Sparkline, RadialBarChart (one bar per category, each extending out from the centre), TreemapChart (a hierarchy as nested rectangles, each area its value's share, squared by the compiler), SankeyChart (a flow between nodes, each ribbon's thickness its value) and SunburstChart (the same hierarchy as concentric rings, each arc its value's share of its parent) — the three self-laying charts, whose layout the scene compiler resolves and the document route paints — and ChartLegend (square/dot/line marks, optional row values), A chart is a scene, compiled by TanStack Charts (@tanstack/charts 1.2.1, MIT) — the one chart library whose compiler is DOM-free: createChartScene is a pure function over a definition and a size, and renderChartSvg turns the scene into one SVG document, which the runtime's own renderer paints as one image through setImage. Measured in an environment with no document and no window: a two-series chart compiles to a scene and serialises to one document carrying both series' colours, and the hit test is geometric — the point nearest the pointer is the datum under it, so the readout needs no per-mark event. Only the DOM-free subpaths are used (/scene, /line, /bar, /area, /tick, /svg, /scales/*), which keeps d3-scale, d3-shape and d3-selection out of the bundle: the shipped bundle carries zero d3 references, at 57 kB minified / 21 kB gzip for the scene path, the SVG renderer and the bar/line/area marks, and 41 kB / 15 kB for a minimal scene. The scales come from @tanstack/charts-scales, a package with no dependencies of its own. The runtime has no chart host and no canvas, and <svg source> is one tinted mask (chart-raster measured a five-layer icon painting one colour), so the document route is the default paint: the chart's SVG travels as a data URL on an <img>, which the runtime's own SVG decoder paints as one image through setImage — one document carrying every series' colour, instead of one tinted mask per series. The one-tint mask route stays as render="mask". The document element itself is role="none", and the tree is kept by a named element per series (a transparent <div role="img">), so each series' name and values are still announced; the axis, category labels and legend are text and boxes beside the plot. The shared frame carries a grid, top/bottom legend, a cursor-following hover readout (dot/line/no indicator, label format; the plot's origin comes from onLayout), an optional active band, pie slices that report presses through onSliceActivate against a controlled activeIndex, per-point readouts (pointTooltip on line/area/radar) and a brushed category window (domain) with an on-chart drag strip (brush / onDomainChange), a continuous zoom/pan (view / zoom / onViewChange, fractional category units with clipping; drag-select and wheel work on the plot too), icon rows in the readout (tooltipIndicator="icon" + tooltipIcons), a parity gallery that renders every reference example as a native chart (each row is mountable through /live?scene=parity&only=<id>), and an optional data-change tween (animate / animationDuration, off by default; a reduced-motion provider paints the target at once). The geometry is a stroke/circle/path model: a line is a stroked path, an area is a filled path with nested opacity bands for its fade, a radar is a polygon over webs, a radial is concentric rings and a bar may be rounded (rect rx). src/chart/geometry.ts holds the signed range (chartRange, so a negative bar stands below zero), curves, area clipping, radar and radial rings; src/chart/frame.tsx holds the axis, grid, category zones and the hover card; src/chart/geometry.test.ts holds each rule. docs/charts.mdx documents a section per type with its usage, every prop and its default. The runtime has no chart host and no canvas, and <svg source> is one tinted mask (chart-raster measured a five-layer icon painting one colour), so the document route is the default paint: the chart's SVG travels as a data URL on an <img>, which the runtime's own SVG decoder paints as one image through setImage — one document carrying every series' colour, instead of one tinted mask per series. The one-tint mask route stays as render="mask". The document element itself is role="none", and the tree is kept by a named element per series (a transparent <div role="img">), so each series' name and values are still announced; the axis, category labels and legend are text and boxes beside the plot. chart/logic.ts is pure arithmetic: niceCeiling (1/2/5 × a power of ten), axisTicks, barSlot/barMarks/groupedBarMarks/stackedBarMarks (the stack's scale is its totals — stackedScale, the same function StackedBar prints on its axis, so a 120 stack cannot be labelled 100), linePath, donutSegments (a full ring is two halves; an arc from an angle to itself is empty), chartMarkup (no NaN), and seriesColour cycling seriesColors(theme) — the categorical scale the tokens package measures on every plot surface, for distinctness and under simulated colour-vision deficiencies. A value that is NaN, infinite or negative paints nothing; a zero keeps its bar slot and paints no ring segment. src/chart/logic.test.ts (29 cases) holds the geometry and src/behaviour/chart.test.tsx (19 cases) mounts the shipped example in the real renderer: one mask per series in its own colour, every layer stretched over its plot, legend swatches matching the plot, categories and ticks, a zero datum painting no slice, and the sparkline in the first series colour. The axis measures the slots the labels name: chartScale (with seriesMax/chartMaxOf) takes the painted category count, so a value past the last label — which draws nowhere — cannot raise the axis and shrink every painted bar. Measured, a [3, 6, 999] series under two labels painted 0.3px/0.6px slivers under a 1000 ceiling; the labelled 6 now scales to 10 and its bar stands 60 of the plot's 100px. src/chart/logic.test.ts holds the measure and the painted mark, src/behaviour/chart.test.tsx mounts the ragged chart (its axis reads tick-10, never tick-1000), and the no-bar-past-the-labels case now reads the bar's top at 0 rather than 99. A plot's box is the caller's, or the default when a side cannot paint: width/height (a donut's size, a sparkline's pair) is often computed — a share of a pane, a measurement taken before the first layout — and chartBox reads a side that is NaN, an infinity, zero or negative as that component's default rather than letting it reach the viewBox, the plot's box, the axis column or every label's slot width as 0. The axis column takes the box's height, not the caller's raw prop, so a computed side cannot spread the ticks over a box the plot does not have. src/chart/logic.test.ts holds the seam and src/behaviour/chart.test.tsx mounts a chart given width={NaN} / height={Infinity} and reads the 320×160 plot, its viewBox, a 160px label slot and the axis column's own height out of the renderer. A tick's label takes its precision from the step between ticks: tickLabel prints 0, 0.0013, 0.0025, 0.0037, 0.005 for a four-step axis over 0.005 and 0, 25, 50, 75, 100 over 100 — a fixed two decimals rendered a chart of rates or seconds as a column of zeros, so its bars had no number to be read against; ticks reads an unreadable count as four and caps the steps at AXIS_STEP_LIMIT (20) rather than mounting an unbounded column of labels. Directions measured: restoring the two-decimal label fails the window case by name, and the old axisTicks fails the unit case with [ NaN ]. A step of a hundred or more prints whole numbers: the guard digit was added to Math.ceil(-Math.log10(step)) unconditionally, so a revenue chart with a 250 tick step asked toFixed(-1), which throws RangeError — mounting a chart of values in the thousands crashed the renderer instead of printing 0, 250, 500, 750, 1000. The precision is floored at zero now; src/chart/logic.test.ts holds a 1000 scale and the 100 step, and src/behaviour/chart.test.tsx mounts a 4200/3800 chart and reads its tick-1250/tick-5000 labels out of the window. Directions measured: without the floor the unit case throws the RangeError and the window case fails on the missing tick-5000. Limits: one tint per layer, no hover tooltips or zoom (a mark cannot answer a press), no tween between data sets, and a category label wider than its slot is clipped at the slot's edge: the label's box is its slot, it is one line, and the slot clips what still runs past it, so two neighbouring categories never paint over each other. Measured before the rule, "September" in a 50px slot painted a 63px run from 6.5px outside its own slot; src/behaviour/chart.test.tsx's clip case holds the box and src/chart/docs.test.ts holds the painter, this row and the page. The drawing route is measured by chart-raster in a window — a raster setImagePixels buffer keeps each pixel's own colour and a transparent margin composites onto the card behind it (the route for a translucent stacked mark), while this package draws vector masks, which need no byte buffer. The axis names the stack it drew: a stack is one bar of its categories' totals, so stackedScale rounds the ceiling of the tallest total and ChartFrame feeds that one number to both the axis and the marks. The shipped example's two series happen to agree (widest value 60 → 100, tallest total 84 → 100), so nothing on that screen could see a stack labelled with the per-series ceiling; src/chart/logic.test.ts holds the measure (chartScale 100 against stackedScale 200 for two series of 60, a ragged series and an all-zero set) and src/behaviour/chart.test.tsx mounts a two-series stack whose axis must read 0, 50, 100, 150, 200 with its top 40px down the plot. Directions measured: reading the per-series ceiling fails three unit cases by name, and dropping the frame's scale fails the window case on the missing tick-200. A chart's picture of numbers is announced: each series image carries its values in aria-description (Q1 3, Q2 6), a value past the last label is not described, an unreadable one reads no value rather than the geometry's 0, a ring slice reads its own value, a ring datum that draws no slice keeps its image too (Referral 0), and a Sparkline is one image named by the caller (Image ×2 before the fix), a blank name reading as the documented Trend. A series keeps its image while it has a labelled slot, even when no value draws: a bar chart of zeros paints no bar and a negative value no geometry can show, yet the series reads Q1 0, Q2 -4 instead of disappearing from the tree and leaving the legend naming a series nothing describes — the layer is decided by the slots, not by the marks. src/behaviour/chart-a11y.test.tsx (13 cases) holds the tree and seriesDescription/seriesName's cases in src/chart/logic.test.ts the words; a series' name is content, so a blank series, ring-datum or legend label reads as its key (seriesName is the one reading the image's name and the legend row share) rather than publishing an unnamed image beside a nameless row. A chart's list is stored data: every part reads its list through listOf, so a saved configuration whose series, labels, data, values or legend items is null paints its empty frame rather than throwing TypeError: object null is not iterable (or Cannot read properties of null (reading 'map')) out of the render and taking the surface down, and a series whose own values is null paints no layer while the chart paints; an entry inside the list is stored twice, so recordsOf keeps only the records that name themselves with a key and a hole ([null, { … }]) paints nothing while its neighbours paint — before the read, one hole threw Cannot read properties of null (reading 'values'), (reading 'key') or (reading 'value') out of the series chart, the legend and the ring. src/behaviour/chart-boundary.test.tsx (11 cases) mounts each part with the stored absence and with a holed list. A category label is stored content too: a labels entry the store wrote as a hole or as whitespace names no category — its slot stays (a chart draws one value per label, so filtering it would move every later value under the wrong label) and the datum is announced by its number alone (Q1 3, 6), never by the JS word null (textsOf, seriesDescription); src/chart/logic.test.ts and src/behaviour/chart-a11y.test.tsx hold both directions. docs/charts.mdx documents every part, its props and its defaults. The paint is window-verified: chart-surface-lifecycle mounts the shipped charts in a real window and counts each series' tint inside its own plot crop (two colours in one plot), reads a stack from its floor, three ring slices and no pixel of the zero datum's colour, the sparkline in the caller's colour, and a widened bar repainting the point it did not reach — 41 checks on 0.11.0, with a measured negative control (tinting every layer from the first palette slot fails the second series' count). A blank sparkline colour is a stored absence: color ?? swatch only covers an absent prop, so a saved "" reached style.color, the runtime dropped it and the line painted no tint while its description still read the values; chosenColour keeps a real colour as written and reads a blank one as the first categorical swatch, held by behaviour/chart.test.tsx. A legend's ink is a port read: a theme without the colour group leaves the swatch label's color unset, so it paints the runtime's default rather than a colour the theme does not own (themeColors, held by src/paint-readers.test.ts). |
| auth-service | production | ready | 74s | ||
| billing-api | production | ready | 92s | ||
| edge-gateway | production | failed | 36s | ||
| media-proxy | development | building | 129s | ||
| reports-worker | staging | ready | 211s |
A header press sorts, a row press selects, the field filters, and the Columns menu hides a column.
What ships today
15 pages in 1 package · generated from each package's docs folder
| Page | What it ships |
|---|---|
| Advanced filtering / query builders | A builder over a typed predicate tree |
| Charts | Bar, stacked, line, area, pie, donut, radar, radial and sparkline charts |
| Column configuration | Show, hide, reorder and resize a table's columns |
| Data toolbars | The row of controls a data surface acts from |
| Expandable rows | A table row that opens a detail panel under itself |
| Filtering controls | The field a data surface narrows its rows from |
| Frozen columns | A pinned column beside a grid that pans |
| Key-value displays | A two-column list of labels and values |
| Property panels | A panel of property rows over one object the caller owns |
| Statistic / metric cards | A card that shows one derived reading |
| Table selection | A checkbox column for a data table |
| Table sorting | The press cycle a sortable header reports |
| Tree views | Expandable rows of a hierarchy, with arrow keys |
| Virtual trees | A hierarchy painted one window at a time |
| Virtualization | A long list and table that mount one window of rows |
| Piece | Page |
|---|---|
| Table | Table — the frame, fixed header, measured body, sort, selection, filter, column menu and virtual body |
| List / ListItem | List |
| Pagination | Pagination |
| Empty state | Empty state |
| Cards, badges, progress | Components |
| Message scroller | Message scroller |
| Command menu | Command menu |
Edit this page on GitHubapps/docs/content/docs/data-display/index.mdx