# What the grid does today, and what it measures The state of av-grid, by subsystem rather than by task order. Every number here was measured in a real browser on one of the [test boards](boards.md) — none is an estimate. The full history, and the measurements that mislead if taken carelessly, is in [`tasks/benchmark-results.md`](../tasks/benchmark-results.md). For the public surface see [`api.md`](api.md); for the rules that keep the numbers where they are, [`invariants.md`](invariants.md). --- ## The performance thesis 100,000 rows in a real browser, through the *whole* grid rather than the engine alone: **first paint 6.7 ms, 60 fps at both the top and row 99,000, a flat-cost ratio of 1.08×**, a full repaint of every visible cell in 0.2 ms doing **zero DOM mutations**, and a theme change costing **zero paints**. The same holds sideways. 70,000 rows × **300 columns**: horizontal paint cost **0.218 ms at the left edge against 0.215 ms out at column ~290 — a horizontal flat-cost ratio of 0.99×**, 60 fps at both, first paint 13.8 ms. A 12-of-300 projection (`hidden` on 288 columns) paints within noise of a grid whose other 288 columns do not exist — `hidden` is filtered once into the visible list, never consulted per cell; `setColumns()` widening 12 → 40 visible is **one paint against the same root**; and one filter over 70k rows costs the same with 300 visible columns as with 12, because the filter pass is row-bound. All measured in the 2026-08-31 task-44 row of [`benchmark-results.md`](../tasks/benchmark-results.md). Each subsystem has its own gate, and each is about the shape of the cost rather than its size: | What | The gate it passed | |---|---| | **Range selection** (task 10) | Dragging from row 0 through row 99,000 — a live selection of 99,001 rows against 101 at the top — marks **2 cells dirty per pointer move at both ends**: 0.0141 ms against 0.0147 ms, a ratio of **1.04×** | | **Row selection** (11) | Selecting all 100,000 rows: 24.9 ms, **19 rows** marked, **0 DOM mutations** | | **Editing** (12) | A full repaint while a cell is being edited does **0 DOM mutations** and hands back the same `` — the first thing in the library that would notice if the render path stopped preferring `p.previous`. Gate with editing on: first paint 5.7 ms, 0.94×, 60/60 fps | | **Clipboard** (13) | Pasting into 1,000 cells marks **1 repaint** and mutates **0 DOM nodes** — a paste writes silently and marks the viewport once at the end | | **Structure** (14) | Inserting a row *above* the viewport at row 90,000 mutates **0 DOM nodes** and keeps both the scroll position and the focus — after fixing a focus-recentre that was moving the viewport 261 px behind the user's back | | **Filtering** (15) | 100k rows down: **5.9 ms, 1 repaint, 0 mutations**. Back up: nothing at all. Searching a computed column costs **+8.7%**, because it puts a host callback inside the row loop | | **Popovers** (16) | Opening one over 100,000 rows marks **1 cell dirty** and mutates **0 DOM nodes**, whether the column has five distinct values or a hundred thousand | | **Tree column** (59) | 10,520 tree rows fully expanded: the gutter costs **1.01×** the same grid without it per scroll frame (0.121 against 0.121 ms), a full repaint does **0 mutations** inside a row's gutter, a chevron press through the real `pointerdown` costs **0.9 ms** including the host's re-flatten | | **Context menu** | Opening one marks **0** things and mutates **0** DOM nodes; **2.3 ms with every row selected against 2.9 ms with one**, because `e.selection` is a getter nothing built-in reads | | **Teardown** | 100 create/destroy cycles leak **0 DOM nodes** and leave **100/100 grids collectable**, at 6.5 ms a cycle | --- ## The grid itself `AVGrid.create(el, { rows })` renders a real grid — columns, header labels, widths, row keys and data types all inferred — with header sorting, column resize and reorder (`disableColumnReorder` turns the drag off by the host's choice), custom cell renderers, a search filter, cell focus, full keyboard navigation, range selection by drag or by shift, row selection through a checkbox column, in-cell editing, Excel-compatible clipboard copy/cut/paste, rows and columns added and deleted by button, keyboard or API, column filters, and a stylesheet driven entirely by CSS custom properties. ### Editing `editable: true`. An edit opens on a press on the cell that **already has the focus** — so a cold cell takes two clicks and a focused one takes a single click, as the reference did — and the caret lands **where the click landed**, with nothing selected, while Enter / F2 / `startEdit()` select the whole value instead. An editable boolean cell carries a checkbox that toggles on the first click, and **only the box toggles** — the cell around it just selects. Tab and the vertical arrows commit and move on; the horizontal ones stay with the caret. The row that ArrowDown adds off the bottom is taken back if the focus leaves it without anyone typing in it, so holding the key to read the end of a grid does not leave a blank row behind. **An edit freezes the row order**, so the row being typed into cannot jump away — and that freeze is released by a change to search, filters *or* sort, through `RowsModel.refilterRows()`. It used to be released only by a sort, which left filtered-out rows on screen after an edit; `updateRows()` short-circuits into `updateFrozenRows()` while frozen, which adopts new row objects in place and re-filters nothing. Only a browser could find it: it takes an edit, then a filter change, then a look at what is actually on screen. ### The cell dropdown A column with `options` opens a themed, virtualized list rather than a native `` could not manage — and Escape or a click elsewhere cancels rather than committing. ### Column widths **A column that omits `width` is sized from its content** whether the columns were inferred or written out by hand. For a long time only the inference path did it, so naming your own columns — which is what anyone does the moment they want a header label — silently gave every one of them a flat 140 px, against a doc that had always promised detection. The first host to hit it re-ran the exported `inferColumns()` over a probe of display text purely to copy the widths back off it. The measurement is by character count over 50 rows, not by text metrics: laying out the rows to size a column would cost more than the first paint. It reads `columnDisplayValue`, so a `formatValue` or a `displayFormat` is what gets measured — a `displayFormat: "date"` column comes out at 84 px rather than the width of the 40-character `Date.toString()` behind it. Nothing to measure means no width and the 140 px default, which is what keeps a column `addColumns()` has just made from being the narrowest thing on screen at the moment it most needs room. A percentage width (`width: "35%"`) is measured against what is left after the fixed columns and the vertical scrollbar, and re-resolves on every resize. One is enough to fit the grid to its container — `fitToWidth` is only for stretching a set of columns that are all fixed widths. That was not always true: `calcInnerSize` adds 20 px of slack past the last column so it can scroll clear of the edge, and dropped it only under `fitToWidth` — so a percentage column got a perfect fit **and** the slack on top, which is a horizontal scrollbar by construction. `hasPercentLength()` now decides `columnsFitted`, which the geometry and the dirty-set check read in place of the option. --- ## Pinned columns **Both edges, positionally.** `pinned: "left"` pins the *leading* run of the visible columns and `pinned: "right"` the *trailing* run — **data columns that happen to be sticky**, keeping sort, filter, edit, copy, focus and resize; the one loss is drag-reorder (left: fixed in place; right: within the band only). `isStatusColumn` is the separate *chrome* flag — the checkbox, a row number — which is what turns those affordances off. (Through 2.7.x `pinned: "left"` meant chrome too; task 53 split sticky from chrome so an identity column can stay visible and stay a data column — the keyboard focus clamp now lands past chrome rather than past the sticky band, verified live: grip drag 160 → 220 px with the scrolling band re-anchored to the new edge, pointer focus and range copy from the pinned column, the checkbox unchanged.) The engine already maintained all eight sticky regions; the grid layer's wiring is one count each way (`stickyLeft` = the last left-pinned index + 1, `stickyRight` = the trailing run length), both computed over the **visible** columns so a hidden pinned column just shortens its run. Measured on 70,000 rows × 300 columns with one left + two right pinned (`measurePinned` on AVGridBoard): the bands hold their columns at both scroll extremes, **72 band cells are never evicted across a 60-column scroll**, horizontal paint costs 0.50 / 0.73 ms at 60 fps (the unpinned grid reads 0.24 — the difference is repositioning three bands per frame, ~3% of a frame budget), and a selection dragged across the band boundary copies its cells **in visible order** with no coordinate seam. Drag-reorder refuses a drop that would cross the boundary — no indicator, order kept — and works inside the band; the right-pinned resize grip sits on the column's *left* edge and dragging it left widens (90 → 130 px, measured). Shipping it found and fixed an engine defect: a column pinned or unpinned **migrates** its live cell between regions in one paint, and `syncRegion` evicted it — a `removeChild` against the wrong parent in one sync order, an on-screen element released into the pool in the other. `paint()` now builds the union of every region's next set and evicts only what is in none. The gate did not move (0.79 / 0.92× warm, 0-mutation full repaint). --- ## Footer rows **`footerRows` are rows, and only rendered.** The same row shape through the same columns — the consumer's formatting is written once — pinned in `avg-sticky-bottom`, and excluded from everything that treats a row as data: sorting, filtering, search (and its marks), row selection and select-all, `getVisibleRows` / `onVisibleRowsChange`, editing, focus/range/copy, the add-row affordances, and `getRowKey` (keys are minted `avg-footer-`). The exclusions are structural — a footer cell carries no `data-row`, so the interaction layer cannot resolve one — and each is pinned by its own test. Measured on 100,000 rows with a two-row footer (`measureFooter` on AVGridBoard): the band holds at both scroll extremes, and **60 one-row scroll frames leave the footer elements untouched by identity** — the band costs zero extra DOM mutations per frame, because its cells are never in the scrolling window. Two geometry rules make it compose: with a band, the default trailing slack is 0 (the band is the slack) but an explicit `whiteSpaceY` still buys room between the last data row and the band; and the area publishes `--avg-sticky-bottom`, which anchors `extraElement` and the add-row button *above* the band — measured as `extra.bottom` 890 = `band.top` 890, and verified by screenshot. --- ## Column groups **`Column.group` is the whole API.** Put one string on the columns that belong together and the header becomes two rows; ungrouped columns span both as one tall cell; the band leaves with the last visible grouped column. Interleaved group columns are gathered together stably rather than raised on, `getColumns()` returns the normalized order, drag-reorder is off while groups show (a grouped order is a prepared view), and a pinned column cannot carry a group — each rule chosen so the option cannot be half-applied. **The engine was not touched.** `stickyTop` stays 1: while groups are active, row 0 is doubled by a per-row `rowHeight` function — doubling the *header band* (`headerHeight`, which is `rowHeight` unless the host sets it; task 60) — grouped headers shrink to its lower half with one style adjustment in the renderer, and the band itself is a **header overlay** (`addOverlay(el, "header")`) — one absolutely positioned div per group in content-x coordinates, invisible to the cell pool and to `syncRegion`, positioned from the engine's own `columnStarts` / `columnLength` so percentage widths and `fitToWidth` stay exact by construction. That is why every row-coordinate choke point — `gridRowToDataRow`, `update({rows:[0]})`, the aria indices, hit-testing, `scrollToRow` — is untouched, and why the band's paint cost is **once per group, not once per column**. Measured on 100,000 rows in 8 groups × 3 columns (`measureGroups` on AVGridBoard): first paint 5.9 ms, **8 band cells for 8 groups at both scroll extremes**, alignment with the leaf headers at **0.0 px** for every checkable group, and 120 scroll frames queue **0 mutation records inside the band**. An ungrouped grid pays nothing — it still hands the engine a plain number. --- ## Multi-column sort **Arity follows `multiSort`.** Off (the default), `sort` / `getSort()` / `onSortChange` hold one `SortColumn`, and every original code path — RowsModel's sort-then-reverse for descending included — runs unchanged. On, they hold arrays (`[]` when unsorted), a plain header click still resets to the clicked column, and **Ctrl+click (Cmd+click on macOS) appends a level**: ascending, then descending, then removed, the rest of the list untouched. Two or more sorted columns show position numbers beside their arrows; one sorted column is pixel-identical to the single-sort look, and `aria-sort` stays on the primary only. The list resolves to **one decorate tuple plus one composite comparator** with each level's direction folded in as a sign — so `Column.sortValue` keeps its once-per-row contract per level, `Column.rowCompare` levels stay pairwise, and final ties keep source order. The value guard compares lists element by element, which is what keeps `sort={sort}` terminating through the React wrapper in either arity. Measured on 100,000 rows (`measureMultiSort` on AVGridBoard): a two-level sort costs **99.8 ms against 66.4 ms single-level (1.5×)**, the gesture verified end to end through the real header, and the 100k gate re-run on the same bundle is unmoved (first paint 2.3 ms, 0.84×, 60/60 fps, 0-mutation full repaint). --- ## Tree column **`treeColumn` is the gutter, not the engine.** One column gets `depth` indent guides and a chevron or an equally wide stub in front of its ordinary content — the default text, `formatValue`, or the host's `render` (string or element) with its icon, label and action buttons. The host's rows are already flat and in display order; the grid reads three values off each row and never decides which rows exist. That keeps *"the host owns the row set"* intact and keeps tree-aware sort and filter semantics — a phase, not a task — out of the library; the docs say to pass `disableSorting` and pair with `externalFilter`. **The gesture follows the callback.** With `onTreeToggle`, a chevron press (resolved on `pointerdown`, the third invariant) and `→` / `←` on the focused tree cell ask the host for the other state and repaint that row; without it the chevrons are inert and the arrows navigate as on any cell — they never go dead. `chevrons` removes the slot for the grid or per row, which is how an always-expanded first level starts flush at the cell edge. A tree cell copies its `path`, never indentation; the editor mounts over the content zone and the gutter stays. **Pooling-safe by construction.** The gutter is synced in place — a cell recycled from depth 8 to depth 2 loses six guides, the slot is swapped, nothing is rebuilt — and the content host follows the same rules every cell follows (`written` skips an unchanged `render` string — true since 2.11.2; before task 62 the host was emptied and rebuilt on every paint, see `plan-done-16.md`). Measured on AVGridBoard (`measureTree`, 10,520 rows fully expanded, a pinned-left tree column beside grouped columns): first paint 8.1 ms, scroll paint **0.121 ms at the top against 0.121 ms without the gutter (1.01×)**, **0 mutation records inside a row's gutter across a full repaint**, a real chevron press 0.9 ms with the host's re-flatten of 10k rows inside it, and all 14 shape, gesture, keyboard, copy and static-shape checks pass. --- ## Selection, focus and the pointer **DOM focus lands on the root from any press inside the grid.** It used to be done by the branch that resolved a *data cell*, so a press on a header, on the empty area past the last row, or on the band a status column occupies left focus wherever it had been — and since the keyboard is bound to the root, sorting by clicking a header killed the arrow keys until something else focused it. It is now the first thing `pointerdown` does, and skips only a press that lands on a control which takes focus itself, because stealing it there would close the editor the press was opening. **The row highlight follows the pointer everywhere it should.** It read `mousemove`, and `onCellPointerDown` calls `preventDefault()` to stop the browser starting a text selection — which **suppresses the compatibility mouse events for the rest of that pointer's stream**, so a range drag delivered no `mousemove` at all and the highlight stayed on the cell the drag began from until the button came up. It reads `pointermove` now. A wheel scroll under a stationary pointer re-resolves the row *after* the paint, on the scroller's own `scroll` event — before the paint the hit test returns the row that *was* there. The gate is untouched, because that handler returns immediately when no pointer is over the grid, which is every programmatic scroll. A real wheel scroll under the pointer costs **0.291 ms a paint against 0.120 ms**; the highlight moving on its own is **0 mutations and 0.045 ms**. --- ## Filtering **From the header.** Every column carries a funnel (`filterType: null` takes it off one, `disableFiltering` off all of them); clicking it opens a searchable checklist of the column's distinct values, cascaded against the other filters, with select-all, Apply and Clear. The option list is supplied by the library unless a host passes `onGetOptions`, which may return a promise. A column with 100,000 distinct values puts **12** rows in the checklist — `VirtualList` earning its keep. **Through the API, and across a reload.** `grid.applyFilter({ columnKey: "status", value: ["open"] })` narrows 100,000 rows to 40,000 in **5.9 ms with one repaint and zero DOM mutations**, and clearing it costs **0.0 ms**, because an unfiltered pass returns the same array it was given. `persistFilters: { name }` remembers the filters across a reload through an injected store — `localStorage` by default, never written to unless asked — reviving `Date` values without losing the labels around them. **As removable chips.** `filterBar: true` puts a bar above the grid, or `AVGrid.createFilterBar(el, { grid })` puts one anywhere — both make the same object, and a grid can have any number of them, all live. A chip reads `Status: open,pending (+3)`; clicking its body reopens the popover *anchored to the chip*, its ✕ removes that filter and the ✕ at the right end removes them all. The bar takes no space at all until something is filtered. Every filter change while the bar is up mutates **zero** DOM nodes; the bar appearing or going away costs one row of cells, because it changes the grid's height by 32 px. **A text filter, built in.** `filterType: "text"` swaps a column's checklist for one input and a row of operator chips — contains (the default), equals, starts with, and, where a column opts in through `textFilterOps`, *is empty* / *is not empty* (`blank` / `notBlank`, no text; every operator is accepted on every text column, the chips alone are opt-in) — the comparing three matched case-insensitively against the *displayed* text, the same `formatValue` → `displayFormat` → `row[key]` projection the search box reads, so a formatted date column filters by what its cells show; *empty* is that same text, empty after trim. A host renames any chip through `filterLabel`, which `describeFilter()` honours too. The value is public surface and JSON-shaped — `{ op, text }`, a bare string accepted and normalized on the way in — so it persists with no `serialize` and translates straight into a server predicate. Measured at 100,000 rows against the checklist on the same column: **2.1 ms** for the options pass, **3.8 / 4.2 / 5.1 ms** for equals / starts with / contains — the price of `String()` + `toLowerCase` per row, with the needle lowercased **once per pass** and never written into the value. Enter applies, empty (trimmed) text removes the filter, and the chip reads `Name: contains smith`. **Or the host filters, and the grid only reports.** `externalFilter: true` keeps the whole filter UI — funnels, popovers, chips, persistence, `onFiltersChange` — while the grid never tests a row against `filters`: for rows that arrive already filtered, usually by a server. `externalSort: true` is the same deal for the sort — arrows, position numbers, `aria-sort` and `onSortChange` all live, the rows never reordered, `sortValue`/`rowCompare` never called. The two are independent, both are plain `setOptions`-togglable options (turning `externalFilter` off re-validates the columns, so a `match`-less filter definition accepted under the flag can never be left silently keeping every row), and `searchString` deliberately stays local. Measured at 100,000 rows with three filters applied: the local pass costs **12.2–17 ms** per filter change; the same call under `externalFilter` costs **0.2–0.4 ms** — that difference is the feature. The one rule the docs insist on: pass `onGetOptions` with `externalFilter`, because a server-filtered page has the column's own filter baked in, so a checklist built from loaded rows can narrow but never re-widen. **Free text, and where it matched.** `searchString` keeps a row when every whitespace-separated word appears in some column's displayed value, and each of those words is then marked inside the cells, so it is visible *why* a row survived. The mark is the accent colour by default; `highlightSearch` switches it to a tint behind the letters, to both, or off. It is measured on the 100k board with a search active: **60 fps scrolling, and a full repaint of every visible cell in 0.1 ms with 0 DOM mutations** — indistinguishable from no search at all. Three things buy that: the words are split once per pipeline run rather than per cell, a cell with no match never leaves the single-text-node path, and the *shape* is one attribute on the root, so choosing between the three repaints nothing. What is marked is the **displayed** text — the same text the search matched. A `render` column is left alone, because its markup belongs to the host — and is given `CellContext.highlight` to opt in with — one call that escapes the text and marks the same words the rest of the grid is marking: ```js { key: "full", render: (c) => c.highlight(`${c.row.firstName} ${c.row.lastName}`) } ``` **Words from somewhere else.** `highlightString` is a second source of words to mark that filters *nothing* — for a term that came from outside this grid: a search-results panel that navigated the user here, a URL fragment, a filter applied upstream. There the rows are already the right rows, so narrowing them would be a bug. The rule is `searchString` when the user is searching *this* grid and `highlightString` when the words came from elsewhere; set both and the marked set is their union. `highlightSearch` governs both, `false` silences both, and `c.highlight()` marks both, so a custom `render` column does not go unmarked beside a plain one. It costs nothing when unused: with neither option set the word list is still the *shared* empty array, which is what keeps the per-cell paint path allocation-free. Marked output is wrapped in one inline box, which is not decoration: a data cell is `inline-flex` and flex discards the whitespace *between* items, so a mark that split the text rendered "Alan Dijkstra" as "AlanDijkstra". Measured against the same row unmarked, width and `scrollWidth` are identical — a mark changes appearance, never layout. --- ## The context menu Right-click offers Copy, Copy as… (headers / JSON / HTML table), Paste, and Insert / Add / Delete for both rows and columns, each label counting what the selection actually covers — a header gets the two column items instead. `getContextMenuItems` adds items above the built-in ones; `onGridContextMenu(e, items)` receives the point and the items the grid *would* have shown and draws its own menu instead; `disableContextMenu` hands the gesture back to the browser, which is also what an open cell editor gets, because Cut/Paste and spelling are things only the platform can offer. The menu itself is a primitive, `Menu`, on `Popover`: submenus on hover or click, arrow keys, and a search box past twenty items. **Every built-in item carries a stable `id`**, prefixed `avg-` — `avg-copy`, `avg-paste`, `avg-insert-rows`, and eleven more, [tabulated in `api.md`](api.md#the-built-in-item-ids) as public contract. That is what a host drawing its own menu matches on: the labels are counted, pluralised and translatable, so re-iconing by label was guesswork. The prefix matters twice — host items share the array and `id` drives the menu's keyboard navigation, so a collision is a real bug, and `item.id?.startsWith("avg-")` tells a host which items are the library's. **A grid can say what it holds.** `rowNoun` renames the row in the three row items and on the add-row button — `Insert 3 links`, `+ add link` — pluralised by the grid from the singular noun. There is no `columnNoun`; nothing has asked to rename those. --- ## Customization — the seven host hooks All seven are documented in [`api.md`](api.md#customization-at-a-glance), demonstrated together in [`examples/10-customization.html`](../examples/10-customization.html), and checked as **20 pass/fail claims** by [`CustomizationBoard`](../test-boards/CustomizationBoard/CLAUDE.md). ### The four class hooks `Column.cellClass` and `Column.headerClass` take a constant string or a function of the cell; `rowClass` on the options highlights a whole row; `onCellClass` predates them. All four are additive on top of the state classes, accept an array with its falsy entries dropped, and are reassigned whole on every paint, so a class **disappears** when its hook stops asking for it: removing all four from a live 100,000-row grid takes 70/10/130/1 marked cells to 0/0/0/0 and restores exactly those counts. That is the pool trap, and no happy-dom test can see it. The cost is **+0.012 ms on a full repaint of every visible cell and 0 DOM mutations** — measured as alternating pairs, because timed in blocks the bare grid came back *slower* than the hooked one. There is no row element to hang a class on, by design, so `rowClass` lands on each of the row's cells; and a host rule has to out-specify `.avg-data-cell`, so write `.avg-data-cell.overdue`. ### `Column.editor` — a custom cell editor Receives the `EditorContext` the built-in text box and dropdown already use — value, row, column, indices, `openedBy`, and `setValue` / `commit` / `cancel` — and returns an element, or `{ element, focus?, destroy? }` when it owns a panel somewhere else. Four lines for a date picker. The grid **adopts** whatever comes back: adds `avg-cell-editor`, which is what positions it inside the cell, marks it `data-type="cell-editor"` so its keys and clipboard are not the grid's, and binds Escape and Tab so an editor that handles neither still cancels and still lets the user move on. **Commit-on-blur is deliberately not the grid's** — it lives in `CellInput`, which is what lets a date picker take the focus without closing the edit underneath it. A column with an `editor` opens it whatever its `dataType`, including `boolean`, which then shows a tick and no checkbox, because the checkbox's press toggles and would make the editor unreachable. The broken `editRender` is gone: it was handed a `CellContext`, so whatever it drew had no way to record a value or end the edit. ### `Column.filter` — a custom filter type Takes a definition — `{ name, create, label, match }`, plus `serialize` / `deserialize` if the value is not JSON-shaped — and the funnel then opens the host's element instead of the checklist. `create(ctx)` returns `{ element, getValue, focus?, destroy? }` and nothing more: the grid keeps the panel, the **Apply** and **Clear**, the chip that reads `label` and reopens the body anchored to itself, the cascade into the other columns' option lists, and the persistence. **No registration call** — the definition is an object on the column, so reuse is a shared `const` and there is no order of calls to get right. A filter naming a type its column is not filtered by is rejected, which is what makes a stored filter whose definition has gone drop with a warning instead of silently matching nothing. **A host `match` is *cheaper* than the built-in test it replaces — and 56× more expensive written the obvious way.** On 100,000 rows: a definition whose `getValue` parses its bounds once and whose `match` is two numeric comparisons filters in **1.7 ms** against **2.1 ms** for the built-in `"options"` test, which resolves each row through `filterValue` and `optionMatches` before it reaches `===`. The same date-range filter re-parsing its bounds inside `match` takes **90 ms**. That is why there is no `prepare`-style second hook: the value is data the definition controls, and putting the parsed form in it is the whole optimization. ### `Column.copyValue` — what a cell copies Used by every copy path — ctrl+C, ctrl+X, all four Copy as… modes, `getSelectionText()` — for the cell whose screen form is a bar, a badge or an icon. It is **6× cheaper than the fallback it overrides** (0.20 ms against 1.24 ms for 1,000 cells), because reducing `render`'s markup to text costs an `innerHTML` write and a `textContent` read per cell. There is no `pasteValue`: `validate` already coerces an incoming value on typing, pasting and range-delete alike. ### `Column.sortValue` — sorting by something other than the value `sortValue: (row) => any` is a projection — `sortValue: (row) => RANK[row.status]` is a status priority, and whatever it returns is compared the way the grid compares anything. `Column.rowCompare` stays for an order that is genuinely pairwise: a collator, a natural sort, a tie-break across two properties. It wins where both are set. Both sort ascending, because a descending header click reverses the sorted array. **`sortValue` is read once per row, not once per comparison.** `Array.sort` calls its comparator O(n log n) times — **1,209,558 times for 100,000 rows**, counted on the board — so a projection written inside a `rowCompare` runs that often too; decorating the rows with their sort values and sorting *those* is 100,000 calls. Twelve times fewer calls buys **5%** on a table lookup (10.3 ms against 10.8 ms — the wrapper object per row eats the rest) and **2.7×** on a projection that lowercases and concatenates (17.5 ms against 46.9 ms). It never loses, and it wins in proportion to what the projection does. ### Which hook feeds what [One table in `api.md`](api.md#which-hook-feeds-what) covers screen, sort, search, a filter's row test, a filter's option list, copy and paste, each with the order it tries. Writing it by reading the code rather than recalling it found **two documented claims that were false** — `formatValue` was said to feed sorting, and `dataType` to govern the comparator; neither does, because `defaultCompare` compares `row[key]` by its *runtime* type. One real asymmetry stays: **copy prefers `formatValue` over `render` while the screen prefers `render`**, because a `formatValue` is already the plain text a spreadsheet wants. A column that wants its rendered text copied says so with `copyValue`. --- ## Host chrome after the last row **`extraElement`** puts one host element into the scrolling content below the last row — a "Load more" footer, an empty-state line, a total. The grid parents it and touches nothing else: never inspected, cleared, restyled beyond adding `avg-extra`, or destroyed, and `destroy()` hands it back intact and remountable. It goes in as an *overlay*, which is what makes that promise keepable — `syncRegion` only removes elements it appended itself, so the element is invisible to reconciliation and a listener bound to it survives both a repaint and a scroll long enough to evict every cell around it. Unlike an element a cell renderer returns, **the library positions this one** — a full-width band at the bottom of the content. There the engine writes `top` and `left` and the host adds `position`; here nothing writes anything, so an unpositioned element would lay out in flow among absolutely positioned cells and land at the top-left behind them, invisible and still hoverable. One class overrides the default, and no colour, size or padding is set, because only the host knows what the grid's background is. **`whiteSpaceY`** exposes the trailing slack below the last row — 20 px by default, `0` to take it away, or as tall as a footer needs. The engine has always taken it and it was simply never exposed; splitting it out from `extraElement` is deliberate, because an element that silently changed the grid's geometry would be a footgun. `whiteSpaceX` is *not* exposed: the horizontal slack interacts with `fitToWidth` and percentage widths through the column-fitting arithmetic, which has already produced one spurious horizontal scrollbar, and nothing is asking for it. --- ## Theming **⚠ `--avg-*` on an ancestor does nothing.** The grid root, `.avg-popover`, `.avg-list` and `.avg-filter-bar` each define the whole token block *on themselves* from `--p-*`, because a popover lives on `document.body` and cannot inherit from a grid, and an element's own definition shadows any ancestor's. So: - **`--p-*` on an ancestor reaches everything** - **`--avg-*` on an ancestor is shadowed** - **`--avg-*` on the element itself wins** The docs used to say otherwise; running the theming example in a browser is what caught it. Whether the defaults should move to `:root` so an ancestor's `--avg-*` works is **still open**. **On a board the grid looks like Persephone's own.** Four tokens name their `--p-*` counterparts rather than deriving values: the header band and the filter bar take `--p-bg-dark` (#181818 against a #1f1f1f body — *darker* than the grid, which a `color-mix` tint of the text colour could never be), and the cell lines moved to a token of their own, `--avg-grid-line` = `--p-border-light` (#2b2b2b), so they recede without thinning the popover borders that shared `--avg-border-color` with them. The two `+` buttons brighten to `--p-text-strong` on hover instead of turning blue, and a menu row takes the full `--p-selection-bg` / `--p-selection-text` pair rather than the 18% tint a checklist wants. Every fallback is the old value, so a bare HTML page is unchanged. ### Contrast of the shipped defaults Measured as WCAG 2.1 ratios over the default light tokens (computed from the stylesheet's own values; `color-mix` resolved in sRGB): | Pair | Ratio | Rule it meets | |---|---|---| | Cell text `#202020` on `#ffffff` | **16.29 : 1** | AA text (4.5:1), AAA | | Muted text `#767676` on `#ffffff` (disabled, sort icon) | **4.54 : 1** | AA text | | Header text on the header band | **14.17 : 1** | AA text, AAA | | Focus / selection outline (accent `#0078d4`) against the cell background | **4.53 : 1** | Non-text 3:1 | | Cell text on the 18% selection tint | **12.79 : 1** | AA text | | The blurred-grid selection outline (`--avg-text-muted`) | **4.54 : 1** | Non-text 3:1 | The focus indicator is the accent outline, present in both themes (on a board it takes `--p-accent` against `--p-bg`, which Persephone's own palette keeps above 3:1). **A consumer overriding tokens owns its own palette** — these numbers describe the defaults, not every theme. --- ## Accessibility — what conformance we claim **Keyboard-operable, with grid semantics and sort state exposed; not a fully conformant ARIA grid, because there are no row elements.** ARIA's grid pattern wants `role="row"` owners, and this library deliberately has none — that absence is what keeps a one-row scroll to a handful of node touches (invariant 2's corollary). A consumer whose audit requires row elements should know that before adopting, not discover it in the audit. What is there, all pinned by tests: - The root is `role="grid"` with live `aria-rowcount` / `aria-colcount` (header and footer rows counted, filtering reflected) and `aria-multiselectable="true"`, and sits in the tab order (`tabIndex 0`). - Header cells are `role="columnheader"` with `aria-colindex`, and the sorted one carries **`aria-sort`** — set with the sort, removed with it, never stale on a pooled element. - Data and footer cells are `role="gridcell"` with 1-based `aria-rowindex` / `aria-colindex` (the header is row 1); footer cells add `aria-readonly="true"`. - The whole grid drives from the keyboard: Tab reaches it, the first navigation key lands on the first cell, and navigation, range selection, editing, clipboard, the filter popover (**Alt+↓**, the Excel gesture, added by this audit) and the context menu (the Menu key, now resolved to the *focused* cell and anchored there, also from this audit) are all reachable without a pointer — the keyboard reference in `docs/api.md` is the audit list, and the board's `measureKeyboard()` drives it end to end. - **The named gap: sorting has no keyboard binding.** A header click is the only built-in gesture; a keyboard-first consumer sorts through its own UI and `setSort()`. Header cells are not focusable — a header focus mode is a phase-11 question, not a quiet omission. Screenshots beat accessibility snapshots for *layout* testing here for the same reason: sticky-band bugs are invisible to the accessibility tree. --- ## Introspection and teardown **`getState()` answers "what is going on" in one line, and it survives `JSON.stringify`.** Every field is plain data — the columns are flattened to key/name/width plus sorted, filtered, editable and `hasRender` / `hasOptions`, because it used to hand back the live `Column[]` with its functions on it. There are no row objects anywhere: counts, keys and indices only, so logging a 100,000-row grid stays readable and puts no host data in the line. A `viewport` block reports the window the engine believes is on screen and the size it thinks it has, `width: 0` being the answer to the commonest integration failure. **`destroy()` releases everything.** 100 create/destroy cycles of a fully-loaded grid leak **0 DOM nodes** and leave **100/100 grids collectable**, at 6.5 ms a cycle. Afterwards a mutator warns once and does nothing while the getters still answer — `getState().destroyed` is `true`, which is exactly when a post-mortem wants it. --- ## The cell pool, and lists whose rows differ Evicted cells go to a bounded `CellPool` and come back on the next admission, which is what makes a scroll settle into allocating nothing: after the first frame warms it, a 100,000-row scroll creates **zero** elements. The pool deliberately does **not** reset what it hands back — reusing the inner structure is most of the saving — so a renderer overwrites everything it sets. That is right for a grid, where every cell is the same shape, and wrong for a **list whose rows are not**: a log of text, tables, images and errors would get a cell built for one kind handed to a row of another, and rebuilding it costs more than the allocation the pool avoided. So a renderer can stamp a cell with an opaque **reuse key** — `p.recycle?.(kind)` and `p.setReuseKey?.(cell, kind)`, one line each, [documented in `api.md`](api.md#reuse-keys--when-the-rows-are-not-all-alike). A keyed request is served only from cells released under the same key and **misses rather than returning something incompatible**. Keys are held one bucket per kind, so a keyed acquisition is a lookup and a `pop` — **flat in the size of the pool**. The obvious alternative, scanning the pool for a match, was measured and rejected: at a 2,000-element pool with half the requests asking for a kind the pool does not hold, the scan costs **2,177 ns against 24.8 ns**, and grows from there. A lookup that scales with the pool is the same mistake as a repaint that scales with the dataset. Measured end to end over a heterogeneous list: once warm, **every rebuild corresponds to a pool miss and not one reuse is of the wrong kind** — with no key, each mismatch would have been a rebuild. A pool that is never given a key is a single bucket and behaves exactly as it did before keys existed, hit and miss counts included. **Every cell the pool hands out is accounted for by the next paint.** `renderCell` runs during the model's *recompute*, and a frame can hold more than one — two scroll events, or two state writes landing in separate microtasks. Only the last render info is painted; the passes before it are dropped, but the cells they recycled came out of the pool, and with `keepCellsAttached` a pooled cell is still an (un-hidden, repositioned) child of its region that no `syncRegion` will ever evict — stale rows on screen for the life of the grid. So `recycle` is wrapped in a **loan ledger**: everything the pool hands out since the last paint is recorded, and at the end of `paint()` anything the painted info did not claim is re-parked. The same ledger reclaims a cell a renderer takes with `recycle()` and then drops. Cost: one `Set` insert per recycled cell per recompute, measured at nothing — the gate below ran on this code. --- ## Holding on to scroll position, and to what a cell owns Two defects with one cause: something detaches a subtree, and Chromium discards the scroll state inside it without saying so. Both were measured before being fixed, and again after. **A host that moves the grid.** A framework re-rendering the slot the grid lives in detaches and re-appends its subtree. Chromium zeroes every scroller inside, keeps it zeroed on re-insertion, and fires **no scroll event**, so nothing downstream notices. Measured on a 100,000-row grid at `scrollTop: 20000`: it came back at `0` with the model still at `20000`, **the entire viewport unpainted**, and it stayed blank until someone scrolled. The grid now recovers by itself, through two detectors, because neither covers both cases. A detach that spans a frame shows up as the grid measuring nothing. A host that removes and re-inserts within a **single task** — what a framework does when it moves a node during a commit — resets the scroller just the same but produces no observable size change at all (`resizeObserverSaw0x0: false`), so the grid also watches its ancestors' child lists, which are nodes it never touches and which record nothing on a page that leaves the grid alone. `revalidate()` is the manual form. After: `20000` restored and **0 px unpainted** in both cases. **The discrimination is the hard part, and it is not a heuristic.** A scroll event is delivered a frame after the scroll it reports, so a container ahead of the model is the *normal* state during a user scroll — writing the model back there is the documented failure that produces a list which scrolls half the time. A scroll the browser performed is always followed by an event; a position it discarded is followed by none. So a disagreement is carried two frames and any scroll event vetoes it. Verified against the regression it would cause: scrolling to the top while an ancestor churns every frame lands at the top and stays, and 30 frames of continuous scrolling under the same churn land exactly where asked. **The engine doing it to its own cells.** Evicting a cell with `removeChild` destroys what the cell owned. Measured on a cell holding a nested scroller and an `