TOCLOCO Inc

Lattice Grid API

Every configuration property, method and event on the public surface. Extracted from the shipped source, not from the specification: where the two disagree, this document follows the code and says so.

Version 1.65.0 Zero dependencies Developer guide →

Construction

Two files are all you need: a stylesheet and a script. Nothing is fetched at runtime (no CDN, no font, no icon sprite) however the two files themselves got there.

<!-- Script tag, from your own build. Everything is on one global. -->
<link rel="stylesheet" href="dist/lattice-grid.min.css">
<script src="dist/lattice-grid.min.js"></script>

<script>
  const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>

Or straight from jsDelivr, no npm install, no bundler, no local copy at all:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.min.js"></script>

<script>
  const grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
</script>

Or as modules, importing the bundle by path: your own build, npm, or jsDelivr:

// With a renderer, in a browser. Any of:
import { createGrid } from './dist/lattice-grid.esm.js';
// import { createGrid } from '@toclocoinc/lattice-grid';
// import { createGrid } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/lattice-grid.esm.min.js';
const grid = createGrid(document.getElementById('grid'), config);

// Headless: the same API without a renderer. Data, filters, sort,
// grouping, totals, formatting and export all work; grid.element is
// null and the DOM-only chrome is simply absent.
// Runs in Node, for tests and server-side export.
import { createHeadlessGrid } from './dist/lattice-core.esm.js';
const grid = createHeadlessGrid(config);

jsDelivr mirrors every version published to npm at cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1/<file>. @1 pins the major: a page in production picks up fixes within 1.x and never a breaking release, where @latest would. To freeze a page on one exact build, replace @1 with the full version getVersion() reports. The same convention reaches any module: .../modules/htmx.esm.min.js, .../modules/react.esm.min.js, and so on.

Framework adapters

The web component carries the grid inside it, so use it or createGrid in a page, not both: two copies keep separate registries, and a renderer registered through one is invisible to the other. One optional bundle per framework. The framework and createGrid are passed in rather than imported, so the adapters add no dependency and carry no second copy of the grid.

// React
import React from 'react';
import { createGrid } from './dist/lattice-grid.esm.js';
import { createLatticeGrid } from './dist/modules/react.esm.js';

const LatticeGrid = createLatticeGrid({ React, createGrid });
<LatticeGrid columns={columns} rows={rows} rowKey="id" onCellChanged={fn} />

// Vue 3
import * as vue from 'vue';
import { createLatticeGrid } from './dist/modules/vue.esm.js';

const LatticeGrid = createLatticeGrid({ vue, createGrid });
<LatticeGrid :columns="columns" :rows="rows" row-key="id" @cell-changed="fn" />

// Svelte, an action, so no framework runtime is needed
import { createLatticeAction } from './dist/modules/svelte.esm.js';

const lattice = createLatticeAction({ createGrid });
<div use:lattice={{ columns, rows, rowKey: 'id' }} on:cell-changed={fn}></div>
Entry pointFactoryNeeds
modules/reactcreateLatticeGrid({ React, createGrid })Returns a component. Forwards a ref exposing .grid. Since 1.63 the same entry point also builds a component for every other viewer and the data router.
@toclocoinc/lattice-grid-angular<lattice-grid [config]="…">A package of its own, not a bundle: standalone components compiled ahead of time, one per viewer, plus the data router as a service. See Angular. modules/angular, which needs the JIT compiler, is deprecated.
modules/vuecreateLatticeGrid({ vue, createGrid })Returns a Vue 3 component definition.
modules/sveltecreateLatticeAction({ createGrid })Returns a use: action.
PropTypeDoes
any config keyas documented belowApplied through grid.setAll() when the reference changes. Never rebuilds the grid.
sortSortEntry[]grid.sort.set()
filtersFilterSetgrid.filters.set()
quickFilterstring | { text, mode }grid.filters.quick()
selectedKeysstring[]grid.selection.set()
on<Event>(e: GridEvent) => voidOne per event. cell:changedonCellChanged in React; @cell-changed in Vue; on:cell-changed in Svelte.
className, style, idstring | objectReact only. Applied to the host element, not the grid.

React: every viewer, not only the grid

Until 1.63 the React adapter wrapped createGrid and nothing else. Every other shipped viewer — the KPI panel, a chart, the board, the Gantt, the layout, the tab strip — and the data router had no React surface, so a React application wrote its own useEffect per viewer. It does not have to any more: there is one component per viewer, and each keeps the contract the grid component already kept.

import React from 'react';
import ReactDOM from 'react-dom';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
import { createLatticeReact } from '@toclocoinc/lattice-grid/modules/react';

// Built once, at module scope. Building components inside a component hands React a
// new type on every render, and a new type is a different element: the subtree would
// unmount and remount, destroying and rebuilding the grid on every keystroke.
const L = createLatticeReact({
  React, ReactDOM, createGrid, createKPI, createChart, createDataRouter,
});

function Dashboard({ rows }) {
  const router = L.useLatticeRouter(ROUTER_CONFIG);
  return (
    <L.LatticeRouterProvider router={router}>
      <L.LatticeGridProvider>
        <L.LatticeGrid name="quakes" route="all" {...GRID_CONFIG} rows={rows} />
        <L.LatticeKPI gridName="quakes" tiles={TILES} columns={5} />
        <L.LatticeChart gridName="quakes" type="bar" x="region" y="count" />
      </L.LatticeGridProvider>
    </L.LatticeRouterProvider>
  );
}
ExportSignatureWhat it is
createLatticeGrid({ React, createGrid })The grid component. Extended in 1.63 with rowUpdates, predicates, onGridReady, onGridDestroy, name and route.
createLatticeKPI({ React, createKPI })The KPI panel. Grid-bound through context by default; pass rows instead for a panel with no grid.
createLatticeChart({ React, createChart })A chart. Requires a grid, so nothing is mounted until one exists; a changed spec key goes to chart.update() and the chart redraws rather than being rebuilt.
createLatticeKanban({ React, createKanban })The board. rows, quickFilter, sprint, epic, loading and error are live props.
createLatticeGantt({ React, createGantt })The Gantt. tasks and dependencies are live props.
createLatticeLayout({ React, createLayout })The layout. Windows are driven through the ref; its events arrive as onLayoutChanged, onWindowMoved and the rest.
createLatticeTabs({ React, ReactDOM, createTabs, createGrid? })The tab strip, with React-rendered tab content: a tab's content is a React element (or a function returning one) rendered into the strip's own panel through createPortal, so a tab's grid is a real <LatticeGrid> with props, a ref and the surrounding context. A tab with no content is left to the module, so the two kinds mix on one strip.
createLatticeGridContext({ React })Returns { LatticeGridProvider, useLatticeGrid }. A grid-bound viewer needs the grid instance, which appears after the first render; a ref cannot help because writing to a ref re-renders nobody. Several grids may publish under one provider — name on the grid, gridName on the viewer.
createLatticeRouter({ React, createDataRouter })Returns { useLatticeRouter, LatticeRouterProvider, useRouter }. The hook creates the router in an effect and destroys it in that effect's cleanup, so it returns null on the first render; the config is read once, because rebuilding would drop every attached grid and every row held.
createLatticeViewer({ React, viewer, mount, … })The generic behind all of the above, and the escape hatch for a viewer with no named factory yet.
createLatticeReact({ React, ReactDOM?, …factories })Every binding from one call: pass the factories the application uses and it builds those components, leaving the rest undefined. Nothing is imported, so an application that never uses the board never loads the board.
createViewerController({ viewer, mount, element, props })The framework-free viewer lifecycle — mount once, push changed props into the live instance, destroy — shared by every adapter.
VIEWER_EVENTSRecord<string, readonly string[]>Every event each non-grid viewer emits, keyed by viewer name.
VIEWER_APPLYRecord<string, Record<string, Function>>Which props each viewer can take live, and the instance call each becomes. Anything not listed is mount-time configuration.
viewerHandlerName(event) => stringA viewer event as its React prop: card:move becomes onCardMove.
DEFAULT_GRID_NAMEstringThe name a grid publishes itself under when you do not choose one: default.
const A = await import('../packages/modules/react/index.js');

// A stand-in for React and react-dom. The adapter never imports either, so a
// factory only needs the handful of names it destructures — which is exactly
// why this example runs in Node with no framework installed.
const React = { createElement: () => ({}), createContext: () => ({}), forwardRef: (f) => f };
const ReactDOM = { createPortal: () => ({}) };
const stub = () => ({ on: () => () => {}, destroy() {} });

// One call builds whichever components the factories you pass support.
const L = A.createLatticeReact({
  React, ReactDOM, createGrid: stub, createKPI: stub, createChart: stub, createDataRouter: stub,
});

// …or one factory at a time.
const components = [
  A.createLatticeGrid({ React, createGrid: stub }),
  A.createLatticeKPI({ React, createKPI: stub }),
  A.createLatticeChart({ React, createChart: stub }),
  A.createLatticeKanban({ React, createKanban: stub }),
  A.createLatticeGantt({ React, createGantt: stub }),
  A.createLatticeLayout({ React, createLayout: stub }),
  A.createLatticeTabs({ React, ReactDOM, createTabs: stub }),
  A.createLatticeViewer({ React, viewer: 'kpi', mount: stub }),
].filter((c) => typeof c === 'function');

// The provider/hook pair and the router hook are built the same way.
const { LatticeGridProvider, useLatticeGrid } = A.createLatticeGridContext({ React });
const { useLatticeRouter } = A.createLatticeRouter({ React, createDataRouter: stub });

// And the lifecycle underneath, driven with no framework at all: mount once,
// push a changed live prop into the instance that already exists, destroy.
const seen = [];
const controller = A.createViewerController({
  viewer: 'kpi',
  element: {},
  props: { rows: [{ id: 1 }] },
  mount: () => ({ setRows: (r) => seen.push(r.length), on: () => () => {}, destroy() {} }),
});
controller.update({ rows: [{ id: 1 }, { id: 2 }] });
controller.destroy();

return `${components.length} components, ${A.viewerHandlerName('card:move')}, `
  + `${A.DEFAULT_GRID_NAME}, rows ${seen.join('/')}, `
  + `events ${A.VIEWER_EVENTS.kpi.length}/${Object.keys(A.VIEWER_APPLY).length}`;

Live props versus mount-time props. The grid takes any changed configuration key through one call (grid.setAll); no other viewer does. So each viewer declares which props it can take while it is running — the table above, and VIEWER_APPLY at runtime — and everything else is mount-time configuration. A mount-time prop that changes is not silently ignored and not silently remounted (that would throw away scroll position, selection and expansion): it is named once in a warning that tells you to give the component a key that changes when the rebuild is wanted, or to drive the instance through the ref.

Two props rebuild a viewer rather than update it: the grid it is bound to, and anything it cannot exist without. A chart holding a destroyed grid is not stale, it is invalid, so a new grid tears the old chart down and builds a new one against it — in that order, never the reverse.

A live feed: rowUpdates and predicates. rowUpdates is a keyed diff handed straight to grid.rows.apply(), applied when the object's identity changes — a feed produces a new change object per batch, so identity is the right trigger and re-applying the same object would re-land rows the grid already has. predicates is { name: fn } mapped to grid.filters.where(name, fn), diffed by name, with a name that has gone removed. Those compose with whatever filter the reader set in the tool panel; the filters prop cannot, because it maps to filters.set and replaces the whole condition tree.

StrictMode creates one instance. React 18's StrictMode deliberately mounts, unmounts and mounts again in development. Every component here builds its instance in an effect with an empty dependency list and destroys it in that effect's cleanup, so the first is destroyed before the second is built and exactly one survives. There is no module-level "already mounted" flag, because that would defeat a genuine remount.

Props are diffed with Object.is, and that is a promise about identity. An inline columns={[{ field: 'a' }]} is a new array on every render, so it counts as changed every render and reconfigures the grid every render. Hoist it to module scope or wrap it in useMemo. This is not a defect to work around: a deep compare of a million-row array on every render would cost more than the reload it prevents.

Sharing one rows array between grids is safe. Since 1.63 the grid copies the array it is handed on ingest, so two grids given the same rows={EMPTY} default no longer contaminate each other. The row objects are still shared, as they always were — mutate one and both grids see it.

React 18 and 19, no SSR. Nothing in the adapter uses an API added in 19 or removed in 19 (forwardRef is still supported there). Every component owns a real DOM element, so there is no server rendering and no React Server Component support: render them on the client.

One build warning is gone. The version resolver carried a Node-only fallback whose import('node:' + 'module') Vite could not analyse statically, so every Vite build of every application printed "The above dynamic import cannot be analyzed by Vite" about a line that could never run. The build now deletes that branch from every emitted artefact.

Angular: a compiled package, @toclocoinc/lattice-grid-angular

Angular's components are not objects a library can assemble at run time in a production build. modules/angular did assemble them that way, which needs Angular's JIT compiler in the page — present on a development server, absent from every AOT build. So Angular gets a package of its own: TypeScript components compiled by @angular/compiler-cli into a partial-Ivy library, which your build's Angular Linker turns into definitions exactly as it does for any other Angular library you install. No compiler in your bundle, and one standalone component per viewer.

npm install @toclocoinc/lattice-grid @toclocoinc/lattice-grid-angular
import { bootstrapApplication } from '@angular/platform-browser';
import { createGrid } from '@toclocoinc/lattice-grid';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import {
  LatticeGridComponent, LatticeKpiComponent, LatticeChartComponent, provideLattice,
} from '@toclocoinc/lattice-grid-angular';

@Component({
  selector: 'app-dashboard',
  imports: [LatticeGridComponent, LatticeKpiComponent, LatticeChartComponent],
  template: `
    <lattice-grid #grid name="quakes" [config]="config" [quickFilter]="search()"
                  (cell-changed)="save($event)" />
    <lattice-kpi  gridName="quakes" [config]="{ tiles }" />
    <lattice-chart gridName="quakes" [config]="{ type: 'bar', x: 'region', y: 'count' }" />
  `,
})
export class Dashboard {
  // The live grid, the same object createGrid returns.
  grid = viewChild<LatticeGridComponent>('grid');
}

// Each factory is injected, not imported by the library: an application that
// shows a grid downloads the grid, and never the board or the eighteen charts.
bootstrapApplication(Dashboard, {
  providers: [provideLattice({ createGrid, createKPI, createChart })],
});
ExportElementWhat it is
LatticeGridComponent<lattice-grid>The grid. [config] is every configuration key; [sort], [filters], [quickFilter] and [selectedKeys] are applied through the matching API; [rowUpdates] and [predicates] drive a live feed. Every grid event is an output under its kebab-case name, and (grid-ready) hands you the instance. Give the element a height.
LatticeGridDirective[latticeGrid]The same component on an element your template already owns: <div [latticeGrid]="config" class="tall"></div>. Same inputs, outputs and grid reference.
LatticeKpiComponent<lattice-kpi>The KPI panel. Grid-bound by default — [gridName] picks which published grid — or give it [rows] for a panel with no grid.
LatticeChartComponent<lattice-chart>A chart. Requires a grid, so nothing is built until one exists; a changed spec key goes to chart.update() and the chart redraws rather than being rebuilt. (click), (hover) and (leave) on this element are the chart's events, carrying the datum under the pointer.
LatticeKanbanComponent<lattice-kanban>The board. [rows], [quickFilter], [sprint], [epic], [loading] and [error] are live inputs.
LatticeGanttComponent<lattice-gantt>The plan. [tasks] and [dependencies] are live inputs. It takes an explicit [grid] but never adopts a published one.
LatticeLayoutComponent<lattice-layout>The dashboard layout. Windows are driven through the instance; its events arrive as (layout-changed), (window-moved) and the rest.
LatticeTabsComponent
LatticeTabDirective
<lattice-tabs>
ng-template[latticeTab]
The tab strip, with Angular-rendered tab content: a tab's content is an <ng-template latticeTab="id"> in your own template, rendered into the strip's panel through this component's ViewContainerRef — so a tab's grid is a real <lattice-grid> with inputs, a reference and your injectors above it. A tab with no template is left to the module, so configuration-driven grid tabs still work and the two kinds mix on one strip.
LatticeGridRegistryinject(LatticeGridRegistry)Where <lattice-grid name="…"> publishes itself and grid-bound viewers find it, as a signal per name — a panel declared before its grid exists mounts itself the moment the grid arrives. providedIn: 'root'; put it in a component's own providers to scope a registry to that subtree.
provideLatticeprovideLattice({ …factories })The factories the components build through. Give it the ones your application uses; a component whose factory is missing names the import and the provider call that fixes it.
provideLatticeRouter
LatticeRouter
providers: [provideLatticeRouter(cfg)]The data router as a service. Put it in a component's providers and it is created when the first <lattice-grid route="…"> under it attaches, and destroyed with that component — its configuration read once, because rebuilding would drop every attached grid and every row it holds. Each grid detaches before it is destroyed, so the router never holds a dead grid.

Change detection: zone or zoneless, unconfigured. The grid is created inside NgZone.runOutsideAngular, because it installs its own scroll, wheel and pointer listeners and running change detection on every scroll frame of a million-row grid is the difference between smooth and unusable. An event that reaches an output you have bound re-enters the zone, so (cell-changed)="count = count + 1" repaints exactly as you expect; an output nobody bound costs nothing. Under zoneless change detection that machinery is Angular's own no-op and a signal you set in a handler repaints the view. The one thing to know when reading the grid's DOM from Angular: the grid paints on its own schedule, not Angular's, so measure a cell in a grid event, not in ngAfterViewInit.

OnPush is safe everywhere. None of these components asks its parent to re-render: each owns one element, builds one instance in afterNextRender and pushes changed inputs into it from ngOnChanges. A host on OnPush that never re-renders still gets a fully live grid, because the grid is not rendered by Angular.

Inputs are diffed by identity, and never rebuild the instance. A changed input reaches the viewer that is already on screen — scroll position, selection, expansion and any open editor intact. Two things rebuild rather than update: the grid a viewer is bound to, and an input it cannot exist without. An input a viewer has no live setter for is named once in a warning rather than silently dropped. And because the comparison is Object.is, an inline [config]="{ rows: rows }" is a new object on every pass: hold it in a field or a signal.

Cleanup is the component's. ngOnDestroy detaches from the router, withdraws the grid from the registry and destroys the instance — in that order. An @if that closes and opens again leaves exactly one instance alive, and destroying the application leaves no instance, interval or listener behind. That is asserted with counters in a real browser, against the linked package, in test/angular-package-browser.test.js.

Angular 17 and up, browser only. The library is compiled partially, so your own Angular version compiles it: its declarations need a linker no newer than 14, and its peer range is >=17. Nothing is created on the server — isPlatformBrowser guards every build and afterNextRender does not run there — so a server-rendered page emits the empty host element and the grid is built on hydration. Angular Universal is not otherwise supported.

modules/angular is deprecated. The old bundle still works where it always worked — a page with @angular/compiler loaded — and it now says so once, and fails with a [lattice] message naming this package when it finds a real Angular with no JIT compiler, instead of leaving you with Angular's own. In 1.65 it also gained the fix that <div [latticeGrid]="config"> binds the configuration through the directive's selector, as its documentation always said it did. It will be removed in a later release; move to @toclocoinc/lattice-grid-angular, which covers every viewer rather than the grid alone.

Published as @toclocoinc/lattice-grid: npm install @toclocoinc/lattice-grid, then import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react' (swap the module name for Vue or Svelte) resolves like any other package. Type declarations resolve automatically through the package's own types field, no @types package to install. Importing the built file by path, or the script tag, both still work for a project with no npm install step at all.

FileNeeded?What it is
lattice-grid.min.jsyesThe whole product as a UMD build: core, renderer, editors, exports. Defines window.LatticeGrid, and also works with AMD or CommonJS loaders.
lattice-grid.min.cssyesThe single stylesheet. Without it the grid is in the DOM and unreadable, no column widths, no scrolling, no theme.
lattice-grid.esm.min.jsalternativeThe same thing as an ES module, if you are importing rather than script-tagging.
lattice-core.esm.jsoptionalHeadless core only, for Node. No renderer.
lattice-grid.d.tsoptionalType declarations, for editor tooling.
the unminified buildsoptionallattice-grid.js, .esm.js, .css: readable source for debugging. Ship the minified ones.
SignatureReturnsNotes
createGrid(element, config?) Grid Resolves the document from element.ownerDocument, so a grid inside an iframe uses that frame's document. Throws with a clear message if there is no DOM. The exported name itself cannot be reassigned to wrap it — see the note below.
createHeadlessGrid(config?) Grid Core only. Everything below except grid.element and the DOM-only config keys works unchanged. What that does and doesn't reach is spelled out below.
defaults(config?) object House-wide defaults, merged beneath the config of every grid built afterwards, through either factory. The per-grid value always wins. defaults() reads the current set; defaults(null) clears it. See the note below.

createGrid cannot be monkey-patched. Wherever it is exported — window.LatticeGrid.createGrid from the UMD build, or the named import from the ESM build — it is defined with Object.defineProperty(..., { get, enumerable: true }) and no setter, and configurable defaults to false because the descriptor never sets it. Assigning to it in an ordinary (non-strict) script is not an error: the assignment is simply discarded and LatticeGrid.createGrid still returns the original function. In a module or any script under 'use strict' — which every ES module is — the same assignment throws TypeError: Cannot set property createGrid of [object Object] which has only a getter. Either way, a house-wide patch applied this way has no effect, and in the sloppy-mode case nothing tells you it didn't. There is no supported way to replace the function in place. The supported pattern is a factory your own code owns:

// your-lattice.js — the one place that knows your house defaults
import { createGrid as baseCreateGrid } from '@toclocoinc/lattice-grid';

export function createGrid(element, config) {
  return baseCreateGrid(element, { theme: 'house', density: 'compact', ...config });
}

// everywhere else
import { createGrid } from './your-lattice.js';

The wrapping function above is still a good seam when the wrapper does more than supply options. When all it does is supply options, use defaults() instead: it applies to every grid built afterwards through either factory, including the ones built for you inside a framework adapter or a module, which a wrapper in your own code never reaches.

  • The per-grid config always wins. Defaults sit beneath what the factory is passed: a key the grid names keeps the grid's value, a key it omits takes the house one. A key passed as undefined means "say nothing" — as it does everywhere else in the config surface — and so takes the house value rather than blanking it.
  • Plain objects deep-merge; arrays and everything else replace. A house views: { storage } and a grid's views: { local: true } both survive. A grid's columns array replaces the house one rather than extending it. Which keys behave as option bags follows from the value at the key, not from a fixed list.
  • Calling it again replaces the set, it does not accumulate, so the result never depends on the order your modules load. Extend explicitly with defaults({ ...defaults(), density: 'compact' }).
  • Never retroactive. The merge happens as a grid is built, so a grid that already exists is never revisited. Nothing reached from the defaults is shared between two grids: nested objects and arrays are copied per grid.
const { createHeadlessGrid, defaults } = await import('../packages/core/src/index.js');

// One place says what every grid in this application starts from.
defaults({ rowKey: 'id', selection: 'multiple' });

// This grid says nothing about rowKey, so the house value applies.
const a = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'name' }],
  rows: [{ id: 'a1', name: 'Ada' }],
});
const house = a.rows.byKey('a1').data.name;

// This one names its own rowKey. The grid's own config always wins.
const b = createHeadlessGrid({
  rowKey: 'sku',
  columns: [{ field: 'sku' }],
  rows: [{ sku: 's9', id: 'ignored' }],
});
const own = b.rows.byKey('s9') !== undefined && b.rows.byKey('ignored') === undefined;

a.destroy();
b.destroy();
defaults(null);                // clear: grids built after this are unaffected
return `${house}; own-key ${own}; cleared ${JSON.stringify(defaults())}`;

What createHeadlessGrid covers, and what it cannot. It builds the same core the DOM build attaches a renderer to, so everything that is not the renderer itself is exercised exactly as it runs in a browser:

  • Covered: data (rows, columns), state (grid.state, saved views), sort, filter, group, total and pivot, formulas and computed columns, editing and optimistic write-back, export, and every event the grid emits.
  • Not covered: the DOM renderer, layout and measurement (column widths, row heights, scrolling), focus, and anything whose behaviour depends on a real box being painted on screen — grid.element is null and there is nothing to measure.

See How it works in the guide for the two specifics that have cost real debugging time: a grid mounted where it has no rendered box, and what the in-repo test DOM stub does and does not stand in for.

<lattice-grid> web component

A self-contained module bundle that registers a custom element on import. One script, one tag, no build step, for Rails, Django, Laravel or any page without a bundler. Load this or lattice-grid.esm.js, not both: the module carries the grid with it.

<link rel="stylesheet" href="dist/lattice-grid.min.css">
<script type="module" src="dist/modules/webcomponent.esm.min.js"></script>

<lattice-grid row-key="id"
  columns='[{"field":"id"},{"field":"city"}]'
  rows='[{"id":"A","city":"Leeds"}]'></lattice-grid>
AttributeConfig keyNotes
themetheme
densitydensity
localelocale
row-keyrowKey
row-heightrowHeightNumber.
header-heightheaderHeightNumber.
auto-heightautoHeightBoolean attribute.
selectionselectionnone, single or multiple.
rowsrowsJSON. Prefer the property.
columnscolumnsJSON. Prefer the property.
PropertyTypeNotes
rowsunknown[]Row data. A structure, so a property rather than an attribute.
columnsColumn[]Column definitions.
configGridConfigMerges any configuration without an attribute of its own.
gridGrid | nullRead-only. The underlying grid, for the full imperative API.

Events are re-dispatched as CustomEvents named lattice- plus the grid name with colons hyphenated: cell:changed becomes lattice-cell-changed. The payload is on event.detail. The prefix avoids colliding with platform events, the grid emits one called scroll.

dhtmlx Grid compatibility wrapper

A Grid class shaped like dhtmlx's own dhx.Grid (Suite 5+), backed by a real Lattice grid. Covers column definitions, .data, .selection, .history, .export.csv/.xlsx, and a name-mapped subset of .events: see the guide for exactly what is and is not covered. Not the classic pre-Suite-5 dhtmlXGridObject.

import { Grid } from './dist/modules/dhtmlx-compat.esm.min.js';

const grid = new Grid(container, {
  columns: [{ id: 'name', header: [{ text: 'Name' }], sortable: true }],
  data: rows,
});
grid.data.serialize(); // every row's data, in source order
NamespaceCovers
.dataadd, update, remove, removeAll, parse, load, find, findAll, exists, getItem, getId, getIndex, getLength, forEach, serialize, sort, filter, resetFilter. Index means current display order throughout.
.selectionsetCell(rowId, colId, ctrlUp?, shiftUp?), getCell, getCells, isSelectedCell, removeCell. row/column carry .id; a row's own fields sit alongside it, matching dhtmlx's own IRow.
.historyundo, redo, canUndo, canRedo, clear, getHistory.
.exportcsv, xlsx. pdf/png throw, no raster export to translate to.
.eventscellClick/cellDblClick/cellRightClick/afterEditStart/afterEditEnd/afterSort call your handler with dhtmlx's own positional arguments. afterRowDrop fires (data, event) from a same-grid reorder settling or a row landing from another grid. Every other mapped name passes Lattice's own event object. Every before*/can*/cancel* event, and row/column drag negotiation (a handler refusing or steering a drop mid-gesture), is unmapped: logged once per name if subscribed to.
.rangeSelectionBest-effort only: its range shape is this wrapper's own design, not dhtmlx's genuine RangeSelection module.

Which dhtmlx keys are translated

A key outside these lists is not silently dropped: the wrapper names it through warnOnce at construction, so a migration is told what did not come across rather than discovering it later.

Constructor keyBecomes
rowKeyThe grid's rowKey. Defaults to id, as dhtmlx does.
columnsTranslated column by column; see the next table.
dataThe initial rows.
autoHeightautoHeight.
rowHeightrowHeight.
headerRowHeightheaderHeight.
multiselectionselection: 'multiple'.
dragItemrowReorder when set to row. It does not also enable rowTransfer: dhtmlx lets any two grids on a page exchange rows by default, and enabling that implicitly is the accident Lattice's opt-in design exists to prevent.
rowTransferPassed through. There is no dhtmlx property to translate it from, so a pair of grids that exchange rows names it explicitly.
Column keyBecomes
idThe column id, and its field.
headerThe title. A multi-row header collapses to its first row, and says so: Lattice titles are a single string.
typeThe data type, by name.
width, minWidth, maxWidthlayout.width, layout.min, layout.max.
resizablelayout.resizable.
hiddenlayout.hidden.
draggablelayout.movable.
aligncell.align.
tooltip, tooltipTemplatecell.tooltip.
templatecell.render. dhtmlx returns HTML and Lattice mutates an element, so the return is treated as markup only when htmlEnable is set.
htmlEnableWhether template output is trusted as markup.
sortablesort.enabled.
editableedit.enabled.
editorTypeedit.editor, by name. An unrecognised one warns and falls back to the text editor.
editorConfigedit.props.
optionsedit.props.options, for a select-shaped editor.
summaryThe column's total.
Config keyBecomes
dragItem: 'row'rowReorder: true: same-grid drag-to-reorder.
rowTransferPassed straight through, unlike every other key: dhtmlx allows any two dragItem: 'row' grids on a page to exchange rows by default; Lattice's rowTransfer is deliberately opt-in per pair, so there is nothing to derive it from.

Declarative init, hydration & state

Core-level primitives, usable with or without any framework adapter or the htmx module below. An element built by createGrid is discoverable from itself: element.__lattice holds the live instance, cleared on destroy().

ExportDoes
autoInit(root)Builds a grid on every [data-lattice-grid] element under root not already built: idempotent, safe to call again after new content arrives. Config comes from a sibling <script type="application/json" data-lattice-config>; without one, a <table> element is hydrated instead.
hydrateTable(table, config?)Reads a <table>'s header row for column definitions and body rows for data (type-inferred per cell), then replaces the table with the grid. config (columns, rows, anything else) always wins over what was inferred.
serialiseState(grid) / restoreState(grid, encoded)A compact, URL-safe encoding of everything grid.state covers (sort, filters, column order and widths, scroll position, selection) diffed against the grid's own defaults first, so an untouched grid encodes to a handful of characters.
<meta name="lattice-license" content="…">Read automatically when no licence is passed to createGrid, no imperative call required.

htmx integration

One file. modules/htmx re-exports createGrid, autoInit, hydrateTable, readTable, serialiseState and restoreState alongside its own exports below, so a page using htmx integration never also loads the base bundle: that would mean two independent copies of the whole engine on one page. Registers on import: builds grids from [data-lattice-grid] elements or server-rendered <table>s, tears them down before htmx detaches a swapped-out subtree, and rebuilds them in newly-loaded content. driveServerMode/driveInfiniteScroll drive sort, filter and infinite scroll over plain htmx requests; driveOobUpdates applies out-of-band row updates in place. See the guide for the full request-lifecycle wiring and why infinite scroll uses two triggers.

import { autoInit, driveServerMode, driveInfiniteScroll } from './dist/modules/htmx.esm.min.js';

autoInit(document); // builds every [data-lattice-grid] under it
ExportDoes
createGrid, autoInit, hydrateTable, readTable, serialiseState, restoreStateThe same functions documented above, re-exported: this is the one import a page using htmx integration needs for both grid construction and htmx wiring.
driveServerMode(grid, trigger, opts?)A sort or filter change fires a request on trigger carrying offset/limit/sort/filters; the response replaces the grid's rows. opts.columns for the HTML-fragment ingest path.
driveInfiniteScroll(grid, sentinel, opts?)Appends rows as the grid's own visible window nears the end of what's loaded. sentinel's own hx-trigger names revealed, lattice:scroll-near-end, the first fires the initial chunk, the second every chunk after. opts.threshold (default 20) sets how many rows from the end counts as near.
driveOobUpdates(grid, opts?)Applies an out-of-band swap landing on [data-lattice-row="<key>"] to that row, in place: scroll, selection and filter state untouched.
Browser historyserialiseState/restoreState above, wired automatically to htmx:beforeHistorySave/htmx:historyRestore once this module is imported: browser back restores the prior sort, filter and scroll position.

Configuration properties

Every key is settable at runtime through grid.set(key, value). Keys marked dom are read by createGrid and ignored by a headless grid.

Data and structure

PropertyTypeDefaultDescription
columns(Column | ColumnGroup)[], Column definitions. Groups may nest.
columnGroupsColumnGroup[], Header grouping declared separately from the columns.
rowsunknown[], Row objects. The array is copied on ingest; the row objects in it are not. The grid keeps a shallow copy of the array you pass here (and of source.rows, and of the array given to rows.load()), so your array is never written to: after rows.apply, a sort, a group or an edit it holds exactly what it held when you passed it, and two grids built from one array are independent. The objects inside it are still yours — row.data is the object you supplied and rows.data() returns those same objects, so identity round-trips (see ingest.retainSource). rows.apply({ update }) does not write through either: it merges into a new object, which replaces that slot in the grid's copy only, so row.data for an updated row is a new object and the one you passed is untouched. An in-place cell edit does: edit.setCells, or typing in a cell, writes the new value into the shared object, which is the other face of row === sourceObject. ingest.retainSource: false and ingest.dropSourceRows opt out of sharing altogether.
rowKeystring | (row) => string, Stable row identity. Without it the grid assigns a key per row object and warns: enough for sorting, filtering, selection and copying within a session, but change tracking, streaming dedupe, selection persistence and remote reload all switch off, because new objects are new rows. If it is configured but resolves to nothing for some rows — a field absent, or present on some rows only — those rows collapse onto one key and the grid warns once, naming the field(s) and how many rows were affected, on rows.load() as well as at construction.
sourceSourceConfigmemoryWhere rows come from: memory, paged, remote or stream. See Sources.
ingestIngestConfig, { retainSource, dropSourceRows }. How rows enter the column store. retainSource defaults to true: the caller's row objects are held by reference so rows.data() returns them unchanged and row === sourceObject holds. Set it false to stop the store retaining them and reconstruct a row on demand — but the source layer and grid config still hold the array, so the resident footprint does not actually fall. dropSourceRows: true closes that gap: it releases the objects from the source layer too, so the packed columns become the only copy and the footprint drops by roughly an order of magnitude at scale. Either way rows.data() then returns freshly reconstructed objects, so identity checks and row.sourceObject no longer hold and equality becomes value-based. Cell values are unchanged.
treeTreeConfig, { path } or { parentKey }, plus label, orphans. Rows form a hierarchy. See Tree data.
detailDetailConfig, { rows, config, render, isMaster, height, cacheLimit, target }. A master row expands into a nested grid, inline or into an element you supply. See Master-detail.
contextunknown, Arbitrary value passed to every callback, so formatters and renderers need no closures over app state.

Defaults and registries

PropertyTypeDescription
columnDefaultsColumnMerged under every column before its own definition.
columnPresetsRecord<string, Column>Named bundles applied with preset: 'money'.
dataTypesRecord<string, DataType>Custom types. Registered ahead of the built-ins, so a name here overrides one of ours.
sampleSizenumberValues read per undeclared column when inferring its type. Default 100.
targetSize'default' | 'large'Raises every interactive target to a comfortable size for touch, leaving the type alone. Applied automatically on a coarse pointer; 'default' opts out of that.
componentsRecord<string, Ctor>Renderers, editors and filters addressable by name.
pipesRecord<string, fn>Template pipes for cell.template.
totalFnsRecord<string, TotalFn>Custom aggregations, addressable from column.total.
variantsRecord<string, VariantDefinition>Semantic colour tokens for decorations.

Behaviour

PropertyTypeDefaultDescription
selectionSelectionConfig | 'single' | 'multiple' | 'none', Object form adds checkbox (a pinned column of row checkboxes), headerCheckbox (tri-state select-all in its heading), checkboxOnly (only that column may change selection — for a row with its own click action), groupSelectsChildren, ranges, fillHandle, fill. See Selection and ranges.
editEditConfig | boolean, { enabled, mode: 'cell' | 'row', start: 'single' | 'double' | 'key', enterMovesDown, undoDepth, commit, confirm, pendingTimeout, pastePreview }. commit/confirm/pendingTimeout turn on optimistic writes; pastePreview (default off) shows a confirm/cancel diff before a bulk paste commits.
paginationPaginationConfig | boolean, Local or remote paging.
quickFilterTextstring, Initial quick-filter term. Equivalent to grid.filters.quick(text).
hostFilter{ active(), passes(row) }, An application-level predicate composed with the grid's own filters.
pivotobject, { enabled, groupTotals, totalsLabel, maxColumns, separator }. groupTotals: 'before' | 'after' adds a column group totalling every value column across all pivot values, at the near or far edge; omitted, it adds none. totalsLabel heads it, defaulting to Total. maxColumns defaults to 500, counts the totals group, and fails with a message rather than locking the browser.
grandTotalRowboolean | 'bottom'falsetrue puts it inline at the end of the rows; 'bottom' pins it above the status bar. Maintained incrementally on a memory source: see Grouping, totals and pivot.
pinnedTopRowsobject[], Rows held above the scrolling body. Rendered through the ordinary column pipeline, but not part of the data: not counted, sorted, filtered, grouped, selectable or exported. See Pinned rows.
pinnedBottomRowsobject[], As pinnedTopRows, held below the body instead. Sits under the grand total when both are shown.
fullWidth{ when, render }, Draw matching rows as one band across every column instead of dividing them into columns, a section banner, a note, a “load more” affordance. when(row) picks them, render(params) fills them. Still ordinary data rows in every other respect. See Full-width rows.
groupRenderer(params) => string | Node | void, Draw the group row yourself — a section header with a chevron, a rollup, a count, a progress bar — instead of the grid's expander-and-label. The row is drawn as one band across every column and no ordinary cells are mounted underneath it. Return an HTML string, a node, or write into params.element. A string is inserted as markup here, unlike fullWidth.render, because a group heading is synthesised by the grid and has no data row: the string can only be your own template, the same contract the board's cardRenderer has. The renderer is handed the group key, the grouped column id, the value, the level, the expanded state, leafCount, the group's totals and leaves() for the rows themselves. Mark any element in your markup data-lat-group-toggle to make it expand and collapse the group. See Group rows you draw yourself.
groupDefaultExpandedboolean | number | (group) => boolean, Which groups start open before anyone has touched one. true (the default) opens every group, false closes every group, a number opens the first N levels (0 closes everything, a negative opens every level), and a predicate answers per group — the current sprint open while the rest start closed. It is handed { key, column, value, level, path }. Only ever consulted for a group nobody has expanded or collapsed: once the user or your code decides, that decision stands. See Group rows you draw yourself.
groupFooterbooleanfalseA closing total row per group.
totalFilteredOnlybooleantrueTotals reduce the filtered set. false totals the whole dataset, group totals included. See Grouping, totals and pivot.
totalOnlyChangedColumnsbooleanfalseReduce only the totalled columns an edit actually changed. Off by default, it asserts that each total depends on nothing but its own column. See Grouping, totals and pivot.
showTotalInHeaderbooleantrueUnder grouping or pivot, a totalled column's heading names its reduction on a line above the column name. See Grouping, totals and pivot.
aggregateChooserbooleanfalseLet the user pick a column's reduction from the column menu. On, the totalling entry becomes an Aggregate submenu offering only the aggregates the column's type says are meaningful (sum, average, min, max, count and so on — never sum on a category column), with the current one ticked and a None to stop totalling; it is keyboard-operable through the standard menu and drives grid.columns.setTotal(), reusing the existing reduction model. Off by default and non-breaking: the menu keeps its plain Total this column toggle. See Grouping, totals and pivot.
allowUnsafeTemplatesbooleanfalseOff by default. Templates are escaped unless this is explicitly set. When set, an interpolated value may contain presentational markup, but script is still removed from it: <script>, <iframe> and the other executable tags, on* handler attributes, and javascript: URLs. The flag permits markup, not code.
licencestring, Signed licence key. Removes the trial watermark; unlocks nothing, because nothing is locked.

Presentation

PropertyTypeDefaultDescription
messagesobjecten-GBReplaces the grid's own text: labels, menus and screen-reader announcements. A partial catalogue laid over the built-in British English one, so anything you leave out stays in English. Twenty catalogues are bundled: EN_GB, EN_US, FR_FR, FR_CA, IT_IT, ES_ES, PT_BR, DE_DE, NL_NL, SV_SE, DA_DK, NB_NO, FI_FI, PL_PL, CS_CZ, HU_HU, RO_RO, UK_UA, EL_GR, JA_JP and AR. They are exports of the package, not separate files, so importing one does not reduce what is bundled. EN_US is a partial overlay carrying only what differs from British English. AR_SA is an alias for AR: the Arabic catalogue is pan-Arabic, and a region appears in a name only where two variants ship. resolveCatalogue(tag) finds the catalogue for any tag, so resolveCatalogue('es-MX') returns the Spanish one. Every key is listed in MESSAGE_KEYS; auditCatalogue() reports what a catalogue of your own is missing.
localestringruntimeBCP-47. Drives every formatter and one shared Intl.Collator.
direction'ltr' | 'rtl' | 'auto'autoWriting direction. Left unset, it follows the element's computed dir and then the locale, so locale: 'ar' renders right to left without further configuration. Set it explicitly to override both.
theme'light' | 'dark' | 'high-contrast' | 'terminal', Stamped as data-theme on the grid's root. Unset follows the viewer's prefers-color-scheme. See Theming.
density'compact' | 'standard' | 'comfortable' | 'spacious' | number'compact'One scale that every geometry token derives from: row height, spacing, decoration sizes, and type at a damped rate. Row heights are 23.8 / 28 / 42 / 56px. A number scales 28px, so 1.4 gives 39.2px for anything between the presets. Virtualisation follows it; an explicit rowHeight overrides it.
rowHeightnumber | (row) => number28A function enables variable-height rows.
headerHeightnumber32Per header row.
titlestring, A caption drawn above the column headings. Inside the grid rather than an element placed above it, so it scrolls with the grid, sits in the region a screen reader announces, and is kept by image capture and print.
showHeaderbooleantrueDraw the column headings at all. false removes the row, and removes it from the accessibility tree rather than only from view. Distinct from showColumnFunctions, which keeps the headings and drops only their sort, filter and menu controls.
overscannumber4Rows rendered beyond the viewport.
autoHeightboolean | 'visible', Size rows to their content: cells wrap instead of ellipsising, and each row takes the height its tallest cell needs. Only rendered rows are measured either way, the difference is that true gives up above 10,000 rows and returns to fixed heights, while 'visible' keeps measuring at any size and accepts a scrollbar that shifts as rows are measured on the way past.
columnVirtualisationAbovenumber30Column count above which columns virtualise too.
stateGridState, Restore a saved view at construction.

Performance

PropertyTypeDefaultDescription
useWorkerbooleantrueCompute column distributions off the main thread. Sorting, filtering and grouping run on the main thread.
workerThresholdnumber50000Row count above which a distribution is sent to the Worker.
workerUrlstring, External worker file, for a CSP that forbids blob:. Settled when the Worker is built; changing it rebuilds one.
sharedMemorybooleanfalsePass columns to the worker in a SharedArrayBuffer instead of copying them, where the page is cross-origin isolated. Retains a shared copy of each column that crosses.

Chrome dom

PropertyTypeDescription
statusBarboolean | { panels }Composable panels along the bottom. Default set: rowCount, selectedCount, aggregation, comments, updates, progress. Each is silent when it has nothing to report.
maximisebooleantruefalse removes the rail button and grid.maximise, for an application with its own full-screen mode.
toolPanelboolean | objectSide dock. panels: columns, filters, views, quick, formatting, statistics, regression. side: 'left' makes it the icon rail, which also turns on actions (undo, redo, pause, restore, maximise, then the export group: export, excel, clipboard, print: nine in all, and an array takes these names rather than the button labels) and icons. An explicit array replaces that list rather than extending it; a bare '-' in it renders a divider between groups. exportName names the CSV. annotate is a three-state option, not a boolean flag. annotate: true adds the native annotation tools — pen, arrow, rect, highlight — to the rail as toggle buttons (pressed while in use, pressed again to exit), and they stay put whether or not a presentation is running. annotate: false opts OUT: none of the four tools are ever added, presentation or not. Omitting annotate keeps the default: the tools are off until a presentation starts, appear for its duration, and leave when it ends.
groupPanelboolean | objectA drag-and-drop group-by strip above the column header — the row-group panel. Drag a heading into it to group by that column; the active groups show as removable, reorderable chips, and reordering the chips changes the nesting order. It is keyboard-operable — arrows move between chips, Shift with an arrow reorders, Delete ungroups, and an add control groups any column — and every change is announced through the live region. Off by default and non-breaking; it drives the same model as grid.columns.group() and reimplements nothing. The object form takes hint, the placeholder shown while nothing is grouped.
kpisStatConfig[]A built-in KPI/stat strip: a labelled band of stat tiles the grid places for you above the column header. Each entry is a createStat spec — of, fn, title, interval, footer, format and the rest, minus grid and container, which the grid supplies — so a strip tile and a hand-placed one are the same object. The tiles follow the grid's filters, recomputing on every change like a stand-alone stat does. Off by default and non-breaking; it reuses createStat and reimplements no compute.
timeZonestringAn IANA zone every date column formats and parses in, so a grid shows one zone whatever the viewer's machine says. Individual columns may override it.
formulaFunctionsobjectYour own functions, added to the formula language by name. The built-in list is closed on purpose; this is the one way in, and a function you add is called exactly as a built-in is.
formattingobjectConditional formatting rules to seed, keyed by column id or '*'. The same shape grid.formatting.all() returns, so a saved view can be handed straight back.
facetsboolean | objectHeader histograms that double as a filter. collapsed, height, and per-column strategy and buckets.
updatesobjectHow a live feed behaves: batching, the queue that holds while paused, and the highlight a changed cell flashes.
commentsobjectThreaded cell comments: storage, the current author, and whether the indicator shows on an unread thread.
presenceobjectLive cursors, selections and edit locks. Carries intent and never values; see grid.presence.
environmentfunctionExtra fields for the diagnostics bundle: build number, tenant, region. Called when a bundle is taken, never on the render path.
contextMenuboolean | (p) => MenuItem[]Right-click menu. The function form is (params, defaults) => items: see custom items. false suppresses it: what a read-only grid wants, since the default menu offers Paste, Clear and Fill down. A column takes its own contextMenu (also accepting a bare MenuItem[], which is appended after the grid-level items), which composes onto this one as a chain and outranks it on suppression.
columnMenuboolean | (p) => MenuItem[]The header's 3-dot menu, and a right-click on a column heading. The function form is (params, defaults) => items, with params carrying colId, column and grid: see custom items. false suppresses it.
rangeChartfn | { onChart } | booleanOff by default. Offers Chart selection in the cell menu and binds Alt+F1 when a selected range has a number to plot. The DOM layer draws no charts, so the handler you give — a function, or { onChart }, called (grid, range) — is where the page wires in chartRange from the charts module.
shortcutsbooleantrueThe ? keyboard shortcut overlay. false suppresses it, for a host that wants ? for itself. See Keyboard.
findboolean | FindConfigtrueThe in-grid find bar: Ctrl+F (Cmd+F) with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; Enter / Shift+Enter step through the matches. { shortcut, debounce }: shortcut: false keeps the bar reachable through grid.find.open() only; debounce is the typing quiet period in ms (120). false removes the bar and the binding; grid.find(text) still searches. See Find.
rowReorderboolean | { column }, Let a user reorder rows by dragging a handle or with Alt+Shift+arrows. The handle goes in the first visible column unless column names another. Refused, with a reason announced, while a sort, filter or grouping is active. See Row reorder.
rowTransferboolean | { send, receive, mode, group }, Let rows be dragged between grids. Off by default. send and receive are both on when present, so one-way is { receive: false } or { send: false }. mode: 'copy' leaves the row behind; group restricts which grids may exchange. See Moving rows between grids.
alignedGridsGrid[], Other grids to stay column-aligned with. Widths, order, visibility, pinning and horizontal scroll are shared; sort, filters, selection and rows stay independent. Declare it on the grid created last. See Aligned grids.
stickyGroupHeadersboolean | number | { depth }falseKeep the enclosing group headings pinned above the viewport while scrolling inside a group. Off by default; true turns it on and stacks at most two, a number sets the cap, and each costs a row of viewport. See Sticky group headings.
gridLinesboolean | 'both' | 'horizontal' | 'vertical' | 'none''horizontal'Which rules are drawn between cells. Horizontal is what the grid has always drawn; vertical rules are additive. 'rows' and 'columns' are accepted aliases. Only the rules between data are affected, the header underline and pinned seams are structure.
cornerRadiusboolean | number | string, Round the grid's outer corners. true adopts the theme's radius, a number is pixels, a string is used as written.
stripedRowsbooleanfalseShade alternate data rows (zebra striping). Strictly opt-in, so an existing grid is unchanged on upgrade. Parity follows each row's logical index, so a stripe survives a scroll; group headings, footers and the grand total are never striped; selection and hover still win. Uses the theme's --lattice-surface-alt, so dark, high-contrast and terminal come for free.
verticalAlign'top' | 'middle' | 'bottom', Vertical alignment of cell content within a row, as a default for every column — the vertical counterpart to the per-column align. A column's own verticalAlign (or cell.verticalAlign) overrides it. Omitted, the grid keeps its historical placement (centred in a fixed-height row, top in an autoHeight row), so an existing grid is unchanged on upgrade. Setting a value aligns every column uniformly, including auto-height rows, unless a column opts out. See Vertical alignment.
tooltipTooltipConfig, { delay, maxWidth } — grid-level defaults for the rich cell tooltip. delay is how long the pointer or the keyboard cursor must rest on a cell before anything is built, 400ms by default; maxWidth is how wide the tooltip may grow (a number is pixels, a string is used as written). Defaults only: it switches nothing on, and a grid whose columns declare no cell.tooltip has no tooltips whatever is set here. See Rich cell tooltips.
scrollbars'auto' | 'always' | 'custom' | { x, y }'auto'How the scroll viewport's scrollbars are drawn. 'auto' is the platform's native behaviour, where overlay scrollbars fade when idle; 'always' keeps that native bar shown whether or not the pointer is over the grid; 'custom' makes the grid draw its own bar instead — always visible, the same in every browser, and sized by the --lattice-scrollbar-* tokens rather than by the platform, for a target bigger than a 7px overlay ribbon. Scrolling itself is unchanged in every mode. The object form { x, y } sets each axis on its own, so { y: 'always' } keeps the vertical bar while the horizontal one stays native; note that 'custom' on one axis hides the native bar on both, and the grid warns once when the two disagree. Omitted, the grid is unchanged on upgrade. See Always-visible and grid-drawn scrollbars.
columnTagFilterboolean | { multiple, label }, A bar above the headings for showing only the columns carrying a chosen tag. Draws nothing unless some column has tags. See Column tags.
rowTemplatestring | { template, cardsPerRow, maxCardWidth, gap, className, role, itemRole }, Draw each row with a template instead of dividing it into columns, a card list, a feed, a search-result list. Compiles once; binds with {{data.field}}. cardsPerRow or maxCardWidth puts several on a line. The pipeline underneath is unchanged. See Cards, lists and feeds.
galleryboolean | { template, tileWidth, tileHeight, cardsPerRow, gap, className, role, itemRole }, Present rows as a gallery of tiles, laid out by the same 2-D virtualisation the grid runs. true generates a tile per row from the columns; tileWidth sizes them and the count across follows the container, or cardsPerRow fixes it. Presentation only — sort, filter, group and export are unchanged. See Cards, lists and feeds.
recordCardboolean | { template, cardHeight, className, role, itemRole }, Present each row as a record card — a form of label/value pairs, one line per column in display order, showing the same text the table shows. true generates the form from the columns. A card list underneath, so it inherits the virtualisation and every card interaction. Presentation only. See Cards, lists and feeds.
boardboolean | { template, laneWidth, cardHeight, laneGap, gap, className, role, itemRole }, Present rows as a board — a kanban of grouped lanes of cards. The top-level group becomes a lane and every leaf under it becomes a card stacked in it; group the grid to give the board its lanes. true generates a card per row from the columns. Both axes are virtualised, the lanes across and the cards down each, so a board of many long lanes draws only what is on screen. A board card is still a row: it clicks, selects and drags through the grid's own handlers. Presentation only — sort, filter, group and export are unchanged. See Cards, lists and feeds.
responsive{ maxWidth, template, rowHeight }, Collapse to cards when the container is at or below maxWidth (640 by default), and return to a table above it. Sorting, filtering and export keep working. Emits presentation:changed. See Cards, lists and feeds.
rowFormboolean | { mode, load, fields, title, width, trigger, timeout, container }, Open a row for editing on a form. mode: 'drawer' (default) or 'dialog'; without load the fields are the grid's own columns. A field entry is { field, label, editor, type, props, lookup }: any editor, including your own. Takes double-click on the row unless trigger: false. A load that has not answered within timeout milliseconds (2000; false waits indefinitely) is reported as a failure with a retry. container builds the form in an element of your own instead of over the grid. See Editing a row on a form.
showColumnFunctionsbooleantruefalse leaves each heading as its label, with no sort, filter or menu control. Those remain reachable through the API, the keyboard and the tool panel.
headerControls'hover' | 'always' | 'hidden''hover'When the per-column header controls — the sort arrow, the filter funnel and the menu button — are shown, as a default for every column. 'hover' reveals them on hover or keyboard focus (the historical behaviour); 'always' keeps them visible; 'hidden' draws none of them for a clean read-only heading and leaves them out of the tab order. A column's own headerControls overrides this default for that column. Distinct from showColumnFunctions: false, which also drops the furniture but keeps the functions reachable from the keyboard; 'hidden' is the read-only choice.
significantFiguresnumber, On a unit column, render to this many significant figures rather than a fixed number of decimals, so precision is the same on every rung of the ladder. Rounding is applied before the unit is chosen. Set inside the unit configuration a data type is built from. See Units.
typeOptionsobject, Per-column options a data type reads. ratio and percentRate use { weight } to name the column their average is weighted by. See Aggregate safety.
highlightOnChangeboolean | string | objectFlash a cell when its value changes. { colour, duration }; duration: 0 stays until cleared.
rowClassstring | string[] | (p) => …A class, or classes, for every row. Re-evaluated on each repaint.
rowStyleCellStyle | (p) => CellStyleInline styles for every row. Camel-case or hyphenated property names.
viewsobjectsaved, allowSave, storage. See grid.views.
permissionsstring | object | fnPer-column access. See grid.permissions.
diffobject{ snapshot } turns on audit mode.
historyBarboolean | objectA standalone undo/redo toolbar with a timeline.
aiobject{ ask } mounts the prompt bar. Your ask receives { prompt, schema, schemaText, message, context } and returns the model's reply.
dataTypesobjectCustom types by name. createRadixType and createUnitType are exported for building them.
editBarbooleanA spreadsheet-style input above the header. When on, it hosts the column's real editor and inline editing is suppressed.
paginationboolean | objectRenders the pager control: page size, a summary, first/previous/next/last, and a page number you can type into and press Enter to jump.

Column definition

Everything is optional. A column with only field infers its type from sampled data and takes every default from there.

Inferring a Date, or an ISO string. Inference walks boolean, number, date, dateString, datetime, text, object and takes the first type that matches every sampled value. A Date whose local wall clock reads exactly 00:00:00.000 infers as date and stores YYYY-MM-DD; a Date carrying any time of day infers as datetime and stores YYYY-MM-DDTHH:mm (with seconds when they are non-zero), because a date column would discard the clock on ingest and nothing downstream could recover it. An ISO string follows the same rule: a date-only string (YYYY-MM-DD) infers as date; a string with a time part (T plus a time, with or without a zone offset — '2026-09-12T14:30:00Z') infers as datetime and keeps the time, and a column mixing both forms infers datetime rather than falling back to text. A timestamp such as '2026-09-12T14:30:00Z' therefore keeps its 14:30 on ingest, rather than being read as the calendar day '2026-09-12' with nothing to say that a time had been dropped. This is a heuristic with one stated blind spot: a genuine timestamp that lands on exactly local midnight — a nightly batch stamped 00:00:00.000, or a bare '2026-09-12' string that really meant an instant — is indistinguishable from a date-only value and is still inferred as date, so its time of day is still discarded. Sub-second resolution is never retained by datetime. If you group by such a column, declare it: an undeclared Date column groups by the instant, which is one group per row, where a type: 'date' column groups into day buckets. Date filters are unaffected either way — they compare on the day and return the same rows. Declare type and none of this applies: 'date' truncates on purpose, 'datetime' keeps a wall clock, and 'timestamp' keeps the instant to the millisecond. rows.value() returns that stored form — and the same one on every path: the rows you passed at construction, rows.load(), rows.apply({ add }) and rows.apply({ update }), edit.setCells and a typed cell edit, a stream chunk and a stream re-send, a store-backed or columnar store, off-thread ingest, a bound KPI tile and a chart binding all answer the same shape for the same instant, so a host can do arithmetic on it without testing what it got. rows.text() is the formatted form, and row.data is always the raw value you supplied, unconverted. For millisecond arithmetic use type: 'timestamp', which answers the epoch number on every one of those paths. A column of ISO timestamps that must stay a calendar day opts out with an explicit type: 'date' — no warning is logged for that column, because the value is preserved (declared, not narrowed) and there is nothing to disclose.

PropertyTypeDescription
idstringDefaults to field. Required when there is no field.
fieldstringDotted paths supported: 'site.address.postcode'.
titlestringHeader text. Defaults to a humanised field.
typeTypeName | falseA data type bundles format, parse, compare, storage, editor, filter, renderer and Excel behaviour. false disables inference. 'image' treats the value as a URL and draws it: see image columns.
presetstring | string[]Named bundles from columnPresets.
tagsstring | string[], Labels grouping columns together, used by the column tag bar. A bare string is accepted for one tag.
formatFormatSpec | stringShorthand strings like 'percent:1' or 'date:dd MMM yyyy'.
lookupLookupSpecId-to-label mapping. Nested children are flattened, so a tree-shaped list resolves labels everywhere.
valueColumnValueSpecComputed values and the value lifecycle.
cellColumnCellSpec | stringA bare string is a renderer name.
editColumnEditSpec | boolean | stringA bare string is an editor name.
sortColumnSortSpec | boolean
filterColumnFilterSpec | boolean | FilterName
groupobject | boolean{ enabled, index, explode }.
pivotobject | boolean{ enabled, index }.
totalTotalName | TotalFnOne property drives the group row, the tree node, the pivot cell and the grand total. Split it per scope with groupTotal / grandTotal when the subtotals and the grand total should reduce differently.
groupTotalTotalName | TotalFnThe reduction for group subtotals — group footers, tree-node rollups and pivot cells — where it should differ from the grand total. Overrides total for those scopes only; omitted, total applies.
grandTotalTotalName | TotalFnThe reduction for the pinned grand-total row, where it should differ from the subtotals. Overrides total for the grand total only; omitted, total applies.
layoutColumnLayoutSpec | numberA bare number is the width.
headerColumnHeaderSpec | string
exportColumnExportSpec{ lookup: 'label' | 'value' | 'columns', csv, excel }.
allowGroup / allowPivot / allowTotalbooleanWhether the tool panel offers the column for that zone.
nullablebooleanAffects storage choice and null ordering.

Column sub-specs

value

KeyTypeDescription
compute(deps, ctx) => unknownDerived value. Receives only its declared dependencies. A pure result is computed at ingest and cached; it is re-run when its row is replaced by rows.apply({ update }) or rows.load(), when the grid a derived grid follows changes, and when the host asks with rows.refresh({ rows, columns, force: true }). An in-place cell edit to one of its deps does not currently re-run it. For an answer that arrives later (an id-to-name lookup, a rate table), return a placeholder, then call rows.refresh({ rows, columns: [id], force: true }) once it resolves — or declare pure: false.
depsstring[] | '*'Declared dependencies. Cycles are caught at compile time, not at render. An edit to a column outside deps does not re-run a pure compute.
purebooleanDefault true: the result is cached and served until a dependency changes or a refresh forces it. false guarantees the compute is re-evaluated on every read and every paint — never served from a cache — and is the right declaration for a value that depends on something the grid cannot see. A DEV-mode proxy flags pure computes that read outside their deps.
format(p) => stringOverrides the type's formatter.
parse(p) => unknownEditor output to value. Always called, whatever the editor emitted.
apply(p) => booleanWrites the value back into the row object.
key(p) => stringGroup key override.
compareComparatorOverrides the type's comparator.
quickFilterText(p) => stringWhat the quick filter matches against.

cell

KeyTypeDescription
renderRendererName | RenderFn | CtorRenderer name or component. The built-in names are listed under built-in renderers.
propsobjectPassed to the renderer.
decorationDecorationName | specpill, bar, fill, dot, edge.
variantVariantSpecMaps a value to a semantic token: { map }, or { when: [...], default }.
templatestringEscaped unless allowUnsafeTemplates is set.
classstring | string[] | (p) => …Classes for this column's cells.
classWhen{ [class]: (p) => boolean }A class per predicate, re-evaluated as values change.
style / cssCellStyle | (p) => CellStyleInline styles, static or computed.
tooltipstring | (p) => string | ColumnTooltipSpecA string or a function is the plain-text case and becomes the browser's own title. An object is a tooltip the grid draws itself — { render, mount, unmount } — which can carry structure, markup or live content, is shown on keyboard focus as well as hover, and can be dismissed with Escape. See Rich cell tooltips.
align'start' | 'center' | 'end' | 'left' | 'right'Horizontal alignment; also accepted at the top level of the column. start, center and end are logical: they follow the writing direction, so an end-aligned number column sits on the right edge in a left-to-right grid and on the left edge in a right-to-left one (direction). left and right are physical: they name an edge and keep it in both directions. centre is accepted for center. Omitted, the column takes its data type's default (numbers end, booleans center, text start). The heading follows the cell unless header.align says otherwise.
wrap / autoHeight, Presentation flags.
spanColumns / spanRows(p) => numberSpanned cells render in their own layer so row recycling cannot clip them.

Custom CSS, by scope. Cells: cell.class, cell.classWhen, cell.style and cell.css, all of which may be functions of the cell. Columns: the same four, declared on the column, so they apply to every cell in it; the header takes header.class. Rows: rowClass and rowStyle on the grid.

All of them are re-evaluated on every repaint and remove what they added last time first. That is not caution: rows and cells come from pools, so an element that carried a class for one row will later carry a different row, and a class written once and left alone smears down the grid as the user scrolls.

edit, sort, filter, layout, header

SpecKeys
editenabled (boolean or predicate), editor, props, popup, validate
sortenabled, direction, order, nullsFirst
filterenabled, type, props
layoutwidth, fit, min, max, flex, pin, hidden, resizable, movable, lockVisible, lockPosition. fit: 'content' is the declarative form of columns.autoSize(): the column is sized to what it is showing on the first paint and measured again whenever the rows change, the columns are shown, hidden, reordered or pinned, or the grid is resized — but not as it scrolls, which would make the columns jitter. It measures the mounted rows and the heading, as autoSize() does, so it sizes to visible content rather than to the widest value in the dataset. A declared width outranks it, and so does a width the user drags to, a resize being recorded as a width; flex is resolved first and wins. width is a pixel number or a percentage string ('25%'): a share of the grid's inner width that follows the viewport — after the container changes size the column is re-resolved against the new width, clamped to its min/max, so a '50%' column is half of an 800px grid and half of the same grid at 400px. Percentages summing past 100 overflow and scroll rather than being scaled down. A pin of 'start' or 'end' holds the viewport edge while there is something to scroll; where the columns do not fill the grid, spare width falls beyond the last column rather than in front of it.
headertemplate, render, props, class, tooltip, align. render draws a custom heading and may be a function or a component (a class with a render method); the two forms are interchangeable and each may either append to the passed heading element itself (returning nothing) or return an Element (attached for you) or a string (used as the heading text). class adds a class to the heading cell; template is not read.

Rich cell tooltips

cell.tooltip as a string or a function gives you the browser's own title: one line of plain text, on the browser's schedule, unstyled, and invisible to a keyboard user. The object form declares a tooltip the grid draws instead, so a cell can show a related record, a small chart, a list of validation errors or an edit history (BACKLOG-0001204). The plain-text form is untouched and still becomes a title, so an existing grid behaves exactly as it did.

render(params) returns one of four things, and the difference between the last two is a security property rather than a matter of taste:

ReturnRendered as
an HTMLElementAttached as it is. Your DOM, your responsibility.
{ title, rows, note }A TooltipSpec, drawn by the grid: a heading, label/value lines, and a closing note. Every field is written as text, so a spec built out of row values needs no escaping.
{ html: '…' }The only wrapper that inserts markup, scrubbed of script by the same rules the cell layer applies to allowUnsafeTemplates output.
a stringAlways text, whatever it contains. A string holding <b>bold</b> shows those characters; it does not embolden.

That last rule is the load-bearing one. The most natural tooltip anyone writes is render: (p) => p.value, and a value comes from row data — data the developer did not write and usually cannot audit. If a bare string were treated as markup, a name field holding <img src=x onerror=…> would execute and nothing in the code would have looked dangerous. Markup therefore has to be asked for in the source, where a reviewer can see it, and no value arriving from data can promote itself.

mount(el, params) and unmount(el) carry live content. Inside mount you call createChart or createKPI from a module bundle your application loaded — the grid core never imports a module — and unmount is called every time the tooltip closes, so nothing keeps running behind a hidden box. One tooltip element is built and re-used for every cell.

Accessibility. Nothing is built until the pointer or the keyboard cursor has rested on the cell for delay (400ms by default), so sweeping across the grid mounts nothing. Focusing a cell shows the same tooltip after the same delay and the cell points at it with aria-describedby; the tooltip can be hovered without closing, and Escape dismisses it (WCAG 2.2 AA, 1.4.13). Escape is only consumed while a tooltip is open, so it still reaches the editor, the menu and the maximised view.

It closes on scroll. Rows and cells are pooled and re-used, so a tooltip left open across a scroll would be anchored to a node that is now showing a different row. Closing is the honest answer and costs nothing: the browser re-hit-tests after a scroll, so a pointer parked over the grid simply gets a fresh tooltip for the row that is actually there. Content is resolved when the tooltip opens rather than when the pointer arrived, so it always names the row that node is showing at the moment it opens.

Grid-level defaults, and a column declaring a spec tooltip

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  tooltip: { delay: 250, maxWidth: 360 }, // the defaults for every tooltip
  columns: [
    { field: 'name' },
    {
      field: 'owner',
      cell: {
        tooltip: {
          // Returned as a spec: the grid writes every field as text.
          render: (p) => ({ title: p.value, rows: [{ label: 'Row', value: p.key }] }),
        },
      },
    },
  ],
  rows: [{ name: 'a', owner: 'Ada' }],
  rowKey: 'name',
});

const defaults = grid.get('tooltip');
const spec = grid.columns.get('owner').cell.tooltip;
const content = spec.render({ value: 'Ada', key: 'a' });
grid.destroy();
return `${defaults.delay}, ${defaults.maxWidth}, ${content.title}, ${content.rows[0].value}`;

Grid methods

Top-level members. Everything else hangs off a namespace.

MemberReturnsDescription
getVersion()stringThe version this grid came from, e.g. '1.65.0'. Also on the module as getVersion(), for when you have no grid to hand.
get(key)unknownRead any configuration key.
set(key, value)voidWrite one key. Every key is live; nothing needs a rebuild.
setAll(values)voidWrite several in one pass. Emits one config:changed for the batch, not one per key.
config()GridConfigThe whole live configuration as a shallow copy. Pairs with setAll for a read–modify–write round trip. Nested objects are shared by reference, so treat it as read-only.
setPinnedRows(rows, opts?)voidPin rows outside the scrolling body. opts.edge is 'top' (the default) or 'bottom'. Pass a new array rather than mutating the previous one: array identity is the change signal. See Pinned rows.
getPinnedRows(opts?)object[]The objects pinned at one edge, as a copy.
on(event, handler)() => voidReturns its own unsubscribe. '*' subscribes to everything; the handler still receives one event object, and reads event.type to tell which arrived.
rendererHost()objectThe host object a renderer reads: columns, rows, callbacks. Deliberately a plain bag rather than the grid itself, so a renderer cannot reach into core internals. You need this only when writing a renderer of your own.
once(event, handler)() => void
off(event, handler)void
emit(event, payload)voidEmit on the grid's bus, for custom components.
attachRenderer(renderer)voidBind a renderer to a headless grid.
destroy()voidRelease listeners, workers and pooled buffers.
elementHTMLElement | nullThe rendered root; null when headless.
readyboolean
destroyedboolean

grid.rows

Data usually arrives after the grid does. Build it with rows: [], then load when your fetch resolves, the sort, filters, grouping and column layout you set up in the meantime all survive, and apply to the new data.

const grid = createGrid(el, { columns, rowKey: 'id', rows: [] });
grid.overlay.show('loading');

const data = await fetch('/api/circuits').then(r => r.json());
grid.rows.load(data);          // replaces whatever was there
grid.overlay.hide();
MethodReturnsDescription
load(rows)voidReplaces the data. The view (sort, filters, grouping, column layout) is kept. Same as grid.set('rows', data).
get(index)RowBy display index, after filtering, grouping and flattening.
byKey(key)RowBy row key, whether or not it is on screen.
matchCount()numberData rows passing the filters, across every page. Excludes group headers, footers and totals, the numerator of "1,204 of 100,000".
coverage(){ covered, total, windowed }How much of the data a figure computed from this grid covers — the question the counters above cannot answer, because on a windowed source they all report the rows it is holding and so agree with each other while the data that has been through is larger. covered is the rows a figure would be computed over; total is the rows the source knows about, or null when it cannot know (a stream still open that has evicted nothing has no idea how many rows are coming, and says so rather than repeating covered); windowed is true when a window bounded the computation. covered < total, or total === null, means the figure is approximate — that is the test to write. False windowed means the figure is over all of it: a memory source, or a stream that finished having dropped nothing. Read on demand and cheap (one matchCount() and one progress() on the source, nothing cached), so call it beside every figure you publish rather than once at setup — on a live stream the answer moves.
count()numberDisplay rows: group rows included, collapsed children excluded.
totalCount()numberSource rows before filtering. The denominator of "1,204 of 100,000".
value(key, colId)unknownThe stored value.
text(key, colId)stringThe formatted display text.
values(key)objectEvery column's value for one row.
data()unknown[]The caller's original row objects, in source order.
forEach(fn)voidWalks display rows without materialising them all.
forEachAll(fn)voidWalks every row in the data, before any filter: leaf rows only, in the order they arrived. What you want for a total, an export or a reconciliation, where forEach would give you the view instead. A remote or paged source holds only what it has fetched and says so.
forEachExcept(colId, fn)voidWalks the rows surviving every filter except that column's own, the faceting question, asked of the rows. It is what lets a header histogram keep every bar after one is clicked, and what lets a cross-filtering panel avoid narrowing itself out of existence. Needs a memory source; anything else falls back to the filtered rows and warns.
apply(change)objectTransactional add / update / remove. Needs rowKey.
queue(change)voidBatches a change into the next frame, the high-frequency path.
refresh(opts)voidRe-run computed values and repaint, without re-running sort, filter or grouping. { rows, columns } narrows it to those cells; nothing named means every cell. Either way, the cached results for the named cells are discarded from every cache the grid keeps — the one behind rows.text() and the painted cell, and the one sort and filter read — so a non-stored computation is re-run on the next read, sort or filter. force: true also recomputes a pure (stored) computation for those cells and rewrites it, and repaints cells whose text did not change — the call to make when the answer changed for a reason the grid cannot see, such as an async lookup resolving. A column declared pure: false is never cached, so a plain refresh() is enough to show its new value. Without force, whether a stored (pure) computation is re-run for the named cells depends on the store the grid chose for the row count, and it flips at columnarBelow: below that many rows the named cell is re-run on its next read, at or above it the stored value stands until a force: true. Pass force: true when you want the same answer whatever the row count.
move(key, to){ moved, from, to, reason? }Move a row to another position in the data. Refuses, naming the reason, while a sort, filter or grouping is active. Emits row:moved; persisting the new order is yours.
groupHeadings(index)Row[]The group rows enclosing a display index, outermost first. Empty when the grid is not grouped. Useful for a breadcrumb of your own.
expand(key, deep?)void
collapse(key)void
expandAll() / collapseAll()void

grid.columns

MethodReturnsDescription
all()ResolvedColumn[]Every column, hidden included.
visible()ResolvedColumn[]In render order, including generated group and pivot columns.
get(id)ResolvedColumn
show(ids) / hide(ids)void
move(id, to)voidIndex into the full column order.
pin(id, side)void'start', 'end' or null.
resize(id, px)void
autoSize(ids)voidFit each column to its rendered content.
fit()voidSize the visible resizable columns so that every column the grid draws, together, exactly fills the body viewport's client width at the moment of the call: without the vertical scrollbar when there is one, the full inner width when there is not. Columns it does not size (resizable: false, and the grid's selection checkbox, detail expander, group and tree columns) keep their width and are taken out first; the rest share what is left in proportion to their widths, within each min/max. If that is less than their minimums, each goes to its minimum, never below, and the grid scrolls horizontally, with a warning. One-shot: it sets fixed widths (a flex column included) and does not follow later size changes; call it again after a resize or after late rows bring a scrollbar in.
group(ids)voidSet the row-group columns, in order.
pivot(ids)void
totals(ids)voidWhich columns carry an aggregation.
setTotal(id, fn, opts?)voidChange one column's aggregation. null stops totalling it. A named total the column's type says is meaningless is refused (§9.4), the same way it is at configuration. With no opts, fn becomes the shared total and clears any group/grand overrides; pass { scope: 'group' } or { scope: 'grand' } to set the group subtotals and the grand total independently (a scope with no override follows total).
aggregates(id)TotalName[]The aggregate names meaningful for a column, honouring its type's declaration — what the aggregate chooser offers.
distinct(id)unknown[]Distinct values, read from the dictionary rather than by scanning rows.
state()ColumnState[]Serialisable column state.
apply(state)StateApplyReportRestore it. Never throws and never refuses: a saved view written against an older column set applies as much of itself as still makes sense, and the returned { applied, skipped } names what it could not use and why. Columns added since the view was saved appear in their declared state, after the ones it names. See Saved views.

grid.selection

MethodReturnsDescription
keys()string[]Selected row keys.
rows()Row[]
all()Row[]Including rows selected but currently filtered out.
set(keys)voidReplace the selection. In mode: 'single', only the first key of the array is kept; the rest are dropped, they are not an error.
clear()void
ranges()Range[]Cell ranges, for spreadsheet-style selection.
setRange(range)voidReplace every range with one.
addRange(range)voidAdd a range without discarding the others, the API form of ctrl-click. Becomes the anchor extendRange grows.
startRange(rowIndex, colId, opts)voidBegin a range at a cell. opts.additive keeps the existing ranges.
extendRange(rowIndex, colId)voidExtend the newest range, keeping its anchor.
corner(){ row, colId } | nullBottom-right cell of the newest range, where the fill handle sits.
inRange(rowIndex, colId)booleanIs a cell inside any selected range?
cells(){ key, colId }[]Every cell in the selected ranges.
statistics()object | nullEverything summary() reports plus median, quartiles, deviation, distinct and outliers: over the selected cells, so a rectangle spanning three columns is one set of numbers. Null with nothing selected.
summary()objectcount, sum, min, max, avg over the range.

grid.filters

MethodReturnsDescription
get()FilterSetThe whole condition tree.
set(filters)voidReplace it. null clears everything.
quick(text, opts?)voidThe quick filter, applied across every column.
clear()void

grid.sort

MethodReturnsDescription
get()SortEntry[]{ col, dir, nullsFirst? }, in priority order.
set(entries)voidMulti-sort by passing several entries.
clear()void

grid.edit

MethodReturnsDescription
start(key, colId)voidOpen an edit session. The row must be rendered.
stop(cancel?, opts?)objectCommit or discard. Pass { value, key, colId } to write a value.
undo() / redo()voidDepth from edit.undoDepth.
setCells(writes, type?)numberWrite many cells as one undoable step. Returns how many landed.
pasteInto(anchor, text, extent?)numberPaste tab-separated text, using Excel's tiling rules.
previewPaste(anchor, text, extent?)objectCompute what a paste would change without committing: { changes, rejected }. The engine behind edit.pastePreview.
pastePreviewbooleanWhether a bulk paste is previewed before it commits (edit.pastePreview).
settle(id, ok, reason?)booleanReport the outcome of an optimistic write. Only needed with edit.confirm: 'manual'; the id arrives on cell:pending.
pending()OpenWrite[]Writes still awaiting an outcome. Empty unless edit.commit is set.
status(key, colId)'pending' | nullWhether a cell has a write in flight.
addRow(row)string | nullAppend a row optimistically and persist it (over a source declaring mutate.append). Returns the client temp key; on the server key it fires row:confirmed after rekeying selection, expansion, focus and in-flight cell edits. null when append is unavailable.
deleteRow(key)string | nullDelete a row optimistically and persist it (over a source declaring mutate.delete). Tombstones then confirms, or restores on refusal. null when delete is unavailable.
deleteRows(keys?, opts?)string[] | PromiseThe user-gesture delete (what the Delete key and the "Delete row" menu item call), through the cancellable beforeDelete event — on a memory grid as well as a remote one. Off until config.rowDelete. Keys default to the selection; returns the keys removed (empty on a veto or when disabled), or a Promise when a handler deferred.
settleRow(id, ok, reason?, reconcile?)booleanReport the outcome of a structural op. Only needed with edit.confirm: 'manual'; the id arrives on row:pending.
rowStatus(key)'pending' | nullWhether a row has an append/delete in flight.
pendingRows()OpenRowOp[]Structural ops still awaiting an outcome. Empty unless the source can append or delete.

Appending and deleting rows. Over a remote source whose adapter declares mutate.append/mutate.delete, grid.edit.addRow and grid.edit.deleteRow are the structural counterparts of the cell edit path. An appended row shows at once under a client temp key; when the server hands back the real key the row is rekeyed everywhere the grid tracks it — the source row, selection, expansion, focus and any in-flight cell edits all follow — and row:confirmed fires. A delete tombstones the row immediately and either purges it on confirmation or restores it on refusal. The example drives both against a mock adapter, and shows the rekey moving a selection:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');

// A mock adapter that persists append and delete. append returns the server key.
let nextId = 1;
const adapter = {
  name: 'mock',
  capabilities: { sort: true, mutate: { append: true, delete: true, returning: 'key' } },
  async execute() { return { rows: [], total: 0 }; },
  async mutate(op) {
    if (op.kind === 'append') return { ok: true, keys: [`srv-${nextId++}`] };
    return { ok: true };   // delete confirmed
  },
};

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name' }],
  source: createPushdownSource({ adapter, edit: true }),
  selection: 'multiple',
  edit: true,
});

// The structural lifecycle events, bridged onto the grid's own bus.
const fired = [];
grid.on('row:pending', (e) => fired.push(`pending:${e.kind}`));
grid.on('row:confirmed', (e) => fired.push(`confirmed:${e.kind}`));
grid.on('row:reverted', (e) => fired.push(`reverted:${e.kind}`));
grid.on('row:conflict', () => fired.push('conflict'));

// Append: shows immediately under a temp key, then rekeys to the server key.
const temp = grid.edit.addRow({ name: 'Ada' });
grid.selection.set([temp]);                  // select the optimistic row
await new Promise((r) => setTimeout(r, 0));   // let mutate resolve; row:confirmed fires
const movedTo = grid.selection.keys()[0];    // selection followed the rekey

// Delete: tombstones then confirms.
grid.edit.deleteRow(movedTo);
await new Promise((r) => setTimeout(r, 0));
const gone = grid.rows.byKey(movedTo) === undefined;

grid.destroy();
// fired: pending:append, confirmed:append, pending:delete, confirmed:delete
return `${movedTo} selected; ${gone ? 'deleted' : 'still-there'}`;

The built-in delete gesture. deleteRow above only fires beforeDelete on a remote source. For the common case — letting a user delete rows of a memory grid with the Delete key or a "Delete row" menu item, confirmed through the same beforeDelete hook — set config.rowDelete: true. It is off by default because deleting data on a keystroke is destructive; every deletion still flows through beforeDelete, so a handler can confirm or veto it with preventDefault(reason). grid.edit.deleteRows is the programmatic entry.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }],
  rows: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
  rowKey: 'id',
  selection: 'multiple',
  rowDelete: true,   // opt in to the Delete-key / menu gesture and the API
});

// A confirm hook that vetoes deleting row 'b' but allows the rest.
grid.on('beforeDelete', (e) => { if (e.rows.includes('b')) e.preventDefault('kept'); });

grid.edit.deleteRows(['a']);   // allowed — 'a' is removed
grid.edit.deleteRows(['b']);   // vetoed — 'b' stays, delete:cancelled fires

const count = grid.rows.count();
grid.destroy();
return `${count} rows`;   // 2 rows: 'b' and 'c'

Previewing a bulk paste. A paste can rewrite dozens of cells at once, and one that lands somewhere unexpected looks exactly like one that worked. Set edit.pastePreview: true and a paste into more than one cell opens a confirm/cancel dialog first, listing every cell that changes (old → new) and every cell a commit would reject. It is off by default, so existing paste behaviour is unchanged. previewPaste computes that same diff without any UI:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  // A per-row rule: the middle row is locked, so a paste over it is refused.
  columns: [{ field: 'a', edit: { enabled: (p) => p.row.data.locked !== true } }],
  rows: [
    { id: '0', a: 'A0', locked: false },
    { id: '1', a: 'A1', locked: true },
    { id: '2', a: 'A2', locked: false },
  ],
  rowKey: 'id',
  // Opt in: off by default, so a plain paste is unaffected.
  edit: { enabled: true, pastePreview: true },
});

// What *would* happen — nothing is committed yet.
const preview = grid.edit.previewPaste({ key: '0', colId: 'a' }, 'X\nY\nZ');

// Confirming is setCells of the changes — the ordinary paste path.
grid.edit.setCells(preview.changes.map((c) => ({ key: c.key, colId: c.colId, value: c.newValue })), 'paste');

const changed = preview.changes.filter((c) => c.changed).length;
return `${changed} change, ${preview.rejected.length} rejected`;   // rows 0 and 2 change; row 1 refused

grid.form

The row edit form, a drawer or dialog holding one control per field. Present whether or not rowForm is configured; without it every method declines rather than throwing, so a caller need not guard. See Editing a row on a form.

MethodReturnsDescription
open(key)booleanOpen a row by key. False if there is no such row, or no form is configured.
close()voidClose without saving. Focus returns to where it was.
save()booleanCommit the fields and close. False if a validator refused, or there is nothing to save.
isOpen()booleanWhether the panel is showing.

grid.pagination

A window over the rows the query already produced, not another query. A page change re-slices; it does not re-filter, re-sort or re-group, so paging a million rows costs nothing beyond the repaint.

MethodReturnsDescription
get(){ page, pageSize, total, pageCount }total is the filtered row count, so it moves when a filter does.
set({ page?, pageSize? })voidMove, resize, or both. pageSize: 0 turns paging off and shows everything. Emits page:changed once the rows have moved.

grid.scroll

MethodReturnsDescription
position(){ top, left }
toRow(row, align?)voidalign: 'start', 'centre', 'end'.
toColumn(id)void
to(at)voidScroll to { top, left }. left is the logical offset: zero at the content's start whichever way the grid reads.
toCell(row, colId, align?)voidScroll a cell into view, both axes in one call. row is a row key or a display index.

grid.export

MethodReturnsDescription
csv(opts)string | BlobFields sanitised against formula injection.
excel(opts)Promise<Blob>Real .xlsx, written without a ZIP dependency. Large exports stream.
clipboard(opts)PromiseTSV, with the grid's own paste parser as its counterpart.
print(opts)voidSwitches virtualisation and pinning off for the printed document.
rows(mode)Row[]'all', 'visible' or 'selected'.

Excel value conversion. A data type may declare toExcelValue and excelKind, because a spreadsheet's number formats are not free-form. A time of day is written as a fraction of a day under hh:mm:ss; a duration as days under [h]:mm:ss, where the brackets are what stop Excel wrapping at 24 hours. Both stay numeric, so they still sort and subtract in the sheet.

IP columns are stored as packed integers so they sort as addresses, and declare excelKind: 'string' so the dotted form is what reaches the file. Radix columns export decimal: OOXML cannot express base 16, and staying numeric was judged worth more than display fidelity.

grid.import

The mirror of grid.export: rows coming in from delimited text — a CSV or TSV file, or the tab-separated block a spreadsheet puts on the clipboard. The pipeline is parse, infer each column's type, map the columns onto the grid's own fields, then preview and confirm. The API is always present; set config.import to add the DOM affordances (a "Import rows from CSV…" cell-menu item, a file drop target, and paste).

MethodReturnsDescription
preview(text, opts?)ImportPreviewParse, infer and map without changing the grid — the columns, the coerced records, a sample and any warnings a confirm dialog needs.
csv(text, opts?)object[]Parse delimited text into coerced record objects, the inverse of export.csv.
apply(input, opts?)ChangeResult | nullAdd (mode: 'append', the default) or replace (mode: 'replace') the grid's rows from text, a preview or a record array. A client-side operation, so it applies to a memory-source grid; on any other source it declines rather than show rows that cannot persist.

Excel. .xlsx is not read: it needs an inflate and XML reader the zero-dependency envelope does not carry. Save the sheet as CSV — every spreadsheet does — and import that.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'age', type: 'integer' }],
  rows: [{ id: 'seed', age: 1 }],
  rowKey: 'id',
  // Opt in to the DOM affordances; the grid.import API is present either way.
  import: true,
});

// Preview changes nothing — it is what a confirm dialog shows.
const preview = grid.import.preview('id,age\nx,20\ny,30');

// Confirming appends the previewed rows; `age` arrives coerced to a number.
grid.import.apply(preview);

return `${preview.rowCount} previewed, ${grid.rows.count()} rows`;   // 2 previewed, 3 rows

grid.state

MethodReturnsDescription
get()GridStateVersioned and serialisable: columns, columnOrder, filters, quick, sort, group, pivot, expanded, selection, scroll, pagination.
apply(state, opts?)objectRestore a view. Returns a report of anything it could not apply (a column that no longer exists, for instance) rather than failing silently.
baseline()GridState | nullThe state the grid started in, captured once after config.state and any default view, so the baseline is the grid you shipped, not the one before your own configuration ran.
reset()object | nullPut the grid back to that baseline, as one undo entry. Clears anything the baseline does not mention, including the quick filter.
modified()booleanWhether anything has changed since construction. Lets a "restore" control disable itself rather than offering an action that would do nothing.

grid.history

Undo across the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied view and a restore all record an entry, and each carries a label written for a button: "sort by Region", not "sort".

MethodReturnsDescription
undo()object | nullThe entry that was undone.
redo()object | null
canUndo() / canRedo()boolean
peek(direction?)object | nullWhat the next undo or redo would do, so a control can name it before it is pressed.
list()object[]The timeline, newest first.
transaction(label, fn)object | nullGroup several changes into one entry. Nested transactions join the outer one.
clear()void

A multi-cell paste is one entry, not one per cell. An AI plan is one entry however many actions it contains, labelled with what it did.

grid.diagnostics

What the grid is doing, as data: render counts and their causes, memory layout, operation and provider timing, event listener counts, effective configuration, and a list of things that look like mistakes.

This is the API; the devtools panel is a consumer of it. Built in that order deliberately: instrumentation shaped by a UI tends to report what is convenient to display rather than what is true, and an API that only exists behind a panel cannot be asserted against in a test.

// the assertion this exists to make possible
const before = grid.diagnostics.renders().dom.cellWrites;
grid.filters.set({ col: 'status', op: 'eq', value: 'active' });
await nextFrame();
const written = grid.diagnostics.renders().dom.cellWrites - before;
expect(written).toBeLessThan(200);
MethodReturnsDescription
snapshot()objectEverything, in one structure.
renders()objectCounts by cause, the last render's phase timings, DOM write counters and viewport state.
store()objectPer-column backing kind and byte footprint, total bytes, rows against physical slots, tombstoned rows.
operations()objectCount, mean and worst per operation kind, with a bounded sample of recent calls.
providers()objectCalls, errors, in-flight count and latency per provider, with failures retained.
events()objectListener count per event type.
config()object{ effective, supplied, defaulted }, which values you chose and which the grid chose.
warnings()object[]Everything flagged, newest first, each with a stable id.
dismiss(id)voidHide a warning for this session. Not permanently.
bundle()objectA support bundle. Contains no row data.
checkOptions(options)booleanTrue when an options object changed identity without its contents changing.
record(kind, detail)voidRecord your own operation, so custom work appears alongside the grid's.
reset()voidZero the counters. Warnings and configuration are left alone.

The support bundle

bundle() returns configuration, query state, timing history, warnings, provider statistics, version and environment. It never contains row data, cell values or column values, and says so in its own contains field. The guarantee is the point: a bundle that had to be read for confidential content before sending is a bundle nobody sends.

Warnings

Each carries a stable id a support conversation can name, a plain description, and the specific values involved. Two sources are merged: checks run against the grid, and everything the grid has reported through its own one-per-cause warnings.

IdWhat it means
options-identity-churnAn options object rebuilt on every parent render. A wrapper comparing by identity will tear the grid down each time.
duplicate-row-keysTwo rows share a key. Presents as "the wrong row updated", never as an error.
query-references-unknown-columnA filter or sort names a column that does not exist. Silently matches nothing.
listener-count-growingProbable subscription leak in the host. Presents as gradual slowdown.
main-thread-eligible-for-workerA large operation ran on the main thread despite the worker threshold.
slow-providerA provider took more than a second to answer.

The devtools panel

An optional module. It imports nothing (the grid is handed to it) so deployments that never load it pay nothing.

import { createGrid } from '@toclocoinc/lattice-grid';
import { createDevtools } from '@toclocoinc/lattice-grid/modules/devtools';

const grid = createGrid(el, config);
createDevtools({ grid });          // Ctrl+Shift+D collapses it

Nine tabs over the API above, a compact vitals strip to leave open while working, and a render heat overlay that tints cells as they are written: blue for a new value, red for a cell rewritten with the value it already held. The second colour is the one worth chasing: it is work the grid did not need to do, and no counter alone will tell you where it is.

The panel observes and never mutates. A configuration editor would create a second path into state that has to be kept correct forever, so there is not one. Nothing leaves the browser: there is no telemetry, and the bundle is produced only when you ask for it.

grid.presence

Who else is on this grid and what they are doing: cursor, selection, active edit, and an optional advisory lock. It prevents the two failure modes of multi-user data work: two people editing the same cell unaware of each other, and one person unable to tell whether anyone else is there at all.

The grid never opens a connection. You supply the transport and the identity; the grid renders what arrives and publishes what changes. A WebSocket, MQTT, a CRDT library or a polling endpoint all satisfy the interface. Without a provider the feature is inert.

presence: {
  provider,                              // subscribe + publish
  me: { id: 'u_17', name: 'Tony' },
  throttleMs: 60,
  lock: true                             // advisory; see below
}
MethodReturnsDescription
enabledbooleanFalse without a provider.
peers()Peer[]Everyone else, most recently active first, each with idle, hidden and cursorFresh.
hiddenCount()numberPeers none of whose positions are in this view.
editorOf(rowId, colId)Peer | nullWho is editing a cell, if the claim is fresh.
lockedBy(rowId, colId)Peer | nullNull unless lock is on. Advisory.
jumpTo(peerId)booleanScroll to a peer's cursor. False when their row is not in this view.
publish()voidPublish now. The grid already does this on cursor, selection and edit changes.
setPublishing(on)voidReceive without appearing, for observer and supervisor roles.
setPaused(paused)voidSuspend publishing. Done for you while the tab is hidden.
connect(provider)voidAttach or detach after construction.
stats()objectPublished, received, throttle drops, provider errors, peer count.

The provider

{
  subscribe(onMessage) { /* call onMessage(peer) or onMessage(peer[]) */ return unsubscribe; },
  publish(state)       { /* send it however you like */ }
}

A message is one peer state or an array of them, so a transport that sends a full roster on connect and deltas afterwards needs no unwrapping. { id, left: true } removes a peer.

Presence carries intent, never values. A peer's committed edit must reach the grid as data, through whatever channel you already use, as a transaction, so it gets the flash-on-change treatment. Presence is throttled, lossy and ephemeral by design, so a value carried on it is a value that can be dropped. That is the kind of bug that surfaces once a month in production and cannot be reproduced.

Positions travel as row keys

Peers sort and filter independently, so a row index addresses a different record on every screen. Presence is positional in data terms: a peer's cursor renders wherever that row currently sits in your view, and is held but not drawn when the row is filtered out, on another page, or evicted from a bounded window. Those peers are counted by hiddenCount() and shown in the roster as “not in view”, so their absence does not read as a disconnection.

Idle and removal

Derived from local receipt time, never the timestamp in the payload. Clocks between clients disagree by seconds routinely, so a peer with a fast clock would look permanently fresh and one with a slow clock permanently idle. Silence past idleMs desaturates them; past removeMs they go. An explicit left signal is used when your transport provides one.

Locking is advisory

Locking reduces collisions. It does not eliminate them. Presence is throttled and can arrive out of order, so two clients can enter an edit at the same moment. The authoritative resolution is the conditional write in edit.commit, which returns a conflict and rolls the optimistic edit back. If you treat locking as a guarantee and skip that write, you will lose data.

With lock: true, starting an edit on a cell a peer holds returns false from edit.start, emits presence:lockRefused, and announces the holder through a live region, a cell that silently refuses to enter edit mode is indistinguishable from a broken grid.

What is drawn

A peer's cursor is a dashed border in their colour; your own focus ring is solid, and the difference is in the kind of line rather than only the hue so the two can never be confused. Their name shows for a moment after their cursor moves and on hover, then fades to the bare border. A selection is a low-opacity tint, with the most recent peer winning a contested cell outright rather than blending. An active edit is solid and tinted, the loudest treatment, because it is the state that matters most.

Nothing is inserted into the grid: every treatment is written onto cells that already exist, so presence cannot shift layout, cover an in-cell chart, or intercept a click. The roster is the exception, because it is a control.

Colours are assigned by hashing the peer id against --lattice-peer-1--lattice-peer-8, so one person is the same colour on every screen and across reloads.

grid.comments

Threaded comments attached to individual cells, for collaborative data review: flagging an anomaly, asking why a figure changed, recording the reason behind a manual correction. A commented cell carries a small triangle in its upper-right corner; clicking the corner opens the thread.

The grid owns presentation and interaction only. Storage, identity and permissions are yours. Comment data lives wherever you put it and is reached through a provider.

A stable rowKey is required. Comments are keyed on row identity plus field, never row index, and they outlive the values they annotate. Configure the grid without a rowKey and comments are disabled: named in the same console warning as the other identity-dependent features: rather than silently filing threads against positions that move on the next sort.

Identity must be stable across sessions and across data reloads, not merely within one session. A key derived from load order is not enough: reload the data in a different order and every comment reattaches to the wrong row.

comments: {
  provider,                        // required; without it the feature is inert
  mode: 'anchored',                // or 'docked' for a side panel
  markdown: false,                 // restricted: emphasis, code, links
  rowLabel: (row) => row.data.name  // so the panel says what is being discussed
}
MethodReturnsDescription
enabledbooleanFalse without a provider or without stable row identity.
unavailable()string | null'no-provider', 'no-row-identity', or null.
at(rowId, colId)object | null{ count, unresolved, updated } for one cell. Counts only: this is read on every repaint.
open(rowId, colId)PromiseOpen a thread and load its bodies.
close(opts?)voidClose and discard the bodies.
add(body, opts?)PromiseAdd to the open thread. opts.parentId replies within it.
edit(commentId, body)Promise
remove(commentId)Promise
resolve() / unresolve()PromiseMark the open thread.
request(rowIds, fields?)voidAsk for index entries. Debounced; the viewport does this for you.
refresh()voidReload the index for known rows, after your application learns of a change elsewhere.
loadAll()Promise<boolean>Load the index for every row, which the comments-only filter needs first.
completebooleanWhether the index covers the whole row set.
hiddenUnresolved()numberUnresolved threads on rows the current filter hides. Zero when the index is partial.
filterToCommented(opts?)booleanRestrict to rows carrying comments. unresolvedOnly narrows further. False when the index is incomplete.
thread / openKey / loading, The open thread, its cell key, and whether it is still loading.

The provider

Every method returns a promise. A rejection surfaces in the panel without disturbing grid state, and an optimistic write is rolled back.

MethodDescription
loadIndex(rowIds, fields)Counts and timestamps for the requested cells. Never bodies. Called for the viewport and on scroll, debounced.
loadThread(cellKey)The ordered comments for one cell.
addComment(cellKey, body, parentId, ctx)ctx.value is the cell's value at the time of writing. Store it.
editComment(id, body)
deleteComment(id)
resolveThread(cellKey) / unresolveThread(cellKey)

The grid performs no authorisation. A comment may carry can: { edit, delete, resolve } and the grid draws affordances accordingly, but that is a convenience for the user and never a security control. Absent flags mean every affordance is shown. Your provider must reject what it must reject.

Author information is rendered exactly as the provider supplies it: author: { name, avatarUrl, initials }. The grid does not know who the user is and does not guess.

Bodies are text. The default path never produces markup. With markdown: true the panel handles emphasis, code and links only, builds elements rather than assigning HTML, and refuses any link scheme other than http, https and mailto.

Comments follow their row through sorting and grouping. When a commented row is filtered out its comments are not lost and not shown; hiddenUnresolved() reports what is outstanding on hidden rows so their absence does not mislead, and the status bar’s comments panel puts that count on screen whenever it is not zero. Comments remain available while streaming, and a thread whose row is evicted by a bounded window closes with an explanation. Comments do not appear in exports and do not serialise into saved views, a view captures display configuration, not data.

Keyboard: Alt+M opens the thread on the focused cell. The panel traps focus while open and returns it to the originating cell on close. Cells carrying comments announce the fact, and the unresolved count, through their accessible description.

grid.facets

A distribution chart in each column heading, which is also a filter control. Clicking a bar filters to that bucket; dragging across bars on an ordered column filters to the range. As filters are applied, the other columns' charts recount, so a dataset can be explored by clicking through headings rather than opening a dialog.

Off by default. The band roughly doubles the header's height, which is a cost no grid should pay without being asked.

facets: { enabled: true }                // grid-wide

// per column, layered over the grid's settings
{ field: 'price', type: 'number', facet: { strategy: 'quantile', buckets: 16 } }
{ field: 'notes', facet: false }         // opt one column out
MethodReturnsDescription
get(colId)object | null{ bounds, counts, unfiltered, stale, suppressed }. Schedules the computation if it has not run; redraw on facet:computed rather than awaiting.
suppression(colId)string | nullWhy there is no chart: type, cardinality, rows, streaming, no-provider, disabled. Null when there is one.
config(colId?)objectThe resolved settings, column layered over grid.
select(colId, from, to?, opts?)booleanFilter to a bucket, or to the range fromto. opts.additive adds to a categorical set. Selecting what is already selected clears it.
clear(colId)booleanRemove only this column's filter, leaving every other filter in place.
selected(colId)number[]Which buckets the column's own filter currently covers.
toggle(colId, open?)booleanExpand or collapse the chart. Rides in a saved view.
isExpanded(colId)boolean
refresh(opts?)voidRecount every chart. immediate skips the debounce.
expanded()string[]Every expanded column.

A column is never counted against its own filter. Every other active filter applies; that column's own conditions are pruned out. Without this, clicking a bucket would collapse the chart to that single bar, leaving no way to see what was excluded or to widen the selection.

The filters are ordinary filters. They go through filters.set, so they undo, ride in saved views, and appear in whatever filter UI you already have. A drag emits a between range rather than a set of bucket indices, so it still means something after the data is replaced and the edges move.

OptionDefaultDescription
enabledfalseGrid-wide, or per column.
collapsedtrueStart as a one-line density strip that opens on click.
height28Band height in pixels.
buckets20Numeric and date columns.
strategy'equal'equal, quantile or log. Equal width looks wrong on skewed data.
granularityautohouryear. Chosen from the span when omitted.
order'count'count or alpha, for categorical columns.
cardinalityLimit50Distinct values above which a text column has no readable chart.
aboveLimit'suppress'suppress, or topN for a top list with an aggregated remainder.
rowCeiling2000000Rows above which charts are suppressed.
debounce120Milliseconds a filter change waits before charts recount.
whilePausedtrueWhether a paused stream re-enables charts.
provider, Async bucket counts for a paged or remote source. Without one, charts are suppressed silently.
format, (bucket, count, unfiltered) => string for tooltips and accessible names.

Live streams suppress charts. Buckets that move under the pointer are worse than no chart, the control lies about what clicking it will do. Filters already made stay applied, because they are ordinary filters. Pausing the stream brings the charts back; set whilePaused: false if you would rather it did not.

Server-side sources need a provider. It receives the column, the current filter state with that column's own conditions removed, and the bucketing settings, and returns counts. Results are cached against the filter state, but this is still one query per column per filter change, a grid with eight faceted columns will ask eight questions every time a filter moves, and the backend has to be able to absorb that.

Charts are keyboard operable: focus enters from the header, arrows move between buckets, Enter toggles, Shift with arrows extends a range on ordered columns, Escape clears. Each bucket carries its range and count as an accessible name, and the chart as a whole carries a one-sentence description of the distribution's shape, which is the part bar-by-bar labels cannot convey.

grid.updates

Control over an incoming feed: hold it, let it through, and see what the batching is actually saving you. Pausing does not drop anything: held changes keep merging, so a long pause costs one entry per changed row rather than one per update.

grid.updates.pause();                // hold the feed; it keeps arriving and merging
grid.updates.stats();                // { pending, queued, coalesced, flushes, ... }
grid.updates.flush();                // apply what is waiting, stay paused
grid.updates.resume();               // apply everything and go live again
MethodReturnsDescription
pausedbooleanTrue while updates are held.
pause()booleanHold incoming updates. True when this call paused it.
resume()objectApply everything held and start applying again. Returns the rows added, updated and removed.
flush()objectApply what is waiting without leaving the paused state, a single step.
stats()objectCounters for the feed and the buffer: what arrived, what will be applied, and the difference.
log(opts?)object[]The timestamped changes still held, oldest first. since narrows to a time window.

coalesced is the number a batching strategy is actually bought with: rows that arrived more than once in a window and were written once. A feed where it stays at zero is not being coalesced, whatever the interval says.

The log is bounded two ways, because an entry is not a fixed size, one carrying a single changed cell and one carrying a fifty-thousand-row batch both count as one.

OptionDefaultDescription
updates.logLimit2000How many changes are kept.
updates.logRows100000How many rows those changes account for between them. A feed delivering large batches reaches this one first.
updates.flush'frame'frame lands on a paint boundary, which is what makes one repaint per batch reliable. microtask at the end of the current task, interval on the coalescing window, manual only when you call flush().
updates.maxQueued20000Queued rows that force an early flush, whatever the strategy: including manual.
updates.budgetMs10Milliseconds one flush may spend applying before deferring the rest to the next frame.

Applying changes

rows.apply(change) applies immediately and returns what happened; rows.queue(change) batches into the next flush and returns a promise. Both take the same shape: add, update, remove, and an optional at insert position.

An update is a patch, not a replacement. Fields absent from the update are untouched, so a delta from a websocket or a save response can be applied as it arrives without reading the row back first. Coalescing merges fields too: {price} and {volume} arriving as separate messages inside one window both survive.

grid.rows.apply({ update: [{ id: 'R1', price: 42 }] });
// every other field on R1 is left alone

Rows that cannot be applied are reported, not thrown. A batch of a thousand containing three bad rows applies the other 997 and lists the three.

ReasonMeaning
unknown-idAn update or remove naming a row that is not in the grid.
duplicate-idAn add whose key already exists. Refused rather than admitted: selection, expansion, comments and the key index all resolve one key to one row.
const result = grid.rows.apply({ update: [ ... ] });
result.rejected;  // [{ operation, id, reason }]

These are batches, not database transactions. There is no isolation and no all-or-nothing guarantee: partial application with per-row rejection is the defined behaviour, which is why the API is not called a transaction.

stats() reports held against heldLimit: what the log is carrying now, against what it will carry. rows is a lifetime total of everything that ever arrived and says nothing about memory; these two do. Raise logRows for a deeper scrubber on a grid you have measured, and lower it on a feed of very wide rows.

grid.timeline

Moves the grid back through recent data changes: what a row held a minute ago, before the number moved. It reads the change log rather than the undo history: history records what you did, and on a live grid the question is what the data did.

Nothing is scrubbable until attach(). What a value used to be is not recoverable after the fact, and reading a row per key on every change is real cost on a busy feed, so recording is off until you ask for it and the window fills from that moment.

grid.timeline.attach();              // start recording; a scrubber appears
grid.timeline.seek(5);               // stand five changes back
grid.timeline.step(-1);              // one further back
grid.timeline.at();                  // the moment being shown
grid.timeline.toLive();              // return, applying everything stepped over
grid.timeline.detach();              // stop recording; the scrubber goes
MethodReturnsDescription
attachedbooleanWhether the scrubber is recording.
livebooleanTrue when the grid is showing the present.
positionnumberHow many steps back the grid is standing. Zero is live.
depthnumberHow many steps back it is possible to go.
attach()voidStart recording what changes replace.
detach()voidStop recording and return to the present.
seek(steps)numberStand a number of steps back, 0 being live. Clamped, not refused, at both ends.
step(by)numberMove relatively; negative goes back in time.
toLive()numberReturn to the present, applying everything stepped over.
at()number | nullThe timestamp being shown.
span()object | null{ from, to }, the range the scrubber can move over.

Cells whose value moved during a seek are marked and stay marked until the next seek, in --lattice-timeline-changed. On a wide row the change you are hunting for is easy to scroll past, and a flash you can miss helps nobody. Marking compares rendered column values rather than raw fields, so a computed column that moved because its inputs moved is marked too. Chart columns redraw as you scrub, like any other cell.

Value changes reverse; row additions and removals do not, a window containing them scrubs over the value changes and leaves the row set alone. While scrubbed back the grid is not live: changes keep being recorded but are not applied, and returning to the head applies everything missed. The delta renderer is the one cell type to keep off a scrubbed grid, because it samples on a wall-clock timer and reads a seek as a real movement.

grid.presentation

Enlarges the grid, drops the chrome and steps through saved views, for a screen share or a room. State only at this level: full screen and the pixels are the DOM layer's, so a headless grid can still be put into presentation state and asked about it.

grid.presentation.start({ scale: 1.5, views: ['q3', 'q4'] });
grid.presentation.step(1);                       // next view
grid.presentation.setSpotlight({ colIds: ['revenue'] });
grid.presentation.nudge(1);                      // a little larger
grid.presentation.stop();                        // or Esc

Esc ends the presentation, not just full screen: leaving one without the other would strand an enlarged, chrome-less grid in the page with no control left to turn it off. An open editor or menu still closes first.

MethodReturnsDescription
activebooleanTrue while a presentation is running.
scalenumberThe current enlargement.
start(options?)booleanBegin presenting. scale, views, chrome keep-list, interval for auto-advance.
stop()booleanStop and put the grid back as it was.
setScale(value)numberSet the enlargement, clamped to 0.5–4.
nudge(steps?)numberMove the enlargement by steps, for the live keyboard adjustment.
optionsobjectThe options the running presentation started with.
viewsstring[]The view ids being stepped through.
indexnumberPosition in the sequence, -1 when there is none.
viewIdstring | nullThe view id currently shown.
step(by?)numberStep forward or back through the sequence.
goTo(index)numberShow a numbered position.
spotlightobject | nullWhat is currently lit.
setSpotlight(target?)booleanLight rows, columns or their intersection and let the rest recede. Call with nothing to clear.
reset()booleanPut the current view back as it was saved, discarding what the presenter has sorted or filtered since.

Enlargement is a CSS scale factor multiplied into the same tokens density uses, so text, rows, padding and controls grow together rather than the grid being zoomed as an image. Font size is damped against it: type that scaled linearly with a 2× row height reads as shouting.

grid.annotate

The drawing layer over the grid: pixels on a transparent canvas, never data. A presenter picks a tool (pen, arrow, rect, highlight) and draws; the layer is inert until one is chosen, so scrolling and selection pass straight through otherwise. Marks are stored in content coordinates, so a circle drawn round a cell stays on that cell as the grid scrolls and resizes rather than hanging over the viewport.

Marks can also be seeded and added without drawing (BACKLOG-0000813), which is what lets a host ship a pre-drawn callout or restore one from storage. A mark descriptor is { type, points, colour? }type is freehand, arrow, rect, highlight or text (pen is accepted as an alias for freehand); points are {x, y} in content coordinates (a trail for freehand, the two endpoints for an arrow or rectangle, a single anchor for text). Seeded and added marks are durable: they survive a presentation ending, unlike a live-drawn mark, and they round-trip through getState and a saved view.

A text mark (BACKLOG-0000875) is a label anchored at one content point, carrying its text string and a basic style: colour, an optional fontSize in content pixels (default 14, scaled with a presentation), and an optional background colour drawn behind it. Like every mark it is held in content coordinates, so the label tracks the cell it annotates through scroll and resize.

// Seed a mark at construction — rendered on first paint, the way redaction seeds.
createGrid(el, {
  columns, rows,
  annotate: true,
  state: { annotations: [
    { type: 'arrow', points: [{ x: 40, y: 120 }, { x: 220, y: 80 }], colour: '#e0245e' },
    { type: 'text', text: 'Q3 spike', points: [{ x: 232, y: 72 }], colour: '#1a6bc7', background: '#fffbe6' },
  ] },
});

// Or add one durably at runtime — no synthesised pointer input.
grid.annotate.add({ type: 'rect', points: [{ x: 40, y: 100 }, { x: 260, y: 160 }] });
grid.annotate.add({ type: 'text', text: 'review', points: [{ x: 48, y: 108 }], fontSize: 16 });

// Persist and restore: seeded and added marks come back out of the state.
const marks = grid.getState().annotations;   // [{ type, points, colour }, …]
grid.state.apply({ annotations: marks });     // re-seed a fresh grid

annotate.add adds to the model and paints — it never synthesises pointer events, so a mark is exactly what the descriptor says. undo() removes the most recent mark and clear() removes them all, as before; annotation:changed still fires on every change. A presentation ending clears the presenter's live-drawn marks but keeps the durable ones, which are view state a host means to persist.

grid.redaction

Obscures a column's values on screen while leaving the shape of the data (row count, sort, filters, layout) perfectly readable. Built for presenting and screen sharing. Right-click a column heading for Redact column.

This is not a security control. The values stay in the model, the DOM, the clipboard and every export; anyone with the page can read them from devtools or by turning off one CSS rule. It defeats a camera, which is the whole claim. For a value that must not reach the browser at all, use permissions with writeOnly.

grid.redaction.toggle('salary');     // returns the state it is now in
grid.redaction.add('salary');
grid.redaction.set(['salary', 'bonus']);
grid.redaction.list();               // ['salary', 'bonus']
grid.redaction.clear();              // back to normal when the call ends
MethodReturnsDescription
has(colId)booleanIs this column redacted?
list()string[]Every redacted column id.
toggle(colId)booleanRedact, or stop. Returns the state it is now in.
add(colId)void
remove(colId)void
set(ids)voidReplace the whole set.
clear()voidStop redacting everything.
activebooleanTrue when at least one column is redacted.

The treatment is a CSS token, so a host can swap it: --lattice-redaction-filter defaults to blur(5px) contrast(0.85) and accepts anything the filter property does, including url(#your-svg-filter) for a mosaic.

grid.formatting

Conditional formatting rules the grid holds as runtime state, so an end user can change them. Rules travel in a saved view and undo like any other change. A scope is a column id, or '*' for every column; grid-wide rules are evaluated first, then the column's own, as one ordered list in which the first match wins.

This is distinct from compileRules() feeding cell.style, which compiles at configuration time and is what you want for rules a user should not be able to change. Both work at once: a runtime rule layers over whatever cell.style produced, winning only for the properties it names.

createGrid(el, {
  formatting: {                                   // optional seed
    margin: [{ when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } }],
  },
});

grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: 'red' } });
grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } });
grid.formatting.move('margin', ruleId, 0);        // order is meaning
grid.formatting.update('margin', ruleId, { enabled: false });
grid.formatting.remove('margin', ruleId);
grid.formatting.clear('margin');                  // or clear() for everything
MethodReturnsDescription
list(scope?)Rule[]The rules for one scope, in evaluation order.
all()objectEvery rule keyed by scope, the shape a saved view carries.
scopes()string[]Every scope holding at least one rule.
add(scope, rule, opts?)Rule | nullAppends, or inserts at opts.at. Returns the rule with its generated id.
remove(scope, idOrIndex)booleanBy id or position.
update(scope, idOrIndex, patch)Rule | nullMerges fields. The id is identity and cannot be reassigned.
move(scope, idOrIndex, to)booleanReorder, which can change which rule wins.
set(scope, rules)Rule[]Replace one scope.
replaceAll(rules)voidReplace every scope at once.
clear(scope?)voidOne scope, or all of them.
styleFor(colId, value)object | nullWhat the rules alone would paint, for an export or a preview.

A rule held here must be JSON: style may not be a function, because the rules are serialised into views and undo slices. Config-time cell.style still accepts one. Group rows are not formatted, matching the way decoration is dropped for them.

Rules that describe the data, not a threshold

gt: 100 needs somebody to know that 100 is the interesting number. Often nobody does, the interesting cells are the top decile, or the outliers, and where those fall is a property of the data rather than of the rule. These operators say that directly, and the grid works out the threshold from the column itself, over the filtered rows.

grid.formatting.add('margin', { when: { op: 'outlier' }, style: { background: '#fbeceb' } });
grid.formatting.add('qty',    { when: { op: 'topPercent', value: 10 }, style: { bold: true } });
grid.formatting.add('score',  { scale: { from: 'quantile', colours: ['#f8f9fa', '#1a6bc7'] } });

grid.formatting.distribution('margin');   // { n, min, max, mean, stddev, median, q1, q3, iqr }
grid.formatting.restat();                 // re-derive every threshold from the data as it stands
OperatorvalueMarks
topPercent10 or 0.1The top tenth of the column. Written either way; both mean the same thing.
bottomPercent10 or 0.1The bottom tenth.
topN5The five largest, ties included: three rows sharing second place in a top three all take the colour.
bottomN5The five smallest.
aboveMean / belowMean, Either side of the mean.
aboveMedian / belowMedian, Either side of the median, which is the one to reach for on a skewed column.
zAbove / zBelow2That many standard deviations from the mean. A column with no spread marks nothing rather than everything.
outlier1.5Outside Tukey's fences at that many IQRs, the same definition a box plot draws, so the marked cells are the ones its whiskers exclude.

A colour scale can take its bounds the same way, with from in place of min and max: 'minmax' spans the data, 'quantile' spans low to high (5th to 95th percentile by default), 'stddev' spans deviations either side of the mean. The quantile form is the better default on real data, one mistyped order of magnitude otherwise compresses every real value into the first swatch.

Thresholds are pinned when the rules compile and do not move on their own. That is deliberate: a boundary that re-derived itself as rows were filtered would repaint cells whose values had not changed, and nobody comparing two screenshots could tell which of the two things had moved. grid.formatting.restat() is how you move it, and a "recalculate" control is the natural place to put it.

Data bars and icon sets as rules (BACKLOG-0000955)

A rule can carry a data bar or an icon set instead of a style or a scale, so the same declarative, view-persisted, headless rule list that already paints colour scales also paints proportional bars and per-band glyphs. Both compile to a plain style object — a data bar is a CSS gradient on the background, an icon set a background-image — so they need no extra element, compose with the cell's text, and resolve the same way for a server-side export as for a browser paint. This is the rule-engine sibling of the cell decoration below; reach for a rule when you want the visual to travel in a saved view and to derive its bounds from the column's distribution.

// A data bar spanning the data, and a three-arrow icon set split at the tertiles.
grid.formatting.add('revenue', { dataBar: { from: 'minmax', colour: '#5b9bd5' } });
grid.formatting.add('score',   { iconSet: { set: 'trafficLights' } });

// Bounds and bands can be pinned instead of derived; bars that straddle zero
// grow both ways from a shared axis, in their own colours.
grid.formatting.add('delta', { dataBar: { min: -100, max: 100, colour: '#2e7d32', negativeColour: '#c0392b' } });
grid.formatting.add('rank',  { iconSet: { set: 'arrows', thresholds: [10, 20], reverse: true } });

A data bar takes min/max to pin its scale, or from: 'minmax' | 'quantile' | 'stddev' to derive it from the column; colour and negativeColour fill the two sides of a zero axis, and direction: 'rtl' reverses it. An icon set names a built-in — the keys of ICON_SETS (arrows, trafficLights, ratings) — or supplies its own icons; thresholds place the band edges, or, given none, the column is cut into equal-count bands; reverse flips the order so a high value can read as red.

const { createHeadlessGrid, ICON_SETS } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'revenue', type: 'number' }, { field: 'score', type: 'number' }],
  rowKey: 'id',
  rows: [1, 2, 3, 4, 5].map((n) => ({ id: 'r' + n, revenue: n * 20, score: n })),
  formatting: {
    revenue: [{ dataBar: { from: 'minmax', colour: '#5b9bd5' } }],
    score: [{ iconSet: { set: 'trafficLights' } }],
  },
});

// A data bar compiles to a gradient the cell layer paints as a background.
const bar = grid.formatting.styleFor('revenue', 100).backgroundImage.includes('linear-gradient') ? 'bar' : 'none';

// An icon set resolves different bands to different glyphs.
const low = grid.formatting.styleFor('score', 1).backgroundImage;
const high = grid.formatting.styleFor('score', 5).backgroundImage;
const icon = low !== high ? 'icon' : 'flat';

const sets = Object.keys(ICON_SETS).length;   // the three built-in sets
grid.destroy();
return [bar, icon, sets].join('|');

Runtime decorations: data bars and icon sets on demand

Where a colour rule paints the cell's background, a decoration changes the shape the cell renders as, a data bar sized by value, or a threshold icon set. grid.columns.decorate(id, spec) turns one on, changes it, or clears it with null, after the grid is built. It is presentation config rather than query state: unlike a formatting rule it is not on the undo timeline and does not travel in a saved view. Icon sets carry an aria-label per band and keep the value beside the glyph, so the meaning is announced, never only shown.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'score' }, { field: 'trend' }],
  rows: [{ id: '1', score: 72, trend: 8 }],
  rowKey: 'id',
});

// A data bar, sized 0..100, set at runtime.
grid.columns.decorate('score', { type: 'bar', min: 0, max: 100 });
const bar = grid.columns.get('score').cell.decoration.type;

// A built-in three-arrow icon set on the trend column, with its own bands.
grid.columns.decorate('trend', { type: 'icon', bands: [
  { min: 0, icon: 'chevronUp', label: 'increasing', variant: 'success' },
  {         icon: 'chevronDown', label: 'decreasing', variant: 'danger' },
] });
const set = grid.columns.get('trend').cell.decoration.type === 'icon' ? 'arrows' : 'none';
const band = grid.columns.get('trend').cell.decoration.bands[0].label;

// Clearing a decoration returns the column to plain text.
grid.columns.decorate('score', null);
const cleared = grid.columns.get('score').cell.decoration === undefined ? 'cleared' : 'still-set';

// A runtime colour rule is the durable, view-persisted sibling.
grid.formatting.add('score', { when: { op: 'lt', value: 50 }, style: { background: '#fdecea' } });
const painted = grid.formatting.styleFor('score', 20) ? 'painted' : 'plain';

return [bar, set, band, cleared, painted].join('|');

grid.validation

Declarative column validation (BACKLOG-0000956). Where edit.validate is an imperative function you write, this is the same job said as data: required, min/max, minLength/maxLength, pattern, oneOf, and a crossField predicate, declared per column in validation. Each rule is checked against a new value before it is written, riding the cancellable beforeEdit before-event: a failing value cancels the commit so no cell is written, marks the cell with the grid's ordinary invalid state (an accessible error, not only a red border), and fires validation:failed. A corrected value clears the mark and, where you are watching, fires validation:cleared. The cancellation carries reason: 'validation:<code>', so a host logging cancellations can tell a validation veto from any other.

Only a user-initiated edit is gated, the same contract beforeEdit itself keeps: a host API write (grid.edit.setCells) and a remote/router-applied delta are the authority and do not self-veto. A grid whose columns declare no rules wires no gate and keeps the byte-for-byte synchronous edit path.

// Declared per column, config-time.
createGrid(el, {
  columns: [
    { field: 'name', edit: true, validation: { required: true, minLength: 2 } },
    { field: 'age',  type: 'number', edit: true, validation: { min: 0, max: 120 } },
    { field: 'code', edit: true, validation: { pattern: '^[A-Z]{3}$', messages: { pattern: 'Three capitals.' } } },
  ],
});

grid.validation.check('age', 200);          // { code: 'max', message: 'Must be at most 120.' } — records nothing
grid.validation.errorFor('r1', 'age');       // the recorded error for a cell, or null
grid.validation.errors();                    // every cell that currently holds an error
grid.validation.define('age', { min: 18 }); // set or replace a column's rules at runtime
grid.validation.clear('r1', 'age');          // drop a mark by hand
const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  columns: [{ field: 'id' }, { field: 'age', type: 'number', edit: true, validation: { min: 0, max: 120 } }],
  rowKey: 'id',
  rows: [{ id: 'r1', age: 30 }],
});

let failed = 0;
grid.on('validation:failed', () => { failed++; });
grid.on('validation:cleared', () => {});

// A user edit that breaks the rule is refused: the cell is not written.
grid.edit.start('r1', 'age'); grid.edit.stop(false, { value: 999 });
const blocked = grid.rows.byKey('r1').data.age;              // still 30
const why = grid.validation.errorFor('r1', 'age').code;      // 'max'

// A valid value writes through and clears the mark.
grid.edit.start('r1', 'age'); grid.edit.stop(false, { value: 40 });
const now = grid.rows.byKey('r1').data.age;                  // 40

grid.destroy();
return [blocked, why, now, failed].join('|');

grid.statistics

What the grid knows about its own numbers, and about how they have changed since the page loaded. Every figure is computed over the filtered rows, through the same column handles the totals row uses, so a median here and a median in the footer are the same number, by the same definition (R type 7).

grid.statistics.profile('margin');
// { column, rows, present, missing, distinct, min, max, mean, median,
//   q1, q3, iqr, stddev, outliers, histogram: [{ from, to, count }, …] }

grid.statistics.profile('region');   // a categorical column (BACKLOG-0000959)
// { column, rows, present, missing, distinct, …numeric figures null…,
//   histogram: [], topValues: [{ value, count, share }, …] }

grid.statistics.reduce('margin', 'p95');          // any registered kernel
grid.statistics.correlation('spend', 'revenue');  // Pearson's r, clamped to [-1, 1]
grid.statistics.weightedAverage('price', 'qty');

grid.statistics.covariance('spend', 'revenue');
grid.statistics.regression('spend', 'revenue');   // { slope, intercept, r2, stdError, n }
grid.statistics.spearman('spend', 'revenue');     // rank; one outlier cannot drag it
grid.statistics.kendall('spend', 'revenue');      // tau-b, null past 5,000 rows
grid.statistics.weightedQuantile('price', 'qty'); // the median by default

grid.statistics.series('price', { by: 'date', periodsPerYear: 252 });
// { volatility, annualisedVolatility, growth, maxDrawdown, maxDrawdownFrom,
//   maxDrawdownTo, autocorrelation, upDays, downDays, … }

grid.statistics.capability('mm', { baseline: 20 });
// { cp, cpk, pp, ppk, sigmaWithin, sigmaOverall, outOfSpec, defectRate,
//   limits: { centre, upper, lower, sigma }, violations: [{ index, rule }] }

grid.statistics.shadow('price', 'delta', 'R42');  // one row's shadow value
grid.statistics.rebase('price');                  // "mark all": today's values become the baseline
grid.statistics.tracking();                       // { columns, rows, forgotten }

The statistics tool panel is the end-user half of profile(): a column picker, the twelve figures and a histogram of the column's shape, all following the filters. Add it with toolPanel: { panels: ['columns', 'statistics'] }. It profiles categorical columns too (BACKLOG-0000959): a text column shows its count, distinct count and Top values (each value with its count and share) instead of the numeric figures and the histogram it has none of.

The column header menu carries a Column statistics item that opens this panel seeded on the column it belongs to. It emits column:profile:open with { colId } rather than reaching into the dock, exactly as the header filter affordance emits column:filter:open; a mounted tool panel turns that into the open, seeded statistics panel.

The regression tool panel is its multi-column sibling (BACKLOG-0000812): it fits the model you name and shows the coefficient table — each term's estimate ± standard error with its t and p — alongside R² and adjusted R², the variance-inflation factor per predictor, and the Breusch–Pagan heteroscedasticity flag, all over the filtered rows and computed by the one core engine (grid.statistics.regressionModel). Name the model on the panel: toolPanel: { panels: ['columns', { name: 'regression', props: { predictors: ['x1', 'x2'], response: 'y' } }] }. p-values are reported as numbers with a documented method, never a significance verdict.

The same fitted model can live in the data as shadow columns (BACKLOG-0000812): shadow: { kind: 'fitPredicted', model: { predictors: ['x'], response: 'y' } }, and likewise fitResidual and fitInfluence — plus fitStdResidual, fitLeverage and fitCooksD (BACKLOG-0000872), which surface the internally studentised residual, the hat-matrix leverage and Cook's distance the engine already computes. They are ordinary numeric/boolean cells — sortable, filterable, groupable, exportable — that read the fit by row key and follow the grid's filters (the model refits over the filtered rows); a row outside the fit reads null. fitInfluence flags Cook's D > 4/n by default (overridable with threshold), keeping "not influential" (false) and "cannot tell" (null) distinct.

It shows the twelve one-pass figures, then Shape (skewness, kurtosis, Jarque–Bera), Robust (trimmed and winsorized means, MAD, robust outliers), Concentration (Gini, HHI, entropy, evenness, top-3 share) and Capability where the column declares a spec. A section whose reductions all return null is left out rather than shown as a column of dashes.

Or put it in your own page. mountPanel takes no dock and does not create one: toolPanel may be off entirely, so a statistics readout can sit beside a chart, in your own sidebar, or in a settings dialog, at whatever size you give it. It repaints on the same events the rail does, so it stays in step with filters, edits and saved views without you subscribing to anything.

import { mountPanel } from '@toclocoinc/lattice-grid';

const stats = mountPanel({ grid, panel: 'statistics', container: sidebar });
stats.refresh();   // for a change the grid does not announce
stats.destroy();   // yours to call: the element belongs to your page

Any built-in panel works: columns, filters, views, quick, formatting, statistics, regression, compare, insights, as does a constructor of your own.

The insights panel

The insights tool panel is the on-screen half of the comparison analytics (§9.9): the API-only subsetVsPopulation(), datasetVsDataset(), capability() and compareGroups() rendered without you building any UI. It is opt-in — off unless you name it. Add it with toolPanel: { panels: ['columns', 'insights'] }.

It shows four things, all over the filtered rows: the columns of the current filtered subset ranked by effect size against the whole; the same ranking against a second grid when you pass one as toolPanel: { panels: ['insights'], /* config */ } with insights: { compareWith: otherGrid }; process capability for the chosen column where it declares a spec; and a two-group comparison — pick a column and a column to group by, and the panel shows the named test, its confidence interval and its effect size together.

The stance is enforced on screen. The effect size is never shown without its interval beside it; the test or method that produced a figure is always named, from the API's own method string; and nothing renders a significant flag, a verdict, a badge or a star — the p-value is shown as the plain datum it is, when it is shown at all. The panel adds no statistic of its own: every number it shows is the one grid.statistics returns.

createGrid(el, { toolPanel: { panels: ['columns', 'insights'] } });

// with a second dataset to rank this grid against:
createGrid(el, {
  toolPanel: { panels: ['insights'] },
  insights: { compareWith: otherGrid },
});

The reductions

Forty-one, all available to a totals row, to reduce() and to the profiling panel. Names are the same everywhere and the labels come from the message catalogue, so a grid in Polish reads in Polish.

GroupNames
Basicsum, avg, min, max, count, countValues, first, last, distinct, mode, range
Spreadvariance, varianceP, stddev, stddevP, iqr, mad, sumSquares
Quantilesmedian, p25, p75, p90, p95, p99
Shapeskewness, kurtosis, jarqueBera: above 5.99 the column is not plausibly normal
Meansgeomean, harmean, weightedAvg, trimmedMean, winsorizedMean
OutliersrobustOutliers: by the modified z-score, which an outlier cannot hide inside the way it inflates an ordinary one
Concentrationhhi, entropy, evenness, top3Share, top10Share, gini, the only group that reads a text column, because "how concentrated is this" is a question about categories
Positionalargmin, argmax

Process capability

Declare the customer's tolerance on the column, and the capability figures, a control chart and any rule marking an out-of-tolerance cell all read the same limits.

columns: [{ field: 'mm', type: 'number', spec: { lower: 9.5, upper: 10.8, target: 10 } }]

Cp and Cpk use short-term variation, estimated from the moving range; Pp and Ppk use the overall standard deviation. The gap between them is the point: Cpk well above Ppk means the process drifted. Cp above Cpk means it is precise and aimed wrong, which needs a different fix from being too variable.

baseline fixes the control limits over the first N readings. Without it the limits are computed over everything (including whatever the process did wrong) so a step change pulls the centre line between the two levels and both halves land outside three sigma. Technically true, and useless for finding when it moved.

Seeing it

The statistics have chart types to match, in modules/charts. Each takes its numbers from this namespace rather than recomputing, so a coefficient in a matrix and the same one from the API cannot drift apart.

TypeWhat it shows
correlogramEvery numeric column against every other, on a ramp centred at zero so the sign reads first. method: 'spearman' ranks instead; where the two disagree, the pair is related but not linearly.
qqSample quantiles against normal ones. Jarque–Bera says a column is not normal; this shows how, a heavy tail bends the ends, a skew bows the whole line. The reference runs through the quartiles, as R's qqline does, because a fitted line is dragged by the very tails you are inspecting.
ecdfThe share at or below each value, as a step. No bins, so its shape is not partly a choice, and two overlay cleanly where two histograms fight.
lorenzThe curve a Gini is read off, against the diagonal a perfectly even column would trace.
controlAn individuals chart: control limits from the moving range, the specification, and points breaking a Western Electric rule. The control limits are the process talking and the specification is the customer talking: conflating them is the classic error, so they are drawn differently.
histogramcurve: true overlays a kernel density estimate, which has no bin edges and so separates what is in the data from what is in the binning.
scatterfit: true draws least squares per series with R² beside it.

Values the grid maintains for you

Three of the ideas in this section are not standard grid vocabulary, so it is worth saying what they have in common before the detail. Each of them is a value the grid keeps up to date from data you already have, declared once rather than maintained by hand.

The alternative, in every application that needs one of these, is a parallel structure in the host: a copy of what each row looked like a moment ago, a rank recomputed on every tick, a cumulative total that has to be redone whenever the sort changes, and a dashboard panel running its own query beside the table. That code works for a while and then produces a number nobody can account for, usually because one part of it noticed a filter and another did not.

ConceptWhat it isReach for it when
Shadow column An extra column, declared against a real one, holding something the grid works out about it: how it has changed, or where it sits among the others. You want “what was this an hour ago”, “how many places has it moved”, or “which decile is it in” as a column you can sort and filter on.
Running column A cumulative value: the total, or the share of the total, by the time you reach this row. You want a running balance, a cumulative percentage, or a Pareto curve down the page.
Derived grid A whole second grid whose rows are built from the first: grouped, unnested, filtered, ranked or profiled. You want a top-five panel, a breakdown by region, an exceptions list or a statistics summary beside the table, and it must never disagree with it.

The line between a shadow and a running column is the sort order. A shadow is a function of the column: a row’s own history, or where its value sits among the others. Sort the grid differently and a rank is still the same rank. A running total is the opposite : it answers “how much by the time we reach this row”, and by the time is the order the rows are in, so re-sorting changes every value in the column. That is why they are declared separately rather than as two kinds of one thing.

A derived grid is a different scale of the same idea. A shadow adds a column to the rows you have; a derivation produces different rows altogether: one per sales person rather than one per sale. Because it is a source rather than a special kind of grid, the result sorts, filters, totals, themes and exports like any other, and can itself be the source of another.

All three read the rows the grid is currently showing, so a filter applied to the table moves the ranks, the running totals and every derived panel together. That is the property worth having: not that any one of them is clever, but that they cannot disagree.

Shadow columns

A shadow column is declared against another column and maintained by the grid. It has no field in the data and it is not a pure computed column either, because its value depends on what happened before. It is a real column throughout: sortable, filterable, totalled, grouped, exported, saved into a view, which is what makes "show me every circuit repriced more than twice this session, most-changed first" one gesture rather than a report.

columns: [
  { field: 'price', type: 'number' },
  { id: 'moved',  title: 'Change',  shadow: { of: 'price', kind: 'delta' } },
  { id: 'churn',  title: 'Updates', shadow: { of: 'price', kind: 'updates' } },
  { id: 'run',    title: 'Streak',  shadow: 'streak' },   // shorthand: shadows the column beside it
]
KindValue
updatesHow many times the row's value has changed. Arrival is not a change, so a freshly loaded grid reads zero rather than one.
updatedAtWhen it last changed, as a Date.
sinceUpdateMilliseconds since it last changed.
deltaCurrent value minus the baseline.
deltaPercentThe same as a percentage. A change from nothing has no percentage and reads null rather than infinity.
rateChange per second, from the last two readings.
historyThe recent readings, oldest first. depth sets how many; the default is 20. Counts changes by default — see the time-windowed form below for a real time series.
firstValueThe baseline itself.
streakConsecutive moves in one direction, signed. It resets on a turn, because "seven rises" means something and "seven changes" does not.

A second family answers where the row sits among the others rather than what it did before. They share the same declaration and the same state, the tracker already holds every row's current value and its baseline, which is exactly what a rank and a rank change need.

KindValue
rankCompetition rank, largest first: ties share the better rank and the next value skips, so two firsts are followed by a third.
rankAscThe same ranking read from the other end.
rankChangePlaces climbed since the baseline. Positive means climbed, even though the rank number itself falls: this is the "top movers" column.
percentileThe share of rows at or below this one, 0 to 100.
quartile1 to 4, agreeing with percentile: the 60th percentile is in the third quartile.
zScoreDeviations from the mean. A column with no spread reads null rather than zero.
shareOfTotalThe value over the column's total, as a percentage. A total of zero (a column of offsetting positions) reads null rather than a division by it.

Running totals

A running column answers “how much by the time we reach this row”: a balance that accumulates down the page, or the share of the total accounted for so far. It is the column a Pareto chart is made of, and the one a finance report opens with.

It is declared separately from a shadow column, and the sort order is the reason. Every shadow is a function of the column (of a row's own history, or of where its value sits among the others) so it reads the same however the rows are arranged. A running total does not: sort the grid differently and every value changes, because the question is "how much by the time we reach this row", and by the time is the sort order.

columns: [
  { field: 'amount', type: 'number' },
  { id: 'cum',   title: 'Running',      running: { of: 'amount', kind: 'total' } },
  { id: 'share', title: 'Cumulative %', running: { of: 'amount', kind: 'percent' } },
]

Computed in one pass over the display rows and cached against that ordering, so a hundred thousand rows are walked once per sort rather than once per cell. A running column is not sortable. Sorting on one asks the sort to depend on its own output (the value is defined by the display order) so the column does not offer a sort unless its definition asks for one, and the query layer refuses such a sort with a warning rather than computing it. Sort by the column it runs over instead. Group headings and totals rows are skipped, a running total that counted a subtotal would double everything below it, and a row with no value carries the figure forward unchanged rather than resetting it.

History has two clocks: a count of changes, or a span of time (BACKLOG-0001043)

Plain history (above) is an event series: it records a reading only when the value changes, so a row that sits still never advances it. Bound to a cell.render sparkline ('line', 'area', 'column', 'winloss') that means a static row's sparkline freezes, then jumps when a change finally lands — while a title like "last 60s" keeps claiming a span the column never measured. Add a time window to make it a real time series instead:

{ id: 'spark', title: 'Last 60s', shadow: { of: 'price', kind: 'history', window: { kind: 'time', span: 60_000 }, depth: 20 } }

This is the same window: { kind, span } shape the rolling kinds below already accept — not a second spelling of it — and it changes what history means rather than adding a new kind: depth (20 here) is now how many buckets the span divides into, so this is sixty seconds as twenty three-second buckets. Each bucket is sampled once, at its close, as the row's last known value at that moment — carried forward from the previous bucket when nothing changed in between. A static row therefore draws a flat line that keeps advancing, one sample per bucket, and a change lands in the bucket it actually happened in rather than being appended at the end. The bucket clock runs on its own low-frequency timer (never a render loop), so the series keeps moving even while no data event ever reaches the column. window: { kind: 'count' } and { kind: 'session' } are refused for history — a plain count is already what depth means without a window, and a session has no fixed span to divide into buckets — and a caller who never sets window gets the original count-based behaviour, unchanged.

Positional kinds rank over every tracked row, not over the filtered set: a rank that changed as you filtered would make "the top ten movers" depend on what happened to be on screen, and the column would disagree with itself between two views of the same data. Pass scope: 'filtered' on the shadow spec to rank within what the filters left instead: both answers are legitimate, which is why it is a choice rather than a default.

Shadow state is keyed by row key, never by index: after any sort an index-keyed history would report one row's past against another row's present, and the wrong number would be sortable. Memory is capped at 200,000 tracked rows per column; past that the oldest are dropped and tracking().forgotten says how many, rather than a smaller number being reported as though it were the truth.

Rolling time-series columns

A total answers "how much"; a rolling total answers "how much lately, as the series ran" — the seven-day average that smooths a daily figure, the trailing sum, the change on the period before. These are rolling shadow kinds (BACKLOG-0000748): real columns, sortable and filterable and exportable like any other, computed in one ordered pass and cached by row key. They compose the windowed-aggregate model the grid already uses for "the average lately" over a live stream, asked instead over a column arranged in a stated order.

The order is explicit and required — an orderBy column, never the screen sort, because a rolling figure defined by the current sort would change on every header click and a column sorted on its own rolling value would define itself. The window is the last span rows (count), the last span of the order axis (time), or the whole series so far (session). The first rows carry a partial window; that figure is still emitted, but a windowCoverage companion stamps how much of the window it actually covers, so a two-day average is never shown as a seven-day one. within chooses per-group (the default, partitioned by the grid's grouping) or across the whole dataset.

A rollingQuantile (a trailing median, a p95, set by q) is exact while the window is small and comes from a KLL sketch past an internal span cap and for a session window — where a windowApproximate companion reports which rows are approximate, so a sketched quantile is never presented as exact. At a million rows the single ordered pass stays well within the suite's budget (≈380ms for the window aggregates, ≈490ms for the exact rolling median, ≈420ms for the session sketch on the reference bench).

columns: [
  { field: 'day',  type: 'date' },
  { field: 'sales', type: 'number' },
  { id: 'ma7',   title: '7-day avg', shadow: { kind: 'rollingAvg', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 7 } } },
  { id: 'cover', title: 'Coverage',  shadow: { kind: 'windowCoverage', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 7 } } },
  { id: 'p50',   title: '30-day median', shadow: { kind: 'rollingQuantile', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 30 }, q: 0.5 } },
  { id: 'ytd',   title: 'Cumulative', shadow: { kind: 'cumulativeToDate', of: 'sales', orderBy: 'day' } },
  { id: 'delta', title: 'vs prev',   shadow: { kind: 'periodOverPeriod', of: 'sales', orderBy: 'day' } },
]

A rolling window is a property of the series, so it is computed over every row before any filter: hiding rows with a filter narrows what you see, never what "the last seven" means. A missing reading is a gap, skipped rather than treated as a zero that would report a plunge and a rebound the series never made.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A short series, ordered by t: values 2,4,5,4,5.
const base = { of: 'v', orderBy: 't', within: 'all' };
const win = { kind: 'count', span: 3 };
const grid = createHeadlessGrid({
  columns: [
    { field: 't', type: 'number' },
    { field: 'v', type: 'number' },
    { id: 'sum', shadow: { kind: 'rollingSum', window: win, ...base } },
    { id: 'avg', shadow: { kind: 'rollingAvg', window: win, ...base } },
    { id: 'cov', shadow: { kind: 'windowCoverage', window: win, ...base } },
    { id: 'med', shadow: { kind: 'rollingQuantile', window: win, q: 0.5, ...base } },
    { id: 'cum', shadow: { kind: 'cumulativeToDate', ...base } },
    { id: 'pop', shadow: { kind: 'periodOverPeriod', ...base } },
  ],
  rows: [
    { id: 'r1', t: 1, v: 2 }, { id: 'r2', t: 2, v: 4 }, { id: 'r3', t: 3, v: 5 },
    { id: 'r4', t: 4, v: 4 }, { id: 'r5', t: 5, v: 5 },
  ],
  rowKey: 'id',
});
const round4 = (x) => Math.round(x * 10000) / 10000;
const round2 = (x) => Math.round(x * 100) / 100;
return [
  grid.rows.value('r3', 'sum'),          // 2+4+5 = 11
  round4(grid.rows.value('r3', 'avg')),  // 11/3
  round4(grid.rows.value('r5', 'avg')),  // (5+4+5)/3
  round2(grid.rows.value('r1', 'cov')),  // 1/3 of the window filled
  grid.rows.value('r5', 'cum'),          // running total to the end
  grid.rows.value('r2', 'pop'),          // 4 - 2
  grid.rows.value('r3', 'med'),          // median of 2,4,5 = 4
].join('|');

Seasonal decomposition

Splitting a series into trend + seasonal + residual (BACKLOG-0000873) answers "what's the underlying trend with the weekly pattern removed?". It is classical decomposition — the same algorithm statsmodels.seasonal_decompose uses, verified against it in the reference suite — delivered as four shadow columns over the same ordered pass: tsTrend (a centred moving average), tsSeasonal (the repeating index), tsResidual (what the two leave behind), and tsCoverage.

The period is caller-declared and required — 7 for a weekly cycle in daily data, 12 for a monthly cycle in monthly data; there is no auto-detection in v1. The model is additive by default; decomposition: 'multiplicative' is a declared option that is undefined on a non-positive series (those rows report null, with a warning). The centred window runs off the ends, so the leading and trailing rows have no trend — they are partial edges, reported as null and stamped tsCoverage: 0 rather than emitted as if full.

columns: [
  { field: 'day',  type: 'date' },
  { field: 'sales', type: 'number' },
  { id: 'trend',  title: 'Trend',    shadow: { kind: 'tsTrend',    of: 'sales', orderBy: 'day', period: 7 } },
  { id: 'season', title: 'Weekly',   shadow: { kind: 'tsSeasonal', of: 'sales', orderBy: 'day', period: 7 } },
  { id: 'resid',  title: 'Residual', shadow: { kind: 'tsResidual', of: 'sales', orderBy: 'day', period: 7 } },
  { id: 'cover',  title: 'Coverage', shadow: { kind: 'tsCoverage', of: 'sales', orderBy: 'day', period: 7 } },
]
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A period-4 series: trend 10+i plus a season [2,-1,0,-1], so value = trend + season.
const season = [2, -1, 0, -1];
const base = { of: 'v', orderBy: 't', within: 'all', period: 4 };
const grid = createHeadlessGrid({
  columns: [
    { field: 't', type: 'number' },
    { field: 'v', type: 'number' },
    { id: 'trend',  shadow: { kind: 'tsTrend', ...base } },
    { id: 'season', shadow: { kind: 'tsSeasonal', ...base } },
    { id: 'resid',  shadow: { kind: 'tsResidual', ...base } },
    { id: 'cover',  shadow: { kind: 'tsCoverage', ...base } },
  ],
  rows: Array.from({ length: 8 }, (unused, i) => ({ id: String(i), t: i, v: (10 + i) + season[i % 4] })),
  rowKey: 'id',
});
return [
  grid.rows.value('4', 'trend'),   // centred MA recovers the trend: 14
  grid.rows.value('4', 'season'),  // the phase-0 seasonal index: 2
  grid.rows.value('4', 'resid'),   // nothing left over: 0
  grid.rows.value('4', 'cover'),   // interior row: full, 1
  grid.rows.value('0', 'trend') === null ? 'null' : 'x',  // partial edge: null, not invented
  grid.rows.value('0', 'cover'),   // edge stamped partial: 0
].join('|');

Exponential smoothing

Smoothing pulls the signal out of a noisy series (BACKLOG-0000873). tsSmoothed is the fitted level — not a forecast of the future — from single exponential smoothing (smoothing: 'ses', the default) or Holt's level+trend (smoothing: 'holt'). The recursion matches statsmodels and is verified against it in the reference suite. Holt-Winters (seasonal) smoothing is deferred; seasonality is covered by decomposition above.

The smoothing factor is either caller-set (alpha, and beta for Holt) or fit by minimising the in-sample SSE when omitted — and the chosen value is reported, not hidden, by the tsSmoothingAlpha / tsSmoothingBeta companion columns.

columns: [
  { field: 'day',   type: 'date' },
  { field: 'sales', type: 'number' },
  { id: 'level', title: 'Smoothed', shadow: { kind: 'tsSmoothed',       of: 'sales', orderBy: 'day', smoothing: 'holt' } },
  { id: 'a',     title: 'α',        shadow: { kind: 'tsSmoothingAlpha', of: 'sales', orderBy: 'day', smoothing: 'holt' } },
  { id: 'b',     title: 'β',        shadow: { kind: 'tsSmoothingBeta',  of: 'sales', orderBy: 'day', smoothing: 'holt' } },
]
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// SES at alpha 0.5 over 4,8,6,10: level runs 4, 6, 6, 8.
const base = { of: 'v', orderBy: 't', within: 'all', smoothing: 'ses', alpha: 0.5 };
const grid = createHeadlessGrid({
  columns: [
    { field: 't', type: 'number' },
    { field: 'v', type: 'number' },
    { id: 'sm', shadow: { kind: 'tsSmoothed', ...base } },
    { id: 'a',  shadow: { kind: 'tsSmoothingAlpha', ...base } },
  ],
  rows: [4, 8, 6, 10].map((v, i) => ({ id: String(i), t: i, v })),
  rowKey: 'id',
});
return [
  grid.rows.value('1', 'sm'),   // 0.5*8 + 0.5*4 = 6
  grid.rows.value('3', 'sm'),   // 0.5*10 + 0.5*6 = 8
  grid.rows.value('0', 'a'),    // the factor used, reported: 0.5
].join('|');

Stationarity (ADF)

Before you compare two series or detrend one, it helps to know whether it is stationary — reverting to a level or trend — or wandering with a unit root. grid.statistics.adf runs the Augmented Dickey-Fuller test (BACKLOG-0000873) and returns a scalar readout, not a per-row column: the statistic, the augmenting lag chosen by AIC, MacKinnon's critical values, an interpolated p-value (stamped approximate), and a plain-language verdict at the 5% level. The constant+trend regression and the AIC lag choice match statsmodels' adfuller, against which the statistic and lag are verified.

The lag search and what it costs. maxlag caps the number of augmenting lags the AIC search considers; left out, it is the Schwert rule ⌈12·(n/100)^0.25⌉ — 34 candidates on 7,000 rows — itself capped so the fixed sample keeps degrees of freedom. The candidates are nested, so the search builds one design matrix at the cap and reads every smaller candidate off it — one pass to accumulate the normal equations, then a small solve and a single residual pass per candidate, rather than a fresh fit each time (BACKLOG-0001347). The default search over 7,000 rows is a matter of milliseconds. Setting maxlag narrows the search, never the arithmetic: the lag chosen, the statistic and the p-value are whatever the data says, and a cap that still contains the AIC-preferred lag returns exactly the same readout.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A random walk (a unit root): it wanders rather than reverting.
const walk = [0.138, -0.725, -1.26, -0.536, -0.267, 0.167, -0.765, -0.146, -0.311, -0.586,
  0.376, -0.41, 0.282, 0.865, -0.024, 0.149, -0.2, -0.654, -1.338, -1.917, -1.832, -1.333,
  -0.492, -1.259, -1.89, -2.145, -2.146, -1.297, -1.26, -0.716, -0.846, -1.206, -2.126,
  -1.724, -0.945, -1.599, -1.316, -0.413, 0.304, 0.732, -0.257, 0.086, -0.572, -0.501,
  -1.153, -1.186, -1.455, -1.607];
const grid = createHeadlessGrid({
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
  rows: walk.map((v, i) => ({ id: String(i), t: i, v })),
  rowKey: 'id',
});
const adf = grid.statistics.adf({ of: 'v', orderBy: 't' });
return [adf.verdict, adf.usedLag].join('|');   // non-stationary, 0 lags

Autocorrelation (ACF / PACF)

grid.statistics.acf shows how far back a series depends on itself (BACKLOG-0000873): the autocorrelation (ACF) and partial autocorrelation (PACF) arrays out to a maximum lag, each with the approximate ±1.96/√n white-noise band (stamped approximate) — a lag whose bar clears the band is evidence of real dependence. The estimators are the biased ACF and the Yule-Walker (Levinson-Durbin) PACF, matching statsmodels, verified in the reference suite. Lag 1 is the single source of truth: acf[1] is the same number statistics.series(...).autocorrelation reports, and pacf[1] === acf[1].

The correlogram is the arrays fed to a bar chart over explicit points, with the band as reference lines — reusing the existing chart primitives:

const { acf, bounds } = grid.statistics.acf({ of: 'sales', orderBy: 'day', maxlag: 20 });
createChart({
  grid, container: '#acf', type: 'bar',
  points: acf.map((v, lag) => ({ x: lag, y: v })),
  reference: [{ value: bounds.upper }, { value: bounds.lower }, { value: 0 }],
});
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A deterministic AR(1): each reading leans 0.6 on the one before.
let s = 5; const rand = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296 - 0.5; };
const y = []; let prev = 0;
for (let i = 0; i < 200; i++) { const v = 0.6 * prev + rand(); y.push(v); prev = v; }
const grid = createHeadlessGrid({
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
  rows: y.map((v, i) => ({ id: String(i), t: i, v })),
  rowKey: 'id',
});
const res = grid.statistics.acf({ of: 'v', orderBy: 't', maxlag: 6 });
const series = grid.statistics.series('v', { by: 't' });
return [
  res.acf[0],                                    // lag 0 is always 1
  res.pacf[1] === res.acf[1],                    // the first partial equals the first acf
  Math.abs(res.acf[1] - series.autocorrelation) < 1e-9,  // lag 1 is the single source of truth
].join('|');

grid.highlight

One mechanism for two jobs: the flash a changed cell makes, and a marker you paint deliberately. A target is a cell ({key, colId}), a row ({key}, or a bare row key) or a column ({colId}). Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over it.

createGrid(el, {
  highlightOnChange: { colour: '#ffe08a', duration: 1200 },   // or just true
});

grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
grid.highlight({ key: 'r3' }, { colour: '#fdeaea', duration: 0 });   // 0 = until cleared
grid.highlight({ colId: 'margin' }, { colour: '#e7f1fd', duration: 0 });
grid.highlight.clear({ key: 'r3' });
grid.highlight.clear();                                             // everything
MethodReturnsDescription
highlight(target, opts?)booleancolour (or color) and duration in milliseconds. duration: 0 stays until cleared.
clear(target?)booleanOne target, or every highlight when called with nothing.
list()object[]Every active highlight and its remaining duration.
colourFor(key, colId)string | nullWhat a given cell is painted, after precedence.

A highlight belongs to the row, not the element. Rows are recycled as you scroll, so highlights are reapplied after every paint, they survive scrolling, sorting, filtering and paging without any of them knowing highlights exist.

grid.views

Named states the user can return to. Views supplied in config.views.saved are defined views: listed apart in the picker, and neither renamable nor deletable, refused by the model as well as hidden in the interface. Views the user saves are their own and carry rename, share, default and delete.

createGrid(el, {
  views: {
    saved: [{ id: 'escalations', name: 'Escalations', description: 'Worst SLA first',
              state: { filters: { col: 'statusId', op: 'eq', value: 4 },
                       sort: [{ col: 'utilisation', dir: 'desc' }] } }],
    allowSave: true,   // false removes the save form entirely
    local: true,       // saved views live in this browser's localStorage, no backend
    // storage: { read, write }, // or bring your own backend; see the note below
  },
});
MethodReturnsDescription
list() / get(id)object[] / object
save(name, opts?)objectCaptures the current state. opts: id, description, shared, isDefault.
apply(id)object | nullOne undo entry. Resets to the baseline first, so a view is a destination rather than a patch, the same view gives the same grid whatever was applied before it.
rename(id, name)object | nullNull for a defined view.
remove(id)booleanFalse for a defined view.
setDefault(id)object | nullnull clears it. A default view is applied on load, without recording an undo entry.
export(id) / import(json)objectA JSON payload. What "sharing" means is yours to decide.
diff(id)object | nullWhat applying a view would change.
reload()voidRe-read from storage, discarding what is in memory.
activeIdstring | null

The grid makes no network calls. storage.write is a synchronous mirror. To persist to a server, listen for view:saved, view:renamed, view:removed and view:default: each carries the one view that moved, so you can send a single record rather than diffing two lists. Because the grid does not track whether your write landed, a failed request leaves the view visible locally: catch it and call views.reload().

views.local is the no-backend option: true stores views under a default localStorage key, shared by every grid on the origin; { key: '…' } picks a key of your own, for more than one grid whose views should stay apart. Given alongside an explicit storage, storage wins and local is ignored, with a console warning, the two are never merged. Built on createLocalViewStorage, exported for direct use (a custom key, or a different Storage-shaped backing such as sessionStorage) without the local shorthand.

grid.diff

Audit mode. Give it a prior snapshot and every row reports whether it was added, removed or changed, and which cells moved.

MethodReturnsDescription
setSnapshot(rows) / clear()voidAlso settable as config.diff.snapshot.
summary()object{ added, removed, changed, unchanged }.
statusOf(key)string'added', 'removed', 'changed' or 'unchanged'.
changedColumns(key)string[]
before(key, colId)unknownThe prior value. Also on the cell as data-before.
enabledboolean
swap()booleanShow the snapshot as the grid's data, and compare it against what was live until now. The snapshot is held as plain objects and never enters the columnar store, so a removed row cannot be sorted or filtered among live ones; swapping is the answer to that, the old rows become real rows with the whole pipeline behind them. Costs one ingest of each set, so it is a deliberate action rather than a toggle. The comparison reverses: what was an addition is now a removal. swapped reports which way round the grid is, and it is worth saying so in your interface.
swappedbooleanTrue while the snapshot is the data.
removedRowsfalse | 'pinned' | 'data'Whether a row in the snapshot but gone from the data is shown, and whether it counts as data. false (the default) leaves it out. 'pinned' shows it beneath the rows, struck through, outside the row set, not counted, not exported, not selectable. 'data' appends it to the set, so it is counted and exported. Neither is sorted or filtered among the live rows, because its values are the snapshot's; neither can be edited, because there is nothing left to write to.
strictNullbooleanOff by default, so null, undefined and an absent field all count as the same absence. Set it to tell them apart, for an audit where a field being cleared and a field never being sent are different events. It compares the data as supplied, not as stored, so it is a statement about your snapshot rather than about the grid.

Built-in renderers and editors

Both are addressable by name. Anything you register through components is addressable the same way, and a name you register wins over a built-in one.

Cell renderers, for cell.render:

NameDraws
areaA filled sparkline over a series.
bulletA value against a target and qualitative bands.
checkboxA boolean, optionally as a switch.
colourA colour swatch with its value.
columnA column sparkline.
deltaMovement since the last value, with direction.
detailExpanderThe master-detail chevron. Generated; not usually named directly.
donutA donut chart from a series.
gaugeA value on an arc against a range.
groupThe group and tree label, with its expander and indent.
iconAn icon chosen from the value.
imageA picture from a URL. Selected automatically for type: 'image'.
lineA line sparkline.
linkAn anchor, with the text and href drawn from the row.
pieA pie chart from a series.
pillA status chip carrying a semantic variant.
progressA progress bar with an optional label.
qrcodeA QR code of the value.
rangeA span between a low and a high value.
ratingA star rating.
skeletonA loading placeholder for a row not yet arrived.
stackedA stacked proportion bar.
twolineA primary value with a secondary line beneath it.
winlossA win/loss sparkline of signed values.

Editors, for edit.editor. Each column type already selects a sensible one, so naming an editor is for overriding that choice:

NameEdits
checkboxA boolean.
codeSource text, in a monospace field.
colourA colour.
dateA calendar date.
datetimeA date and a time together.
durationA length of time.
iconPickerOne icon from a set.
ipaddressAn IPv4 or IPv6 address.
multiSelectSeveral options, as chips.
numberA number, with the column's constraints.
objectPickerA record chosen from a list.
passwordA masked secret.
radixA value in its own base.
ratingA star rating.
segmentedOne of a few options, as a segmented control.
selectOne option from a list.
sliderA number on a track.
textA single line. The default.
textareaSeveral lines.
timeA time of day.
treeSelectA value from a hierarchy.
unitA quantity with a unit.

grid.permissions

Four levels per column, resolved from configuration or a callback. They are the four corners of read × write rather than a ladder:

LevelVisibleReadableEditableFor
hidden, , , Absent from the grid, the tool panel, exports, the clipboard, saved state, the filter model and formula references.
readyesyes, No editor opens; paste, fill and range-clear skip it.
writeOnlyyes, yesA secret: an API key a user may rotate but never read. The cell shows a mask and the editor opens empty, and a formula in another cell cannot reference it.
writeyesyesyesThe default, so the feature is opt-in.
permissions: 'read'                                  // blanket
permissions: { salary: 'read', ssn: 'hidden' }       // map; '*' sets the default
permissions: (column, ctx) => ctx.context.role === 'admin' ? 'write' : 'read'
permissions: { default: 'read', columns: { name: 'write' }, resolve }

grid.permissions.setContext({ role: 'clerk' });      // re-resolves everything

For three of the four this is a usability control, not a security boundary. Anything the grid can render it has already loaded, and devtools reaches it. writeOnly is the exception, and the reason it is worth having: nothing in the grid needs the value, so your server can send null for that field and the column still works: at which point the secret is genuinely not on the page. Enforce everything else on the server; permittedColumns and permittedExport are pure and dependency-free so the same policy can run there.

grid.ai

The grid composes a prompt describing its own columns and operators, you send it to whichever model you like, and it validates the reply before anything is applied. It makes no network call and has no default model.

MethodReturnsDescription
schema(opts?)objectThe schema: columns, types, permitted operators.
prompt(text, opts?)stringThe message to send, schema included.
plan(reply, opts?)objectParse and validate. Unknown columns and operators are rejected with a reason; valid actions in the same reply are kept. plan.describe() renders it in plain English for confirmation.
apply(plan)objectApplies an approved plan as one undo entry, labelled with what it did.

Actions: setFilters, setSort, groupBy, showColumns, hideColumns, setQuick, clear. Nothing else is executable, so a model cannot be talked into an operation the vocabulary does not contain. docs/AI-SKILL.md is the reference to hand your model.

Ask-your-data (modules/ai)

The opt-in AI module (createAI, UMD LatticeGridAI) turns a question into a validated read-only query spec, runs it in the grid's own engine over the grid.ai skill layer, and — on apply — fans the answer to any router-attached viewers (a chart, a KPI tile) through the Data Router's load(). It uses the same BYO ask() seam as the rest of the module: the grid makes no model call and holds no key. Ask-your-data is read-only; a write/mutation the model asks for is refused and never executed (writes are a separate, human-gated feature).

MethodReturnsDescription
query(question, opts?)Promise<result>Ask the model, validate the reply into a read-only spec. result.describe() shows the resolved query; nothing applies until result.apply() (or autoApply).
applyQuery(result, opts?)objectApply a reviewed result. Re-gated at the seam: an unsafe plan is refused. opts.router fans the answer to other viewers.
askBar(el, opts?)controllerMount the ask/review/apply bar with an "auto-apply safe reads" toggle (off by default). Mounts only when createAI's enable allows 'query'/'ask' (all allowed when omitted); query() itself is never gated.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'region' }, { field: 'amount', type: 'number' }],
  rows: [{ id: 1, region: 'EMEA', amount: 100 }, { id: 2, region: 'AMER', amount: 300 }, { id: 3, region: 'EMEA', amount: 200 }],
});
// Your model returns a schema-constrained SPEC, never rows. WE run it, read-only.
const reads = createAI(grid, { ask: async () => ({ actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] }) });
const result = await reads.query('EMEA only');
result.apply();
const rows = grid.rows.data().length;
// A write verb is refused by the read-only gate and never executed.
const writes = createAI(grid, { ask: async () => ({ actions: [{ type: 'setCells', edits: [] }] }) });
const write = await writes.query('change the data');
return `${rows} rows; write ${write.ok ? 'allowed' : 'refused'}`;

AI as a governed actor (modules/ai, writes)

The governed actor lets the model propose edits — a single NL-targeted change (“set the Network Upgrade project to In Progress”) or a bulk cleanup — that a human previews as a before/after diff and approves. The model NEVER writes. On approval the edit applies through the grid's own gate, exactly like a person's edit: a grid cell edit via grid.edit.setCells(writes, 'cell', { origin: 'ai' }) (the beforeEdit veto), a Kanban card move via board.move(…, { origin: 'ai' }) (the beforeMove veto). The AI can never bypass the gate: a host beforeEdit/beforeMove handler that calls preventDefault() (or vetoes async) stops the write, and nothing persists. Writes carry origin: 'ai' on the before-event payload, so a host can allow a person's edit while vetoing the AI's — policing AI writes distinctly. Approved edits are optimistic and revert on a source reject through the shipped write-back (there is no separate beforeCommit event; the edit gate is beforeEdit, and the optimistic/revert half is grid.edit.settle).

NL targeting and any bulk edit bind to the current filtered view (grid.rows.data()), never the whole table implicitly; a target not in the view is surfaced for an explicit opt-in widen, not edited silently. A human label is resolved to the column's stored option value; an unknown label is rejected, not coerced. An ambiguous match (more than one row) surfaces its candidates for the user to pick.

MethodReturnsDescription
propose(instruction, opts?)Promise<proposal>Ask the model for structured edits, validate + resolve them against the current view, and return a reviewable proposal with a before/after diff. Nothing is written. opts.widen opts into the full dataset; opts.board routes a Kanban move.
applyProposal(proposal, opts?)Promise<report>Apply an approved proposal through the gate (origin: 'ai'). A vetoing before-handler stops it; the report gives applied/vetoed.
actorBar(el, opts?)controllerMount the propose → review-diff → approve bar, stating the scope. Mounts only when createAI's enable allows 'actor' (allowed when omitted); propose() itself is never gated.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');
const status = { options: [{ id: 'todo', label: 'To do' }, { id: 'doing', label: 'In Progress' }] };
const seed = () => createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name' }, { field: 'status', lookup: status, edit: { enabled: true } }],
  rows: [{ id: 'r1', name: 'Network Upgrade', status: 'todo' }, { id: 'r2', name: 'Payroll', status: 'todo' }],
});
// A MOCK ask() returns a structured PROPOSAL — never a write, never rows.
const ask = async () => ({ structured: { edits: [{ match: 'Network Upgrade', column: 'status', value: 'In Progress' }] } });

// Propose: a before/after diff, nothing written yet.
let grid = seed();
let ai = createAI(grid, { ask });
const proposal = await ai.propose('set the Network Upgrade project to In Progress');
const d = proposal.diff[0];
const before = `${d.oldDisplay} then ${d.newDisplay}`;

// A vetoing beforeEdit handler on the AI write => NOTHING persists.
grid.on('beforeEdit', (e) => { if (e.origin === 'ai') e.preventDefault('reviewed elsewhere'); });
await ai.applyProposal(proposal);
const vetoed = grid.rows.byKey('r1').data.status;

// Approve on a grid with no veto => the edit lands through the gate.
grid = seed();
ai = createAI(grid, { ask });
await ai.applyProposal(await ai.propose('set the Network Upgrade project to In Progress'));
const approved = grid.rows.byKey('r1').data.status;

return `diff ${before}; vetoed kept ${vetoed}; approved wrote ${approved}`;

grid.overlay

MethodReturnsDescription
show(kind, message?)void'loading' or 'empty'.
hide()void

grid.maximise

Fills the browser window with the grid, and puts it back. The rail's last button is this; grid.maximise is the same thing, so an application can bind its own control or keyboard shortcut. Esc restores.

grid.maximise.toggle();     // what the rail button calls
grid.maximise.enter();
grid.maximise.active();     // true while it fills the window
grid.maximise.exit();
MethodReturnsDescription
enter()booleanFill the window. false when the host element is not in the document.
exit()booleanBack to the page. false when it was not maximised.
toggle()booleanWhether the grid is maximised afterwards.
active()booleanWhether it is filling the window now.

The host element is moved to <body> and pinned to the viewport, then moved back between the same two siblings. A position: fixed element is positioned against the nearest ancestor carrying a transform, filter, contain or will-change (any card, animated panel or sticky shell) so styling alone fills the window on one page and lands in a small box on the next. A hidden placeholder holds the vacated space at the size the grid had, so the page behind neither reflows nor loses its scroll position.

Geometry is applied as inline styles and every displaced property is handed back exactly as it was found, because the element being restyled is yours. While maximised the element carries .lat-maximised and <body> carries .lat-maximised-host, as hooks for your own CSS.

grid.licence

There is one Lattice Grid and every copy is feature-identical. No community edition, no pro tier, no feature held back behind a key. A licence removes the trial watermark; that is the whole of what it does.

Free to develop against, licensed to deploy. A grid on localhost, or any loopback host: needs no key at all. On any other domain an unlicensed grid still renders everything and carries a small trial watermark linking to latticegrid.dev. Nothing ever refuses to render: the failure worth avoiding is a broken production screen, and no licensing state is worth causing one.

// Before creating a grid.
LatticeGrid.setLicence('LG1.…');           // your key; setLicense also works

const grid = LatticeGrid.createGrid(el, config);
grid.licence.state();      // 'licensed' | 'localhost' | 'trial'
MethodReturnsDescription
set(key)objectInstall a key for the process. Returns the provisional verdict; the check is asynchronous and licence:changed fires when it settles.
state()string'licensed', 'localhost' or 'trial'.
info()object{ valid, reason, issuedTo, expires, product }. expires is undefined for a perpetual key (the default) and an ISO date only for one deliberately issued with a term.
watermark()booleanWhether the trial mark is showing.
readyPromiseSettles when the licence check finishes.

Domains

A key names the hosts it covers. *.acme.com matches app.acme.com, a.b.acme.com and acme.com itself, a wildcard that refused the apex would be a puzzle rather than a licence. A bare acme.com matches only itself, and a key naming no domains is valid anywhere. Matching ignores case and a trailing dot.

HostNo keyKey for *.acme.com
localhost, 127.0.0.1, ::1, *.localhosteverything, no markeverything, no mark
app.acme.comeverything, trial watermarkeverything, no mark
acme.comeverything, trial watermarkeverything, no mark
other.example.orgeverything, trial watermarkeverything, trial watermark

.local, .internal and private IP ranges are not exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.

Getting a key

Keys are issued from latticegrid.dev. A key is issued per deployment rather than per developer or per seat: name the domains the grid will run on and one key covers every developer, every build and every user on them.

Keys are perpetual by default. A key carries no expiry unless one was deliberately issued (a trial, a time-boxed pilot) so the ordinary key is valid until the domains it names change, not one that quietly lapses on a date nobody is tracking.

Checking a key needs no network. There is no licence server, no call home, and nothing that can fail at three in the morning, a key carries its own answer and the grid reads it locally, fresh on every load. A key for the wrong domain, or a key that will not read at all, does the same thing a genuinely expired trial key does: log one console warning and show the watermark.

Install the key before creating a grid. Setting one later still works, licence:changed fires and the watermark is removed, but the first frames of the grid will carry it.

Sources

Where rows come from. memory is the default and needs no configuration.

A memory grid's rows may be declared either way. The top-level rows option is the usual one and what every example here uses; the same array inside the block works identically — createGrid(el, { columns, source: { mode: 'memory', rows } }) opens with exactly those rows, with the same count(), value(), text(), type inference and bound KPI and chart readings as createGrid(el, { columns, rows }), and rows.load() and rows.apply() behave the same afterwards. Either array is copied on ingest, so the one you passed is never written to (see rows under Configuration). Declare them once: a grid given both uses the top-level rows, ignores the block's, and warns once naming both places. Only memory reads a rows key from the block — the fetching modes take theirs from the server.

ModeNeedsDescription
memoryrowsEverything is present. The grid filters, sorts, groups and totals it.
pagedfetchA page at a time from a server that paginates.
remotefetchBlocks fetched as the viewport reaches them, with sort, filter and grouping pushed to the server.
streamconnectRows arriving over time. Promotes to memory once complete.
derivedfromRows built from another grid: grouped, unnested, filtered, ranked or profiled. Read-only, and follows the source.

Load from a URL: a JSON or NDJSON file

createUrlSource(url, opts) points the grid straight at a file. A JSON file (a top-level array, or a nested array picked out with rowsPath or map) is read whole and handed over as rows. An NDJSON / JSONL file — one JSON value per line — is streamed in incrementally: the first rows render while the rest is still arriving, and a large file never sits in memory as one string. It is built on the stream source, so it inherits the frame-coalesced render, the stream:chunk / stream:end progress events and promotion to memory once a small file has fully landed.

Format is resolved in order: an explicit format: 'json' | 'ndjson' wins; else the URL extension (.ndjson/.jsonl vs .json); else the Content-Type (application/x-ndjson vs application/json); else a sniff of the first bytes, or a clear error asking for an explicit format. Pass fetch to supply auth or a proxy (default is the global fetch), headers to merge request headers, batchSize to tune NDJSON chunking (default 500), lenient: true to skip a malformed NDJSON line with a warning rather than failing, and poll (ms) to re-fetch on an interval, replacing the rows each pass. Every failure — a non-2xx status, a network error, a bad body, a malformed line — surfaces as source:error and leaves the grid usable, never an uncaught throw. The fetch is aborted when the grid is destroyed. Zero new dependencies: fetch, response.body.getReader() and TextDecoder.

A JSON file loaded through a mock transport so the example runs headless with no network; in an app, omit fetch and the global one is used. Run on every build.

const { createHeadlessGrid, createUrlSource } = await import('../packages/core/src/index.js');

// A mock transport, so this runs with no network. In your app, drop `fetch`.
const file = JSON.stringify([{ id: 1, city: 'Oslo' }, { id: 2, city: 'Lima' }, { id: 3, city: 'Cairo' }]);
const fetchImpl = async () => new Response(file, { headers: { 'content-type': 'application/json' } });

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'city' }],
  source: createUrlSource('https://example.test/cities.json', { fetch: fetchImpl }),
});

await new Promise((r) => setTimeout(r, 50)); // let the file load
const count = grid.rows.count();
grid.destroy();
return count; // 3

For NDJSON, point it at a .ndjson or .jsonl URL — createUrlSource('/events.ndjson') — and the rows stream in as they parse.

Derived sources: a grid built from another grid

Most dashboards put a summary panel beside a table, the top five sales people, the breakdown by region, the exceptions list. Built by hand, that panel runs its own query, and sooner or later somebody filters the table and the panel does not follow. Everyone who has shipped a dashboard has been in the meeting where two numbers on one screen disagree.

A derived grid removes the possibility. It is a second grid whose rows are built from the first (grouped, unnested, filtered, ranked or profiled) so the panel is the table, one derivation later, and one filter moves both. It answers the questions a summary panel exists for: the top five sales people, the most-sold SKUs, a statistical profile of whatever the user has filtered to.

It is a source rather than a new kind of grid, so everything downstream: its own sorting and filters, totals, shadow columns, formatting, export, themes: works on the result and knows nothing about where the rows came from. Charts bind to one as readily as to any grid.

source: {
  mode: 'derived',
  from: salesGrid,
  follow: 'filtered',

  groupBy: 'rep',
  select: { revenue: { of: 'amount', fn: 'sum' }, deals: { fn: 'count' } },
  sort: [{ col: 'revenue', dir: 'desc' }],
  limit: 5,
}

The pipeline runs in one order, and the order is the contract: unnest → where → bucket → group → reduce → sort → limit. where sits before grouping deliberately: filtering afterwards is a different question, which groups, not which rows, and one key cannot mean both.

KeyTypeDescription
fromGrid | UnionSourceOptions[]Required. The grid to read — or several to combine into one row set before the rest of the pipeline runs (a union; see below). A bare Grid in the array is shorthand for { grid }.
follow'filtered' | 'all' | 'selected' | 'grouped'Which of its rows to read. filtered by default. grouped re-aggregates by whatever dimension the user has grouped the source by, so a panel tracks the reader rather than a dimension fixed when the page was built; with the source ungrouped it falls back to groupBy. Ignored (with a warning) when from is a union array — each entry has its own follow instead.
unneststringExpand an array property, one row per element, keeping the parent's fields. Address the element with a dotted path afterwards: lines.sku is the element, region is still the parent. A row whose property is absent or empty contributes nothing.
join{ with, on, type, select, prefix, follow }Match each row against a second grid on a shared key and bring some of its fields across. Runs after unnest and before where, so a condition (and a grouping, and a total) can read a field the join produced.
where(row) => booleanA row predicate, applied before grouping. With no groupBy the rows pass through as themselves, which is how an exceptions list is built.
bucket{ of, by }Round a date column down to the start of its period and group on that. by is day, week, month, quarter or year; weeks start on the ISO Monday.
groupBystring | string[]The dimension, or dimensions, to group by. Omit to pass rows through.
selectRecord<string, {of, fn}>The reduced columns, by output id. fn is any key of the totals-row kernels, so median, p95, stddev and gini are available as readily as sum. count needs no of.
sort{ col, dir }[]Order the derived rows before limiting them. The grid's own user-facing sort is separate and unaffected.
limitnumberKeep at most this many rows.
limitPerstringApply limit within each distinct value of this column rather than overall, the best three SKUs in each region, which a global limit cannot express.
cumulative{ of, upTo }Keep rows until their running share of the total reaches upTo, 0 to 1. The Pareto question. The row that crosses the cutoff is kept, because the set has to reach the share.
profilestring | string[]Replaces the pipeline with a transpose: one row per column, with count, present, missing, distinct, min, max, mean, median, quartiles, deviation and outlier count as its columns.
orient'columns' | 'metrics'With profile, emit one row per statistic instead of one per column, the shape a dashboard tile wants.
crossFilterboolean | stringLet this grid filter the grid it derives from. true cross-filters through whatever it groups by; a string names a different source column.
refresh'live' | 'idle' | 'manual' | numberWhen to re-derive. idle by default, coalescing to a frame, because a hundred cell updates in one frame are one derivation. A number debounces by that many milliseconds. live re-derives on every change. manual never re-derives on its own: the host triggers it by calling rows.load() on the derived grid, with no argument, which re-reads from there and then — a frozen panel refreshed on a button press, executed.
// refresh: 'manual' — the summary re-derives only when the host asks.
refreshButton.addEventListener('click', () => summary.rows.load());

The relational statistics, as rows

A single-column statistic already has a route: select reduces a group with any kernel the totals row uses, and that table is a superset of the statistics one, so select: { p95: { of: 'amount', fn: 'p95' } } works, along with median, stddev, gini and the rest. statistics is for what select structurally cannot reach: the figures needing two or more columns, or a second grid. Like profile it is a terminal producer — it replaces the pipeline rather than joining it, and the two cannot be used together. Every row carries n, the rows the figure covered. Full detail, including the measured re-derive cost of each producer, is in the detail reference.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const readings = Array.from({ length: 20 }, (_, i) => ({ id: i, t: i, v: 100 + i * 3, w: 50 - i }));
const plant = createHeadlessGrid({ rowKey: 'id',
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
  rows: readings });
plant.rows.count();

// One row per column PAIR: { a, b, coefficient, n }. `w` runs exactly against `t`.
const pairs = createHeadlessGrid({ rowKey: '__key',
  columns: [{ field: 'a' }, { field: 'b' }, { field: 'coefficient', type: 'number' }, { field: 'n', type: 'number' }],
  source: { mode: 'derived', from: plant, refresh: 'live',
    statistics: { fn: 'correlation', columns: ['t', 'v', 'w'] } } });
let tw = null;
pairs.rows.forEach((row) => {
  if (pairs.rows.value(row.key, 'a') === 't' && pairs.rows.value(row.key, 'b') === 'w') {
    tw = pairs.rows.value(row.key, 'coefficient');
  }
});

// One row per METRIC of the series summary, not one per point.
const series = createHeadlessGrid({ rowKey: '__key',
  columns: [{ field: 'metric' }, { field: 'value', type: 'number' }],
  source: { mode: 'derived', from: plant, refresh: 'live',
    statistics: { fn: 'series', of: 'v', by: 't' } } });

// One row per compared column, against a second grid. The peer is watched.
// `t` is on this grid only, so it is reported as unmatched rather than dropped.
const peer = createHeadlessGrid({ rowKey: 'id',
  columns: [{ field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
  rows: readings.map((r) => ({ id: r.id, v: r.v * 2, w: r.w })) });
peer.rows.count();
const compared = createHeadlessGrid({ rowKey: '__key',
  columns: [{ field: 'column' }, { field: 'magnitude', type: 'number' }],
  source: { mode: 'derived', from: plant, refresh: 'live',
    statistics: { fn: 'datasetVsDataset', with: peer, columns: ['v', 'w'] } } });

let shared = 0, unmatched = 0;
compared.rows.forEach((row) => {
  if (compared.rows.value(row.key, 'magnitude') === null) unmatched += 1; else shared += 1;
});

return `t/w ${tw}; pairs ${pairs.rows.count()}; metrics ${series.rows.count()}; compared ${shared} + ${unmatched} unmatched`;

Union sources: combining several grids into one

"Worst performers across two datasets" is easy when the two datasets share a key: a join brings the second grid's fields onto the first. It is not expressible at all when they do not: incidents from two regions with no shared identifier, orders from two systems, this quarter and last as one ranked list. from takes an array of sources for exactly this: stack several row sets into one, then rank, group or filter the combined set with the same pipeline a single from already runs.

source: {
  mode: 'derived',
  from: [
    { grid: eastIncidents, label: 'east' },
    { grid: westIncidents, label: 'west' },
  ],
  sort: [{ col: 'severity', dir: 'desc' }],
  limit: 10,
}

Every source is read (each narrowed by its own follow, filtered by default) and concatenated in declaration order, deterministic rather than interleaved, before unnest/join/where/bucket/ groupBy/select/sort/limit/limitPer/ cumulative run once over the result — so "the worst across both" is one derivation, not a hand-merged array.

KeyTypeDescription
gridGridRequired. This source's grid.
labelstringIdentifies this source. Carried onto every row as __source, and used to namespace that row's __key. Defaults to the source's position in the array ('0', '1', …).
follow'filtered' | 'all' | 'selected' | 'grouped'Which of this source's rows to read, independent of every other source's. filtered by default.
map(row) => unknownReshape this source's rows into a common shape before they join the rest — typically a rename or a projection for a field this source calls something else.

__source is required, not optional. Every row carries it — the entry's label, or its declaration index when unlabelled — because without it a combined list cannot be read, filtered or grouped by where it came from, which is most of the point of stacking several sources. It is an ordinary field to where, groupBy and select, exactly like a column the data itself carries.

The union of fields, not the intersection. A field present on only one source is undefined on rows from the others — not fabricated, not coerced. Sources are not type-reconciled: if two disagree on what a field means, map is where you make them agree, before they combine, not something the union guesses at for you.

The key is namespaced. A derived grid's __key is the source row's own key when nothing is grouped, and two sources sharing the same identifiers would otherwise collide. So it is qualified by the source tag when there is no groupBy. Grouped, the key is the group value exactly as it always has been — rows from different sources landing in the same group when their group values agree is the point of grouping a union, not a collision to guard against.

Not a join. There is no dedup and no merge-on-key: two sources reporting the same fact both appear as separate rows, and there is no UNION-vs-UNION-ALL distinction to draw. Reach for join when two sides share a key and you want them matched rather than stacked; use groupBy on the combined set when you want them summed together.

Empty and failing sources. A source with no matching rows contributes nothing; the rest of the union still derives. A source that throws while being read (or mapped) is named in a warnOnce and skipped for that pass — reported, never silently dropped, because a silently missing source would make "worst across both" quietly wrong.

A cycle is refused, not recursed. A source list that includes the grid being derived, directly or through a chain of other derived grids, is refused when the source is built, naming the offending source.

Not supported alongside a union. crossFilter has no single target once there is more than one parent, and profile reduces one grid's own columns, so both are refused with a warning rather than guessed at.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const east = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'title' }, { field: 'severity', type: 'number' }],
  rows: [{ id: 'e1', title: 'disk-full', severity: 9 }, { id: 'e2', title: 'slow-query', severity: 3 }],
});
// A second, unrelated incident log — its own "rating" field, no shared id with `east` at all.
const west = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'name' }, { field: 'rating', type: 'number' }],
  rows: [{ id: 'w1', name: 'oom-kill', rating: 10 }, { id: 'w2', name: 'stale-cache', rating: 2 }],
});

const worst = createHeadlessGrid({
  columns: [{ field: 'title' }, { field: 'severity', type: 'number' }, { field: '__source' }],
  source: {
    mode: 'derived',
    from: [
      { grid: east, label: 'east' },
      // `map` brings west's differently-named fields into the common shape.
      { grid: west, label: 'west', map: (row) => ({ title: row.name, severity: row.rating }) },
    ],
    sort: [{ col: 'severity', dir: 'desc' }],
    limit: 2,
  },
});

const titles = [];
worst.rows.forEach((r) => titles.push(worst.rows.value(r.key, 'title')));

// A per-source breakdown reads `__source` like any other field.
const bySource = createHeadlessGrid({
  columns: [{ field: '__source' }],
  source: { mode: 'derived', from: [{ grid: east, label: 'east' }, { grid: west, label: 'west' }] },
});
const sources = [];
bySource.rows.forEach((r) => sources.push(bySource.rows.value(r.key, '__source')));

worst.destroy(); bySource.destroy(); east.destroy(); west.destroy();
return `worst=${titles.join(',')}; sources=${sources.join(',')}`;

Read-only. A derived row is an answer, not a record: there is no write-back for the sum of four hundred rows, so writes are refused with a reason rather than accepted and discarded on the next refresh.

They chain. A derived grid can be the source of another to any depth: a profile of the top five, and a change at the root travels the whole chain. A cycle is refused rather than recursed.

The key. rowKey defaults to the derived key and need not be set. It is the group value, which is what makes a live ranking readable: the row moves rather than the values under it changing.

Cost. The first derivation is linear in the rows read and largely independent of what is reduced: roughly 900 ms per 200,000 rows grouped into forty, whether the selection is one sum or four statistics. After that, a change that names the rows it touched is patched rather than re-derived: only the groups those rows entered or left are reduced again, so a live feed costs time proportional to what changed rather than to the table. Five hundred updates against that same source take under 300 ms in total, not 300 ms each. A joined derivation is maintained the same way from both sides: the lookup is held between derivations rather than rebuilt, a change to the fact table rejoins only the rows that moved, and a change to the lookup rejoins only the rows behind the keys whose match actually changed, about 2 ms per fact update and 1.5 ms per lookup edit against a 200,000-row source joined to 2,000 customers. A change that cannot be reasoned about that way, a new filter, a regrouping, a derivation using unnest or where, or a lookup row arriving for rows an inner join had dropped, falls back to a full derivation, which is correct but costs the full linear pass. Narrow with follow: 'filtered' so the derivation reads what the user is looking at rather than the whole table.

What a change firing promises

Anything maintaining state from rows:changed (a derived grid, a chart, your own cache) needs to know whether a firing names the rows that moved or merely says that something did. One rows.apply announces itself more than once: the source reports how many rows moved, the row model reports which ones, and the grid reports that a change happened. Acting on all three does the work three times over.

FieldMeaning
identified: trueadded, updated and removed are arrays naming exactly the rows that moved. Safe to patch from.
companion: trueA second announcement of a change already reported with identity, or one made before the grid's own view caught up. Ignore it.
neitherA real change whose extent cannot be named: rows replaced wholesale, or a row moved, where what changed is the order. Re-read.

The default is the safe one. A firing that carries neither flag is treated as a change of unknown extent, so a listener re-reads rather than assuming nothing moved. Read the flags rather than the shape of the payload: a firing that reports a row move carries counts, because no row changed value, and only the flags distinguish that case from a duplicate announcement.

Confidence intervals: how much to trust the figure

Every other statistic here describes the data you have. An interval describes how well that data pins down the figure you actually care about, and it is the one thing a descriptive tool can honestly say about the world beyond its rows.

The line this draws. Lattice quantifies uncertainty; it does not adjudicate hypotheses. There are no p-values, no significance tests and no verdicts, and reading two non-overlapping intervals as a significance test is a mistake often enough to be worth not encouraging. An interval says "the mean is 42, and the sample pins that down to between 39 and 45". It does not say 42 differs from 40.

CallReturnsDescription
statistics.interval(colId)ConfidenceIntervalThe interval for a column's mean, using the t distribution rather than the normal: below about thirty readings the normal interval is noticeably too narrow, and at five it understates the width by roughly a sixth.
statistics.keyOf(data)string | nullThe key a row's data resolves to, without needing the row. What a caller holding raw data uses to reach the grid's view of it.
statistics.maintenanceRecord<string, string>Which reductions can be maintained against a change and which must rescan: sum and avg exactly, min and max only away from the extreme, median and the rest never. Ask before putting one in a footer over a million rows on a live feed: the difference is a totals row that costs nothing per tick and one that costs a full pass.
statistics.intervalOf(values)ConfidenceIntervalThe column-free form, for readings that are not a column, the rows behind one bar, a subgroup, a hand-assembled sample. One t-quantile serves the chart's whiskers and the panel's bounds alike.
statistics.interval(colId, { kind: 'proportion' })ProportionIntervalA Wilson score interval for a rate. where decides which rows count as successes; truthiness by default.
statistics.capability(colId).ruleSetstringWhich rule set produced violations. Named in the result because the two number their rules differently: “rule 3” means a trend under Nelson and four-of-five-past-one-sigma under Western Electric.
statistics.capability(colId).intervalCapabilityIntervalAn interval for Cpk, by Bissell's approximation, and intervalPp for Ppk.
slopeInterval(fit)objectAn interval for a regression slope, from the standard error regression already reports.

Every interval carries its level. The result's confidence field says what it was computed at, so a figure copied out of one cannot lose the thing that makes it readable. An interval without its level is not a smaller claim, it is an unreadable one.

A proportion is Wilson, not Wald. The textbook p ± z√(p(1−p)/n) fails exactly where a rate is most interesting: near zero it reaches below zero, and at no observed successes it collapses to the single point zero: claiming perfect certainty from the least informative sample there is. The Wilson interval stays inside 0 to 1 and stays sensible at the extremes, so "none of forty failed" correctly reads as "the failure rate is under 9%" rather than "the failure rate is zero".

Report the capability interval. Its absence is the commonest way a capability study overstates itself. A Cpk of 1.35 measured on thirty parts has a lower bound below 1.0, so a process that has "passed" a 1.33 requirement on thirty parts has demonstrated very little. The point estimate alone does not say that; the interval does.

It follows the filters. Like every statistic here, an interval reads the rows the filters left, so it narrows as the user narrows the grid. That is the correct behaviour and worth knowing: it describes the filtered population, not the whole table.

Reading an SPC chart

The figures are only half of it. A capability claim is made in a picture, and these are the two the discipline expects.

TypeShows
controlThe readings in order, with the centre line and control limits the process itself sets, the tolerance the customer set, and every rule break marked and numbered.
capabilityThe readings as a histogram with the tolerance drawn across them, and a fitted normal curve for each of the two spreads: short-term and overall.
movingRangeThe lower half of an I-MR pair: the gap between consecutive readings, against its own limits. Only the upper limit signals, because a range cannot be negative.

The lines are named, on opposite edges. CL, UCL and LCL at the right; LSL, USL and Target at the left. Control limits are what the process does; specification limits are what the customer asked for, and reading one as the other is the classic misreading of a control chart. A capable process puts its control limits just inside its tolerance, so the two families sit close together, which is exactly when they need telling apart, and why they are named at opposite ends rather than left to collide.

Rule breaks carry their number. With Western Electric's four rules a marked point was readable on its own; with Nelson's eight it is not. A spike (rule 1) is a bad part; a six-point trend (rule 3) is tool wear or a drifting sensor. They call for different responses, and a chart that marks both the same way has told you the less useful half of what it knows. Set the rule set with rules: 'nelson' on the chart, as on statistics.capability.

The capability report draws two curves, not one. Cp and Cpk are computed from short-term variation, Pp and Ppk from overall. When a process has drifted the two differ, and the four indices say so only as numbers a reader has to know how to compare. Drawn, the gap is the finding: a narrow solid curve inside a wide dashed one is a capable process that has been allowed to wander, a scheduling problem, not a machine problem.

What this covers, and what it does not. These are individuals charts: one reading per point, with short-term variation estimated from the moving range. That is the right instrument when readings arrive one at a time, a sensor, a test rig, a single-piece flow.

It is not the right instrument for subgrouped data, and the difference is not cosmetic. If you measure five parts an hour, the correct chart is X̄-R: its limits come from within-subgroup variation and are roughly √n tighter, which is what makes a shift in the process centre visible. Run an individuals chart over the same readings and the limits are computed from differences that mix within- and between-subgroup variation; they come out around twice as wide, and a two-sigma shift that X̄-R flags a dozen times over reads as scattered noise. Lattice does not ship X̄-R, X̄-S, or the attribute charts (p, np, c, u), and an individuals chart should not be substituted for them.

Pair the two control charts. An individuals chart asks whether the process has moved; a movingRange chart asks whether it has become less repeatable. A process can fail either without failing the other: it can drift while its point-to-point variation holds steady, and it can hold its average while shaking itself apart. The second is close to invisible on the individuals chart alone, because a wider spread pulls that chart's own limits wider with it: it rescales to accommodate the very thing that has gone wrong. Drawn one above the other, they are the standard I-MR pair.

Bind it to the readings, not to a summary of them. A whisker is computed from the values the chart can see behind each mark. Bound to a grid whose rows are already one per mark, a summary or a derived panel, the chart sees a single value per category and there is no spread to draw: the readings that produced each average are upstream and no longer reachable. Bind the chart to the rows the summary was computed from, or carry a margin yourself and use error: { of: 'margin' }. A chart asked for whiskers it cannot compute says so once rather than drawing nothing in silence.

Uncertainty on a chart. error: true draws a whisker on each mark, computed from the readings behind it. Four bars side by side invite a comparison the numbers alone cannot support, a five per cent gap between two categories of eight readings is noise, and between two of eight hundred it is the finding. The whisker is what tells them apart, and its absence is why bar charts are so often over-read. A mark with a single reading gets none, because one reading has no spread and a zero-height whisker would claim certainty rather than admit ignorance.

Fitted lines. fit: true draws a least-squares line through a scatter with its R² beside it; fit: 'line' draws the line alone. A cloud of points invites a reader to draw the line themselves, and people are consistently poor at it, the eye is pulled by the extremes, which is exactly what least squares is not.

The interval travels with the index. The statistics panel shows the bounds under Cpk and Ppk, and createStat takes an interval function that puts them under the value. A tile is where a figure is read fastest and questioned least, which makes it the place an interval earns its keep rather than the place it is least needed.

Pushdown adapters: one query, many engines

A remote source already receives a structured request: range, sort, filters, quick text, grouping, pivoting and totals. A pushdown adapter turns that request into whatever an engine speaks, so connecting a new back end is a translation layer rather than a new source.

import { createPushdownSource, odataAdapter } from 'lattice-grid';

const source = createPushdownSource({
  adapter: odataAdapter({ url: 'https://api.example.com/Orders' }),
  compute,
});

createGrid(host, { source, columns: [...] });

An adapter never carries an engine. Each one takes what it needs as a parameter: restAdapter takes a fetch and bundles no HTTP library, dfqlAdapter takes a token, and duckdbAdapter takes a connection you have already made. So a grid can drive a full analytical engine without this package carrying one, and installing Lattice never installs anything else.

An adapter declares what it can answer. No engine speaks the whole query. OData takes a condition tree but only some operators; a single-term API takes one field and one value; a hand-written endpoint may take nothing but a page number. The adapter states its capabilities, the SDK divides the request accordingly, and the grid finishes whatever is left.

CapabilityValuesMeaning
filterfalse | 'term' | 'flat' | 'tree'Nothing, a single field and term, a flat conjunction, or a full condition tree.
operatorsstring[]Which comparisons the engine understands. A condition using anything else stays with the grid.
sortfalse | 'single' | 'multi'How many columns it can order by.
quickbooleanWhether free-text search across columns can be pushed.
rangebooleanWhether it can return a window rather than the whole result.
totalbooleanWhether it can report how many rows match.

Anything left over means the whole result is fetched. Filtering a window of rows in the browser is not a slower way to get the right answer, it is a fast way to get a wrong one: the rows that belong on the first page may be on the ninth, and the count is whatever the engine happened to return. So when the grid has work left to do it asks the engine for the complete result, applies the remainder, and pages from what it holds. It says so once, naming the part that could not be pushed, because the fix is usually a wider adapter rather than a bigger machine. source.lastPlan() reports the division for any request.

A conjunction splits; a disjunction does not. An and group narrows with each condition, so the engine can apply the conditions it understands and the grid narrows what comes back. An or group widens with each branch, so pushing only the supported branches returns fewer rows than the filter allows, and the grid cannot recover rows that were never fetched. A disjunction the engine cannot fully answer therefore stays with the grid whole. The same asymmetry governs faceting.

A sort is pushed whole or not at all. Ordering by the first column and fixing the rest in the browser needs every row anyway, so a partial sort buys nothing and returns rows in an order that is wrong until the grid corrects it.

AdapterForNotes
odataAdapterAny OData v4 endpointWrites $filter, $orderby, $top, $skip and $count. System options keep their $ unencoded, which several servers require.
restAdapterThe API you already haveParameter names are yours to choose. Paging and sorting are assumed; filtering is assumed absent until you declare operators, because an adapter that claims to filter when the endpoint ignores it returns the wrong rows silently.
duckdbAdapterA DuckDB connectionWrites SQL and takes the whole query: filter tree, multi-column sort, paging and grouping — a grouped grid is answered by GROUP BY, one level at a time, with the group counts, the subtotals, the matching count and the grand total all computed in the engine. from is any FROM expression, so read_parquet('s3://bucket/*.parquet') is as valid as a table name. The engine is yours to create and install; this imports nothing, so the bundle is unchanged whether you use it or not.
dfqlAdapterDemandFlow entitiesSpeaks POST /v1/query. Sends the entity, the key attribute and the prefix to match, a field projection and one field-and-term filter, matched as a case-insensitive substring. It cannot sort or page, so the grid does both, and every request carries a countOnly line because limit caps rows scanned rather than matched: a filtered query returns an arbitrary subset, and the count is the only thing that reveals it.
graphqlAdapterAny GraphQL endpointConfigured, not zero-config: GraphQL has no fixed query semantics, so you pass buildQuery to turn the plan into a { query, variables } operation and parseResponse to read data back into rows and total. Defaults cover an offset/limit list with totalCount and a Relay cursor connection (first/after with pageInfo). The default pushes only the window and the total; declare operators or capabilities for filter/sort only alongside a buildQuery that emits them. A cursor connection is forward-only, so a deep window costs round trips proportional to its offset.

What each adapter takes

Every adapter is a function of one options object. The tables below list what each accepts, the type, the default where it is not obvious, and what it means. The defaults are the load-bearing part: an adapter is designed to work when handed almost nothing, so most of what you can set is about telling it what your endpoint cannot do rather than switching features on.

odataAdapter
OptionTypeDefaultMeaning
urlstringThe entity-set endpoint, e.g. https://api.example.com/Orders. Required.
headersRecord<string, string>{}Sent on every request, merged over Accept: application/json. This is where a fixed bearer token or an API key goes. See authenticating.
fetchtypeof fetchthe global fetchYour own fetch, for a token that expires, a proxy, or a non-browser runtime. The adapter bundles no HTTP client. See authenticating.
countbooleantrueWhether to ask for $count=true and read @odata.count. On by default because the grid sizes its scrollbar from the total; set false for a server that does not support it, or to spare a server the count for a grid that never shows one. With it off the adapter reports no total and the grid scrolls open-ended — it does not substitute the page length, which before 1.51 told the grid the entity set was exactly one page long. The count travels inline in the same request, so there is nothing to split out: suppression is the only lever OData offers.
searchbooleanfalseWhether the server implements $search. Off by default, so quick-filter text stays with the grid until you confirm the endpoint honours it; true pushes it as $search.
editbooleanfalseOpt into write-back. Off keeps the source read-only; true advertises mutate: { update: true, delete: true, append: true, returning: 'row' }, so a committed cell edit is persisted with PATCH, a row delete with DELETE /EntitySet(key), and an add-row with POST /EntitySet reading the created entity back for its server key.
keystringthe row keyThe key property every write addresses a row by in its entity-key URL segment, e.g. /Orders(<key>), and that an add-row is rekeyed to from the created entity. Write-back only.
restAdapter

The parameter names are yours, and the defaults are not zero. Paging and sorting are assumed present; filtering is assumed absent until you declare operators, because an adapter that claims to filter when the endpoint ignores it returns the wrong rows silently. The query-string names default to offset, limit, sort, order, filter and q (search); params overrides any of them.

OptionTypeDefaultMeaning
urlstringThe endpoint, e.g. /api/orders. Required.
headersRecord<string, string>{}Sent on every request, merged over Accept: application/json. Where a fixed token or key goes. See authenticating.
fetchtypeof fetchthe global fetchYour own fetch, for an expiring token, a proxy or a non-browser runtime. See authenticating.
paramsPartial<Record<'offset'|'limit'|'sort'|'order'|'filter'|'search', string>>{ offset:'offset', limit:'limit', sort:'sort', order:'order', filter:'filter', search:'q' }Renames the query-string keys to whatever your endpoint already reads. Only the keys you name change; the rest keep the defaults above.
capabilitiesPushdownCapabilities{ range:true, total:true, sort:'multi', filter:false, quick:false }What the endpoint can answer, merged over the defaults. Declaring operators is the usual way to turn filtering on; reach for this to switch off paging or sorting an endpoint cannot do.
operatorsstring[]— (filtering off)The comparisons the endpoint genuinely applies, e.g. ['eq','gt','lt','contains']. Setting it turns filtering on as a tree; a condition using any other operator stays with the grid.
encodeFilter(filters: object) => stringJSON.stringifyHow the pushed condition tree becomes the filter parameter's value. Override it to emit whatever query language your service parses instead of JSON.
rows(body: unknown) => unknown[]body itself if an array, else body.rows then body.dataPulls the row array out of the response body, for an envelope that nests it somewhere else.
total(body: unknown, rows: unknown[]) => numberbody.total then body.count, else the page lengthReads the count of all matching rows, not the page. The grid sizes its scrollbar from it, so a page-sized total makes a large result look like one page.
editbooleanfalseOpt into write-back. Off keeps the source read-only; true advertises mutate: { update: true, delete: true, append: true, returning }, so a committed cell edit is persisted with PATCH, a row delete with DELETE, and an add-row with POST to the collection URL.
returning'row' | 'key' | 'none'noneThe reconcile contract for a successful write. none is last-write-wins — the optimistic value stands; row reads the server's authoritative row (via writeRow) back before confirm; key reads only the server-assigned key. An add-row needs row or key so the temp row can be rekeyed.
keyFieldstringidThe property an add-row response carries the server-assigned key in, read back (through writeRow) to rekey the optimistic row. Write-back only.
encodeMutation(op: MutationOp) => { method: string, url: string, headers?: object, body?: unknown }the default verb mapFull control of a mutation's HTTP shape, overriding the default method, URL and body. Supersedes writeUrlFor.
writeUrlFor(op: MutationOp) => string${url}/${key}The endpoint a single mutation targets, when the default per-row URL is not what the service uses. Addresses an existing row; an add-row POSTs to the collection url instead. Ignored when encodeMutation is supplied.
writeRow(body: unknown) => unknownthe entity, or body.row/body.dataPulls the authoritative row out of a write response when returning: 'row', and the created row an add-row reads its key from.

Persisting a cell edit. With edit: true the adapter advertises mutate, so a committed cell edit is sent as an HTTP request. REST has no universal write convention, so the request is yours to shape: writeUrlFor names the per-row endpoint, encodeMutation takes full control of method and body, and writeRow reads the authoritative row back when returning: 'row'.

const { restAdapter } = await import('../packages/core/src/index.js');

// The per-row endpoint a mutation targets.
const writeUrlFor = (op) => `/api/orders/${op.key}`;

let sentMethod;
const adapter = restAdapter({
  url: '/api/orders',
  edit: true,          // opt into write-back (advertises mutate.update / delete)
  returning: 'row',    // reconcile to the server's authoritative row
  writeUrlFor,
  // Full control of the request; supersedes the default envelope.
  encodeMutation: (op) => ({
    method: 'PATCH',
    url: writeUrlFor(op),
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(op.patch),
  }),
  // Pull the authoritative row out of this service's envelope.
  writeRow: (body) => body.record,
  fetch: async (url, init) => {
    sentMethod = init.method;
    return { ok: true, status: 200, json: async () => ({ record: { id: '42', status: 'shipped' } }) };
  },
});

const result = await adapter.mutate({ kind: 'update', key: '42', patch: { status: 'shipped' } });
return result.rows[0].status;   // 'shipped', read back from the server
duckdbAdapter
OptionTypeDefaultMeaning
connectionobjectA live connection exposing query, and ideally prepare. Required. A connection without prepare is used only for unfiltered queries, because interpolating a user's filter into SQL is worse than not filtering.
fromstringA table, a view, or any FROM expression. Required. read_parquet('s3://bucket/*.parquet') is as valid as a table name.
fieldsstring[]everything (SELECT *)The columns to select. Name them to narrow the projection when the grid shows a subset of a wide table.
countbooleantrueWhether to count the matching set at all. On by default: the total comes from a separate count(*) carrying the same WHERE, dispatched alongside the page query — see how the total is counted. Set false for a grid that never shows a count; then no count statement is issued, capabilities.total is false, and the result carries no total, so the grid scrolls open-ended rather than being told the page length is the whole set. That is a trade: with no total the scrollbar is open-ended and grid.scroll.toRow(n) cannot reach a row past the discovered end. See how the total is counted.
writablebooleanfalseAllow write-back against a plain writable table. Off keeps the source read-only, so a from that is a view or an expression can never be mutated by accident. Enables update, delete and append.
keyFieldstringidThe key column an update and a delete target in their WHERE, and that an add-row is rekeyed by. Write-back is refused unless this names a real column, because an UPDATE/DELETE without a unique key could touch more than one row.
returning'row' | 'none'rowThe reconcile contract for a successful write. row appends RETURNING * and reconciles server truth (computed columns, triggers); none keeps the optimistic value. An add-row always RETURNINGs at least the key column regardless, since it needs that key to rekey the temp row.

How the total is counted, and what it costs. The grid needs the size of the matching set to size its scrollbar. Until 1.51 every page query carried count(*) OVER () AS "__lattice_total", which looks free: the window is evaluated before LIMIT, so one round trip returns both the window and the size of the set it was cut from. Against a local table it is free. Against a remote Parquet it is the most expensive thing the adapter does — a window function has to see every matching row of the projected columns, so whatever row-group pruning or range reads DuckDB and the file host might otherwise manage between them (see whether a Parquet file streams or downloads whole) cannot help, and the whole file crosses the wire to produce one page. Measured in Chrome on duckdb-eh.wasm against a 10,000,000-row Parquet of 162,386,227 bytes — 162.4 MB decimal, 154.9 MiB binary, and every transfer figure on this page is decimal MB so that it can be compared with it directly — on an origin counting bytes actually served, first paint pulled 162.5 MB. Slightly more than the file, because the reads overlap.

The total is now its own statement — SELECT count(*) FROM <from> WHERE …, the same predicate through the same builder with the same typed casts and the same bound values, and no ORDER BY or LIMIT. Read it with adapter.countSqlFor(query), the counting counterpart of adapter.sqlFor(query); it returns null when count is false. The two statements are dispatched in the same tick — both started before either is awaited — so there is no browser round trip between them.

That does not make the count free on the clock, and on DuckDB-Wasm it often is not. One DuckDB-Wasm connection funnels its statements through a single worker, so the engine still runs the two in sequence; what dispatching together buys there is that the second is already queued the instant the first finishes. Measured on the file below: an unfiltered first paint takes 566 ms with the count and 558 ms without, so the count costs about 8 ms — genuinely hidden. A filtered query takes 2122 ms with the count and 573 ms without, so there the count costs about 1550 ms and is not hidden at all. (It is still faster than the 3064 ms the old window function took for the same query.) A server-side DuckDB with a thread pool runs the two at once and the distinction goes away.

Cheap, not free — and on a filtered query, not even cheap. An unfiltered count(*) over Parquet is answered from the file's footer metadata and reads no data at all: measured against the 162.4 MB file above, first paint costs the same 5.71 MB with the count on as with count: false — the count's own share is 0.00 MB. A filtered count still has to evaluate the predicate. It reads the predicate columns rather than the whole projection, and row-group statistics can prune entire groups (a predicate no row group can satisfy is answered from metadata alone — country = 'ZZ' against this file costs 0.12 MB, count and all). But a predicate whose columns are spread across every row group has to read them all: country = 'GB' AND risk_score > 70 costs 45.2 MB with the count and 3.2 MB without, so 41.9 MB of it is the count. Against the 191.2 MB the old window function cost, that is still a 4× saving — and if your grid never shows a count, count: false makes the same query a 59× one.

One case where 1.51 transfers more, not less: a session that eventually reads the whole table anyway. Every individual query above is cheaper than or equal to its 1.50.0 counterpart, but a whole session need not be. 1.50.0 dragged the entire file down on first paint in a handful of large sequential reads, after which everything was cache-warm and every later query cost nothing. 1.51 reads lazily, and lazy range reads over a big Parquet overlap where one eager read did not — so bytes already paid for can be paid for again. Measured over one browser session doing first paint, a selective filter, a full-table ORDER BY, a deep page and two more filters, with the counter reset between each: 1.50.0 transferred 162.5 MB in total and 1.51 transferred 209.8 MB. The user who never sorts the whole table pays 46.7 MB instead of 162.5 MB and sees a first paint in 566 ms instead of 5808 ms; the user who does sort the whole table has to read the whole file either way, and now pays some of it twice. A full ORDER BY over an unindexed column with SELECT * is unchanged at 162.5 MB before and after — this card neither helps nor hurts it.

Turning the count off changes what the grid knows, on purpose. With count: false the adapter reports no total, and the grid does what it already does for any source of unknown length: it scrolls open-ended and discovers the end when a short page arrives. It does not substitute the page length for the total — a page presented as the whole is a wrong number where a right one goes, and every “showing X of Y”, scrollbar and row count would be wrong with nothing said.

So count: false is a trade, not a free win, and here is the part you will notice first: the grid can only scroll as far as it has discovered. The scrollbar is open-ended rather than proportional, a “showing X of Y” readout has no Y, and grid.scroll.toRow(n) cannot jump to a row beyond the discovered end — asking for row 900 of a not-yet-discovered million lands at the furthest row known so far, and reaching the real row 900 means paging to it. Turn the count off for a grid whose users scroll; leave it on for one whose users jump.

Whether a Parquet file streams or downloads whole is DuckDB's and the file host's doing, not the grid's. duckdbAdapter only writes SQL; it never opens a file, so it has no say in whether read_parquet(...) reads the whole thing or only the row groups a query needs. Measured against a 1.5 MB Parquet on GitHub Pages (BACKLOG-0001324): DuckDB-Wasm 1.32.0's default HTTP path issued 0 Range requests and read 100% of the file, for every query including a single-value chip filter. Running LOAD httpfs; on the connection before the first read_parquet(...) call changed that to 25 Range requests and 30% of the file for the same query. (DuckDB-Wasm 1.29.0 read 119% of the file on the same test — its ranges overlapped — so the exact figures are a version's, not a promise.) A file registered with db.registerFileBuffer(...) is always read whole, whatever version is loaded: a buffer has already been downloaded in full before DuckDB ever sees it. And the file host has to cooperate: it must answer HEAD, advertise Accept-Ranges: bytes and answer a range request with 206 Partial Content and a Content-Range header — GitHub Pages does all three — and, cross-origin, its CORS policy must expose Content-Range and Content-Length or the browser cannot read them back. Any of that missing and DuckDB falls back to reading the file whole, silently.

Typed binding for timestamp and date columns. A prepared statement binds a filter value with the value's own type, not the column's: the grid sends an instant as an ISO-8601 string, the client binds it as VARCHAR, and DuckDB refuses "ts" >= ? against a TIMESTAMP column (Binder Error: Cannot compare values of type TIMESTAMP and type VARCHAR). The adapter therefore types the placeholder: a comparison or IN member against a TIMESTAMP, TIMESTAMP WITH TIME ZONE, DATE, TIME or TIMESTAMP_S/_MS/_NS column is written CAST(? AS <that type>), and the value is still bound, never interpolated. The column's type comes from the engine — one DESCRIBE SELECT * FROM <from> on the first query, cached for the adapter's life and exposed as adapter.describe() — so an untyped grid column over a timestamp is covered. When the schema does not name the column (a DESCRIBE that failed, said once), the grid column's declared type on the condition is the fallback: timestamp/datetime cast to TIMESTAMP, date/dateString to DATE, time to TIME. The engine's type wins when both are known. A Date or an epoch-milliseconds number is bound as its ISO instant, because DuckDB has no cast from a number to a timestamp. The same schema fixes blank: = '' is a conversion error on any non-text column, so a typed column's blank test is IS NULL alone. Text and numeric comparisons (VARCHAR, BIGINT, DOUBLE, DECIMAL, HUGEINT) are written exactly as before, with no cast.

Time zones, honestly. The cast is the engine's, so its zone rules apply. Against a naive TIMESTAMP column the wall-clock digits of the bound string are compared; an instant ending in Z — which is what the grid's own date filter sends — therefore matches a column that stores UTC wall time, the usual convention for log and event data. A non-zero offset in the string is engine-version dependent (DuckDB 1.1 converts it to UTC, 1.5 keeps the digits as written), so send Z instants, not local offsets. Against a TIMESTAMP WITH TIME ZONE column an offset or Z is honoured exactly, and a string with no zone is interpreted in the engine's session TimeZone (UTC in DuckDB-Wasm unless the ICU extension is loaded and the setting changed). Against a DATE column an instant is truncated to its UTC day.

duckdbAdapter: grouping runs in the engine, one level at a time

Grouping a hundred million Parquet rows used to mean fetching a hundred million Parquet rows: the adapter declared no group capability, so the push router never sent it a grouped request and the grid grouped whatever it held. duckdbAdapter now declares group: true and answers the grouped view with GROUP BY — one statement per grid level, paged like any other window.

Nothing is configured. Group a grid over a DuckDB source and the grouping is pushed:

const grid = createGrid(el, {
  columns: [
    { id: 'region' },
    { id: 'tier' },
    { id: 'amount', type: 'number', total: 'sum' },
  ],
  groupBy: ['region', 'tier'],
  source: createPushdownSource({
    adapter: duckdbAdapter({ connection, from: "read_parquet('s3://bucket/sales/*.parquet')" }),
  }),
});

The root level is one statement — SELECT "region", count(*), sum("amount") … GROUP BY "region" ORDER BY "region" ASC NULLS LAST LIMIT ? OFFSET ? — so the group rows on screen cost a grouped scan and no leaf crosses the wire. Expanding a group narrows the next level by its parent's key; expanding the deepest one runs the ordinary row query with the same predicate ANDed on, so the leaves arrive paged and sorted exactly as they would without grouping. Every identifier goes through the same validation the row query uses and every value is bound, so the grouped path is no more exposed than the read path.

Subtotals come from the engine, and a statistic it cannot express shows nothing rather than something. Each totalled column contributes one aggregate expression, taken from the same verified pushdown map the statistics panel uses (see pushing statistics down) — sum, avg, min, max, count, the quantiles, and the rest. A column whose total is a host function, a two-column statistic such as weightedAvg (a grouped request carries no weight column), and the one genuine fallback weightedQuantile are not sent, are named in source.lastPlan().aggregates.client with the reason, and warn once. The group row then carries no value for that column. That is deliberate: the leaves of an unexpanded group are not in the browser, so the only alternative to the engine's figure is a figure computed over something that is not the group.

The counts a grouped grid shows are the engine's. Under grouping the display count is group headers plus whatever is expanded, which is how a grid showing three rows under one group once reported “4 of 3”. The root level's fetch therefore also asks for count(*) over the matching set and the grand total over it, in one extra statement, and an unfiltered count(*) once per adapter (on a Parquet file that is a footer read, not a scan). rows.matchCount() and rows.totalCount() read them, and grandTotalRow: 'bottom' draws its row from them.

Group order, and the collation decision. Group rows are ordered by their own key, ascending unless the sort names that column, which is exactly what the grid does to sibling group rows in memory — a sort naming some other column does not reorder groups in either. Absent keys sort last ascending and first descending, written as explicit NULLS LAST/NULLS FIRST rather than left to the connection's default_null_order. No COLLATE is written and the ICU extension is not loaded: the grid compares group keys with < on the JavaScript string (UTF-16 code-unit order) and DuckDB's default VARCHAR ordering is UTF-8 byte order, and the two agree for every character in the Basic Multilingual Plane. They part only for supplementary-plane characters (emoji, CJK extension B and above) compared against U+E000–U+FFFF. An ICU collation would disagree with the grid everywhere instead, so binary ordering is the pin. Practically: 'North' sorts before 'north' in both.

When grouping is not pushed, it says so. Grouping is all or nothing — group rows counted over the wrong set are wrong rows, not slow ones — so the whole level is refused if anything else in the query stayed behind: a filter that did not fully push, a sort the engine could not take, a quick search, a host where predicate, a grouping key that is not a plain column, or fullDataset (which holds the whole set and groups it client-side on purpose). source.lastPlan() then reports grouped: false, 'group' in unpushed and a groupReason sentence, and a one-time warning names it.

One known difference from a memory grid. A column holding both NULL and the empty string produces two groups in the engine and one in a memory grid, because the grid keys its group nodes on a display path where an absent value and an empty string are both ''. The engine's answer is the right one; the two are otherwise identical group for group, count for count and subtotal for subtotal, which the parity suite asserts at every level.

dfqlAdapter
OptionTypeDefaultMeaning
entitystringThe DemandFlow entity to query. Required.
tokenstringA personal access token, sent as the bearer credential. Required. Never commit one; read it from configuration at runtime.
urlstringhttps://rest.demandflow.comThe API base, for a non-default region or a self-hosted deployment.
comboKey'comboKey' | 'comboKey2' | 'comboKey3'comboKeyThe name of the key attribute to match on. comboKey is the standard hierarchy.
querystringSUBThe prefix matched against the key attribute. SUB alone means every record of the entity in the tenant.
loadstring[]everythingFields to project, which saves bandwidth but not query cost.
limitnumberserver defaultCaps rows scanned, not matched — which is why every request also sends countOnly to reveal the true match count.
headersRecord<string, string>{}Extra headers merged over the bearer token, for a gateway that needs its own.
fetchtypeof fetchthe global fetchYour own fetch, for a proxy or a non-browser runtime.
writeUrlstringthe default write endpointWhere record mutations are POSTed, when the deployment's write endpoint differs from the default. Write-back persists update, delete and add-row.
encodeCreate(row: unknown) => Record<string, unknown>the row's own fieldsMaps a new grid row to the DemandFlow fields an append needs — its required entity/level/comboKey — since the structural append only knows the row's own fields.
graphqlAdapter

GraphQL has no fixed query semantics, so this adapter is configured. A filter, a sort and pagination are whatever the schema defines, so the two hooks are yours to write: buildQuery turns the pushed plan into the { query, variables } body the endpoint is POSTed, and parseResponse reads its data back into { rows, total }. The defaults cover an offset/limit list with a totalCount and a Relay cursor connection; either is replaced whole by passing the hook. The default query pushes only the window and asks for the total, which is why the default capabilities are range and total and nothing more — declare operators or capabilities for filter or sort only alongside a buildQuery that genuinely emits them, or the grid returns the wrong rows silently.

If your schema does not expose totalCount. The adapter reports no total, rather than the number of rows in the page, and the grid scrolls open-ended; the endpoint is named once in a warning so the silence is not mistaken for a working count. Before 1.51 the page length was reported as the total, and that was not only a wrong number on screen — it truncated results. When the grid has residual work to finish it asks for the whole result and the adapter walks offset/limit to get it, stopping when it has as many rows as the total says exist. With the total invented from page one, the walk stopped at page one: a 337-row connection came back as 100 rows, reported as complete, and any client-side filter or sort then ran over that fraction. The walk now stops on a short page or an exhausted cursor, so it returns everything and its count is exact. A schema that does report totalCount was never affected.

OptionTypeDefaultMeaning
urlstringThe GraphQL endpoint, POSTed a { query, variables } body. Required.
headersRecord<string, string>{}Sent on every request, merged over Accept and Content-Type: application/json. Where a fixed bearer token or API key goes. See authenticating.
fetchtypeof fetchthe global fetchYour own fetch, for an expiring token, a proxy or a non-browser runtime. The adapter bundles no HTTP client. See authenticating.
fieldstringitemsThe root query field the default query selects from, e.g. orders. Ignored when you pass buildQuery.
fieldsstring[]['id'] (with a warning)The field names the default query's selection set requests. Name what your grid shows; a default query that selects nothing useful is surfaced rather than left an empty grid.
selectionstring— (uses fields)A raw selection set for nested fields, e.g. 'id name address { city }', overriding fields.
pagination'offset' | 'cursor'offsetThe default convention: an offset/limit list, or a Relay cursor connection (first/after with pageInfo). A cursor connection is forward-only, so a deep window is paged forward to and costs round trips proportional to its offset.
pageSizenumber1000The page size for the two forward walks: pulling the whole result (when residual work forces it) and walking a cursor connection to a window.
countbooleantrueWhether the default query asks for totalCount. A totalCount on a connection is rarely free on the server — it is usually a second COUNT(*) over the same predicate — so set false for a grid that never shows a count: the field is dropped from the selection set, capabilities.total becomes false, and the grid scrolls open-ended. Unlike duckdbAdapter the count is not split into a second operation, because over HTTP that would cost an extra round trip rather than saving one. Ignored when you pass your own buildQuery.
varsPartial<Record<'offset'|'limit'|'first'|'after', string>>{ offset:'offset', limit:'limit', first:'first', after:'after' }Renames the pagination variables the adapter drives per page, to match the names your schema's arguments use.
capabilitiesPushdownCapabilities{ range:true, total:true, filter:false, sort:false, quick:false }What your buildQuery actually pushes, merged over the defaults. Declaring a capability the hook does not honour returns the wrong rows silently, so the default declares only the window and the total.
operatorsstring[]— (filtering off)The comparisons your buildQuery emits, e.g. ['eq','gt','contains']. Setting it turns filtering on as a tree; pair it with a buildQuery that translates the condition tree, or the filter is declared but not applied.
buildQuery(request: RemoteRequest) => { query, variables }the offset or cursor defaultTurns the pushed plan — the window, and whatever filter/sort/quick you declared pushable — into the GraphQL operation to POST. This is where your schema's argument names live.
parseResponse(data: object) => { rows, total, pageInfo? }the offset or cursor defaultReads the operation's data into the row array and the count of all matching rows. For a cursor connection, return pageInfo (hasNextPage, endCursor) so the adapter can walk forward.
buildMutation(op: object) => { query, variables }— (write-back off)Turns a mutation into a GraphQL operation. A declared follow-up wired by the write-back wave; capabilities.mutate stays false by declaration until then.

Authenticating a remote adapter

Two shapes cover almost every endpoint. A fixed credential — an API key or a long-lived token — goes in headers, which odataAdapter and restAdapter send on every request. A credential that expires — a short-lived bearer token you refresh — goes in a custom fetch, which is the one place that can mint a fresh value per request. dfqlAdapter takes its token directly, and headers for anything a gateway adds on top.

A fixed token in headers. The map is sent on every request, so an Authorization header authenticates the whole grid. Below, a custom fetch stands in for the network only so the example can prove the header arrived:

const { odataAdapter } = await import('../packages/core/src/index.js');

let seen;
const adapter = odataAdapter({
  url: 'https://api.example.com/Orders',
  // A fixed credential authenticates every request.
  headers: { Authorization: 'Bearer static-token-123' },
  // Only here to capture what the adapter sent; in a browser, omit it.
  fetch: async (url, init) => {
    seen = init.headers.Authorization;
    return { ok: true, json: async () => ({ value: [], '@odata.count': 0 }) };
  },
});

await adapter.execute({ range: { start: 0, end: 20 } }, {});
return seen;   // the header reached the request

An expiring token in a custom fetch. A token with a lifetime cannot sit in a fixed map, because the map is read once and the token outlives no request that matters. A custom fetch is called afresh for every request, so it is where you refresh the credential and set the header on the outgoing call:

const { restAdapter } = await import('../packages/core/src/index.js');

// Stands in for a token service that hands out a new value each time.
let issued = 0;
const freshToken = async () => `token-${++issued}`;

let lastAuth;
const adapter = restAdapter({
  url: '/api/orders',
  fetch: async (url, init) => {
    // Refreshed per request, then merged over whatever headers the adapter set.
    const headers = { ...init.headers, Authorization: `Bearer ${await freshToken()}` };
    lastAuth = headers.Authorization;
    return { ok: true, json: async () => ({ rows: [], total: 0 }) };
  },
});

await adapter.execute({ range: { start: 0, end: 20 } }, {});   // token-1
await adapter.execute({ range: { start: 20, end: 40 } }, {});  // token-2
return lastAuth;   // a fresh token on the second request

Wiring it to the API you already have

Most data sits behind a service someone on your team wrote. The adapter below sends four parameters and expects { rows, total } back. Start by declaring only what the endpoint genuinely does, and widen it as you teach the endpoint more.

const source = createPushdownSource({
  compute,
  adapter: restAdapter({
    url: '/api/orders',
    // Only the comparisons the endpoint really applies. Claiming more here
    // returns the wrong rows rather than merely running slowly.
    operators: ['eq', 'gt', 'lt', 'contains'],
    params: { offset: 'skip', limit: 'take' },
  }),
});

The request that reaches your service, and the answer it owes:

ParameterExampleMeaning
skip / take40, 20The window. Return exactly that slice.
sort / orderamount,name / desc,ascColumns in priority order, and a direction for each.
filterJSON condition treeOnly the conditions your declared operators cover. Everything else the grid keeps.
qfree textPresent only when you declare quick: true.
// Express. FastAPI and ASP.NET differ only in how the query string is read.
app.get('/api/orders', async (req, res) => {
  const { skip = 0, take = 100, sort, order, filter } = req.query;

  let q = db('orders');
  if (filter) q = applyConditions(q, JSON.parse(filter));   // your translation
  if (sort) {
    sort.split(',').forEach((col, i) => {
      q = q.orderBy(col, (order || '').split(',')[i] === 'desc' ? 'desc' : 'asc');
    });
  }

  // The count is of everything matching, not of the page. A grid scrollbar is
  // sized from it, so a page-sized total makes the grid look empty below.
  const [{ count }] = await q.clone().clearOrder().count({ count: '*' });
  const rows = await q.offset(Number(skip)).limit(Number(take));

  res.json({ rows, total: Number(count) });
});

The total is the commonest mistake. It is the number of rows matching the filter, not the number returned in this page. The grid sizes its scrollbar from it and requests windows against it, so returning the page length makes a large result look like one page.

A worked example, executed on every build so it cannot go stale (data-run, PRD §7 C3):

const { capabilitiesOf, splitFilters, resolveMutate } = await import('../packages/core/src/source/pushdown.js');

// An adapter that understands three comparisons and nothing else.
const caps = capabilitiesOf({ filter: 'tree', operators: ['eq', 'gt', 'lt'] });

// Read-only by declaration: an adapter that says nothing about writing
// cannot mutate, and one that opts in resolves to a complete capability.
if (resolveMutate() !== false) throw new Error('a silent adapter must stay read-only');
if (!resolveMutate({ update: true }).update) throw new Error('an opt-in must resolve');

// A conjunction splits: what the engine knows goes to it, the rest stays here.
const { pushed } = splitFilters({
  op: 'and',
  conditions: [
    { col: 'a', op: 'eq', value: 1 },
    { col: 'b', op: 'gt', value: 2 },
    { col: 'c', op: 'lt', value: 3 },
  ],
}, caps);

return pushed.conditions.length;   // all three are supported

Building an adapter from the parts

createPushdownSource is the whole story for most callers. When an engine needs a source of its own, the four pieces it is assembled from are exported separately, so a custom source can plan and finish work the same way rather than reimplementing the split.

ExportSignatureDescription
capabilitiesOf(declared?) => Required<PushdownCapabilities>Resolves what an adapter declared against the defaults, giving a complete set with no absent keys to test for.
resolveMutate(declared?) => false | MutateCapabilityResolves an adapter's mutate declaration against the defaults. Returns false when the adapter cannot mutate, so a source over it stays read-only by declaration and refuses a write loudly rather than dropping it.
splitFilters(filters, caps) => { pushed, residual }Divides a condition tree into the half the engine takes and the half left over. A conjunction splits; a disjunction that is not fully supported stays whole on the client, because pushing part of an or returns fewer rows than the filter allows and the grid cannot recover what was never fetched.
planQuery(request, caps) => PushdownPlanPlans one request: the query to send, the work to finish afterwards, whether the whole result is needed, and which parts stayed behind.
applyResidual(rows, residual, compute) => unknown[]Applies whatever the engine could not, through the grid's own filter and sort kernels rather than a second implementation, so a residual predicate means exactly what the same predicate means anywhere else.
NO_CAPABILITIESReadonly<Required<PushdownCapabilities>>The set an adapter that declares nothing is treated as having: everything off. Such an adapter still works; the grid simply does all the work.

Residual work needs the complete result. applyResidual expects every matching row, not a window. Filtering a window is not a slower route to the right answer, it is a fast route to a wrong one: the rows that belong on page one may sit on page nine. planQuery sets needsAll whenever that applies, and createPushdownSource switches to fetching everything and paging from what it holds.

Whole-dataset statistics: fullDataset

A windowed source computes a total, statistic or group over the loaded window — the rows on screen — not the whole matching set, unless residual work already forced a whole-result fetch. “Median revenue” in the footer becomes the median of ~200 rows, wrong and looking right. fullDataset.enabled makes the whole-result fetch sticky and explicit: the entire matching set is held client-side once per query and every window, total and statistic is served from it, so the figures are computed over everything. It reuses the same whole-result path residual work already takes — needsAll — rather than a parallel mechanism. It is off by default and strictly opt-in. For a restAdapter, which cannot compute, it is the only way to get a correct whole-dataset statistic at all.

Memory-guarded, refused loudly. A matching set past maxRows or maxBytesEstimate is refused — thrown, surfaced as a source:error with no rows shown — never silently truncated. Presenting a fraction as the whole is the exact failure whole-dataset pull exists to prevent, so it is never the failure mode of the fix itself. Narrow the filter or raise the limit.

KeyTypeDefaultDescription
enabledbooleanfalseHold the whole matching set client-side and serve every window, total and statistic from it.
maxRowsnumber1_000_000Refuse (visible source:error) when the matching set is larger.
maxBytesEstimatenumber512 MBRefuse past this estimated heap cost, sampled from a representative row.
// An adapter that can page but reports the whole matching count. Without
// fullDataset a stat would see only the window; with it, the whole set.
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');
const all = Array.from({ length: 100 }, (_, i) => ({ id: i, amount: i }));
const adapter = {
  name: 'demo',
  capabilities: { range: true, total: true },
  execute: async (query) => {
    const start = query.range ? query.range.start : 0;
    const end = query.range ? query.range.end : all.length;
    return { rows: all.slice(start, end), total: all.length };
  },
};
const source = createPushdownSource({ adapter, fullDataset: { enabled: true, maxRows: 1000, maxBytesEstimate: 5_000_000 } });
// Ask for a 10-row window; fullDataset holds all 100, so the mean is the true one.
const block = await source.fetch({ range: { start: 0, end: 10 }, filters: null, sort: [], quick: '' });
const held = block.total; // 100: the whole set is held, not the 10-row window
return all.reduce((s, r) => s + r.amount, 0) / held; // 49.5, the true whole-dataset mean

Refusing a partial result: allowPartialResults

When a query has residual work — a filter, sort or quick search the engine could not push — the source asks the adapter for the whole matching set, applies the residual here, and pages from what it holds. If the adapter instead returns a page of that result (it paged when told not to), the client-side filter or sort runs over the wrong rows: the rows that belong on page one may be in the fraction that was never fetched, so a page is presented as the full filtered set. That is a wrong answer, not a slow one.

By default the source refuses such a shortfall — it throws, and the remote source surfaces a source:error with no rows shown, rather than filter a fraction and lie. The fix is to make the adapter follow the engine's own paging before returning, or hold the data in memory. allowPartialResults: true is the knowing escape hatch: a caller who accepts the permissive behaviour — an adapter that genuinely cannot page and a result small enough not to matter, or a diagnostic run — keeps the old warn-once-and-proceed path. It is off by default, because a silent wrong answer is the one thing the design refuses. It does not affect the fullDataset memory guard, nor the no-residual short-return warning.

KeyTypeDefaultDescription
allowPartialResultsbooleanfalseAccept a partial/paged result to a whole-set request that residual work will filter over, keeping the warn-once-and-proceed behaviour instead of refusing. Off by default: the shortfall is thrown.
// An eq-only engine: the `gt` filter is residual and runs in the browser. The
// adapter reports 40 matching but returns only 1 row — a page shown as the whole.
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');
const adapter = {
  name: 'shortfall',
  capabilities: { filter: 'tree', operators: ['eq'] },
  execute: async () => ({ rows: [{ id: 1, a: 5 }], total: 40 }),
};
const req = { range: { start: 0, end: 10 }, filters: { col: 'a', op: 'gt', value: 1 }, sort: [], quick: '' };

// Default: refused. Filtering 1 of 40 rows would return the wrong rows.
const strict = createPushdownSource({ adapter });
let refused = false;
try { await strict.fetch(req); } catch (e) { refused = /returned 1 of 40|refused/.test(e.message); }

// Knowing opt-in: warns once and proceeds with the fraction.
const lax = createPushdownSource({ adapter, allowPartialResults: true });
const block = await lax.fetch(req);
return refused ? `refused; opted in: ${block.rows.length} rows` : 'not refused';

Running a host predicate: whereRowLimit

A where predicate is a host function — whether this user may see the row, whether you hold a rate for its currency. No engine can evaluate one, so the only way a pushdown source can honour it is to fetch every matching row and filter here. That is a real answer, and it is also a windowed grid quietly turning into a whole-dataset download — the one thing a pushdown source exists to avoid.

So it is gated rather than done on your behalf. Under whereRowLimit (default 50_000, the same anchor as the grid's workerThreshold) the predicate runs and the counts are whole-dataset counts. At or past it — or when the adapter reports no row total, since the only way to learn the size from such an adapter is to fetch the set — the source refuses: the predicate is not applied, the rows it would exclude stay on screen, and one warning names the adapter, the size, the limit and the way out. Raise the limit when you want the download.

The { condition } twin is the route that works at any size. It is pushed to the engine, which narrows the fetch itself, so no limit applies and nothing is held here. Reach for the limit only when the predicate genuinely cannot be expressed as a condition.

KeyTypeDefaultDescription
whereRowLimitnumber50_000The most rows the source will fetch and hold in order to run a twinless where predicate. At or past this many matching rows the predicate is refused and warned about rather than the whole set downloaded. An adapter reporting no row total counts as over the limit. 0 refuses every predicate.
// Three rows, two of them ana's. The predicate is a host function with no twin,
// so the engine cannot narrow the fetch and the source must hold the set to run it.
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');
const all = [{ id: 1, owner: 'ana' }, { id: 2, owner: 'bo' }, { id: 3, owner: 'ana' }];
const adapter = {
  name: 'demo',
  capabilities: { range: true, total: true },
  execute: async (q) => ({ rows: q.range ? all.slice(q.range.start, q.range.end) : all, total: all.length }),
};
const where = { active: true, names: ['mine'], version: 1, passes: (row) => row.owner === 'ana' };
const req = { range: { start: 0, end: 10 }, filters: null, sort: [], quick: '', where };

// Under the limit: the whole matching set is fetched and the predicate runs.
const under = createPushdownSource({ adapter, whereRowLimit: 1000 });
const applied = await under.fetch(req);

// At or past it: refused and warned about, and every row stays on screen.
const over = createPushdownSource({ adapter, whereRowLimit: 2 });
const refused = await over.fetch(req);

return `applied: ${applied.rows.length}, refused: ${refused.rows.length}`;

Pushing statistics down: aggregates

A DuckDB-class engine can compute a median or a standard deviation over the whole matching set far faster than pulling every row to do it here. The aggregates config decides, at grid setup, which statistics are computed by the engine and which by the grid. It is a design-time developer choice — fixed for the life of the grid, never a runtime toggle, never shown to an end user. Absent, every aggregate is computed client-side, so no existing grid changes behaviour.

Each statistic is classified IDENTICAL (the engine's result equals the grid's own kernel, verified against it) or MAY-DIFFER (the engine computes it by a method that can differ from the grid's definition). The classification drives this documentation and build-time provenance, not whether a stat is pushed — that is your choice. Only weightedQuantile is a genuine fallback: the engine cannot express the grid's midpoint convention, so it is always computed client-side.

KeyTypeDefaultDescription
default'engine' | 'client' | 'engine-if-identical''client'engine pushes everything the engine can express (using its method for MAY-DIFFER stats); engine-if-identical pushes only the verified-identical ones and keeps MAY-DIFFER client-side — the recommended setting for a windowed DuckDB source; client computes everything here.
overridesRecord<stat, 'engine' | 'client'>Per-stat overrides that win over default. A stat the engine cannot express is always client-side regardless.

No mixed provenance. An engine number and a client number never appear in one result set. Aggregates are pushed only when the filter is fully pushed; a residual filter the engine could not apply forces every aggregate client-side, because an engine figure computed over a superset beside a client figure over the real set would be wrong-but-plausible. lastPlan().aggregates reports, per statistic, whether the engine or the client computed it and the class it was assigned — build-time inspection, not a per-figure runtime marker.

The classification table below is generated from the single pushdown map (STAT_PUSHDOWN), so it cannot drift from what the adapter actually emits:

StatisticClassDuckDB expressionNote
sumIDENTICALsum(col)Pushes to DuckDB with the same result.
avgIDENTICALavg(col)Pushes to DuckDB with the same result.
minIDENTICALmin(col)Pushes to DuckDB with the same result.
maxIDENTICALmax(col)Pushes to DuckDB with the same result.
countIDENTICALcount(*)Pushes to DuckDB with the same result.
countValuesIDENTICALcount(col)Pushes to DuckDB with the same result.
rangeIDENTICAL(max(col) - min(col))Pushes to DuckDB with the same result.
varianceIDENTICALvar_samp(col)Pushes to DuckDB with the same result.
variancePIDENTICALvar_pop(col)Pushes to DuckDB with the same result.
stddevIDENTICALstddev_samp(col)Pushes to DuckDB with the same result.
stddevPIDENTICALstddev_pop(col)Pushes to DuckDB with the same result.
sumSquaresIDENTICALsum(col * col)Pushes to DuckDB with the same result.
medianIDENTICALmedian(col)Pushes to DuckDB with the same result.
p25IDENTICALquantile_cont(col, 0.25)Pushes to DuckDB with the same result.
p75IDENTICALquantile_cont(col, 0.75)Pushes to DuckDB with the same result.
p90IDENTICALquantile_cont(col, 0.9)Pushes to DuckDB with the same result.
p95IDENTICALquantile_cont(col, 0.95)Pushes to DuckDB with the same result.
p99IDENTICALquantile_cont(col, 0.99)Pushes to DuckDB with the same result.
iqrIDENTICAL(quantile_cont(col, 0.75) - quantile_cont(col, 0.25))Pushes to DuckDB with the same result.
madIDENTICALmad(col)Pushes to DuckDB with the same result.
distinctIDENTICALcount(DISTINCT col)Pushes to DuckDB with the same result.
skewnessIDENTICALskewness(col)Pushes to DuckDB with the same result.
kurtosisIDENTICALkurtosis(col)Pushes to DuckDB with the same result.
geomeanIDENTICALexp(avg(ln(col)))Pushes to DuckDB with the same result.
harmeanIDENTICAL(count(col) / sum(1.0 / col))Pushes to DuckDB with the same result.
entropyIDENTICALentropy(col)Pushes to DuckDB with the same result.
correlationIDENTICALcorr(weight, col)Pushes to DuckDB with the same result.
hhiIDENTICALCASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_transform(map_values(histogram(col)), lambda v: (v::DOUBLE/count(col))*(v::DOUBLE/count(col)))) ENDPushes to DuckDB with the same result.
evennessIDENTICALCASE WHEN count(col)=0 THEN NULL WHEN count(DISTINCT col)<2 THEN 1.0 ELSE entropy(col)/log2(count(DISTINCT col)) ENDPushes to DuckDB with the same result.
top3ShareIDENTICALCASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,3))::DOUBLE/count(col) ENDPushes to DuckDB with the same result.
top10ShareIDENTICALCASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,10))::DOUBLE/count(col) ENDPushes to DuckDB with the same result.
giniIDENTICALCASE WHEN list_min(list(col) FILTER (WHERE isfinite(col)))<0 THEN NULL WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL WHEN list_sum(list(col) FILTER (WHERE isfinite(col)))=0 THEN 0 ELSE 2.0*list_sum(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v, i: i*v))/(len(list(col) FILTER (WHERE isfinite(col)))*list_sum(list(col) FILTER (WHERE isfinite(col))))-(len(list(col) FILTER (WHERE isfinite(col)))+1.0)/len(list(col) FILTER (WHERE isfinite(col))) ENDPushes to DuckDB with the same result.
trimmedMeanIDENTICALCASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_slice(list_sort(list(col) FILTER (WHERE isfinite(col))), floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1, len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT)) ENDPushes to DuckDB with the same result.
winsorizedMeanIDENTICALCASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v: least(list_sort(list(col) FILTER (WHERE isfinite(col)))[len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT], greatest(list_sort(list(col) FILTER (WHERE isfinite(col)))[floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1], v)))) ENDPushes to DuckDB with the same result.
robustOutliersIDENTICAL(SELECT CASE WHEN len(d.a)=0 THEN NULL WHEN d.mad=0 THEN NULL ELSE len(list_filter(d.a, lambda v: abs(0.6745*(v-d.med)/d.mad)>3.5)) END FROM (SELECT xs AS a, list_median(xs) AS med, list_median(list_transform(xs, lambda w: abs(w-list_median(xs)))) AS mad FROM (SELECT list(col) FILTER (WHERE isfinite(col)) AS xs)) d)Pushes to DuckDB with the same result.
jarqueBeraIDENTICALCASE WHEN len(list(col) FILTER (WHERE isfinite(col)))<8 THEN NULL WHEN list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2)))=0 THEN NULL ELSE (len(list(col) FILTER (WHERE isfinite(col)))/6.0)*(power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),3)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),1.5),2)+power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),4)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),2)-3,2)/4.0) ENDPushes to DuckDB with the same result.
weightedAvgIDENTICALsum(col*weight) FILTER (WHERE isfinite(col) AND isfinite(weight))/nullif(sum(weight) FILTER (WHERE isfinite(col) AND isfinite(weight)),0)Pushes to DuckDB with the same result.
modeMAY-DIFFERmode(col)DuckDB returns a modal value even for an all-distinct column; the grid returns null. Tie-breaking can also differ, and on a text column an empty string counts as a value. Under pushdown you will see a value.
weightedQuantileFALLBACKNo SQL equivalent for the grid's weighted-quantile midpoint convention; always computed client-side (needs a full-dataset pull for a correct figure over a remote source).
// Push the verified-identical stats to the engine; keep the fallback here.
// STAT_PUSHDOWN is the published map every stat's class and SQL comes from.
const { createPushdownSource, STAT_PUSHDOWN } = await import('../packages/core/src/source/index.js');
void STAT_PUSHDOWN;   // the single source of truth for the classification table above
const adapter = {
  name: 'demo',
  capabilities: { filter: 'tree', operators: ['eq'] },
  execute: async () => ({ rows: [], total: 0 }),
  // A real duckdbAdapter runs SQL; here we just echo which stats arrived.
  executeAggregates: async (query, aggs) => Object.fromEntries(aggs.map((a) => [a.id, 1])),
};
const source = createPushdownSource({ adapter, aggregates: {
  default: 'engine-if-identical',   // push only the verified-identical stats
  overrides: { mode: 'client' },     // but always keep mode's exact definition
} });
const split = await source.aggregate(
  { filters: null, sort: [], range: null },
  [{ id: 'a', col: 'revenue', fn: 'median' }, { id: 'b', col: 'size', fn: 'weightedQuantile' }],
);
// median is IDENTICAL so it pushes; weightedQuantile is a fallback so it stays here.
return `engine=${split.engine[0].class} client=${split.client[0].class}`;

Joining two grids

Two grids each holding their own data, and a third showing where they meet. Orders against customers; shipments against carriers; enrolments against students. The third grid derives from one side and names the other as its join partner.

const joined = createGrid(host, {
  source: {
    mode: 'derived',
    from: orders,
    join: {
      with: customers,
      on: { left: 'customerId', right: 'id' },
      select: ['name', 'tier'],
    },
  },
  columns: [{ field: 'ref' }, { field: 'name' }, { field: 'tier' }, { field: 'amount' }],
});
KeyTypeDescription
withGridRequired. The grid holding the other side.
onstring | { left, right }Required. The shared key: one field name when both sides use it, or one each.
type'inner' | 'left'inner by default, keeping only rows that matched, which is usually what “common data” means. left keeps every row and leaves the brought-across fields undefined, the shape you want when the unmatched rows are the finding.
selectstring[]Which of the partner's fields to bring across. All of them by default.
prefixstringRename the brought-across fields, for when both sides have a name worth keeping.
follow'all' | 'filtered'Which of the partner's rows to read. all by default: a lookup table is normally the whole table, and a customer list filtered to Europe would otherwise silently drop every other order from a grid the reader takes to be all orders.

The row count does not change. A key appearing twice on the right keeps the first match rather than emitting a row per pair. SQL would multiply them out; here that would change the row count of a grid the reader thinks of as “the orders” and quietly double every total taken from it.

Both sides are live. The partner is read at derivation time, not captured when the grid was built, and editing it re-derives, a corrected tier in the customer grid moves the order into a different band in the joined one.

Cross-filtering: the path back up

Derivation runs one way. A derived grid reads its source and never writes to it, which is what makes a chain of them safe to reason about. Cross-filtering is the single deliberate path back up: clicking a row in a summary panel filters the grid it summarises.

const byRep = createGrid(panel, {
  source: {
    mode: 'derived', from: main, groupBy: 'rep', refresh: 'live',
    crossFilter: true,
    select: { total: { of: 'amount', fn: 'sum' } },
  },
  columns: [{ field: 'rep' }, { field: 'total' }],
});

byRep.on('row:clicked', (e) => byRep.crossFilter.toggle(e.key));

The event is row:clicked, not row:click. A handler bound to the wrong name subscribes without error and never fires, so this example is executed on every build to keep the name honest: it wires the same handler to a stand-in grid, emits the event, and checks the click reached the cross-filter.

// A stand-in for the grid's event bus and cross-filter, so the wiring above can
// be executed here without a DOM. The names are the product's own.
const listeners = {};
const filtered = [];
const grid = {
  on: (name, fn) => { (listeners[name] = listeners[name] || []).push(fn); },
  emit: (name, e) => { for (const fn of listeners[name] || []) fn(e); },
  crossFilter: { toggle: (key) => { filtered.push(key); } },
};

// The line from the example, verbatim in its event name.
grid.on('row:clicked', (e) => { if (e.key) grid.crossFilter.toggle(e.key); });

// A click on a rep row. The wrong name — 'row:click' — would reach no handler,
// and this block would produce '' instead of the key.
grid.emit('row:clicked', { key: 'EMEA' });
grid.emit('row:click', { key: 'US' });

return filtered.join(',');
MemberReturnsDescription
enabled()booleanWhether this grid can cross-filter a source. False on a grid that is not derived, or whose source has no crossFilter.
column()string | nullThe source column the filter is pushed onto.
get()string[]The keys currently filtering the source.
set(keys)voidFilter the source to these derived rows. null clears.
toggle(key)voidAdd or remove one key: what a click handler wants.
clear()voidTake this grid's filter off its source.

A panel does not filter itself. The grid pushing the filter leaves its own condition out when it reads the source back. Without that, clicking one rep would collapse the panel to that single row and strand the reader with nothing else to click. It is the same rule that keeps a header histogram showing every bar after you click one (facets), applied between grids instead of within one.

Several panels compose. Each leaves out only its own condition, so two panels over different columns narrow each other while both stay whole: pick a rep and the region panel shows that rep's regions, pick a region and the rep panel shows that region's reps.

It needs a memory source. Leaving a panel's own condition out means asking the source for every row that survives the other filters, which a source holding one page cannot answer. Over a remote, paged or stream source the read falls back to the ordinary filtered rows (narrowed by the very column the panel asked to be excluded from) and the panel collapses to the row that was clicked. It warns when it does. Exclude the originating panel on the server instead.

It is an ordinary filter. The condition goes through the source's filters.set, so it undoes, rides in a saved view, and appears in whatever filter UI the grid already has. There is no second filter model beside the real one.

The remote request

Your fetch receives one object and returns { rows, total }.

FieldTypeDescription
range{ start, end }The block wanted, end exclusive. Not from/to.
sort{ col, dir }[]In priority order.
filtersFilterSetThe condition tree, in the wire form described under operators.
quickstringPresent only when the quick filter is set.
groupBy / groupPathstring[] / unknown[]Which columns group, and which node this block belongs to.
pivotBy / pivotModestring[] / boolean
totalsstring[]Columns wanting an aggregate, so the server can compute them.
contextunknownYour own config.context, passed through untouched.
signalAbortSignalAborted when the request is superseded: pass it to fetch.
protocolnumberWire version, so a server can tell what it is talking to.

Blocks are requested as the viewport reaches them and cached. Changing the sort, the filter or the grouping invalidates the cache and re-queries.

Events

One bus. There are no onX configuration properties. Every payload also carries type, origin and grid.

const off = grid.on('cell:changed', e => save(e.row.data));
grid.once('ready', init);
grid.on('*', e => console.log(e.type, e));   // wildcard, for debugging
off();                                       // on() returns its own unsubscribe

origin is 'api', 'user' or 'init'. A host persisting state reads it to ignore its own writes and avoid a feedback loop.

EventPayloadFires when
ready{}First layout is complete and the API is safe to drive.
render:first{}First paint, the number to measure time-to-first-row against.
destroy{}grid.destroy() has run.
model:changed{ reason }Columns, grouping, pivot or another structural change.
rows:changed{ identified?, companion?, added, updated, removed, plan }The row set changed. See what a change firing promises: identified means the three arrays name the rows that moved, companion marks a duplicate announcement of a change already made with identity, and a firing with neither is a real change of unknown extent.
rows:queued{ pending }A batched change is waiting for the next frame.
cell:changed{ row, key, colId, value, oldValue, undo }A committed edit reached the data. undo distinguishes a rollback.
cell:pending{ row, key, colId, value, before, id }Applied optimistically, not yet durable. Only with edit.commit.
cell:confirmed{ row, key, colId, value, id, superseded }The write reached the server.
cell:reverted{ row, key, colId, rejected, restored, reason, id, superseded, applied }The write failed. applied: false means a newer edit owned the cell, so nothing was written back.
cell:conflict{ row, key, colId, value, serverRow, id }The write succeeded but the server row had moved underneath it. Last-write-wins: value stands and serverRow carries the server's truth so the divergence is surfaced, never swallowed.
cell:edit:start{ row, key, colId, column }An edit session opened.
cell:edit:end{ row, key, colId, valid, errors }It closed: committed or cancelled.
cell:clicked{ row, key, index, colId, column, value, text, event }A cell was clicked. Announcement only: nothing is consumed, so editing and selection behave unchanged.
cell:dblclicked{ ...as cell:clicked }
cell:mouseover{ ...as cell:clicked, target }The pointer entered a cell, once per cell. target is the cell element. Moving between children of one cell fires nothing; moving straight to the next cell fires cell:mouseout then cell:mouseover. Delegated on the viewport, so it is correct over pooled rows — a row re-used after a scroll reports the row it shows now. Announcement only, and nothing in the grid is gated on hover.
cell:mouseout{ ...as cell:mouseover }The pointer left a cell, once per cell — including when it left the grid entirely.
cell:mousedown{ ...as cell:clicked, target }A pointer button went down on a cell. target is the cell element. Delegated on the viewport, so it is correct over pooled rows — a row re-used after a scroll between the press and the release reports the row it shows now. Announcement only: nothing is consumed, so the existing focus and click behaviour is unchanged.
cell:mouseup{ ...as cell:mousedown }A pointer button was released over a cell.
row:clicked{ row, key, index, event }Emitted alongside the cell event, cell first.
row:dblclicked{ row, key, index, event }
row:edit:start{ row, key, colId, column }Replaces the cell pair when edit.mode is 'row'.
row:edit:end{ row, key, colId, valid, errors }In row mode an invalid cell blocks the whole commit and the session stays open.
row:pending{ id, kind, key, temp, row }A row was appended or deleted optimistically, not yet durable. kind is 'append' or 'delete'; temp: true means an appended row under a client temp key. Only over a source that declares mutate.append/delete.
row:confirmed{ id, kind, key, tempKey?, row, superseded }The append or delete reached the server. For an append the row has already been rekeyed from tempKey to the server key — selection, expansion, focus and in-flight cell edits followed.
row:reverted{ id, kind, key, tempKey?, reason, row, superseded, applied }The append or delete failed: an appended row is removed, a deleted row restored. applied: false means a newer op owned the key, so nothing was undone.
row:conflict{ id, kind, key, serverRow, row }The op succeeded but the server row had moved underneath it. Last-write-wins: serverRow carries the server's truth so the divergence is surfaced, never swallowed.
cell:contextmenu{ ...cellParams, row }Right-click on a cell, or anywhere else on a row: in the empty tail beyond the last column colId is null, column and value are undefined. See the menu chain.
sort:changed{ sort }The full sort entry list.
filter:changed{ filters } | { quick }The condition tree or the quick filter changed.
group:toggled{ expanded, all? }A group row opened or closed.
column:moved{ colId, to, origin }Reordered by drag or by API.
column:resized{ colId, width, origin }
column:visible{ ids, hidden }Columns shown or hidden.
column:pinned{ id, side }side is 'start', 'end' or null.
column:grouped{ columns }The row-group column list changed.
column:pivoted{ columns } | { pivotFields, remote }
column:menu:open{ colId }Header menu opened.
column:filter:open{ colId }Header filter popup opened.
column:profile:open{ colId }The column statistics ("describe") panel was asked to open on a column, from the column menu's "Column statistics" item. A mounted tool panel opens its statistics panel seeded on colId.
selection:changed{ keys, rows }
range:changed{ ranges }Cell range selection changed.
page:changed{ page, pageSize, total, pageCount }Fired after the rows have moved, whether the page changed by API or by the pager control.
config:changed{ key, value, oldValue } | { keys, values, oldValues }A configuration key changed. Emitted after the grid has rebuilt, so a listener reading the grid back sees the change rather than what it replaced.
scroll{ top, left }Throttled to the frame.
scroll:end{}Scrolling settled, the moment to trigger deferred work.
size:changed{}The viewport resized.
state:changed{ cause, sections, state, report }Every state change, gesture or API call, announced exactly once — with one known gap: a predicate registered through filters.where() changes the where section and the rows on screen without raising it (BACKLOG-0001235). cause is 'user' (a sort, filter, column move, resize, pin or hide, a grouping, a pivot, a page), 'apply' (state.apply(), including an undo and a config.state seed) or 'reset' (state.reset()) — build view persistence on this one event and skip 'reset', or the default is written back over the view the user just left. sections names the GridState keys that moved. state and report are carried by an apply or a reset and are null for 'user': call grid.state.get(), which is permission-sanitised, when you write. Selection, scroll, expansion, facets and annotations do not raise it.
stream:chunk{ loaded, estimated, count, renders }A streamed chunk landed.
stream:end{ loaded, promoted, threshold }Streaming finished; promoted means it switched to in-memory.
source:error{ error, block?, range? }A source or block load failed.
clipboard:copy{ text, ok, rows }A copy left the grid.
export:progress{ ... }Progress on a streamed export.
toolpanel:focus{}The documented keyboard shortcut reached the tool panel.
history:changed{ canUndo, canRedo, undo, redo }Emitted after the entry is pushed, so a toolbar reading it names the right action. Repainting from sort:changed instead reads the timeline one action behind.
history:applied{ direction, step }An action was undone or redone. Distinct from history:changed, which also fires when a new action is pushed onto the stacks and so cannot tell you anything was reversed.
state:reset{ state }The grid was returned to its baseline.
highlight:changed{ highlights }A highlight was added or cleared.
find:changed{ text, caseSensitive, wholeCell, columns, open, count }The find query, its matches, the current match or the bar's open state changed. count is a FindCount; while the bar's sliced scan is still running count.complete is false and the figure is partial.
redaction:changed{ columns }A column was redacted or restored.
header:contextmenu{ colId, column, element, x, y }A column heading was right-clicked.
render:done{ first, last }The cells are written and stable. Anything decorating them from outside must run after this, the cell layer rewrites each cell's className wholesale and would otherwise erase it.
views:changed{ views, reason, view, activeId? }The whole list, plus what moved and why.
view:saved{ view, views, reason }Carries the one view that moved: enough to POST a single record without diffing two lists.
view:renamed{ view, views, reason }
view:removed{ view, views, reason }
view:default{ view, views, reason }view is null when the default was cleared.
view:applied{ view, views, activeId }Emits no storage write: applying a view changes nothing to persist.
permissions:changed{ levels }The context moved and every column re-resolved.
diff:changed{ summary }A snapshot was set or cleared.
licence:changed{ info, state }A key was installed, and again when verification settles.
columns:changedThe column set was replaced or reordered wholesale.
columns:taggedA column's tags changed.
columngroup:changedA banded header was formed, renamed, dissolved, moved, or a column joined or left one — by drag, keyboard or API.
detail:toggledA master-detail row opened or closed.
formatting:changedA conditional formatting rule was added, edited, reordered or restated.
redaction:changedA redaction rule changed.
diff:swappedThe baseline and the current rows were exchanged.
facet:computedA header histogram finished counting. Carries the column and the buckets.
facet:filteredA bucket or a dragged range was applied as a filter.
facet:expandedThe facet band was opened or collapsed.
facet:failedA distribution could not be computed. Carries the reason.
form:openedThe row form opened.
form:closedThe row form closed without saving.
form:savedThe row form committed.
form:errorA commit from the form failed validation or was rejected.
tree:loadingChildren are being fetched for a node.
tree:loadedChildren arrived. Carries the key and the count.
tree:loadFailedA child fetch failed.
tree:loadAbortedA child fetch was cancelled, usually because the node collapsed.
rows:pausedA live feed was paused; updates queue from here.
rows:resumedThe feed resumed and the queue drained.
rows:deferredUpdates were held rather than applied, because an edit is in flight.
row:receivedA row arrived from a source.
row:sentA row was written back to a source.
row:copiedA row was duplicated.
row:movedA row was dragged to a new position.
rowDrag:started{ key, data, over, at, overKey }A row drag passed the drag threshold and began (BACKLOG-0001224). Fires on the grid the drag started in, as do the other three, for a same-grid reorder and a cross-grid transfer alike. See RowDragEvent.
rowDrag:moved{ key, data, over, at, overKey }The drag is over a candidate position. Coalesced to one event per animation frame, carrying that frame's latest pointer position, so a handler runs at the display's rate rather than the pointer's several hundred a second. over is the grid under the pointer (null over none), at and overKey where the row would land in it.
rowDrag:left{ key, data, over, at, overKey }The pointer left a grid: over names the grid it left, at and overKey are null. Emitted on the transition rather than on a frame, so un-highlighting is never a frame behind the pointer.
rowDrag:ended{ key, data, over, at, overKey, dropped }The gesture ended, whether or not a drop followed — including a release outside every grid, where over is null. dropped is whether the release is being acted on; the outcome is reported by row:moved, row:sent, row:received and rowReceive:cancelled. These four are notifications: none is cancellable, because the drop is already vetoable by beforeRowMove and beforeRowReceive.
stream:evictedA streaming source dropped rows to stay within its cap.
header:contextmenuA heading was right-clicked.
timeline:attachedA time brush was connected to the grid.
timeline:detachedThe brush was removed.
timeline:seekThe brush settled on a range.
timeline:seekingThe brush is being dragged. Throttled.
annotation:changed{ tool, count }A drawing annotation was added, edited or cleared. Carries the active tool and the count of marks. A first-class event, so grid.on('annotation:changed', ...) and the framework adapters' onAnnotationChanged reach it directly rather than through the '*' wildcard.
presentation:startedPresentation mode began. Carries the scale, options, views and starting index. presentation:changed covers a later change to the same options, so a listener can tell entry from adjustment.
presentation:changedThe options of a running presentation changed.
presentation:endedPresentation mode ended. Annotations are cleared here.
presentation:viewThe presentation advanced to another saved view.
presentation:scaleThe presentation zoom changed.
presentation:spotlightA region was spotlit or released.
presentation:capturedA PNG was taken.
comment:addedA comment was posted.
comment:editedA comment was changed.
comment:deletedA comment was removed.
comment:resolvedA thread was marked resolved. Carries the cellKey.
comment:unresolvedA resolved thread was reopened. Carries the cellKey.
comment:failedA comment could not be saved. Carries the reason.
comment:threadOpenedA thread was opened in the panel.
comment:threadClosedA thread was closed or resolved.
comment:indexLoadedThe comment index finished loading, so indicators can paint.
presence:publishedThis client's cursor or selection was broadcast.
presence:joinedA peer was seen for the first time. Carries the peer.
presence:updatedA known peer moved or changed selection. Carries the peer.
presence:leftA peer disconnected.
presence:failedA presence transport error. Presence is lossy by design; this is informational.
presence:lockRefusedAn edit was refused because a peer holds the cell.
export:request{ request }A remote export was requested. Past-tense notification.
export:done{ request, remote }A remote export completed. Past-tense notification.
shortcuts:opened{}The keyboard-shortcuts help overlay opened. Past-tense notification.
shortcuts:closed{}The keyboard-shortcuts help overlay closed. Past-tense notification.
print:before{ rows }Print mode is about to snapshot. Past-tense notification, not cancellable.
print:after{ rows }Print mode restored the grid. Fires even if the print was cancelled by the browser.
Cancellable before-events (BACKLOG-0000943). Each gates a user-initiated mutation. The handler receives a BeforeEvent carrying the action context plus preventDefault(reason?), defaultPrevented and reason. Calling preventDefault() (or returning false) cancels the action; the handler may be async, and the mutation is held until every before-handler settles. On a veto the paired <action>:cancelled fires with the reason. Host/API writes and remote/router-applied deltas (origin !== 'user') do not fire these. The origin field distinguishes a genuine user gesture from a module-driven re-entry, which is how a host deduplicates.
beforeEdit{ row, key, mode, changes, origin }A validated cell/row commit is about to apply. changes is [{ colId, oldValue, newValue }]. Validation (edit.validate) is separate and runs first.
beforeSort{ sort, origin }A sort is about to be set.
beforeFilter{ filters?, quick?, kind, origin }A structured (kind: 'structured') or quick (kind: 'quick') filter is about to be set.
beforeColumnMove{ column, to, origin }A column reorder is about to apply.
beforeColumnResize{ column, width, origin }A column width change is about to apply.
beforeColumnHide{ columns, origin }One or more columns are about to be hidden.
beforeSelect{ keys, previous, origin }A user selection change is about to apply. A veto snaps back to the last announced selection.
beforeRowAdd{ row, origin }An optimistic row append is about to apply.
beforeDelete{ key, rows, origin }An optimistic row delete is about to apply — the canonical confirm-before-delete hook.
beforeRowMove{ key, from, to, origin }A row reorder is about to apply.
beforeGroup{ key, expanded, origin }A group/tree expand or collapse is about to apply.
beforeRowReceive{ data, at, overKey, source, origin }A row dragged from another grid is about to be inserted into this one; fires on the receiving grid (BACKLOG-0001225). overKey is the key of the row under the pointer — null past the last row, on empty space, on the header or on a pinned row — at the display index it would take, source the grid it came from. A veto leaves the source untouched: the row stays and neither row:sent nor row:copied fires. See BeforeRowReceiveEvent.
edit:cancelled{ ...context, reason }A beforeEdit was vetoed. reason is 'stale' when a live delta moved the cell during an async gate.
sort:cancelled{ ...context, reason }A beforeSort was vetoed.
filter:cancelled{ ...context, reason }A beforeFilter was vetoed.
columnMove:cancelled{ ...context, reason }A beforeColumnMove was vetoed.
columnResize:cancelled{ ...context, reason }A beforeColumnResize was vetoed.
columnHide:cancelled{ ...context, reason }A beforeColumnHide was vetoed.
selection:cancelled{ ...context, reason }A beforeSelect was vetoed; the selection snapped back.
rowAdd:cancelled{ ...context, reason }A beforeRowAdd was vetoed.
delete:cancelled{ ...context, reason }A beforeDelete was vetoed. reason is 'stale' when the row was already gone.
rowMove:cancelled{ ...context, reason }A beforeRowMove was vetoed. reason is 'stale' when the row had moved.
group:cancelled{ ...context, reason }A beforeGroup was vetoed.
rowReceive:cancelled{ ...context, reason }A beforeRowReceive was vetoed; nothing was inserted and the source still holds the row. reason is 'stale' when the row under the pointer had moved or was gone, or the source row was gone, by the time an async handler settled. See RowReceiveCancelledEvent.

This list is complete, and stays complete: tools/check.js compares every emit() in the grid against the declared event names and fails the build on a mismatch. A chart raises its own events, which belong to the charts module rather than to this bus.

A declared event is reachable directly, executed

Every name in the table above is a first-class event: grid.on(name, ...) binds it without the unknown-name warning, and each maps to a framework handler prop. Shown for annotation:changed, which BACKLOG-876 promoted from a wildcard-only emission to a declared event. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { isKnownEvent } = await import('../packages/core/src/events/index.js');
const { handlerName } = await import('../packages/modules/shared/adapter.js');

const grid = createHeadlessGrid({ columns: [{ field: 'a' }], rows: [] });

// Binding a declared event does not trip the unknown-name warning that an
// undeclared one would — that warning is exactly what BACKLOG-876 removed.
const warnings = [];
const original = console.warn;
console.warn = (...a) => warnings.push(a.join(' '));
const off = grid.on('annotation:changed', () => {});
console.warn = original;
off();
grid.destroy();

return [
  isKnownEvent('annotation:changed'),   // declared at on() time
  warnings.length,                      // 0: no unknown-event warning
  handlerName('annotation:changed'),    // the adapter prop the frameworks expose
].join(' | ');

Cancellable before-events: guarded editing and confirm-before-delete, executed

Every user-initiated mutation has a cancellable before event (BACKLOG-0000943). A handler cancels the pending action with preventDefault(reason?) and may be async — the mutation is held until it settles, which is what makes a confirm dialog or a server check a genuine gate. On a veto the paired <action>:cancelled fires with the reason. Host/API writes and remote deltas do not fire them. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'name', edit: { enabled: true } }, { field: 'v', type: 'number', edit: { enabled: true } }],
  rows: [{ id: 'a', name: 'Ann', v: 1 }, { id: 'b', name: 'Bo', v: 2 }],
  selection: 'multiple',
});

const log = [];

// Guarded editing: veto a commit on a locked row, and hear the cancellation.
grid.on('beforeEdit', (e) => { if (e.key === 'a') e.preventDefault('locked'); });
grid.on('edit:cancelled', (e) => log.push('edit ' + e.reason));

// Guard the query and layout surfaces.
grid.on('beforeSort', (e) => e.preventDefault('view-locked'));
grid.on('sort:cancelled', () => log.push('sort'));
grid.on('beforeFilter', (e) => e.preventDefault('no-filter'));
grid.on('filter:cancelled', () => log.push('filter'));
grid.on('beforeColumnMove', (e) => e.preventDefault('fixed'));
grid.on('columnMove:cancelled', () => log.push('colmove'));
grid.on('beforeColumnResize', (e) => e.preventDefault('fixed'));
grid.on('columnResize:cancelled', () => log.push('colresize'));
grid.on('beforeColumnHide', (e) => e.preventDefault('mandatory'));
grid.on('columnHide:cancelled', () => log.push('colhide'));
grid.on('beforeGroup', (e) => e.preventDefault('frozen'));
grid.on('group:cancelled', () => log.push('group'));
grid.on('beforeRowMove', (e) => e.preventDefault('ordered'));
grid.on('rowMove:cancelled', () => log.push('rowmove'));
grid.on('beforeRowAdd', (e) => e.preventDefault('quota'));
grid.on('rowAdd:cancelled', () => log.push('rowadd'));
// A row dropped in from another grid: fires on the receiving grid, naming the
// row under the pointer, so "assign this to that" can veto the insert.
grid.on('beforeRowReceive', (e) => { if (e.overKey !== null) e.preventDefault('assigned'); });
grid.on('rowReceive:cancelled', () => log.push('receive'));
grid.on('beforeSelect', () => {});
grid.on('selection:cancelled', () => log.push('sel'));

// Confirm before delete: an async handler holds the delete until it settles.
grid.on('beforeDelete', async (e) => { await Promise.resolve(); e.preventDefault('user cancelled'); });
grid.on('delete:cancelled', (e) => log.push('delete ' + e.reason));

// Past-tense notifications a host can also observe (not gates): print, remote
// export, and the keyboard-shortcuts overlay.
grid.on('print:before', () => log.push('print-before'));
grid.on('print:after', () => log.push('print-after'));
grid.on('export:request', () => {});
grid.on('export:done', () => {});
grid.on('shortcuts:opened', () => {});
grid.on('shortcuts:closed', () => {});

// A vetoed sort (sync) and an edit blocked on the locked row.
grid.sort.set([{ col: 'v', dir: 'desc' }]);
grid.edit.start('a', 'name');
grid.edit.stop(false, { value: 'Nope' });

return grid.sort.get().length + '|' + grid.rows.byKey('a').data.name + '|' + log.join(',');

Conditional formatting

Rules compile into the function cell.style already takes, so a compiled rule set installs exactly like a hand-written style function.

import { compileRules } from '@toclocoinc/lattice-grid';

{ field: 'margin', cell: { style: compileRules([
  { when: { op: 'lt', value: 0 },              style: { background: '#fdecea', colour: '#b91c1c' } },
  { when: { op: 'between', value: 0, value2: 5 }, style: { background: '#fdf3e0' } },
  { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
]) } }
KeyDescription
when{ op, value, value2 }, using the same operators as filters: eq, ne, gt, gte, lt, lte, between, outside, contains, notContains, startsWith, endsWith, blank, notBlank, true, false.
styleA style object, or a function of the cell params.
scale{ min, max, colours }, a colour scale. Two or more stops, reached evenly.
stopIfTrueDefault true. false lets a later rule add to this one.
enabledfalse skips the rule without removing it.

Rules are evaluated in order and the first match wins, as in a spreadsheet: "red if overdue, amber if due this week" reads top to bottom and stops. A blank cell satisfies no comparison, so an empty cell is not swept into "less than 100".

A scale's min and max are required rather than derived from the data. A scale that rescaled as rows were filtered would change a cell's colour without its value changing.

Quick filter modes

One box, four ways to match. The mode persists until changed, so a host sets it once and goes on passing text alone.

grid.filters.quick('acme london', { mode: 'words' });
grid.filters.quickState();   // { text: 'acme london', mode: 'words' }
ModeMatchesExample
containsThe text appears somewhere in the row. The default.cir finds CIR-100
wordsEvery term appears, in any order and any column.acme london finds a row with one in each
fuzzyThe characters appear in order, not necessarily together.crc finds CIR-200 Manchester
regexA regular expression, case-insensitive.^CIR-[12]

Matching is against one cached text blob per row, built from the columns the viewer is permitted to see. Hidden and unreadable columns are excluded, so the row count cannot become an oracle for a value behind them.

An unfinished regular expression (foo( on the way to foo(bar)) falls back to a literal search rather than matching nothing, so the grid does not blank on every open bracket. fuzzy does not reorder rows: ranking results would fight the sort the user chose.

Units of your own

Twenty-six unit systems ship: length, mass, pressure, data, bitrate, angle, temperature, flow and the rest. A family of your own takes three things, and all three are yours to set: the symbols, where the symbol sits relative to the number, and how the rungs relate to each other.

import { registerUnitSystem, defineUnit, createUnitType } from '@toclocoinc/lattice-grid';

// `factor` is how many base units one of these is. Exactly one must be 1.
registerUnitSystem('distance', [
  defineUnit('mm', 0.001, ['millimetre', 'millimetres']),
  defineUnit('cm', 0.01,  ['centimetre', 'centimetres']),
  defineUnit('m',  1,     ['metre', 'metres']),
  defineUnit('km', 1000,  ['kilometre', 'kilometres']),
]);

createGrid(el, {
  dataTypes: {
    distance: createUnitType({ system: 'distance', unit: 'm', display: 'auto' }),
  },
  columns: [{ field: 'span', type: 'distance' }],
});

The factor values are the relationship between the rungs: there is no separate ladder to declare, and no ordering to get right, because the ladder is sorted by factor at load. A hand-ordered list of thirty units is one transposition away from an auto display that walks backwards, and that mistake is invisible in review.

OptionDescription
systemA built-in system, or one registered with registerUnitSystem.
unitWhat the column stores. It need not be the system's base: the same ladder with unit: 'km' stores kilometres, and 50mm typed in becomes 0.00005.
display'auto' walks the ladder for the most readable rung, or name a symbol to fix it.
placement'prefix' puts the symbol in front: $1,200, and applies to input as well as display. Defaults to a suffix.
decimalsFixed fraction digits; or minDecimals / maxDecimals, or significantFigures.
localeSeparators and grouping. Follows the grid's locale when unset.

A unit given { auto: false } stays off the display: 'auto' ladder while remaining accepted on input and available as an explicit display. That is how imperial units sit beside metric ones without an auto readout jumping between the two.

The stored value is always a plain number in the column's own unit. Sorting, filtering, grouping, totals and the pivot all read that number and never the text, which is why 250mm sorts below 1.5 cm correctly rather than 1 sorting before 9. registerUnitSystem is global and throws on a duplicate name, so register each system once at startup rather than inside a component that may mount twice.

Compound display: 5 ft 11 in, 1 h 23 m

compound renders one stored number across an ordered subset of the system's units. It is display and parse only: the value stays a single base-unit number, so sort, filter, group and total are the same arithmetic they always were. The order is free — the units are sorted largest to smallest — and the smallest one carries any remainder. Parsing sums the parts, so a paste of 5 ft 11 in round-trips, and a single 71 in or a bare 6 still work.

const { formatUnit, parseUnit } = await import('../packages/core/src/columns/types/unit.js');

// A height column stored in metres, displayed as feet and inches.
const cfg = { system: 'length', unit: 'm', compound: ['ft', 'in'], locale: 'en-GB' };

const shown = formatUnit(1.8034, cfg);       // across the two units
const stored = parseUnit('5 ft 11 in', cfg); // summed back to metres
const stable = formatUnit(parseUnit(shown, cfg), cfg) === shown; // round-trips

return `${shown} | ${stored} | ${stable}`;

The compound units need not include the stored unit, and any unit of the system is accepted on input: 71 in pasted into a feet-and-inches column is still 71 inches. Excel export and display: 'auto' share a rule here — a column of mixed-scale text is not summable in a spreadsheet, so the export uses the raw base number on the configured unit. The compound cell editor (mid-value keystrokes, roll-over between feet and inches, caret behaviour at a boundary) is a separate, later piece; this is the read-and-paste half.

Currency: an amount and a code

Currency is a real type, not a display format. Every other unit multiplies by a factor fixed at load; a currency's “factor” is an exchange rate that moves, so it never joins the unit factory. A value is an amount and a code{ amount: 10, code: 'USD' } is a different value from { amount: 10, code: 'EUR' }, and the code rides on every cell. The grid ships and fetches no rates: the caller supplies a rate source, and a rate that is needed but absent is surfaced loudly, never as zero. A footer refuses to add unlike currencies unless a display currency and rates reconcile every value, the same stance temperature takes for refusing a meaningless sum.

const { createCurrencyType, parseMoney, formatMoney, convertMoney, rateFunction, MISSING_RATE } =
  await import('../packages/core/src/columns/types/currency.js');

// The caller owns the rates; the grid ships none. A missing one is loud, never zero.
const rates = { USD: 1, EUR: 0.92 };
const money = createCurrencyType({
  code: 'USD', display: 'EUR', rates, rateBase: 'USD', decimals: 2,
  nullDisplay: '—', missingRate: 'no rate', excel: '€#,##0.00', codes: ['USD', 'EUR'],
});

const rate = rateFunction(rates, 'USD');
const tenInEur = convertMoney(parseMoney('$10', { code: 'USD' }), 'EUR', rate);
const loud = formatMoney({ amount: 5, code: 'XYZ' }, money.currencyConfig).startsWith('no rate');
const marker = MISSING_RATE.length > 0;

return `${tenInEur.toFixed(2)}|${loud}|${marker}`;
OptionDescription
codeThe default currency code for a bare numeric input. A number with its own symbol or code keeps that code.
displayThe currency to render and total in. Omit to keep each cell in its own currency.
ratesThe caller's rate source: a (from, to) => rate | null function, or a table of rates per unit of a common base.
rateBaseThe code a rate table is denominated in. The cross rate is base-independent, so this documents the table's denomination for the reader.
missingRateThe loud marker rendered when a needed rate is absent. Defaults to MISSING_RATE.
decimalsFixed fraction digits; omit for the currency's own convention.
nullDisplayText shown for an empty cell.
excelAn Excel number-format override for export.
codesThe code list the currency editor's picker offers.

The stored value is always the amount and its own code. Sort, filter, group, copy and Excel export all read the underlying amount — converted to the display currency when rates allow, so £5 and $6 order by real value. Five ready-made types ship (currency, usd, eur, gbp, jpy); a mixed-currency column adds display and rates through createCurrencyType.

The statistic block

createStat draws the tile a dashboard opens with: a label, a value, its change against a baseline, and a line saying what the comparison was. Two things make it worth using rather than writing. It reads the grid, so it cannot disagree with the table beneath it, a tile saying £4.2M above a table filtered to £1.8M is worse than no tile, and that is what a hand-built tile does the first time somebody adds a filter. And it formats through the column's own type: a stat over a seconds column reads 42.1 ms, over a money column with an auto ladder £1.2M, with nothing declared.

import { createStat } from '@toclocoinc/lattice-grid';

createStat({
  grid, container: '#mrr',
  title: 'Monthly recurring revenue',
  of: 'mrr', fn: 'sum',
  baseline: lastMonth,
  footer: 'vs. last month',
});
KeyTypeDescription
gridGridThe grid to read.
containerElement | stringRequired. An element, or a selector resolved against the grid's document.
titlestringThe label above the value. Hidden when absent rather than left blank.
ofstringThe column to reduce. Omit for count.
fnTotalNameAny of the totals-row kernels: sum, avg, median, p95, distinct, gini and the rest. sum by default.
showstringReport this column from the row holding the extreme, rather than the extreme itself: { of: 'sales', fn: 'max', show: 'rep' } is the name of the best rep. Needs min or max; no single row holds an average, so any other reduction is refused with a warning.
valueunknown | fnA literal value (numeric or otherwise) or a function of the grid, instead of a reduction.
footerstring | fnText under the value, or a function of it.
baselinenumber | fnWhat the value is compared against. A zero baseline reports the absolute change and no percentage, because “up infinity per cent” is not a reading anyone can act on.
goodWhen'up' | 'down' | 'neither'Whether a rise is good news, which decides the colour. up by default. Revenue up is green and error rate up is red; a tile that paints every rise green is misleading on half a dashboard.
scope'filtered' | 'all' | 'selected'Which rows feed the value. filtered by default; all for a tile that is deliberately a constant, such as the denominator a filtered number is a share of.
livebooleanfalse stops the tile following the grid. refresh() still works, so a caller can drive it.
format(value, grid) => stringOverride the formatting the column's type would apply.
emptystringShown when there is no value. An em dash by default.
decimalsnumberFraction digits for a value whose reduction changed the unit. 2 by default.

The column's formatter is borrowed only where the reduction leaves the unit alone. A Gini coefficient over a money column is a ratio between 0 and 1, and rendering it as $0.34 says it is thirty-four cents; counts, ratios and variances, which are in units squared: fall back to a plain number.

Returns { element, value, refresh, destroy }. A misconfigured tile returns an inert handle rather than throwing, so a dashboard with one bad tile still renders the other eleven.

The charts module

modules/charts draws thirty-five chart types from the grid's own data. It is optional and imports nothing from the grid (the grid is handed in) so the bundle carries the drawing and none of the grid, and a page that never charts never loads it.

import { createChart } from '@toclocoinc/lattice-grid/modules/charts';

const chart = createChart({
  grid,                       // the grid to read
  container: '#revenue',      // an element or a selector
  type: 'bar',
  x: 'region',                // the category column
  y: 'revenue',               // the measure column
});

A chart reads the grid's filtered rows. Filter, sort or edit the grid and every chart bound to it redraws on the next frame: there is nothing to subscribe to and nothing to keep in step. A chart of rows the user cannot see would be describing a different data set.

The types

FamilyTypesTakes
Cartesianline, step, area, rangeArea, bar, horizontalBar, waterfall, scatter, bubblex, y, optional series
Two axescombo, paretox, measures
Distributionhistogram, boxploty alone
Matrixheatmapx, y, series
Part to wholepie, donut, sunburst, treemapx, y; or the grid's grouping, see below
Specialistradar, gauge, funnel, candlestickvaries; candlestick takes four measures in open, high, low, close order
Geographicgeomapx as an ISO code, y as the value
Flowsankey, chord, networksource, target, y; a network also takes nodes — see Network diagrams
Over timestream, marimekko, violin, ganttvaries; gantt takes label, start, end

A chart given data it cannot draw (a candlestick with three measures rather than four) says so on the chart rather than drawing nothing, because a chart that silently draws nothing is indistinguishable from one that is broken.

Hierarchical data. The part-to-whole types read nested input from the grid's own grouping, not from the spec: with grid.columns.group(['region', 'product']) in place, the tree is that grouping, one level per grouped column in that order, and x is not consulted; depth caps how many levels are read. On a flat grid, x is the single level. What each type draws of that tree: a pie or donut draws the top level; a sunburst draws every level as a ring, each segment its share of the segment inside it, and names a segment on any ring where the name fits, leaving it unnamed where it does not; a treemap nests: children inside their parent's tile, each branch with a header band naming it and padding round its children, to the depth the tree has. A child whose tile would be smaller than a line of text is not drawn and its parent's tile stands for it, so the levels drawn are the levels that can be read; a small group at the top level is always drawn, as a labelled tile with no children inside it. drill descends the tree on click, on either: clicking any tile or arc, at any depth, makes that node the root — a tile nested two levels down, or a segment on the outer ring, not only the top level — and the drill event carries the full path of labels from the root to it. ascend() comes back out a level at a time along the same path.

The spec

KeyTypeDescription
gridGridRequired. The grid to read.
containerElement | stringRequired. Where to draw.
typestringOne of the thirty above.
x / ystringCategory and measure columns. x is one column id: an array there is not a nesting instruction and warns once, naming the option and what it accepts; nest by grouping the grid instead (see Hierarchical data above).
seriesstringSplits the measure into one series per distinct value.
measuresobject[]{col, fn, type, axis}: several measures at once, each reduced by an aggregation. fn is one of sum, avg (alias mean), min, max, count, countValues, first, last; it defaults to sum. An fn that is none of these is a mistake, not a silent sum: it warns once, naming the value and the supported set, and falls back to sum so the chart still draws.
titlestringDrawn above the plot.
schemestring | string[]A named scheme or your own colours. schemeNames() lists the built-in ones, including a colour-blind-safe palette.
legendboolean | objectposition, and isolate so a click shows only that series, which is what a reader with eight series wants, and what plain toggling makes them do in seven clicks.
labelsboolean | objectValues beside each mark. position, format, minGap. A label that would overlap one already placed is dropped rather than drawn over it.
axisobjectTitles, tick density and formatting per axis.
referenceobject[]Horizontal lines: {value, label}.
multiplesstringOne chart per distinct value of this column, on a shared scale.
bucketsnumberHistogram bins. Twelve by default.
canvasboolean | numberDraw to canvas past this many points, for a dense scatter.
subtitlestringA second line under the title.
footnotestringA note under the plot, a source, a caveat, a unit.
emptyTextstringWhat to show when the binding produces nothing. Said rather than left blank, because an empty plot and a broken one look identical.
fitboolean | 'line'A least-squares line through a scatter or bubble chart, one per series. true draws it with its R²; 'line' draws the line alone. Only where the x axis is numeric: on a band scale a slope would be a slope through the order the categories happened to be listed in.
trendboolean | string | ChartTrend | arrayTrend and forecast overlays (BACKLOG-0000952): a linear least-squares line, a movingAverage, or exponential smoothing, one per series. true draws a single linear trend; a method name or a { method, forecast, window, kind, alpha, beta } object configures one; an array draws several. The maths matches the core stats engine to the last digit (a parity test asserts it) but is computed locally to keep the charts bundle lean. For the linear method, forecast: n projects the line n steps past the data as a dashed forecast; a moving average and a smoothed level have no slope to project, so forecast is ignored for them and the fact is said in the accessible description. The trend layer (the fitted line, the forecast line, its band and the R² label) may extend past the plot into the chart's margin, but it is bounded by the chart box, the chart's own <svg>: a forecast that reaches past the chart's edge is cut off there rather than painted over the content beside it (BACKLOG-0001120).
errorboolean | objectWhiskers showing the uncertainty in each mark, computed from the readings the chart can see behind it. { of } takes a symmetric margin from a column instead; { confidence } sets the level. A mark the chart sees only one value for gets none, and the chart says so.
stackbooleanStack the series rather than drawing them side by side.
curvebooleanOverlay a kernel density curve on a histogram, which shows which features are in the data and which are in the binning.
divergingbooleanColour a heatmap outward from zero rather than along a single ramp.
downsamplenumberReduce to at most this many points per series before drawing, keeping the extremes so a spike is not lost.
tooltipbooleanfalse turns the hover tooltip off.
selectionbooleanDraw the grid's selected rows emphasised, and follow the selection as it changes.
drillbooleanClicking a group drills into it.
filterOnClickbooleanClicking a mark filters the grid to it.
size / maxRadiusstring / numberBubble charts: the column driving the radius, and the largest it may be drawn.
min / maxnumberFix the measure axis rather than taking it from the data.
code / codePropertystringA geomap's ISO code column, and the property carrying the code in your shapes.
columns / method / valuesstring[] / string / booleanCorrelogram: which columns to correlate, by pearson, spearman or kendall, and whether to print the coefficients in the cells.
iterationsnumberNetwork layouts: how many relaxation passes to run.
nodesChartNode[]A network's nodes, named by you rather than inferred from the rows: { id, label, icon, x, y }. id matches a value in the source or target column; icon is any name in the grid's icon registry; x/y are fractions of the plot (0 to 1) and pin the node there, out of the force simulation. A node listed here that appears in no row is still drawn. See Network diagrams.
iconstringThe default glyph for a network node that names none of its own. Unset, an undeclared node is a plain disc.
linkWidthnumberFix a network link's stroke width in pixels. Unset, width follows the link's value as a share of the heaviest link.
spec / baseline / rules / confidenceobject / number / string / numberControl and capability charts: a tolerance overriding the column's own spec, how many leading readings fix the control limits, which rule set judges the violations (westernElectric or nelson), and the level for the capability interval.

A reduction over no readings is a gap, not a zero (BACKLOG-0001088). sum, avg/mean, min, max, first and last all answer null for a category whose rows carry no value to reduce, so the line breaks and the bar is absent rather than dropping to zero — a zero is a real reading, and drawing one where the data reported nothing would show a plunge that never happened. count and countValues are the deliberate exception: count tallies rows and countValues tallies the values actually present, so both are honestly zero when that is the true answer. countValues is the one to ask for when “none arrived” is the reading you want drawn as zero rather than as a break in the line.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { bindSeries } = await import('../packages/modules/charts/bind.js');

// 'a' has a reading; 'b' has a row, but the reading itself is absent.
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'day', type: 'text' }, { field: 'sales', type: 'number' }],
  rows: [
    { id: 1, day: 'a', sales: 10 },
    { id: 2, day: 'b', sales: null },
  ],
});

// avg over no readings is null: 'b' is a gap in the line, not a zero.
const gap = bindSeries(grid, { x: 'day', y: { col: 'sales', fn: 'avg' } });
// countValues asks "how many arrived": honestly 0 for 'b', not null.
const none = bindSeries(grid, { x: 'day', y: { col: 'sales', fn: 'countValues' } });
grid.destroy();

return [gap, none].map((bound) => bound.series[0].points.map((p) => String(p.y)).join(',')).join(' | ');

A rolling axis.x.window that has aged past its data shows the empty state, not a picture drawn off-plot. The window's domain ends at the wall clock (see window under the axis options below), so a feed that has gone quiet for longer than the window's span would otherwise have every mark fall outside the plot, with the axes and legend still drawn as if the chart were healthy. The chart shows its empty state instead and warns once per chart instance, naming the span and how old the newest reading is, so a dead feed reads as no data rather than as a chart that quietly stopped moving.

The x scale comes from the column's type, and nothing else (BACKLOG-0001344). A temporal type — date, datetime, timestamp or dateString — draws a time axis; a numeric type draws a linear axis whatever its distinct count; every other type draws bands, one per distinct value. A declared numeric or temporal column is therefore never demoted to a band, which it used to be below thirteen distinct values — silently turning off fit, band and everything else that needs a continuous x. Where the column's type is not what you want, pin the scale with axis: { x: { scale: 'band' | 'linear' | 'time' } }: 'band' is how a numeric code column (a quarter, a rating, a star count) asks for its bands back, and 'time' or 'linear' lifts a column the grid types as text onto a continuous axis. That last case is worth knowing about: a grid built with no rows infers text and does not revisit it when rows arrive, so a streamed date column binds bands where the same column beside a populated grid binds time. The chart warns once when it bands a column whose values all read as dates or as numbers, naming the column, its type and the option that overrules it. A band scale always draws: a line, area or step on one is drawn through the band centres, and its labels are thinned to the pitch the font can be read at rather than one per row (axis.x.every overrides the count).

The margin a chart leaves for its labels is measured from the labels (BACKLOG-0001343). Where a chart names its rows down the left — a correlogram's columns, a horizontalBar's categories, a gantt's tasks, a forest's coefficients, a heatmap's rows — the gutter is the widest name it will draw, capped at two fifths of the chart. Past that cap a name is ellipsised and keeps the whole of itself as aria-label, so a screen reader announces the real name and the glyphs still stop inside the chart; no label is ever cut mid-glyph, and the gutter is re-measured whenever the chart is resized. Widening the chart therefore gives the names more room, which is the thing a fixed margin could not do. Where the left-hand gutter holds a measure axis the room is estimated from five digits instead, because the numbers on it are not known until after the plot has been laid out. margin: { left } is added to whatever the labels need rather than competing with it.

The chart

MethodReturnsDescription
update(spec)voidChange any part of the spec and redraw. Keys you omit keep their values.
draw()voidRedraw now, for a change the grid does not announce.
data()objectWhat the chart last bound: series, categories and the rows behind them.
on(event, fn)functionReturns its own unsubscribe.
ascend(levels?)voidUp one level on a drillable hierarchy.
toSVG(opts?)stringThe chart as markup.
toPNG(opts?)Promise<Blob>scale: 2 for a retina still.
toCSV()stringThe bound data, for a reader who wants the numbers.
destroy()voidYours to call: the element is in your page, not the grid's.
elementSVGElementThe chart's own root.

Clicking a chart

A chart emits click, hover, leave, focus, draw, drill, brush and legend — there is no point:click, point:hover or series:toggle. The common use is filtering the grid from a mark, which makes the pair two views of one selection rather than a chart beside a table. The click and hover payload is flat{ label, category, column, value, series, rowKeys, native, preventDefault }, with no point wrapper — where column is the grid column the mark filters on and category the value to filter it to.

chart.on('click', ({ column, category }) => {
  grid.filters.set({ col: column, op: 'eq', value: category });
});

Simplest of all, set filterOnClick: true in the spec and the chart applies exactly that filter itself on the clicked mark. The click event still fires first, so a handler that calls preventDefault() on the payload takes the click over instead.

Chart a selected range

A user who drags out a block of cells — a text column and the numbers beside it — is asking a question a spreadsheet answers with one gesture: chart this. chartRange is that gesture. It reads the selected range, derives the chart from its shape, and returns the same live Chart createChart does, so nothing about it is a second kind of chart.

The derivation is pure, so a menu can ask what a range would chart as — the type, the dimension, the measures — before anyone draws it. deriveRangeSpec answers that, and chartRange then draws exactly it. This block proves the shape rule on a real grid; drawing needs a container, shown below it.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { deriveRangeSpec, canChartRange } = await import('../packages/modules/charts/index.js');

let charted = null;
const grid = createHeadlessGrid({
  columns: [
    { field: 'region', type: 'text' },
    { field: 'revenue', type: 'number', total: 'sum' },
    { field: 'cost', type: 'number', total: 'sum' },
  ],
  rows: [
    { id: 1, region: 'EMEA', revenue: 300, cost: 120 },
    { id: 2, region: 'AMER', revenue: 500, cost: 240 },
    { id: 3, region: 'APAC', revenue: 200, cost: 90 },
  ],
  rowKey: 'id',
  // Opt in, with the handler that draws — the page's link to modules/charts.
  // The menu and Alt+F1 call it with the grid and the selected range.
  rangeChart(grid, range) { charted = range; },
});

// The rectangle the user dragged: three rows, the text column and both numbers.
const range = { startRow: 0, endRow: 2, columns: ['region', 'revenue', 'cost'] };

// The leading text column is the dimension; the two numeric columns are the
// measures, so the default is a grouped bar (a combo of bar marks).
const plan = deriveRangeSpec(grid, { range });

// What the menu action does when the reader picks "Chart selection".
const handler = grid.get('rangeChart');
if (canChartRange(grid, { range })) handler(grid, range);

return [plan.x === 'region' ? 'Region' : plan.x,
        plan.measures.join(','),
        plan.type,
        charted === range ? 'drawn' : 'no'].join('|');

Drawing is one more call. chartRange takes the container, derives the spec and returns the live chart — or null when the range has no number to plot. The cell menu offers Chart selection, and Alt+F1 triggers it from the keyboard, when the grid is configured with rangeChart. Because the charts module is optional and the grid draws no charts itself, the config carries the handler — a function, or { onChart }, called (grid, range) — which is where a page wires the two together:

import { chartRange } from '@toclocoinc/lattice-grid/modules/charts';

createGrid(el, {
  columns, rows,
  rangeChart(grid, range) {
    // A grouped bar by default; pass `type` to draw it as something else.
    const chart = chartRange(grid, { container: '#chart', range });
    if (chart) chart.update({ scheme: 'colourblind' });
  },
});

Regression diagnostics

A regression is not finished when it has coefficients; it is finished when the residuals have been looked at. regressionPlots turns a fitted model — the one grid.statistics.regressionModel returns — into ready chart specs, so the diagnostic pictures are one call rather than a hand-assembled spec each. It reimplements no charting and no statistics: the fit line’s confidence band is the module’s own ribbon primitive fed by the model’s own interval, and the multicollinearity plot is the existing correlogram paired with the model’s VIF.

The presets that map onto grid columns are returned as drawable specs: the fit with its band, residuals-vs-fitted (over the fitPredicted and fitResidual shadow columns), a QQ plot of the residuals, the multicollinearity correlogram, and — over the fitStdResidual, fitLeverage and fitCooksD columns (BACKLOG-0000872) — residuals-vs-leverage, a bubble sized by Cook's distance. Scale-location (√|standardised residual| vs fitted) is drawn from explicit points computed off the model, since its y is a transform no column holds; the coefficient forest plot draws one row per coefficient — its estimate with a confidence whisker and a line at zero — through the explicit-bound error-bar primitive. A preset a given model cannot support (no multicollinearity for one predictor, no band for several) is returned as a null spec carrying a machine-readable reason rather than silently dropped.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { regressionPlots } = await import('../packages/modules/charts/index.js');

// x=1..5, y=2,4,5,4,5. Shadow columns carry the model's per-row diagnostics —
// fitted value, residual, standardised residual, leverage and Cook's D — so a
// diagnostic chart is a plain chart over columns.
const model = { predictors: ['x'], response: 'y' };
const grid = createHeadlessGrid({
  columns: [
    { field: 'x', type: 'number' },
    { field: 'y', type: 'number' },
    { id: 'yhat', title: 'Fitted', shadow: { kind: 'fitPredicted', model } },
    { id: 'resid', title: 'Residual', shadow: { kind: 'fitResidual', model } },
    { id: 'sresid', title: 'Std residual', shadow: { kind: 'fitStdResidual', model } },
    { id: 'lev', title: 'Leverage', shadow: { kind: 'fitLeverage', model } },
    { id: 'cook', title: "Cook's D", shadow: { kind: 'fitCooksD', model } },
  ],
  rows: [
    { id: 'r1', x: 1, y: 2 }, { id: 'r2', x: 2, y: 4 }, { id: 'r3', x: 3, y: 5 },
    { id: 'r4', x: 4, y: 4 }, { id: 'r5', x: 5, y: 5 },
  ],
  rowKey: 'id',
  source: { mode: 'memory', columnarBelow: 0 },
});

const { plots } = regressionPlots(grid, {
  spec: model, fitted: 'yhat', residual: 'resid', stdResidual: 'sresid', leverage: 'lev', cooksD: 'cook',
});

return [
  plots.fit.spec.type,                 // scatter, with fit:true and band
  plots.fit.spec.band.points.length,   // a band point per row
  plots.residualsFitted.spec.type,     // residual vs fitted, a scatter
  plots.qq.spec.type,                  // a QQ plot of the residuals
  plots.residualsLeverage.spec.type,   // bubble, over the diagnostic columns
  plots.residualsLeverage.spec.size,   // sized by Cook's D
  plots.scaleLocation.spec.type,       // scatter, from explicit points off the model
  plots.coefficientForest.spec.type,   // forest, estimate + whisker per coefficient
].join('|');

Maps

A geomap takes an ISO code from one column and a value from another. Alpha-2, alpha-3 and numeric codes are all accepted, and continent codes draw a continent map without any outline data. Codes that match nothing are counted and reported on the chart rather than dropped, a map missing half its data looks exactly like a map of a world where half the data is zero. The full code tables are in CHART-CODES.md.

Real outlines: geometry packs

A pack is an optional module you import only if you draw that map. Real boundaries are tens to hundreds of kilobytes, so none of them are in the charts bundle and the grid still fetches nothing at runtime: you import the pack you want, exactly as you import an extension chart type, and hand it to shapes. Each pack is TopoJSON — quantised and delta-encoded, decoded by the chart — generated from the published source below by tools/build-geo-packs.mjs, and it carries its own provenance: the source URL, the version, the date it was retrieved, the licence, and the attribution line that licence requires.

import { pack } from '@toclocoinc/lattice-grid/modules/geo-world-110m';

createChart({ grid, container: '#map', type: 'geomap', code: 'iso', y: 'revenue',
  shapes: pack });                       // Equal Earth, fitted to the pack
ModuleRegionsSource and licenceSize (gzipped)Attribution required
modules/geo-world-110m177 countriesNatural Earth 1:110m Admin 0, via world-atlas — public domain39 KBNone
modules/geo-world-50m241 countriesNatural Earth 1:50m Admin 0, via world-atlas — public domain225 KBNone
modules/geo-us-states50 states + DCUS Census cartographic boundaries, via us-atlas — public domain36 KBNone
modules/geo-europe-nutsNUTS 0–2Eurostat GISCO NUTS 2021 1:20m — free re-use with attribution95 KB© EuroGeographics for the administrative boundaries
modules/geo-uk9 regions, 361 local authorities, 650 constituenciesONS Open Geography, generalised clipped — Open Government Licence v3.0292 KBContains OS data © Crown copyright and database right 2026; Source: Office for National Statistics licensed under the Open Government Licence v.3.0

Joining. A pack is keyed by the code its source is published under and by the codes that source also knows: the world packs by ISO alpha-2, with alpha-3 and numeric accepted; geo-us-states by the two-letter USPS abbreviation, with the FIPS code accepted; geo-europe-nuts by NUTS id (DE, DE1, DE11); geo-uk by ONS code (E12000007). A region the pack does not know is reported as unmatched exactly as before.

Choosing a grain. geo-uk ships three layers in one module because they share a coastline: layer: 'regions' (the default), 'local-authorities' or 'constituencies'.

Two notes from the data, not from us. Natural Earth at 1:110m leaves out the micro-states — Singapore, Malta, Monaco have no outline at that scale — so bind country-level data to geo-world-50m if those matter. And geo-us-states places Alaska and Hawaii at their true longitudes rather than in the insets an Albers USA composite uses, so a map of all 51 spans the Pacific; the five US territories are left out of the pack for the same reason.

Projections

Every map names a projection, and a pack declares the right one for itself, so shapes: pack alone gives a sensible map. projection overrides it; projectionOptions passes parallels and centre to the two that take them. The drawn geometry is then fitted to the panel, so a map of the UK fills its box rather than sitting inside the whole globe's.

NameWhat it isUse it for
equalEarthEqual-area (Šavrič, Patterson & Jenny 2018)A world map — the default. Areas are honest and the shapes are recognisable.
robinsonCompromise, tabulatedA world map where the poles matter more than area.
mercatorConformal, cut at ±85.05°Matching a web-map basemap.
albersConic equal-area, two standard parallelsA country or continent in the mid-latitudes — the US and Europe packs default to it.
transverseMercatorConformal about a central meridianA tall, narrow country. The UK pack defaults to it through 2°W, which is what stands Britain upright.
equirectangularLongitude and latitude straight onto x and yBack-compatibility: the projection every map here drew before 1.63.
createChart({ grid, container: '#uk', type: 'geomap', code: 'lad', y: 'claims',
  shapes: ukPack, layer: 'local-authorities' });        // transverse Mercator

createChart({ grid, container: '#us', type: 'geomap', code: 'state', y: 'sales',
  shapes: usPack, projection: 'albers',
  projectionOptions: { parallels: [29.5, 45.5], centre: [-96, 37.5] } });

The antimeridian is handled in the chart. A country whose outline crosses ±180° — Russia, Fiji, New Zealand's Chathams — is split there before it is projected, so it draws as the parts it is rather than as a band running the wrong way across the map. Antarctica is cropped until it carries a value, by its country code AQ as well as the continent code AN.

The module imports nothing from the grid: createChart is handed a grid rather than importing one. That is what keeps the charts bundle to the drawing, and it is why the grid must be created first, and why a chart cannot outlive it.

Network diagrams — icon nodes, links coloured by their value

A network draws the grid's rows as a graph: source and target name the two endpoint columns and y carries the value on the link between them. Three things make it a picture of your network rather than a generic hairball, and each is a fact only you have.

Nodes you name. nodes: [{ id, label, icon, x, y }] gives a node a glyph from the grid's own icon registry — a built-in name, or one you registered — and a label drawn beneath it. A node that appears in the rows but not in nodes takes the chart's icon default (a plain disc when there is none) and its own id as its label. A node listed in nodes that appears in no row is still drawn: a device with no links is a fact worth seeing. The glyphs are SVG paths from the same registry the cells paint from, so they are sharp at any chart size and take the chart's theme colours.

Positions you choose. x and y are fractions of the plot, measured from its top-left. A node giving both is pinned there and takes no part in the force simulation; everything else is laid out around it by the same deterministic relaxation as before, so “core on top, regions below” needs no hand-placed SVG. Half a position is not a position: a node with only x is laid out. A fraction outside 0 to 1 clamps to the edge of the plot rather than drawing where nobody can see it. Pinning one node never reshuffles the others — the layout's seeding draws for every node, pinned or not, precisely so that it cannot.

Colours from the rules you already wrote. Each link's stroke comes from the value column's own conditional-formatting rules, through grid.formatting.styleFor(col, value) — the colour order is background, then backgroundColor, then color; a gradient (a data bar, an icon set) is not a colour and is not read. A link no rule matches keeps the chart's default link colour. There is no chart-level threshold option, deliberately: a second place to say “red above 80” is a second place for the chart and the cell to disagree. The legend lists the rules that actually fired, with their own labels and their own swatches; a rule that matched nothing is not advertised. Change a rule and the links recolour on the next frame without the layout re-running, so nothing moves.

Links are undirected, and parallel links stay parallel. There are no arrowheads, and A,B is the same pair as B,A — a cable has two ends and no direction. Several rows between the same pair are drawn as several lines, side by side, offset perpendicular to the pair by 4 px and symmetric about it, in row order, each with its own value and its own colour. They are not summed: three circuits between two sites are three readings, and one line carrying 120% would be a number nothing measured. The tooltip on any one line names both endpoints and that line's own value, and the pointer picks out the line you are actually over rather than the pair. Width follows the value unless linkWidth fixes it.

Linked like every other chart. The graph is drawn from the grid's filtered rows and follows filter and sort. With selection: true, clicking a link selects its row and clicking a node selects every row it is an end of; the grid's selection then lights those links and nodes and dims the rest. A click handler still fires first and can preventDefault().

The picture, executed

Two core routers pinned across the top, three regional routers pinned below, two circuits between every pair, and one rule set on the load column doing all the colouring. Thirteen rows, thirteen lines, three colours, five glyphs, and a legend that names the three rules.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');

const { document, root } = createTestDom({ width: 640, height: 420 });

// Two circuits between every core and every region: six pairs, twelve rows.
const rows = [];
for (const core of ['core-1', 'core-2']) {
  for (const site of ['emea', 'amer', 'apac']) {
    for (const [n, load] of [['a', 18], ['b', 92]]) {
      rows.push({ id: `${core}/${site}/${n}`, from: core, to: site, load });
    }
  }
}
rows.push({ id: 'core-1/core-2/x', from: 'core-1', to: 'core-2', load: 61 });

const grid = createGrid(root, {
  rowKey: 'id',
  selection: 'multiple',
  columns: [{ field: 'from' }, { field: 'to' }, { field: 'load', type: 'number' }],
  rows,
  // One rule set on the column. The cells and the links read it together.
  formatting: {
    load: [
      { id: 'ok', label: 'Healthy', when: { op: 'lt', value: 40 }, style: { background: '#107c41' } },
      { id: 'busy', label: 'Busy', when: { op: 'lt', value: 80 }, style: { background: '#f0b400' } },
      { id: 'hot', label: 'Saturated', when: { op: 'gte', value: 80 }, style: { background: '#a4262c' } },
    ],
  },
});

const container = document.createElement('div');
container.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(container);

const chart = createChart({
  grid, container, type: 'network', source: 'from', target: 'to',
  y: { col: 'load', fn: 'sum' }, selection: true,
  nodes: [
    { id: 'core-1', label: 'Core', icon: 'square', x: 0.3, y: 0.15 },
    { id: 'core-2', label: 'Core', icon: 'square', x: 0.7, y: 0.15 },
    { id: 'emea', label: 'EMEA', icon: 'circleFilled', x: 0.2, y: 0.8 },
    { id: 'amer', label: 'AMER', icon: 'circleFilled', x: 0.5, y: 0.8 },
    { id: 'apac', label: 'APAC', icon: 'circleFilled', x: 0.8, y: 0.8 },
  ],
});

const find = (tag, cls) => [...container.querySelectorAll(tag)]
  .filter((n) => (n.getAttribute('class') || '').includes(cls));
const at = (key) => find('circle', '__node').find((c) => c.getAttribute('data-node') === key);
const num = (el, name) => Number(el.getAttribute(name));

// The picture: two cores on one row, three regions on another below them.
const top = [at('core-1'), at('core-2')].map((c) => num(c, 'cy'));
const bottom = ['emea', 'amer', 'apac'].map((k) => num(at(k), 'cy'));
const rowsPinned = top[0] === top[1] && bottom.every((y) => y === bottom[0]) && bottom[0] > top[0]
  && num(at('core-1'), 'cx') < num(at('core-2'), 'cx');

// Every link its own line, coloured by the rule its value matched.
const edges = find('path', '__edge');
const stroke = (e) => ((e.getAttribute('style') || '').match(/stroke:\s*([^;]+)/) || [])[1];
const colours = [...new Set(edges.map(stroke))].sort();

// Two circuits between core-1 and emea, drawn side by side 4px apart.
const ends = (d) => d.match(/-?\d+(?:\.\d+)?/g).map(Number);
const pair = edges.slice(0, 2).map((e) => ends(e.getAttribute('d')));
const gap = Math.round(Math.hypot(pair[0][0] - pair[1][0], pair[0][1] - pair[1][1]));

// Five glyphs, drawn from the registry the host extended.
const glyphs = find('path', '__node-icon').length;

// The legend names the rules that fired, not a palette.
const legend = [...container.querySelectorAll('button')]
  .filter((b) => (b.getAttribute('class') || '').includes('__legend-item'))
  .map((b) => b.textContent).join(',');

chart.destroy();
grid.destroy();
return `${edges.length} links, ${colours.join('/')} | pinned ${rowsPinned} | gap ${gap} | ${glyphs} icons | ${legend}`;

Extension chart types — pay only for what you draw

The base charts bundle draws the built-in TYPES and nothing else. A new chart type is a separate, opt-in module a caller imports only if they use it (BACKLOG-0000886, on the slim-core seam BACKLOG-0000884). Importing it self-registers the type with the base module through registerChartType; the base Chart consults that registry for any type it does not draw natively. Because the base never imports the extension, the base bundle does not grow for a type a caller never uses.

import '@toclocoinc/lattice-grid/modules/charts';           // the base
import '@toclocoinc/lattice-grid/modules/chart-ridgeline';  // opt in to one type

createChart({ grid, container: '#dist', type: 'ridgeline', x: 'segment', y: 'value' });

An extension declares { draw, bind?, freeform?, labelled? }. Its draw(ctx) receives the same context a built-in drawer gets — plot, bound, groups, scheme, typography, labels, grid, spec — plus ctx.helpers, the base's own toolkit (element factory, scales, axis drawers, mark pool, distribution kernels). So an extension imports nothing heavy from the base: it receives the toolkit and ships only its own geometry. registeredChartTypes() lists what is registered. Ridgeline (drawRidgeline) is the first: one kernel-density ridge per category, stacked and overlapping, over the distribution of a measure — the reading for "how did this distribution change across segments".

const charts = await import('../packages/modules/charts/index.js');
const ridge = await import('../packages/modules/chart-ridgeline/index.js');

// Importing the module self-registered the type against the base registry.
const registered = charts.registeredChartTypes().includes('ridgeline');
// registerChartType is idempotent by name, so re-registering is safe.
charts.registerChartType('ridgeline', { draw: ridge.drawRidgeline });

return [
  registered,
  charts.registeredChartTypes().includes('ridgeline'),
  typeof ridge.drawRidgeline,
].join(' | ');

More lead-pick types ship the same way, each in its own opt-in module — the base bundle stays flat as they are added. Calendar heatmap (drawCalendar, type: 'calendar') lays a measure out value-by-day, GitHub-style, from a date column. Scatter-plot matrix (drawSplom/bindSplom, type: 'splom') crosses every pair of numeric columns. Hexbin (drawHexbin/bindHexbin, type: 'hexbin') bins a scatter into count-shaded hexagons so a million rows read as a density field. Each reads the grid through the public row API, so it follows the grid's filters and sort.

const charts = await import('../packages/modules/charts/index.js');
const cal = await import('../packages/modules/chart-calendar/index.js');
const splom = await import('../packages/modules/chart-splom/index.js');
const hex = await import('../packages/modules/chart-hexbin/index.js');

// Each import self-registered its type; the base bundle carries none of them.
const types = charts.registeredChartTypes();
return [
  ['calendar', 'splom', 'hexbin'].every((t) => types.includes(t)),
  typeof cal.drawCalendar,
  typeof splom.drawSplom, typeof splom.bindSplom,
  typeof hex.drawHexbin, typeof hex.bindHexbin,
].join(' | ');

The model-evaluation and time-series lead picks ship the same way. ROC / PR / calibration (drawRoc/bindRoc, type: 'roc', curve: 'roc' | 'pr' | 'calibration') evaluates a classifier from a label and a score column, with the AUC. Fan / forecast (drawFan/bindFan, type: 'fan') draws history, a point forecast, and a widening prediction interval from y/forecast/lower/upper. Decomposition panel (drawDecomposition/bindDecomposition, type: 'decomposition') stacks the observed/trend/seasonal/residual components on one x axis — the companion to the grid's own time-series shadow columns.

const charts = await import('../packages/modules/charts/index.js');
const roc = await import('../packages/modules/chart-roc/index.js');
const fan = await import('../packages/modules/chart-fan/index.js');
const decomp = await import('../packages/modules/chart-decomposition/index.js');

const types = charts.registeredChartTypes();
return [
  ['roc', 'fan', 'decomposition'].every((t) => types.includes(t)),
  typeof roc.drawRoc, typeof roc.bindRoc,
  typeof fan.drawFan, typeof fan.bindFan,
  typeof decomp.drawDecomposition, typeof decomp.bindDecomposition,
].join(' | ');

The comparison and ranking family ships the same way. Slope (drawSlope, type: 'slope') connects each series across two periods; dumbbell (drawDumbbell/bindDumbbell, type: 'dumbbell') shows a start/end gap per category; bump (drawBump, type: 'bump') plots rank-over-time; diverging (drawDiverging, type: 'diverging') grows bars from a central zero; parallel coordinates (drawParallel/bindParallel, type: 'parallel') draws one polyline per row across several numeric axes.

const charts = await import('../packages/modules/charts/index.js');
const slope = await import('../packages/modules/chart-slope/index.js');
const dumbbell = await import('../packages/modules/chart-dumbbell/index.js');
const bump = await import('../packages/modules/chart-bump/index.js');
const diverging = await import('../packages/modules/chart-diverging/index.js');
const parallel = await import('../packages/modules/chart-parallel/index.js');

const types = charts.registeredChartTypes();
return [
  ['slope', 'dumbbell', 'bump', 'diverging', 'parallel'].every((t) => types.includes(t)),
  typeof slope.drawSlope,
  typeof dumbbell.drawDumbbell, typeof dumbbell.bindDumbbell,
  typeof bump.drawBump,
  typeof diverging.drawDiverging,
  typeof parallel.drawParallel, typeof parallel.bindParallel,
].join(' | ');

When parallel coordinates is given a colourBy column it colours each line by its category — but a colour with no key is a code, so the chart now emits a legend of those categories (BACKLOG-0000999), one entry per category in the order the colours were assigned, exactly the shape every other coloured type returns. The base draws it and wires the click, so a click on a category toggles it off through the same hide-a-series gesture the rest of the module has, and the drawer skips a hidden category's lines. With no colourBy there is nothing to key and no legend is drawn. The categories the key is built from are the ones bindParallel returns as groups:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { bindParallel } = await import('../packages/modules/chart-parallel/index.js');
const cats = ['red', 'green', 'blue'];
const rows = Array.from({ length: 9 }, (unused, i) => ({ id: `R${i}`, a: i, b: i % 4, grp: cats[i % 3] }));
const grid = createHeadlessGrid({
  columns: [{ field: 'a', type: 'number' }, { field: 'b', type: 'number' }, { field: 'grp', type: 'text' }],
  rows, rowKey: 'id',
});
// The colourBy categories, in colour order — each becomes a legend entry whose
// index picks the same colour its lines use.
const bound = bindParallel(grid, { columns: ['a', 'b'], colourBy: 'grp' });
grid.destroy();
return `key ${bound.groups.join(',')}`;

A least-squares forecast on the trend overlay (trend: { method: 'linear', forecast: n }) no longer draws a bare dashed line: it shades the uncertainty band around the projection (BACKLOG-0000975). By default that is the Student-t prediction band (a future observation); band: 'confidence' shades the narrower mean-response band the fitted line's own doubt describes, and band: false leaves the bare line. The band widens as the line runs further past the data — the honest shape, since a projection is least certain where it reaches furthest — and confidence (default 0.95) sets its level. It is the exact interval the core forecast kernel reports for the linear method, computed locally in the charts bundle (never imported, for the bundle reason the trend maths already is) and asserted equal to the engine's to the last digit:

const { forecast } = await import('../packages/core/src/index.js');
const { linearTrend } = await import('../packages/modules/charts/trendline.js');
const ys = [2, 5, 6, 9, 11, 12];
const pairs = ys.map((y, i) => ({ x: i, y }));
// The trend overlay's forecast band, three steps ahead at 95%…
const overlay = linearTrend(pairs, 3, { confidence: 0.95 });
// …is the core forecast kernel's linear prediction band, to the last digit.
const engine = forecast(ys, { method: 'linear', horizon: 3, confidence: 0.95 });
const b = overlay.band.points[3];
const e = engine.points[2];
return `match ${b.lower === e.lower && b.upper === e.upper}; conf ${overlay.band.confidence}`;

The hierarchy, flow and geographic remainder ships the same way — treemap, sunburst, funnel, radar, sankey, chord and network are already built in, so the new opt-in modules are: icicle (drawIcicle, type: 'icicle', drawn from the grid's group tree), waffle (drawWaffle, type: 'waffle'), alluvial (drawAlluvial/bindAlluvial, type: 'alluvial'), arc diagram (drawArc/bindArc, type: 'arc'), bubble map (drawBubbleMap/bindBubbleMap, type: 'bubblemap') and hexbin map (drawHexMap/bindHexMap, type: 'hexmap'). The two maps place lon/lat directly, so they need no outline data and fetch nothing.

const charts = await import('../packages/modules/charts/index.js');
const icicle = await import('../packages/modules/chart-icicle/index.js');
const waffle = await import('../packages/modules/chart-waffle/index.js');
const alluvial = await import('../packages/modules/chart-alluvial/index.js');
const arc = await import('../packages/modules/chart-arc/index.js');
const bubblemap = await import('../packages/modules/chart-bubblemap/index.js');
const hexmap = await import('../packages/modules/chart-hexmap/index.js');

const types = charts.registeredChartTypes();
return [
  ['icicle', 'waffle', 'alluvial', 'arc', 'bubblemap', 'hexmap'].every((t) => types.includes(t)),
  typeof icicle.drawIcicle, typeof waffle.drawWaffle,
  typeof alluvial.drawAlluvial, typeof alluvial.bindAlluvial,
  typeof arc.drawArc, typeof arc.bindArc,
  typeof bubblemap.drawBubbleMap, typeof bubblemap.bindBubbleMap,
  typeof hexmap.drawHexMap, typeof hexmap.bindHexMap,
].join(' | ');

Map markers — a figure per location, coloured by its own rule

modules/chart-markermap registers markermap: one marker per row, placed by lon/lat over a geometry pack's outlines, showing the row's label and its value beside the dot. Two things come from the grid rather than from the chart, and that is the whole point of the type. The number is the value column's own formatted cell text, so a percentage, a currency or a unit reads on the map exactly as it reads in the table. The colour is whatever grid.formatting.styleFor(valueColumn, value) returns for that row — the rule's background, or its color where it sets no background — so a red / amber / green availability wall is one rule set on one column plus one chart configuration. There is deliberately no chart-level thresholds option and no colour column: the rules are the one source, and a legend lists the rules that actually fired, with each rule's own swatch.

With shapes it draws the pack's regions underneath, through the pack's own projection, and pans and zooms exactly as a geomap of that pack does; without shapes the markers fall back to the projection alone. A row whose coordinates are absent, non-numeric or outside ±180 / ±90 draws no marker and is counted in chart.data().unplaced, which the map also writes under itself. Labels are deconflicted by trying four positions in a fixed order — right of the dot, then left, then above, then below — and a label with nowhere to go is dropped rather than overprinted; labels: false turns them all off on a dense map and leaves the tooltip, which carries the name, the value, the coordinates and the status. With selection: true a click on a marker selects that row in the grid, and the grid's selection emphasises the marker.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 700, height: 460 });
const panel = document.createElement('div');
panel.rect = { width: 700, height: 460, top: 0, left: 0 };
root.appendChild(panel);

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `markermap`; its drawer is drawMarkerMap.
const markermap = await import('../packages/modules/chart-markermap/index.js');
const { pack } = await import('../packages/modules/geo-world-110m/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [
    { field: 'site', type: 'text' }, { field: 'lng', type: 'number' },
    { field: 'lat', type: 'number' }, { field: 'avail', type: 'number', format: '0.00%' },
  ],
  rows: [
    { id: 'ldn', site: 'London', lng: -0.13, lat: 51.5, avail: 0.9995 },
    { id: 'syd', site: 'Sydney', lng: 151.2, lat: -33.87, avail: 0.9991 },
    { id: 'fra', site: 'Frankfurt', lng: 8.68, lat: 50.11, avail: 0.9962 },
    { id: 'nyc', site: 'New York', lng: -74.0, lat: 40.71, avail: 0.9805 },
  ],
});
// Three rules on the column. Nothing below repeats a threshold or a colour.
grid.formatting.add('avail', { when: { op: 'gte', value: 0.999 }, style: { background: '#1b7f3b' }, label: 'Healthy' });
grid.formatting.add('avail', { when: { op: 'gte', value: 0.99 }, style: { background: '#c8a415' }, label: 'Watch' });
grid.formatting.add('avail', { when: { op: 'lt', value: 0.99 }, style: { background: '#c0392b' }, label: 'Breached' });

const chart = createChart({
  grid, container: panel, type: 'markermap',
  lon: 'lng', lat: 'lat', label: 'site', value: 'avail', shapes: pack,
});

// What was painted: a fill per marker, and the text beside each dot.
const fills = [];
const labels = [];
const walk = (node) => {
  for (const child of node.children || []) {
    const cls = String(child.getAttribute('class') || '');
    if (cls.includes('markermap-dot')) fills.push(child.getAttribute('fill'));
    if (cls.includes('data-label')) labels.push(child.textContent);
    walk(child);
  }
};
walk(chart.element);

// The binder is public too, for a host that wants the placed rows itself.
const bound = markermap.bindMarkerMap(grid, { lon: 'lng', lat: 'lat', label: 'site', value: 'avail' });
chart.destroy();
return `${fills.join(' ')} | ${labels.join(' / ')} | unplaced ${bound.unplaced}`;

The data router

modules/data-router is a host-layer demultiplexer: it takes one arriving stream or dataset, splits it by what each record is, and routes each partition to its own grid — or to a headless grid driving a chart. One round-trip, or one live feed, hydrates a whole screen of grids that each see only their slice. It is optional, imports nothing from the grid, and adds no core hook: every grid is driven through the public incremental path, grid.rows.apply({ add, update, remove }). The router never opens a connection itself — the host owns the connection (a WebSocket, SSE, CDC, a message bus, a plain fetch), and the router owns everything once a message has arrived; see a live WebSocket feed for the worked, runnable integration.

import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';

const router = createDataRouter({
  key: 'entityType',          // partition: a property, or fn(row) => value
  rowKey: 'id',               // identity within a grid: a property, or fn(row)
  overlap: false,             // default: first matching route wins
  onUnrouted: (item) => {},   // optional sink for records that match no route
});

router.attach(ordersGrid, 'order');            // a property value...
router.attach(bigGrid, (row) => row.amt > 1e6); // ...or a composite predicate
router.attach(headlessGrid, 'metric', { rowKey: 'ts' }); // per-attach rowKey override; feeds a chart
router.attachDefault(restGrid);                // the "rest" sink: nothing is dropped

const counts = router.load(snapshot);          // keyed diff per grid: [{added,updated,removed}, ...]
router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); // in-place deltas by rowKey

A snapshot is a keyed diff, not a replace. load re-partitions the whole dataset and, per grid, adds the new rows, updates only the changed ones and removes the gone ones — so an unchanged row never repaints and selection, scroll and edit state survive. Deltas are applied in place by rowKey, last-writer-wins within a batch; an upsert whose partition property has changed moves the row (it leaves the route it no longer matches and joins the one it now does, never duplicated). A record matching no route is counted in router.unrouted, handed to onUnrouted, and — if an attachDefault grid exists — routed there, so nothing is ever silently lost. By default a record goes to the first route it matches; overlap: true fans it to every matching route.

Fanning one partition value to several viewers? Set overlap: true. The headline "route one feed to many viewers" — a grid and a KPI panel and a chart, all off one feed — needs overlap: true. With the default overlap: false a record stops at the first route it matches, so a second viewer attached to the same value silently receives nothing. Distinct values (one viewer per value) do not need it. When two routes claim the same value while overlap is false, the router emits a one-time dev warning naming the value.

// One value, several viewers: a grid, a KPI panel and a chart all see 'deal'.
const router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
router.attach(dealsGrid, 'deal');            // the grid
router.subscribe('deal', kpiPanel);          // a KPI tile off the same value
router.attach(dealsChart, 'deal');           // a chart off the same value
router.load(snapshot);                        // every viewer fills; without overlap:true only the grid would
MemberDescription
createDataRouter({ key?, rowKey?, overlap?, onUnrouted?, selectionDebounce?, metricsInterval?, seq?, dedupe?, batch?, coalesce?, time?, now?, onWrite?, onConflict?, config? })Create a router. key is the partition (property or fn(row)) — optional, since a router whose routes all use fn(row) predicates never reads it; rowKey the default identity within a grid; overlap fans a record to all matching routes; onUnrouted is a sink for unmatched records (it receives the row on load and query, and the whole delta on apply). selectionDebounce is the debounce in ms for cross-grid selection refilters (default 16; 0 refilters synchronously). v10: metricsInterval is the ms between periodic on('metrics') emits (default 1000; 0 disables the timer). v3: seq (a version field or fn(row)) turns on ordered de-duplication, dedupe: false opts out; batch (interval ms or { intervalMs }) / coalesce: true buffer a high-frequency push. v4: time (a timestamp field or fn(row)) and an injectable now clock drive time-domain scrubbing. v8: onWrite/onConflict are the router-global write-back callbacks. v5: config is a declarative routing spec, desugared through configure.
attach(grid, predicate, { rowKey? })Route to grid when predicate matches: a value compared to key, or a fn(row) => boolean. rowKey overrides the router default for this grid.
attachDefault(grid, { rowKey? })The "rest" sink: the grid that receives every record no explicit route matched.
load(snapshot)Apply a full snapshot as a keyed diff per grid. Returns per-route { added, updated, removed } counts in attach order.
apply(deltas)Apply { op: 'upsert' | 'delete', row } deltas in place by rowKey.
unroutedHow many records matched no route. Reset to zero by load and by query, then running for deltas — so read it straight after the call you care about, not at the end of a session.
link(source, target, relation)v2: make a selection in source filter what target receives. relation is a key map { from, to } (target rows whose to value is among the selected source rows' from values — multi-select is an IN set, ANY match) or a function fn(selectedSourceRows) => (row) => boolean. No selection shows the full partition; changes are debounced.
flush()v2: apply any debounced selection refilter now, for a deterministic point (and for tests).
attach(grid, predicate, { rollup })v3: feed the grid a grouped/summarised view — rollup: { groupBy, aggregate } gives one summary row per group (op of sum/avg/min/max/count over a field, or a fn(rows)). Applied by keyed diff, so only a moved group repaints.
relate(edges)v3: declare a relationship graph. Each edge { from, to, on, mutual? }; the router resolves multi-hop chains, several sources into one target (AND), and mutual edges on any selection change. Composes with link().
push(delta)v3: feed a live delta. With a batch interval or coalesce: true it buffers and coalesces rapid updates to one key; otherwise it applies at once.
flushStream()v3: apply the buffered deltas now, coalesced into a single apply (a deterministic point, and for tests).
droppedv3: how many stale/duplicate deltas the seq dedupe gate has dropped.
lastSeq() / checkpoint() / seenThrough(mark)v3: the resume point — the highest applied seq, a per-record checkpoint to persist, and a way to prime it after a reconnect so an early replay is dropped.
subscribe(predicate, handler, opts?)v5: route a slice to any non-grid view. handler(change) receives the same keyed diff { add, update, remove } a grid does — drive a KPI tile, detail pane, map or form. A peer to attach: same partitioning and the same transform/filter/sort/rollup options; grids and charts are unchanged. To drive a KPI/pane and a grid off the same partition value, create the router with overlap: true — with the default overlap: false only the first route on that value receives rows (the router warns once when it detects the clash).
alert(predicate, condition, handler, { filter?, debounce?, rowKey? })v5: watch a slice and emit rather than render. condition(rows) is evaluated over the slice on every load and delta; when it first becomes truthy, handler(signal, rows) fires. Edge-triggered (once per crossing, re-arms on release), debounce coalesces a burst, and it never competes for a partition or touches a grid.
configure(spec)v5: the whole routing graph as one data spec — routes (grid/default/subscribe/alert entries), links, relate, buffer — desugared to the imperative API. Composes with imperative calls and round-trips to identical behaviour. Also accepted as createDataRouter({ config }).
attach(grid, predicate, { writable, onWrite?, onConflict? })v8 (BACKLOG-0000912): make a route writable — the router captures the grid's committed edits off its public edit surface (grid.on('cell:changed')grid.edit.setCells) and routes them to onWrite(change, { route, source }) (per-route here, or the router-global onWrite), reverting the cell on reject and re-entering an accepted write as a normal delta. onConflict(change, { serverRow }) surfaces a last-write-wins conflict. A derived (rollup/transform) route cannot be writable — its edits are reverted and warned.
attach(grid, predicate, { where })v7 (BACKLOG-0000914): a route-level where — a filter-wire condition { col, op, value } or an and/or/not group — used only by query-slice routing (query()): the router pushes it down to the engine where the adapter allows and finishes the residual client-side. Distinct from filter (a fn(row) that only ever runs in the browser).
attach(grid, predicate, { label, backpressure })v10/v13: a human label for the route (shown in metrics() and the devtools panel), and a per-route backpressure policy that throttles / coalesces / samples how that route's viewer is refreshed under load — without touching the keyed store or any other route. backpressure: { maxHz, minInterval?, sample?, maxLag? }: maxLag (a backlog depth) sets when it engages (below it, changes pass straight through); maxHz/minInterval cap the refresh rate; sample (an integer > 1) thins intermediate refreshes. A trailing flush always lands the latest state (deletes included), so the viewer converges and is never left stale.
flushBackpressure()v13 (BACKLOG-0000962): refresh every backpressured route to the latest state now. A route with a backpressure policy holds its viewer refresh until its rate limit or sample count allows one, so a test — or a teardown — can observe a route that has not caught up yet; this forces the deferred flush for every route at once and gives you a deterministic point. A no-op for routes without a policy or with nothing pending, and it never touches the keyed store: the rows were always current, only the refresh was held.
query(adapter, request?)v7 (BACKLOG-0000914): source the router from a DFQL/DuckDB (or any pushdown) adapter. Runs adapter.execute, partitions the result across the routes and drives the grids by the same keyed diff load() uses; a route's where is planned against the adapter's capabilities (pushed down where allowed, residual finished client-side). Composes with per-route transform/filter/sort/rollup and links/graph. Async — resolves once every slice is fetched and applied.
lastQueryPlan()v7: the pushed/residual split of the last query(), per fetch — whether a filter reached the engine and what work was left client-side. null before any query. Each entry is { route, pushedFilter, residual } for a where route — route the grid, pushedFilter whether its filter reached the engine, residual the work finished client-side — or { base: true, pushedFilter, residual } for the single base fetch that fed every route without a where. Provenance, so a slow slice is diagnosed rather than guessed.
buffer({ window?, max? })v4 (BACKLOG-0000911): turn on time-travel buffering — record the ordered, de-duplicated stream into a bounded ring (a time window in ms and/or a max delta count; eviction folds the oldest into a moving base, so memory never grows unbounded; a default cap applies if you name neither). Seeded from the current world, so it can be turned on at any time. Opt-in and off by default.
scrubTo(target, { by? })v4: scrub the grids to a past point — the base snapshot plus the buffered deltas up to target (a seq when the router has one, else a timestamp; { by: 'seq' | 'time' } chooses). Pushed by keyed diff, so each view keeps scroll and selection and only changed rows repaint. Live deltas keep arriving into the buffer but do not disturb the view.
replay(from, to, { speed?, by? })v4: walk an incident — scrub to from, then apply each buffered delta in (from, to] in order, one per speed ms (default 0). Returns a promise resolving when the range finishes (or is superseded); the router stays parked at to until live().
pause() / resume()v4: pause an in-flight replay at the current step and resume it from where it stopped. No-ops when nothing is replaying / not paused.
live()v4: return to the head — rebuild the base plus every buffered delta (including those that arrived while scrubbed) and push it by keyed diff, then resume normal live application. A single diff animates the view from the past straight to the present, keeping scroll and selection.
traveling / bufferedv4: whether the grids currently show a reconstructed past, and how many deltas are held in the bounded buffer.
broadcast({ channel })v6 (BACKLOG-0000913): mirror the router's ordered, de-duplicated deltas to other browser tabs/windows over a BroadcastChannel, so a grid popped into its own tab joins the same feed with no second socket. Each tab runs its own router on the same channel name; an inbound mirror is applied without re-broadcasting (no echo loop), and broadcast announces the tab so a peer holding the feed resyncs it mid-stream (snapshot + replay). Off by default; needs a seq/dedupe router to drop replayed deltas cleanly.
broadcastingv6: whether the router is currently mirroring to a BroadcastChannel.
addSource(feed, { map?, key? })v9 (BACKLOG-0000931): register a source feed — fan-in. Returns a handle (load/apply/push/remove, plus id/size) whose rows are normalized by map and namespaced by key (a prefix string, true to prefix with the source id, or a keyFn(row)) so ids from different feeds cannot collide, then merged through the router's ordinary path — partitioned, routed, linked, deduped, buffered and written back exactly as the single-source path. feed is an optional source id or an options object. A source may also carry a join spec (v11) to enrich its rows with fields looked up from another source.
removeSource(ref) / sources()v9: drop exactly the rows a feed contributed (by source id or handle) from every route and unregister it; and list the registered source ids.
metrics()v10 (BACKLOG-0000932): a cheap point-in-time observability snapshot — per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global unrouted / dropped / buffered / lag figures. Throughput is sampled over the interval since the last metrics() call or emit. Each entry in routes[] also carries its label and, when the route declares a backpressure policy, a backpressure: { pending, coalesced } object — pending is the held backlog since the last flush (the route's lag) and coalesced the cumulative change-events it has absorbed into deferred refreshes (null when the route has no policy).
on('metrics', handler)v10: subscribe to the periodic metrics emit (the metricsInterval ms, default 1000; 0 disables it). The timer runs only while at least one listener is registered and stops when the last is removed. Returns an unsubscribe function.
mountDevtools(el)v10: mount an opt-in, DOM-touching live panel (in the module's own devtools.js, so the core stays DOM-free) that renders metrics() into el and re-renders on each on('metrics') emit — so the panel's cadence is the router's metricsInterval, not a setting of its own. Returns a controller with refresh(), which forces an immediate re-render, and destroy(), which unsubscribes and removes the panel from the DOM. The same panel is available without a router: mountRouterDevtools(router, el) is a named export of modules/data-router/devtools.js, and mountDevtools is a one-line wrapper over it. Off unless called.
persist({ key?, debounce?, storage?, indexedDB?, dbName?, storeName? })v12 (BACKLOG-0000961): turn on durable persistence — snapshot the keyed store and the time-travel ring to a durable async key/value store so an offline reload or a browser refresh resumes exactly where it left off. The default backend is IndexedDB (native, no dependency), opened lazily and guarded so private-mode or blocked storage degrades to in-memory with a one-time warning rather than throwing. Writes a coalesced snapshot after each load/apply (debounced by debounce ms, default 250; 0 is eager). Pass storage — any object with async get(key)/set(key, value) — to use another backend (a server, a test double). Opt-in and off by default.
restore()v12: resume from the durable snapshot. Read the last persisted state and apply it — load the live head through the ordinary keyed diff (so grids attached before this call repaint only what differs), restore the resume checkpoint and, when the snapshot carried a time-travel ring, restore buffering and the ring so scrubTo/replay/live work straight after a reload. Call it once, after attaching the grids. async; resolves true when a snapshot was found and applied, false when persistence is off/degraded or nothing was stored.
flushPersist() / persistingv12: flush any pending durable write now (async; cancels the debounce and resolves once the write settles — for a beforeunload handler, a deterministic checkpoint, or a test), and whether durable persistence is on and not degraded to in-memory.
detach(grid)Stop routing to a grid and forget its slice; drop any link/edge it is part of (restoring a filtered sibling). The host still owns and destroys the grid.
destroy()Detach every grid, drop every link, edge and subscription. Detaches only — the host owns and destroys its grids.

Four things worth knowing before you build on this. attachDefault keeps one sink: calling it twice replaces the first, silently, along with whatever slice it held — attach the sink once, at setup. An alert has no removal: detach drops routes, links and graph edges but leaves alerts running against their own partition, so an alert added at setup keeps firing until destroy(), even after every grid is gone. detach does take a subscribe handler as well as a grid — pass the same function you handed subscribe and the subscription goes with it. And a join accepts three spellings the examples below do not use: on for localKey, fromKey for foreignKey, and select for fields; they are equivalent, and a spec the router cannot honour degrades to plain fan-in with a one-time warning rather than throwing.

The router keeps a small Map<rowKey, row> per route to compute the snapshot diff. That is deliberate for v1; a future optimisation could diff against the grid's own key index rather than a shadow copy. Ordering and dedupe across a live feed are the host's to guarantee — a caller that must drop stale out-of-order deltas can carry its own version or sequence field and filter before apply; v1 imposes no version scheme.

Cross-grid selection filtering (v2, BACKLOG-0000880). link keeps each target's full partition separate from what it currently shows: when the source's selection changes, the router recomputes the shown subset from the relation and re-pushes it through the same keyed-diff path, so the target grid stays dumb — it only ever receives rows, never a query or a reference to the source. Selection in the target survives an unrelated refilter, because the keyed path preserves it. No selection (or one the router cannot resolve to routed rows) shows the full partition, and deselecting restores it. The source grid must have selection enabled; still no grid-core change. Debounce is controlled by selectionDebounce (default 16 ms; 0 is synchronous), and flush() forces it.

Cross-grid selection filtering, executed

A customers grid and an orders grid off one feed; selecting customers filters the orders grid to their regions through the keyed-diff path, and deselecting restores the full set. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region', type: 'text' }];
const customers = createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
const orders = createHeadlessGrid({ rowKey: 'id', columns: cols });

// debounce 0 so a selection refilters synchronously in this example.
const router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
router.attach(customers, 'customer');
router.attach(orders, 'order');
router.load([
  { id: 'c1', type: 'customer', region: 'emea' },
  { id: 'c2', type: 'customer', region: 'amer' },
  { id: 'o1', type: 'order', region: 'emea' },
  { id: 'o2', type: 'order', region: 'amer' },
  { id: 'o3', type: 'order', region: 'emea' },
]);
// A selection in customers filters the orders grid by region.
router.link(customers, orders, { from: 'region', to: 'region' });

const full = orders.rows.count();          // 3: no selection, full partition
customers.selection.set(['c1']);            // emea
const oneRegion = orders.rows.count();     // 2: o1, o3
customers.selection.set(['c1', 'c2']);      // emea + amer (IN set)
const both = orders.rows.count();          // 3
customers.selection.set([]);                // deselect restores
const restored = orders.rows.count();      // 3

customers.destroy(); orders.destroy(); router.destroy();
return [full, oneRegion, both, restored].join(' | ');

Per-route transforms and route-level filter/sort (v3, BACKLOG-0000887). A route may reshape and narrow its slice before it reaches the grid, and the grid still stays dumb. attach(grid, predicate, opts) takes transform(row) => row' (map/rename/derive), filter(row) => boolean (the grid receives only the subset), and sort (a comparator or { key, dir }, ordering what the grid receives). Filtering and the cross-grid link predicates run on the original row; the transform then produces the display row, and identity stays the row's rowKey, so the keyed diff is unaffected — an unchanged transformed row never repaints, and a delta that pushes a row across the filter threshold makes it enter or leave the view.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });

// This route shows only amt >= 20, sorted high-to-low, with a derived label.
router.attach(g, 'order', {
  filter: (row) => row.amt >= 20,
  sort: { key: 'amt', dir: 'desc' },
  transform: (row) => ({ ...row, label: `#${row.id}:${row.amt}` }),
});
router.load([
  { id: 'o1', type: 'order', amt: 10 },   // filtered out
  { id: 'o2', type: 'order', amt: 30 },
  { id: 'o3', type: 'order', amt: 20 },
]);
const keys = [];
for (let i = 0; i < g.rows.count(); i++) keys.push(g.rows.get(i).key);

// A delta below the threshold is held; raising it brings it into the view.
router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 5 } }]);
const held = g.rows.count() === 2 ? 'held' : 'leaked';
router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 40 } }]);
const shown = g.rows.count() === 3 ? 'shown' : 'missing';

const out = [g.rows.count() >= 2 ? 2 : 0, keys.join(','), g.rows.value('o2', 'label'), held, shown];
g.destroy(); router.destroy();
return out.join(' | ');

Aggregate/rollup routes (v3, BACKLOG-0000887). A route can be fed a grouped, summarised view of its partition instead of the raw rows — a per-category total for a chart route, say. attach(grid, predicate, { rollup: { groupBy, aggregate } }) gives the grid one summary row per group: groupBy is a property, a fn(row) or an array of either, and each aggregate entry is a { op, field } (sum, avg, min, max, count) or a fn(rows) => value. A route filter runs on the raw rows before grouping; sort and transform run on the summaries. The summary is applied by the same keyed diff, so only a group that actually moved repaints — the router owns the roll-up, the grid stays dumb.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'region', field: 'region' }, { id: 'total', field: 'total', type: 'number' }];
const chart = createHeadlessGrid({ rowKey: 'region', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });

// One summary row per region, the per-region total, biggest first.
router.attach(chart, 'order', {
  rollup: { groupBy: 'region', aggregate: { total: { op: 'sum', field: 'amt' } } },
  sort: { key: 'total', dir: 'desc' },
});
router.load([
  { id: 'o1', type: 'order', region: 'emea', amt: 10 },
  { id: 'o2', type: 'order', region: 'amer', amt: 20 },
  { id: 'o3', type: 'order', region: 'emea', amt: 5 },
]);
const groups = chart.rows.count();     // 2: emea (15), amer (20)
const top = chart.rows.get(0).key;     // amer — highest total

// A new emea order repaints only the emea summary (keyed diff).
router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', region: 'emea', amt: 100 } }]);
const emeaTotal = chart.rows.value('emea', 'total'); // 115

chart.destroy(); router.destroy();
return [groups, top, emeaTotal].join(' | ');

The relationship graph (v3, BACKLOG-0000887). relate([...]) is the scalable form of v2's pairwise link(). Each edge is { from, to, on }, where on is a key map { from, to } or a function fn(sourceRows) => (row) => boolean. The router resolves the whole graph on any selection change, so it handles multi-hop chains (A→B→C: a selection in A narrows B and, through B's resulting rows, C — with no selection in B), several sources into one target (their filters AND together), and mutual edges (mutual: true — selecting in either linked view narrows the other; requires a key-map on). A node's effective set is its own selection when it has one, otherwise the rows its incoming edges leave — that is what carries a selection transitively down a chain. Additive to link(); the two compose.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region', type: 'text' }, { id: 'orderId', field: 'orderId', type: 'text' }];
const mk = () => createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
const customers = mk(), orders = mk(), lines = mk();
const router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
router.attach(customers, 'customer'); router.attach(orders, 'order'); router.attach(lines, 'line');
router.load([
  { id: 'c1', type: 'customer', region: 'emea' }, { id: 'c2', type: 'customer', region: 'amer' },
  { id: 'o1', type: 'order', region: 'emea' }, { id: 'o2', type: 'order', region: 'amer' }, { id: 'o3', type: 'order', region: 'emea' },
  { id: 'l1', type: 'line', orderId: 'o1' }, { id: 'l2', type: 'line', orderId: 'o2' }, { id: 'l3', type: 'line', orderId: 'o3' }, { id: 'l4', type: 'line', orderId: 'o1' },
]);
// customers -> orders (by region) -> lines (by order id): a two-hop chain.
router.relate([
  { from: customers, to: orders, on: { from: 'region', to: 'region' } },
  { from: orders, to: lines, on: { from: 'id', to: 'orderId' } },
]);
customers.selection.set(['c1']); // emea; no selection in orders
const keysOf = (g) => { const a = []; for (let i = 0; i < g.rows.count(); i++) a.push(g.rows.get(i).key); return a.sort().join(','); };
const out = [keysOf(orders), keysOf(lines)]; // o1,o3 | l1,l3,l4
customers.destroy(); orders.destroy(); lines.destroy(); router.destroy();
return out.join(' | ');

Stream hygiene (v3, BACKLOG-0000887). A production feed arrives out of order, gets replayed, and comes faster than a grid should repaint. Configure a seq (a version field or fn(row)) and the router orders each batch by it and drops any delta not newer than the one it already applied for that record (counted in router.dropped) — an out-of-order or replayed feed converges to the newest state. push(delta) with a batch interval or coalesce: true buffers a high-frequency feed and coalesces rapid updates to one key into a single apply (flush a deterministic point with flushStream()). When the host's own connection drops and it reconnects, resume precisely: load a fresh snapshot (a keyed diff that preserves grid state) and replay from lastSeq()/checkpoint() — the deltas the router already saw are dropped by the same gate. The router does not detect or recover from the drop itself; see a live WebSocket feed for the worked reconnect example. seenThrough(mark) primes the checkpoint from a persisted one.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
const router = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
router.attach(g, () => true);
router.load([]);

router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]); // stale — dropped
const n = g.rows.value('a', 'n'); // 30: the stale delta did not clobber it
const dropped = router.dropped;   // 1
router.apply([{ op: 'upsert', row: { id: 'a', n: 99, v: 4 } }]); // fresh
const last = router.lastSeq();    // 4

g.destroy(); router.destroy();
return [n, dropped, last].join(' | ');

A live WebSocket feed (BACKLOG-0001259). The router never opens a connection itself — there is no new WebSocket anywhere in modules/data-router. The host owns the connection; the router owns everything once a message has arrived. Wire a socket's onmessage to the two entry points above: a snapshot message's rows go to load(), a delta message's changes go to apply() (or push(), batched, for a fast feed). Nothing else changes for a real WebSocket, an EventSource, a CDC feed or a message bus — the router takes rows, never a URL or a socket, so the transport is always the host's choice. On a drop, the reconnect pattern is the same snapshot-plus-replay shown above: capture lastSeq()/checkpoint() before the drop, load() a fresh snapshot on the new connection, and let the feed replay from around the last point — anything already applied is dropped by the same seq gate, not re-applied.

A live WebSocket feed, with reconnect, executed

A socket-shaped feed (modules/mock-socket, which frames messages exactly as a real WebSocket does — the same onmessage, the same JSON-framed event.data) drives the router; the connection then drops and reconnects, and a replayed delta already applied is dropped by the seq checkpoint while a genuinely new one lands. Swapping in a real WebSocket is a one-line constructor change — see the mock socket. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const { MockWebSocket } = await import('../packages/modules/mock-socket/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'label', field: 'label' }] });
const router = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
router.attach(g, () => true);

// THE INTEGRATION: a snapshot message loads, a delta message applies. This is
// exactly what a page writes against a real `new WebSocket(url)`.
function wireRouter(r, socket) {
  socket.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.kind === 'snapshot') r.load(msg.rows);
    else if (msg.kind === 'delta') r.apply(msg.changes);
  };
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// First connection: live through v:3, then the socket drops.
function* feedA() {
  yield { kind: 'snapshot', rows: [] };
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@1', v: 1 } }] };
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@3', v: 3 } }] };
}
const socketA = new MockWebSocket({ feed: feedA(), rate: 5, jitter: 0, snapshotDelay: 5 });
wireRouter(router, socketA);
await wait(80);
const beforeDrop = g.rows.value('a', 'label');  // A@3
const resumeFrom = router.lastSeq();             // 3 — the resume cursor
socketA.close();

// Reconnect: the server answers with a fresh snapshot plus a replay that
// includes two deltas already applied (v:1, v:3) and one truly new one (v:4).
function* feedB() {
  yield { kind: 'snapshot', rows: [{ id: 'a', label: 'A@3', v: 3 }] };
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@1', v: 1 } }] }; // replayed
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@3', v: 3 } }] }; // replayed
  yield { kind: 'delta', changes: [{ op: 'upsert', row: { id: 'a', label: 'A@4', v: 4 } }] }; // new
}
const droppedBefore = router.dropped;
const socketB = new MockWebSocket({ feed: feedB(), rate: 5, jitter: 0, snapshotDelay: 5 });
wireRouter(router, socketB);
await wait(80);
const afterReconnect = g.rows.value('a', 'label');        // A@4 — only the new delta advanced it
const replaysDropped = router.dropped - droppedBefore;    // 2 — both replays dropped
socketB.close();

g.destroy(); router.destroy();
return [beforeDrop, resumeFrom, afterReconnect, replaysDropped].join(' | ');

One feed, three grids, executed

A single snapshot fanned to an orders grid, an invoices grid and a "rest" sink, then a delta that changes a row's partition — proving the fan-out, the sink, and that a moved row leaves its old grid and joins the new one rather than being duplicated. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }];
const orders = createHeadlessGrid({ rowKey: 'id', columns: cols });
const invoices = createHeadlessGrid({ rowKey: 'id', columns: cols });
const rest = createHeadlessGrid({ rowKey: 'id', columns: cols });

// One router keyed on `type`; each grid sees only its slice, the "rest" sink
// catches anything that matches no explicit route.
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(orders, 'order');
router.attach(invoices, 'invoice');
router.attachDefault(rest);

// One snapshot hydrates all three grids at once.
router.load([
  { id: 'o1', type: 'order', amt: 10 },
  { id: 'o2', type: 'order', amt: 20 },
  { id: 'i1', type: 'invoice', amt: 99 },
  { id: 'x1', type: 'ticket', amt: 1 },   // matches no route -> the sink, not dropped
]);
const fanned = [orders.rows.count(), invoices.rows.count(), rest.rows.count()].join(',');

// o2's partition changes: it MOVES from orders to invoices, not duplicated.
router.apply([{ op: 'upsert', row: { id: 'o2', type: 'invoice', amt: 25 } }]);
const moved = orders.rows.count() + '/' + invoices.rows.count();

orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
return [fanned, moved, router.unrouted].join(' | ');

Time-travel buffering (v4, BACKLOG-0000911). buffer({ window?, max? }) records the ordered, de-duplicated stream into a bounded ring on top of a base snapshot, so a consumer can scrubTo a past point, replay a range (pause/resume it), and jump back to live() — every reconstructed state pushed to the grids by the ordinary keyed diff, so views keep scroll and selection and only changed rows repaint. The bound is a time window and/or a max delta count; eviction folds the oldest delta into a moving base, so memory stays bounded. Live deltas keep arriving into the buffer while scrubbed but do not disturb the (time-travelled) view; traveling and buffered report the state. Opt-in and off by default — a router that never calls buffer() behaves exactly as v1/v2/v3.

Scrub and return to live, executed

A versioned feed buffered into a bounded ring; the view scrubs to a past seq, reads the reconstructed value, then returns to the live head. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
const router = createDataRouter({ rowKey: 'id', seq: 'v' });
router.attach(g, () => true);
router.buffer({ max: 100 });               // record into a bounded ring
router.apply([{ op: 'upsert', row: { id: 'a', n: 10, v: 1 } }]);
router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]);
router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
const head = g.rows.value('a', 'n');       // 30: the live head
router.scrubTo(1, { by: 'seq' });          // reconstruct the state at seq 1
const past = g.rows.value('a', 'n');       // 10
const traveling = router.traveling;        // true
router.live();                             // back to the head, by keyed diff
const back = g.rows.value('a', 'n');       // 30

g.destroy(); router.destroy();
return [head, past, traveling, back].join(' | ');

Cross-tab / pop-out window sync (v6, BACKLOG-0000913). broadcast({ channel }) mirrors the router's ordered, de-duplicated deltas to other browser tabs/windows over a BroadcastChannel, so a routed grid popped into its own tab joins the same feed with no second socket. Each tab runs its own router on the same channel name and applies the mirrored deltas through the ordinary keyed-diff path, so its grids stay dumb and keep scroll/selection. What is mirrored is exactly what the router applied (post-order, post-dedupe); an inbound mirror is applied without re-broadcasting, so there is no echo loop. Calling broadcast announces the tab, and any peer already holding the feed answers with a snapshot (current world + resume checkpoint) so the new tab resyncs mid-stream via the v3 reconnect path. Off by default; broadcasting reports whether it is on, destroy() closes the channel. Needs a seq/dedupe router to drop replayed deltas cleanly. (Not demonstrated headless: it depends on the browser's BroadcastChannel delivering across tabs asynchronously.)

DFQL/DuckDB query-slice routing (v7, BACKLOG-0000914). query(adapter, request?) sources the router from a query rather than a pushed feed: it runs adapter.execute (any pushdown adapter — a DFQL/DuckDB one, or a createPushdownSource-style object with capabilities and execute(query)), partitions the result across the routes, and drives the grids by the same keyed diff load() uses. Where a route declares a where (a filter-wire condition { col, op, value } or an and/or/not group), that filter is pushed down into the engine where the adapter's capability model allows and the residual is finished client-side; routes without a where share one base query and are partitioned client-side. It composes with the per-route transform/filter/sort/rollup (v3) and cross-grid links/graph, and lastQueryPlan() reports the pushed/residual split per fetch. Async.

Query-slice routing, executed

Two routes over one query, each with its own where; a capability-free adapter pushes nothing, so each residual is finished client-side, and the plan records the split. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }];
const big = createHeadlessGrid({ rowKey: 'id', columns: cols });
const small = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });

// Each route carries a `where`, pushed down where the adapter allows.
router.attach(big, 'order', { where: { col: 'amt', op: 'gte', value: 20 } });
router.attach(small, 'order', { where: { col: 'amt', op: 'lt', value: 20 } });

// A capability-free adapter: nothing pushes, so every `where` is residual.
const adapter = {
  capabilities: {},
  execute: async (query) => ({ rows: [
    { id: 'o1', type: 'order', amt: 10 },
    { id: 'o2', type: 'order', amt: 30 },
    { id: 'o3', type: 'order', amt: 20 },
  ] }),
};
await router.query(adapter);
const bigN = big.rows.count();            // 2: o2, o3 (amt >= 20)
const smallN = small.rows.count();        // 1: o1 (amt < 20)
const plan = router.lastQueryPlan();       // per-fetch pushed/residual split

big.destroy(); small.destroy(); router.destroy();
return [bigN, smallN, plan.length].join(' | ');

Write-back routing (v8, BACKLOG-0000912). A route made writableattach(grid, predicate, { writable: true }) — has its grid's committed edits routed back to a write target the host persists. The router captures edits off the grid's public edit surface (it subscribes to grid.on('cell:changed') and re-enters accepted writes through grid.edit.setCells, so grid-core is untouched) and hands each change to onWrite(change, { route, source }) — the per-route callback here, or the router-global onWrite passed to createDataRouter. A rejected write reverts the cell; an accepted one re-enters as a normal delta. onConflict(change, { serverRow }) surfaces a last-write-wins conflict. A derived route (one carrying rollup or transform) cannot be writable — its edits are reverted and warned. (Documented here from the shipped surface; the grid-driven write-back commit path is demonstrated by the grid's own write-back example above rather than repeated on the router.)

Fan-in: many feeds, one router (v9, BACKLOG-0000931). One router can ingest many feeds. addSource(feed, { map?, key? }) returns a per-feed handle (load/apply/push/remove, plus id and size) whose rows are normalized to the common shape by map and namespaced by key (a prefix string, true to prefix with the source id, or a keyFn(row)) so ids from different feeds cannot collide in the shared keyed store, then merged through the router's ordinary apply/load path — partitioned, routed, linked, deduped, buffered and (v8) written back exactly as the single-source path. removeSource(ref) (or the handle's remove) drops exactly the rows a feed contributed; sources() lists the registered ids.

Fan-in from two feeds, executed

Two feeds with a colliding raw id, namespaced per source so they merge without clobbering; removing one feed drops exactly its rows. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'n', field: 'n', type: 'number' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(g, 'order');

// Two feeds, each namespaced by its source id so raw ids cannot collide.
const crm = router.addSource('crm', { key: true });
const erp = router.addSource('erp', { key: true });
crm.load([{ id: '1', type: 'order', n: 10 }, { id: '2', type: 'order', n: 20 }]);
erp.load([{ id: '1', type: 'order', n: 99 }]);  // same raw id '1' — merged, not clobbered
const merged = g.rows.count();               // 3
const ids = router.sources().join(',');       // crm,erp
const held = crm.size;                        // 2 — what this one feed holds live

erp.remove();                                  // drops exactly erp's row; the handle knows its own feed
const afterRemove = g.rows.count();          // 2

g.destroy(); router.destroy();
return [merged, ids, held, afterRemove].join(' | ');

Fan-in JOIN / enrichment (v11, BACKLOG-0000957). Fan-in above merges feeds side by side; a join spec goes further and enriches one feed's rows with fields looked up from another registered source — e.g. an orders feed enriched with name/tier from a customers source keyed by customerId. Declared per source: addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name', 'tier'], missing: 'hold' } }). localKey is the field on the enriched (left) row holding the foreign key (a field name or fn(row)); foreignKey is the field matched on the lookup row (defaults to localKey's name); fields is what to pull — an array, a { src: dest } rename map, or select(lookupRow, leftRow) => object. No second store is built: the lookup source is an ordinary fan-in source, and the join probes its existing keyed store by an index of join-key → store-id. missing chooses what happens when the lookup is absent or late: hold withholds the row from viewers until its lookup arrives, passthrough (the default) lets it flow unenriched, and null flows it with the pulled fields set to null. Enriched rows reach viewers through the ordinary keyed-diff path. Late lookups re-enrich: when a lookup row arrives, changes, or is deleted, every already-seated left row that references it is re-enriched and re-emitted — a held row is released, a null/passthrough row gains its fields, and a row whose lookup vanished is nulled/stripped (or, under hold, withheld again). Enrichment always recomputes from the untouched base row, so it is idempotent.

Durable resume and backpressure (v12/v13, BACKLOG-0000961 / BACKLOG-0000962). persist({ key }) turns on durability: the router snapshots its keyed store and time-travel ring to a durable async store (IndexedDB by default, or any { get, set } you pass as storage) after each load/apply, and await router.restore() — called once after the grids are attached — rehydrates them through the ordinary keyed diff, so an offline reload or a browser refresh resumes exactly where it left off (blocked/private storage degrades to in-memory with a one-time warning, never a throw). Independently, a route can declare backpressureattach(grid, type, { label, backpressure: { maxHz } }) — to cap how often its viewer repaints under load without slowing the store or any sibling route: maxHz/minInterval rate-limit the refresh, sample thins intermediate ones, and maxLag sets the backlog depth at which throttling engages; a trailing flush always lands the latest state so the viewer converges. What it cost is observable: router.metrics().routes[].backpressure is { pending, coalesced } — the held backlog and the cumulative change-events folded into deferred refreshes (null for a route with no policy).

A JOIN with a late lookup, executed

An order arrives before its customer, so under hold it is withheld; when the customer feed loads, the order is released and enriched with the looked-up name. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'kind', field: 'kind' }, { id: 'customerId', field: 'customerId' }, { id: 'name', field: 'name' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(g, 'order');

// Enrich orders with a customer name looked up by customerId; hold until known.
const orders = router.addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name'], missing: 'hold' } });
const customers = router.addSource('customers');

orders.load([{ id: 'O1', kind: 'order', customerId: 'C1', amount: 100 }]);
const held = g.rows.count();                 // 0 — withheld until the lookup arrives

customers.load([{ id: 'C1', kind: 'customer', customerId: 'C1', name: 'Acme' }]);
const released = g.rows.count();             // 1 — released and enriched
let name;
for (let i = 0; i < g.rows.count(); i++) { const r = g.rows.get(i); if (r.key === 'O1') name = r.data.name; }

g.destroy(); router.destroy();
return [held, released, name].join(' | ');

Observability (v10, BACKLOG-0000932). metrics() is a cheap point-in-time snapshot of the router's runtime — per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global unrouted / dropped (duplicate) / buffered (buffer depth) / lag figures. Throughput is sampled, so it is measured over the interval since the last read or emit. on('metrics', handler) drives it on a periodic timer (the metricsInterval ms, default 1000; 0 disables it) and returns an unsubscribe — the timer runs only while a listener is registered, so collection is off-by-default. mountDevtools(el, { interval? }) mounts an opt-in DOM panel (in the module's own devtools.js, so the core stays DOM-free) that renders metrics() and refreshes on each emit.

A metrics snapshot, executed

A snapshot fanned to a route and a sink; the metrics read reports the route's row count and the unrouted total, and on('metrics') returns an unsubscribe. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id', metricsInterval: 0 });  // 0: no periodic emit; read on demand
router.attach(g, 'order');

const off = router.on('metrics', () => {});   // register; returns unsubscribe (no timer at interval 0)
router.load([
  { id: 'o1', type: 'order' },
  { id: 'o2', type: 'order' },
  { id: 'x1', type: 'ticket' },               // matches no route
]);
const m = router.metrics();
const rows = m.routes[0].rows;               // 2
const unrouted = m.unrouted;                 // 1
off();                                        // last listener gone

g.destroy(); router.destroy();
return [rows, unrouted, typeof off].join(' | ');

The remaining options, each executed

One short example per option family the eight above do not reach: overlap and the unrouted sink, coalesced pushes, write-back, the declarative graph, durable resume, backpressure, and time-domain scrubbing. Each is run headless on every build.

Fan one value to two viewers, and log the strays, executed

With overlap a record goes to every route it matches, so a grid and a second viewer can share one partition; onUnrouted receives what matched none, which is the right sink when a stray record is a bug to log rather than a row to show. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const g1 = createHeadlessGrid({ rowKey: 'id', columns: cols });
const g2 = createHeadlessGrid({ rowKey: 'id', columns: cols });
const strays = [];
const router = createDataRouter({ key: 'type', rowKey: 'id', overlap: true, onUnrouted: (row) => strays.push(row.id) });
router.attach(g1, 'order');
router.attach(g2, 'order');            // a second viewer on the same value: allowed because overlap is on
router.load([{ id: 'o1', type: 'order' }, { id: 'x1', type: 'ticket' }]);
const out = [g1.rows.count(), g2.rows.count(), strays.join(','), router.unrouted];
g1.destroy(); g2.destroy(); router.destroy();
return out.join(' | ');

Coalesce a burst into one repaint, executed

A high-frequency feed goes through push; with coalesce (or a batch interval) rapid updates to one key fold into a single apply, and flushStream gives a deterministic point. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

// A hand-rolled viewer, grid-shaped: the router accepts anything with rows.apply.
const rows = new Map(); let applies = 0;
const g = { rows: { apply(change) {
  applies += 1;
  for (const row of [...(change.add || []), ...(change.update || [])]) rows.set(row.id, row);
} } };
const router = createDataRouter({ rowKey: 'id', coalesce: true, batch: { intervalMs: 50 } });
router.attach(g, () => true);
router.push({ op: 'upsert', row: { id: 'a', n: 1 } });
router.push({ op: 'upsert', row: { id: 'a', n: 2 } });
router.push({ op: 'upsert', row: { id: 'a', n: 3 } });
const before = applies;                    // 0: nothing applied inside the batch window
router.flushStream();                      // one apply, carrying the latest value
const after = applies;
const n = rows.get('a').n;
router.destroy();
return [before, after, n].join(' | ');

A writable route, a conflict, executed

A route attached writable captures the grid’s committed edits and routes them to onWrite; when the server answers with a conflict, onConflict fires with the server row and the optimistic value stands (last-write-wins, no merge engine). Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number', edit: true }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const conflicts = [];
const router = createDataRouter({ key: 'type', rowKey: 'id', onConflict: (change, ctx) => conflicts.push(ctx.serverRow.amt) });
router.attach(g, 'a', { writable: true, onWrite: () => ({ conflict: { id: 'r1', type: 'a', amt: 7 } }) });
router.load([{ id: 'r1', type: 'a', amt: 10 }]);
g.edit.setCells([{ key: 'r1', colId: 'amt', value: 99 }]);   // the user's edit, routed to onWrite
const out = [conflicts.length, conflicts[0], g.rows.byKey('r1').data.amt];
g.destroy(); router.destroy();
return out.join(' | ');

The routing graph as one declarative spec, executed

The same routes and links the imperative calls would make, as data: routes attach a grid when a value matches, links filter one grid by another’s selection. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region' }];
const customers = createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
const orders = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
router.configure({
  routes: [{ grid: customers, when: 'customer' }, { grid: orders, when: 'order' }],
  links: [{ from: customers, to: orders, on: { from: 'region', to: 'region' } }],
});
router.load([
  { id: 'c1', type: 'customer', region: 'emea' },
  { id: 'o1', type: 'order', region: 'emea' },
  { id: 'o2', type: 'order', region: 'amer' },
]);
const all = orders.rows.count();           // 2: no selection shows the whole partition
customers.selection.set(['c1']);
router.flush();
const linked = orders.rows.count();        // 1: only emea orders
customers.destroy(); orders.destroy(); router.destroy();
return [all, linked].join(' | ');

Durable resume through a store of your own, executed

The router snapshots to IndexedDB by default — dbName and storeName say where, and indexedDB lets you hand it a factory (here a tiny fake, so the example is deterministic) — or to any storage with async get/set. A second router over the same store restores what the first one wrote. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

// A fake IDBFactory: one database, one object store, put/get in a transaction.
const dbs = new Map(); const opened = [];
const req = () => ({ onsuccess: null, onerror: null, onupgradeneeded: null, result: undefined, error: null });
const fakeIndexedDB = { open(name) {
  const r = req(); opened.push(name);
  queueMicrotask(() => {
    const fresh = !dbs.has(name); if (fresh) dbs.set(name, new Map());
    const stores = dbs.get(name);
    r.result = {
      objectStoreNames: { contains: (n) => stores.has(n) },
      ['createObjectStore'](n) { stores.set(n, new Map()); return {}; },
      transaction(n) {
        const store = stores.get(Array.isArray(n) ? n[0] : n); const tx = { oncomplete: null, onerror: null, onabort: null }; const ops = [];
        tx.objectStore = () => ({
          put(v, k) { const q = req(); ops.push(() => { store.set(k, v); if (q.onsuccess) q.onsuccess({ target: q }); }); return q; },
          get(k) { const q = req(); ops.push(() => { q.result = store.get(k); if (q.onsuccess) q.onsuccess({ target: q }); }); return q; },
        });
        queueMicrotask(() => { for (const op of ops) op(); if (tx.oncomplete) tx.oncomplete(); });
        return tx;
      },
      close() {},
    };
    if (fresh && r.onupgradeneeded) r.onupgradeneeded({ target: r });
    if (r.onsuccess) r.onsuccess({ target: r });
  });
  return r;
} };

const cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
const a = createDataRouter({ key: 'type', rowKey: 'id' });
const ga = createHeadlessGrid({ rowKey: 'id', columns: cols });
a.attach(ga, 'order');
a.persist({ indexedDB: fakeIndexedDB, dbName: 'demo', storeName: 'router', debounce: 0 });
a.load([{ id: 'o1', type: 'order' }, { id: 'o2', type: 'order' }]);
await a.flushPersist();

const b = createDataRouter({ key: 'type', rowKey: 'id' });      // "after the reload"
const gb = createHeadlessGrid({ rowKey: 'id', columns: cols });
b.attach(gb, 'order');
b.persist({ indexedDB: fakeIndexedDB, dbName: 'demo', storeName: 'router' });
const found = await b.restore();
const store = [...dbs.get('demo').keys()][0];

// Or skip IndexedDB entirely: any async get/set pair is a store.
const mem = new Map();
const c = createDataRouter({ key: 'type', rowKey: 'id' });
c.attach(createHeadlessGrid({ rowKey: 'id', columns: cols }), 'order');
c.persist({ storage: { get: async (k) => mem.get(k), set: async (k, v) => { mem.set(k, v); } }, debounce: 0 });
c.load([{ id: 'o9', type: 'order' }]);
await c.flushPersist();
const out = [opened[0], store, found, gb.rows.count(), b.persisting, mem.size];
ga.destroy(); gb.destroy(); a.destroy(); b.destroy(); c.destroy();
return out.join(' | ');

Throttle one viewer under load, executed

A backpressure policy on one route caps how often that viewer is refreshed without slowing the store or any sibling; a burst inside the window is held to one leading refresh, and flushBackpressure lands the coalesced latest state. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

let clock = 0;
const router = createDataRouter({ key: 'type', rowKey: 'id', now: () => clock });
const view = { refreshes: 0, rows: new Map() };
router.subscribe('x', (change) => {
  view.refreshes += 1;
  for (const row of [...change.add, ...change.update]) view.rows.set(row.id, row);
  for (const key of change.remove) view.rows.delete(key);
}, { backpressure: { maxHz: 100 } });      // at most one refresh per 10 ms
const up = (id, n) => ({ op: 'upsert', row: { id, type: 'x', n } });
router.apply([up('a', 1)]);                // leading edge: refreshes at once
router.apply([up('b', 2)]);                // inside the window: held
router.apply([up('c', 3)]);
router.apply([up('a', 9)]);                // still held; a's latest value wins
const held = view.refreshes;               // 1
clock = 10;
router.flushBackpressure();                // window open: one trailing refresh
const out = [held, view.refreshes, view.rows.get('a').n, view.rows.size];
router.destroy();
return out.join(' | ');

Scrub by wall-clock time, executed

When rows carry a timestamp, time names it and the buffer works in the time domain: scrubTo takes a moment rather than a seq, and live returns to the head. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const cols = [{ id: 'id', field: 'id' }, { id: 'ts', field: 'ts', type: 'number' }];
const g = createHeadlessGrid({ rowKey: 'id', columns: cols });
const router = createDataRouter({ rowKey: 'id', time: 'ts' });
router.attach(g, () => true);
router.buffer({ window: 10000 });
router.apply([{ op: 'upsert', row: { id: 'a', ts: 100 } }]);
router.apply([{ op: 'upsert', row: { id: 'b', ts: 200 } }]);
router.apply([{ op: 'upsert', row: { id: 'c', ts: 300 } }]);
router.scrubTo(150, { by: 'time' });       // what the grid showed at t=150
const then = g.rows.count();               // 1
router.live();
const now = g.rows.count();                // 3
g.destroy(); router.destroy();
return [then, now].join(' | ');

The Gantt module

modules/gantt is a separate, opt-in project-planning module — its own bundle, imported only when you want it, changing nothing in the grid core. It turns a task list into a real schedule: a CPM (Critical Path Method) engine computes each task's early/late start and finish, its slack (total float), and the zero-float critical path, recomputing on every edit. computeSchedule(tasks, deps) is the pure engine; createGantt(opts) is a controller that holds the model, recomputes on setTasks/setDependencies/applyEdit, and emits schedule (or error). Dependencies are the four standard link types — LINK_TYPES is ['FS','SS','FF','SF'] — each with optional lag/lead. A milestone is a zero-duration task scheduled as a point; a summary task (any task named as another's parent) is derived from its children (start = earliest child, end = latest child, duration-weighted progress) and is not scheduled itself. Bad input never throws or loops: a dependency cycle is refused and reported with a code from SCHEDULE_ERROR, and findViolations flags any task placed earlier than its predecessors allow. toISODate converts an engine day-number back to a calendar date for display.

Feed it the rows you already have. fields names your own task properties for the scheduler — { id: 'taskId', start: 'startDate', name: 'jobName', duration: 'dur' }, each a field name or a reader (row) => value — so a task list arrives as it is rather than being renamed first. The vocabulary is id, name, start, end, duration, milestone, percentComplete, parent, baselineStart, baselineEnd, constraint and constraintDate; anything you leave unmapped reads its canonical name, so an existing plan is unaffected. rowKey reaches the scheduler too, so a row carrying taskId and no id is identified by it — a task's own id still wins where it has one. It is a read mapping: applyEdit and level() write the canonical property, so each says so plainly rather than writing to a field the schedule is not read from, and assignee/cost/actualCost belong to the resource and earned-value layers rather than to this list.

import { createGantt, computeSchedule } from '@toclocoinc/lattice-grid/modules/gantt';

const plan = createGantt({
  tasks: [
    { id: 'design', duration: 5 },
    { id: 'build', duration: 6 },
    { id: 'launch', milestone: true },
  ],
  dependencies: [
    { from: 'design', to: 'build', type: 'FS' },
    { from: 'build', to: 'launch', type: 'FS' },
  ],
});
plan.on('schedule', (s) => console.log(s.critical, s.projectDuration));
plan.applyEdit({ id: 'design', duration: 7 }); // recomputes; the critical path shifts
FunctionWhat it does
createGantt({ tasks?, dependencies?, projectStart?, autoSchedule?, grid?, fields?, rowKey? })Create a controller over a task list and a dependency list. Computes the CPM schedule immediately and on every edit; on('schedule'|'error', fn) subscribes; applyEdit/setTasks/setDependencies mutate and recompute; grid is kept for the write-back binding. fields maps your own task property names onto the ones the scheduler reads, and rowKey identifies a task for both rows.apply and the schedule.
rows.apply(change) / rows.forEach(fn) / rows.countThe live consumer surface, the same keyed diff a grid, a board and a KPI panel accept, so one feed — or one tab strip — drives them all: apply({ add, update, remove }) upserts by rowKey and recomputes, forEach visits every task, and count is how many the plan holds.
computeSchedule(tasks, deps?, { projectStart? })The pure CPM engine: forward/backward passes over the leaf tasks honouring FS/SS/FF/SF + lag, slack/float and the zero-float critical path, with summaries derived and cycles refused. Returns { ok, tasks, critical, criticalPaths, projectDuration, ... } or { ok:false, error }.
findViolations(tasks, schedule)The tasks whose user-placed start begins earlier than CPM allows (the manual-with-validation flag). Summaries, whose dates are derived, are skipped.
toISODate(day)Format an engine day-number as an ISO calendar date (YYYY-MM-DD, UTC).
LINK_TYPESThe four dependency link types, in order: ['FS','SS','FF','SF'].
SCHEDULE_ERRORThe error codes the engine reports rather than throwing (cycle, duplicate-id, unknown-task, bad-duration, bad-link-type, unknown-parent, parent-cycle, …).
importMSPDI(xml, { hoursPerDay? })Import a Microsoft Project (MSPDI) .xml document into a { tasks, dependencies, resources, projectStart, calendar } model ready for createGantt: the task tree, typed dependencies with lag, constraints, baseline, %complete, resources with capacity and the resource assignments.
exportMSPDI(model, { hoursPerDay?, projectName? })Serialise a Gantt model (optionally a scheduled one) back to Microsoft Project (MSPDI) XML — the same fields, round-tripping with importMSPDI. The controller offers gantt.toMSPDI() as a shortcut over the current plan.
computeEarnedValue(tasks, schedule, { statusDate?, costField?, actualCostField? })Earned-value management (EVM) from the baseline and %complete at a status date: Planned Value (PV/BCWS), Earned Value (EV/BCWP), Actual Cost (AC/ACWP, from a per-task actualCost), plus Schedule Variance (EV−PV), Cost Variance (EV−AC), SPI (EV/PV) and CPI (EV/AC) — per task, rolled up to summaries and the project. Budget (BAC) is the task's cost, or its duration when no cost is given. The controller exposes gantt.earnedValue({ statusDate }) as the shortcut; the split view surfaces the metrics through kind: 'evm' columns.
getState() / setState(snapshot)Serialise and restore the Gantt's own VIEWER state (BACKLOG-0001042) — which view is mounted ('plain'/'split'/null), zoom, the arrow/progress/baseline toggles, calendar/non-working shading, the plain view's swimlane grouping, the split view's left-panel width and its collapsed summary rows. Not the task data (already covered by setTasks/rows.apply). Versioned, JSON-safe, and setState tolerates an unknown, wrong-typed or newer-version snapshot without throwing, applying only what it recognises.
const { computeSchedule, findViolations, createGantt, LINK_TYPES, SCHEDULE_ERROR, toISODate } = await import('../packages/modules/gantt/index.js');
const tasks = [
  { id: 'design', duration: 5, percentComplete: 100 },
  { id: 'build', duration: 6, percentComplete: 50 },
  { id: 'test', duration: 4 },
  { id: 'launch', milestone: true },
];
const deps = [
  { from: 'design', to: 'build' },
  { from: 'build', to: 'test' },
  { from: 'test', to: 'launch' },
];
const s = computeSchedule(tasks, deps);
const plan = createGantt({ tasks, dependencies: deps });
const cyc = computeSchedule([{ id: 'a', duration: 1 }, { id: 'b', duration: 1 }], [{ from: 'a', to: 'b' }, { from: 'b', to: 'a' }]);
return [plan.schedule.projectDuration, s.critical.join('-'), toISODate(0), LINK_TYPES.join(','), cyc.error.code === SCHEDULE_ERROR.CYCLE, findViolations(tasks, s).length].join(' | ');

Milestones and summary (WBS) tasks: a summary is derived from its children and a dependency may target it.

const { computeSchedule } = await import('../packages/modules/gantt/index.js');
const tasks = [
  { id: 'phase', name: 'Phase 1' },
  { id: 'a', duration: 3, parent: 'phase', percentComplete: 100 },
  { id: 'b', duration: 2, parent: 'phase', percentComplete: 0 },
  { id: 'ship', milestone: true },
];
const s = computeSchedule(tasks, [{ from: 'a', to: 'b' }, { from: 'phase', to: 'ship' }]);
const phase = s.tasks.get('phase');
return [phase.es, phase.ef, phase.percentComplete, phase.isSummary, s.tasks.get('ship').es].join(' | ');

Resource management. Assign people (or machines) to tasks with assignee/assignees or explicit assignments: [{ resource, units }], and give each resource a capacity through the resources option. The controller then reports overAllocations — where one resource is booked beyond its capacity across concurrent tasks — and the full resourceLoad; the split view rings the over-booked avatars and outlines the clashing bars. gantt.level() auto-shifts the lower-priority tasks later to clear the over-allocation, honouring the critical path and the working-time calendar (pass { dryRun: true } to preview the moves).

Microsoft Project. importMSPDI reads a Project .xml (MSPDI) document into a model for createGantt, and exportMSPDI (or gantt.toMSPDI()) writes one back — tasks, typed dependencies with lag, constraints, baseline, %complete, resources and assignments all round-trip.

const { createGantt, exportMSPDI, importMSPDI } = await import('../packages/modules/gantt/index.js');
const plan = createGantt({
  tasks: [
    { id: '1', name: 'Design', duration: 5, assignee: 'Ada' },
    { id: '2', name: 'Build', duration: 3, assignee: 'Ada' },
  ],
});
const overBefore = plan.overAllocations.length;   // Ada is double-booked
plan.level();                                      // shift one task later
const overAfter = plan.overAllocations.length;     // now clear
const xml = exportMSPDI({ tasks: plan.tasks, dependencies: plan.dependencies, schedule: plan.schedule });
const back = importMSPDI(xml);
return [overBefore, overAfter, back.ok, back.tasks.length, xml.startsWith('<?xml')].join(' | ');

Earned-value analytics. With a captured baseline (captureBaseline) and per-task percentComplete, gantt.earnedValue({ statusDate }) (or the standalone computeEarnedValue(tasks, schedule, { statusDate })) reports EVM at a status date — Planned Value (PV/BCWS) from the baseline, Earned Value (EV/BCWP) from %complete, Actual Cost (AC/ACWP) from a per-task actualCost — and the derived Schedule Variance (EV−PV), Cost Variance (EV−AC), SPI (EV/PV) and CPI (EV/AC), per task and rolled up to summaries and the project. Give each task a cost for money-based EVM, or omit it for schedule-only EVM off the durations. The split view surfaces any metric through kind: 'evm' columns. This worked example reproduces the classic four-side fence: at the end of day 3, three sides are planned (PV 3000) but two are done (EV 2000) at a cost of 2500 (AC).

const { createGantt, computeEarnedValue } = await import('../packages/modules/gantt/index.js');
const tasks = [
  { id: 's1', name: 'Side 1', duration: 1, cost: 1000, percentComplete: 100, actualCost: 1250, baselineStart: 0, baselineEnd: 1 },
  { id: 's2', name: 'Side 2', duration: 1, cost: 1000, percentComplete: 100, actualCost: 1250, baselineStart: 1, baselineEnd: 2 },
  { id: 's3', name: 'Side 3', duration: 1, cost: 1000, percentComplete: 0, baselineStart: 2, baselineEnd: 3 },
  { id: 's4', name: 'Side 4', duration: 1, cost: 1000, percentComplete: 0, baselineStart: 3, baselineEnd: 4 },
];
const deps = [{ from: 's1', to: 's2' }, { from: 's2', to: 's3' }, { from: 's3', to: 's4' }];
const plan = createGantt({ tasks, dependencies: deps, projectStart: 0 });
const evm = plan.earnedValue({ statusDate: 3 });                          // via the controller
const direct = computeEarnedValue(tasks, plan.schedule, { statusDate: 3 }); // or standalone
const p = evm.project;
return [p.pv, p.ev, p.ac, p.sv, p.cv, p.spi.toFixed(2), p.cpi.toFixed(2), direct.project.ev].join(' | ');

Render the plan as an SVG timeline with mount(container, options) — bars on a time scale, dependency arrows with a per-link-type anchor and a lag/lead label, the critical path highlighted, a today line, optional non-working-day shading, milestones as diamonds, a progress bar-fill and configurable labels. The view redraws itself whenever the schedule recomputes; unmount() detaches it. All geometry is computed from the schedule, so it draws identically headless or in a browser.

import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';

const gantt = createGantt({ tasks, dependencies });
gantt.mount(document.querySelector('#plan'), {
  today: '2026-03-06',      // a day-number, ISO date or Date; draws the today line
  nonWorking: 'weekends',   // shade Saturdays and Sundays
  label: 'percent',         // bar label: 'name' | 'percent' | 'dates' | (task) => string
  dateAxis: true,           // axis ticks as calendar dates
  zoom: 'week',             // 'day' | 'week' | 'month' | 'quarter' | pixels-per-day; omit to fit width
  scrollToToday: true,      // scroll so the today line is in view
  groupBy: 'assignee',      // swimlanes by a task property or (task) => key
  rowHeight: 26,
  width: 'container',       // the default: fill the container, and keep following it
});

Sizing (BACKLOG-0001079). width defaults to 'container': the view measures the box it was mounted into and redraws itself whenever that box changes, so a plan in a tab, a drawer, an accordion, a responsive panel or a split pane fits without the host writing a ResizeObserver of its own. A container with no box — a hidden tab, or an element that has not been laid out yet — is not treated as a container of zero width: the view holds a 720px fallback and adopts the real width the moment there is one. Pass a number to take the decision yourself; a numeric width is honoured exactly, installs no observer, and keeps the eight-tick axis it always had — only a container-sized plot thins its tick labels to the width it was given, because only a container-sized plot can be somewhere it had not been before. zoom and a numeric width are mutually exclusive: zoom wins — it fixes the pixels-per-day and lets the plot scroll past the container — and passing both now warns rather than discarding the width in silence.

The time axis thins its own labels (BACKLOG-0001319). A Gantt picks its tick rhythm from the calendar — every day at zoom: 'day', every seven days at 'week', every month above that — and that rhythm knows nothing about how wide a date is. Where a label is wider than the gap between two ticks, only every nth label is drawn: the largest regular stride that leaves at least 6px of clear space between neighbours, so the axis keeps an even rhythm a reader can count on rather than the uneven gaps a greedy left-to-right fit would leave. The gridlines and the split view's week separators are not thinned with the text — the fine rhythm is information and costs nothing to read; it was only ever the text that collided. Both surfaces do it: mount's axis and mountSplit's timeline header, which is where a week-zoom header used to read Sun 05 Oct 2025 Sun 12 Oct 2025 Sun 19 Oct 2025 with each date painted across the next. Widening the Gantt now shows more dates rather than the same overlap, and the thinning is re-decided on every draw, so a resize or a zoom change re-fits the labels. An un-zoomed axis is untouched: it already chooses how many ticks to draw from the plot's own width, so its labels always fitted, and a host that passed an explicit width keeps exactly the drawing it had.

The project anchor (BACKLOG-0001079). A mount option, not a mountSplit one: the joined split view below takes neither projectEpoch nor a date-valued today, and its weekend shading is unanchored. The engine's time line is whole days since the Unix epoch, so a plan written as day offsets (0, 4, 9…) legitimately renders as January 1970 — day 0 is 1970-01-01, and the module cannot tell an offset from a real epoch day, so it cannot warn about it. projectEpoch says which calendar date plan day 0 stands for. It and today take an ISO date string, a Date or a day number. A Date is read as the calendar date its local wall clock shows (BACKLOG-0001104), the way a date column reads one: new Date(2026, 2, 2) is 2 March in every time zone, and a Date that carries a time of day is the local day it falls on. Before 1104 the UTC instant was floored, which put local midnight a day early everywhere east of Greenwich. A Date is therefore decided by the reader's zone; a string is the same day on every machine — '2026-03-02' is 2 March in Sydney and in New York alike — which is the form to prefer for an anchor stored with the plan. Recognise your own case: if you worked around the old behaviour by passing new Date(Date.UTC(y, m, d)), a reader west of Greenwich now sees the previous day, because UTC midnight is still the evening before in New York — pass new Date(y, m, d) or the ISO string instead. It is display-only: axis ticks, bar labels, tooltips, screen-reader text and the built-in weekend shading move with it, and nothing the scheduler, getState, the CSV or the MSPDI export produces does — every es/ef you read back is still the number you supplied. A host-supplied nonWorking function keeps receiving raw plan days, since it was written against your day numbers. Use projectStart instead when you want the model itself to be on calendar dates.

// A relative plan: offsets in the data, real dates on the screen.
gantt.mount(el, {
  projectEpoch: '2026-03-02',  // plan day 0 is this Monday
  nonWorking: 'weekends',      // so days 5-6 are the first weekend
  today: '2026-03-06',         // converted into plan space through the anchor
});
gantt.view.scrollToToday();  // also callable on demand
gantt.applyEdit({ id: 'design', duration: 7 }); // the view redraws automatically
gantt.unmount();

Dependency arrows (BACKLOG-0001072). A link is routed by geometry. When the successor's anchor is at or beyond the predecessor's, it goes straight down and then in — a plain zero-lag finish-to-start link, where the two anchors share an x, is a clean vertical. When the anchor is behind the predecessor's — a negative lag (a lead), or two overlapping tasks — "down then in" does not exist, so the link takes a deliberate detour: out on the predecessor's own side, along a lane between the two rows, down, and in. Either way the final segment runs in the direction the arrowhead points, which is what stops a link doubling back on itself. The anchors themselves differ per link type — FS finish→start, SS start→start, FF finish→finish, SF start→finish — and so does the side the arrow arrives on, so an FF or SF link comes in from the right of the successor's finish rather than running through the bar to get there.

Link shorthand. A dependency's type accepts the MS Project string form as well as the structured one: 'FS+2', 'SS-1'. It normalises to { type: 'FS', lag: 2 } on the way in, so gantt.dependencies, the scheduler, the lag label and the MSPDI export all see the one canonical form. A shorthand lag alongside an explicit lag that disagrees warns; the explicit field wins.

createGantt({ tasks, dependencies: [
  { from: 'design', to: 'build', type: 'FS+2' },   // same as { type: 'FS', lag: 2 }
  { from: 'build',  to: 'test',  type: 'SS-1' },   // a lead
] });

Bars are draggable: drag the body to move a task, drag the right edge to resize it. When a grid and a columns map are given, each drag writes the new dates back through the grid's public edit surface (grid.edit.setCells) and reconciles a reverted or conflicted write; autoSchedule: true cascades dependents. A task placed earlier than its predecessors allow is flagged (findViolations), not silently moved.

import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';

const gantt = createGantt({
  tasks, dependencies, grid,          // a Lattice grid over the same tasks
  columns: { start: 'start', duration: 'duration' }, // task field -> grid column
  autoSchedule: true,
});
gantt.mount(document.querySelector('#plan'), { editable: true });
// drag a bar -> gantt.applyEdit(..., { writeBack: true }) -> grid.edit.setCells(...)

Hovering a bar shows a tooltip with its dates, duration, % complete and slack. Tasks are flagged when they slip: overdue (incomplete and finishing before today) and at-risk (negative total float). Negative float needs a target: pass a deadline (a day-number) to createGantt and any task that cannot meet it gets negative slack and is drawn at-risk.

Export: gantt.toCSV() writes the scheduled tasks as CSV ({ dates: true } for ISO dates); when a grid is bound, the grid's own Excel/CSV export works too. gantt.view.toSVG() serialises the drawn chart to a standalone SVG string — the handoff for turning it into an image or PDF.

Accessibility: bars are focusable and carry an aria-label describing the task (name, dates, progress, slack, critical). With the keyboard, arrows move a focused task, Shift+arrows resize it, and L links two tasks (press it on the source, then on the successor) with a finish-to-start dependency; every edit is announced in a polite live region and focus follows the edited task. Set keyboard: false to opt out.

Two panes, two ways. There are two arrangements, and which one you want depends on whether the left pane is your grid or the Gantt's own. gantt.mountSplit(container, options) — the joined split view, whose options are listed with GanttController in the type reference — draws both panes itself as one row-aligned surface, and is what to reach for unless you specifically need your own grid beside the timeline. The arrangement described here is the other one: you create and place a normal Lattice grid over the same task rows, mount the timeline beside it with gantt.mount(), and the Gantt consumes the grid rather than building it.

Binding a grid you built yourself. It is two-way. A drag on the timeline writes the new dates back through the grid's public edit surface (grid.edit.setCells) whenever createGantt was given a grid and a columns map — without both, the drag moves the bar and writes nothing — and an edit made in the grid pane, from any editor, reflects on the timeline. Both panes must be given the same row height for the rows to line up, and view.linkVerticalScroll(el) mirrors vertical scroll between them. Neither is needed with mountSplit, which shares one scroll and one row height by construction.

<div class="split" style="display:grid;grid-template-columns:360px 1fr">
  <div id="tasks"></div>   <!-- the grid pane -->
  <div id="plan"></div>    <!-- the timeline pane -->
</div>
<script type="module">
import { createGrid } from '@toclocoinc/lattice-grid';
import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';

const grid = createGrid({
  element: document.querySelector('#tasks'),
  columns: [{ field: 'name' }, { field: 'start', type: 'number', edit: { enabled: true } },
            { field: 'duration', type: 'number', edit: { enabled: true } }],
  rows: tasks, rowKey: 'id', edit: { enabled: true },
});
const gantt = createGantt({ tasks, dependencies, grid, columns: { start: 'start', duration: 'duration' } });
gantt.mount(document.querySelector('#plan'));
gantt.view.linkVerticalScroll(grid.element);   // keep the two panes aligned
</script>

Live data. The Gantt exposes the same consumer surface as the grid — gantt.rows.apply({ add, update, remove }), keyed by its rowKey (default id) — so a Data Router attaches to it exactly as it does a grid, a board or a chart. One arriving stream can hydrate and update a whole screen, the Gantt included; each change recomputes the schedule and redraws.

import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';

const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(grid, 'order').attach(gantt, 'task');  // one feed drives both
router.load(snapshot);                                // task rows land in the Gantt
router.apply(deltas);                                 // updates recompute the schedule

The board (kanban) view

modules/kanban is an opt-in view of grid rows as cards, grouped into columns by a configurable property — a status, a stage, a state GUID, whatever the host's schema calls it. It is a separate bundle in the same shape as charts and the data router: it adds no weight to a page that does not load it, changes nothing in grid core, and pulls in no dependency. A board is just another dataset viewer: it consumes data through the same keyed-diff contract a grid exposes, board.rows.apply({ add, update, remove }), so a Data Router can attach(value, board) and drive a kanban beside a grid and a chart off one feed. (Distinct from the grid-core board presentation mode, config.board in §7.14, which lays a single grid's rows out as lanes; this is a standalone view module.)

import { createKanban } from '@toclocoinc/lattice-grid/modules/kanban';

const board = createKanban(document.querySelector('#board'), {
  rows,                        // or { grid } to bind to a live grid; reuses its column formatters
  rowKey: 'id',
  columnProperty: 'status',    // the group-by property (configurable)
  columns: [                   // shown even when empty; this order is honoured
    { id: 'todo', title: 'To do' },
    { id: 'doing', title: 'In progress', wipLimit: 3 },
    { id: 'done', title: 'Done', color: '#2e7d32' },
  ],
  pointsProperty: 'points',    // configurable; drives the header points sum
  showPoints: true,
  card: { title: 'title', subtitle: 'assignee', labels: 'tags' },
  readonly: { columns: { done: true } },     // granular: whole board / per column / per card
  onCardClick: ({ card }) => open(card.row),
});

Every structural property is named in config — columnProperty, pointsProperty, orderProperty, swimlaneProperty, sprintProperty, epicProperty — so the same board maps DemandFlow (a status field, points, sprint, epic, a swimlane) and any customer schema without code change. Configured columns show even when empty; a data value outside them gets its own column rather than being dropped. Each column header carries its card count and, with showPoints, its points sum, and is flagged when the count exceeds a column's wipLimit. Accessibility is built in: the board is a labelled group, each column a labelled list, each card a list item in a roving-tabindex focus ring with arrow-key navigation, and a polite live region is present for the move announcements a later cycle adds.

MemberDescription
createKanban(el, config)Create a board. Pass a DOM element to render into, or null for a headless board that computes the same column/card model without a DOM.
columns() / column(id)The columns with their cards and aggregates, or one column by id (id, title, count, points, wipLimit, over, cards).
count(id) / points(id)A column's card count, and its points sum from pointsProperty.
cards() / card(key)Every card model, or one by its key.
rows.apply({ add, update, remove })The keyed-diff consumer contract a grid shares, so the board is a drop-in Data Router target. Also rows.forEach and rows.count.
move(keys, toColumn, toIndex?)Move one or more cards to a column (and, with orderProperty, to a position within it) — the entry point behind drag-and-drop and keyboard move. Runs onBeforeMove first, then writes through the grid's shipped write-back path.
select / selection / isSelected / clearSelectionCard selection: select(keys, 'set'|'add'|'toggle'|'remove'), the selected keys, a membership test, and a clear. Click selects; Ctrl/Cmd toggles; Shift extends within a column.
collapseColumn(id) / collapseLane(id)Collapse, expand or toggle a column or swimlane; emits column:collapse / swimlane:collapse. State survives a keyed-diff update.
reorderColumns(order) / moveColumn(id, before)Reorder the columns (also done by dragging a column header); emits column:reorder.
setColumns(defs)Replace the whole column set after construction — a tenant renaming, adding or removing a column, in one call, without rebuilding the board. Id-keyed like the grid's own columns.apply(state): a column that keeps its id keeps its cards, its collapsed state and its place in a pinned reorderColumns order; the quick filter and the selection are untouched. A column dropped from defs is not specially handled — a card whose value has nowhere configured to go re-derives a plain, humanised ad hoc column rather than being dropped, the same rule an always-unconfigured value already gets.
setQuickFilter(text) / setFilter(fn) / facets(property)Quick text search across card fields, a predicate filter, and distinct-value counts for a facet control.
filters.where(name, fn) / filters.where(name, null) / filters.where() / filters.reapply(name?)Named predicates, composed with AND (BACKLOG-0001229) — the grid's own filters.where convention. Register or replace one under name; where(name, null) removes only that one, leaving the others in force; where() lists the registered names; reapply(name?) re-runs and re-renders. The quick filter is untouched by any of this. setFilter(fn) is unchanged sugar for where(filters.DEFAULT, fn), so it composes with any other named predicate instead of replacing it.
setSprint(id) / showBacklog() / sprints()Sprint view and switcher: show one sprint, the backlog (board.BACKLOG — cards with no sprint), or all; and the distinct sprint values. Emits sprint:changed.
setEpic(id) / epics() / epicRollup() / rollup(property)Epic view and rollup: filter to an epic, list epics, and roll rows up by epic (or any property) into count, points, and progress toward the done columns.
expand(key) / closeDetail() / canExpand(card)Pop a card's children out as a nested child grid (or board) in a drawer/modal/inline container; emits card:expand and card:drill.
editCard(key, field) / applyEdit(key, field, value)Inline-edit a card field opted in with card: { title: { field, edit: true } }: grid-bound it commits through the grid's own field editor path (grid.edit.setCells); standalone it uses a host editor factory or a default input, reverting when onCardEdit rejects. Double-click a card to edit; emits card:edit.
addCard(columnId, seed?)Add a card to a column (with config.addCard's per-column affordance) and open it in inline edit; a host onAddCard(columnId) supplies the row, or one is generated (grid-bound via grid.edit.addRow). Emits card:add.
getState() / setState(snapshot)Serialise and restore the board state — collapsed columns/lanes, column order, quick filter, sprint/epic selection and selection. Also accepted as config.state at construction.
slaThe card-aging / SLA monitor, present only when a sla config is supplied. Read sla.states(), sla.breaches()/sla.warnings() and sla.stateFor(cardOrKey) for each card's age and level; sla.evaluate() re-checks and fires crossings. See the card-aging note below.
setLoading(bool) / setError(message)A loading state and a host-supplied error banner; empty columns already render their placeholder.
fieldsExtra columns of the bound grid to project onto the rows a tile filter sees, beyond the fields the tiles declare. A read of a bound column outside the projection still resolves, and warns once naming the tile and the column; a field naming no column at all is refused by name at first read, and that tile reports unknown rather than an aggregation identity.
setRows(rows) / refresh()Replace the source rows, or recompute and re-render. What refresh() re-reads depends on where the rows come from: a grid-bound board re-reads the bound grid; a board still on its config.rows re-reads that array (a host that mutated it in place sees the change); a routed board — any row has arrived through rows.apply, typically from a Data Router attach — re-reads nothing: it regroups from the rows it holds and shows exactly what it showed before, re-rendered. setRows puts a routed board back on its configured rows.
on(name, fn) / off(name, fn)Events: card:click, card:dblclick, card:contextmenu, card:move, card:reverted, card:confirmed, card:sla, selection:changed, drag:start, drag:end (plus the vocabulary the later cycles emit). On a grid-bound board a move fires card:move optimistically; the grid's write-back then settles it with card:confirmed or, if the server rejects, card:reverted (the card re-reads and the flow transition log rolls the optimistic move back).
readonly(scope)Whether a scope is readonly — the whole board, a { column } or a { card }. A readonly card is not draggable; a move into a readonly column is refused.
destroy()Empty the element and drop the model. The host still owns any bound grid.

Drag-and-drop and keyboard move (write-back). Cards drag between columns (writing columnProperty) and within a column into a position (writing orderProperty with fractional ranking, so only the moved cards' order is written). The same move is available from the keyboard: focus a card, press Space to grab it, use the arrows to choose a target column and position (announced on a live region), Space/Enter to drop, Escape to cancel. Multi-select drags every selected card. A move calls onBeforeMove(card, from, to, index) first — return false (or a promise of it) to veto — then persists: grid-bound, through grid.edit.setCells (the same public edit-commit path inline editing and the write-back adapter use, so the grid's own pipeline owns the optimistic apply, the confirm and the revert); standalone, optimistically with a revert when onCardMove returns false/rejects. A configurable per-card contextMenu (an array or fn(card, selected)) replaces the card:contextmenu event when present.

Swimlanes, collapse, reorder and search. Set swimlanes: true to render a 2D lane×column grid grouped by swimlaneProperty: one band per lane with its own count/points, columns aligned across every lane, the board scrolling vertically through lanes and horizontally through columns inside its own box. A drag across lanes writes the swimlane property too. Columns and lanes collapse (their state survives a keyed-diff update); columns reorder by dragging their header (reorderColumns/moveColumn). setQuickFilter(text) searches across card fields, setFilter(fn) applies a predicate (sugar for filters.where(filters.DEFAULT, fn)filters.where(name, fn) registers any number of independent named predicates, ANDed together), and facets(property) returns distinct-value counts to build a facet control. Naming the swimlaneProperty is separate from turning on the lane view, so a board can carry it for a cross-lane move without switching layout.

Sprint, epic and card pop-out. setSprint(id) shows one sprint, showBacklog() the cards with no sprint, and sprints() feeds a switcher; setEpic(id) narrows to an epic and epicRollup() (or rollup(property)) returns per-epic count, points and progress toward the done columns. A card can pop out a nested grid of its children — an epic's stories, a story's tasks, recursively. The child relationship is a childrenProperty (parent-id within the dataset) and/or a loadChildren(card) (per-card dataset or async fetch), and the child is a full composed createGrid (sort/filter/edit/write-back) — supplied as children.factory — opened in a drawer (default), modal or inline. With children.asBoard the child is itself a board, so it can pop its own children. This reuses the grid by composition and adds no grid-core coupling. expand(key) and the per-card drill affordance emit card:expand; a deeper open emits card:drill.

Live updates. Because the board consumes data through the same keyed-diff contract a grid does, a Data Router drives it directly — router.attach(board, predicate) — and one feed fans out to a grid, a kanban, a chart and a KPI tile at once. A live rows.apply({ add, update, remove }) is applied as a keyed diff (an unchanged card keeps its model) and re-rendered preserving scroll, focus, selection, collapsed columns/lanes and any open pop-out, so a card can appear, move or update under the user without losing their place.

Scale, state and accessibility. virtualize renders only a scroll window of a tall column (with true-height spacers so the scrollbar stays honest), for boards of thousands of cards. getState()/setState() (and config.state) save and restore the collapsed columns and lanes, the column order, the quick filter and the sprint/epic selection, so a reopened board comes back as it was; setLoading/setError add loading and error states. Accessibility runs throughout: the board is a labelled group of labelled column lists, cards are a roving-tabindex focus ring (arrows to move focus, Enter to activate), the move is fully keyboard-driven (Space grab, arrows for column/position, Alt+↑/↓ across swimlanes, Space/Enter drop, Escape cancel) with live-region announcements, and every affordance carries a name.

Card aging / SLA. A sla config ages each card and highlights the ones sitting too long. Thresholds are a raw millisecond count or a { days, hours, … } spec, set globally as { warn, breach }, per column (either sla.columns[id], or a column def's own sla/slaWarn/slaBreach) and per swimlane (sla.lanes[id]); the most specific wins, lane → column → global. basis chooses whether the clock is time-in-current-column (default) or age-on-the-board, resolved from the flow transition log, an enteredProperty/createdProperty timestamp, or arrival. The view puts an age chip on aged cards (showAge: 'always' shows it on every card) and a highlight on breached ones. A rising crossing (ok→warn, warn→breach) fires the card:sla event and the onWarn/onBreach(level, rows) callbacks — the same (signal, rows) shape a Data Router alert route uses, so one handler serves both. It is reached at runtime as board.sla; done-column cards are exempt by default (ignoreDone: false opts them in), and an optional tick re-checks so a card that breaches by simply sitting still still lights up. Example: createKanban(el, { …, sla: { warn: { days: 2 }, breach: { days: 4 }, onBreach: notify } }).

Inline edit and add-card. A field is opted into inline edit with the object mapping form — card: { title: { field: 'title', edit: true } }. Double-clicking a card (or editCard(key, field)) edits it in place: grid-bound, through the grid's own field editor for that column via its public edit path, so the column's parse, validate and optimistic/confirm/revert all run; standalone, through a host editor factory (or a default input), reverting when onCardEdit rejects. A per-column add-card affordance (config.addCard) creates a card carrying the column's group value — from a host onAddCard(columnId), or generated, or appended through grid.edit.addRow when bound — and opens it straight in inline edit on its title, so the user just types. The module imports nothing from the grid's DOM package: grid-bound edits ride the grid's public edit API, standalone edits use the host's editor, so a board-only page never pulls the grid in.

A board, grouped and aggregated, executed

A DemandFlow-shaped set — statuses as columns, points, an empty configured column, a WIP limit, and one status outside the configured set — grouped headless, then a keyed diff applied through the same rows.apply contract the Data Router drives. Run on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 'a', status: 'todo', points: 3, title: 'Login form' },
    { id: 'b', status: 'doing', points: 5, title: 'OAuth' },
    { id: 'c', status: 'doing', points: 2, title: 'Reset flow' },
    { id: 'd', status: 'done', points: 8, title: 'Audit log' },
    { id: 'z', status: 'archived', points: 1, title: 'Old ticket' }, // outside the configured columns
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [
    { id: 'todo', title: 'To do' },
    { id: 'doing', title: 'In progress', wipLimit: 1 },
    { id: 'done', title: 'Done' },
    { id: 'blocked', title: 'Blocked' },   // configured but empty; still shown
  ],
  pointsProperty: 'points',
  card: { title: 'title' },
});

const todo = board.count('todo') + '/' + board.points('todo');       // 1/3
const doing = board.count('doing') + '/' + board.points('doing');    // 2/7
const over = board.column('doing').over ? 'over' : 'ok';             // over the WIP limit of 1
const extra = board.column('archived') ? 'archived' : 'dropped';     // an out-of-set value is kept

// The keyed-diff consumer contract a Data Router drives: move b to done, drop c.
board.rows.apply({ update: [{ id: 'b', status: 'done', points: 5, title: 'OAuth' }], remove: ['c'] });
const done = board.count('done') + '/' + board.points('done');       // 2/13

board.destroy();
return [todo, doing, over, extra, done].join(' | ');

Move, reorder and veto, executed

The move path drag-and-drop and keyboard move share: a reorder writes the order property with fractional ranking, a cross-column move writes the column property, and onBeforeMove can veto. Run headless on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 'a', status: 'todo', ord: 1, title: 'A' },
    { id: 'b', status: 'todo', ord: 2, title: 'B' },
    { id: 'c', status: 'todo', ord: 3, title: 'C' },
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done' }],
  orderProperty: 'ord',
  // Veto any move into 'doing'; allow the rest.
  onBeforeMove: (card, from, to) => to !== 'doing',
});

await board.move('c', 'todo', 0);          // reorder C to the top of todo
const order = board.column('todo').cards.map((x) => x.key).join(',');  // c,a,b

await board.move('a', 'done');             // cross-column move writes the column
const moved = board.card('a').columnId;    // done

await board.move('b', 'doing');            // vetoed by onBeforeMove
const vetoed = board.card('b').columnId;   // still todo

return [order, moved, vetoed].join(' | ');

Swimlanes, collapse and search, executed

Lanes with per-lane aggregates, a collapsed column, a reorder, and a quick-filter — all headless, run on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 1, status: 'todo', assignee: 'Ann', points: 3, title: 'Login' },
    { id: 2, status: 'doing', assignee: 'Ann', points: 5, title: 'OAuth' },
    { id: 3, status: 'doing', assignee: 'Bob', points: 2, title: 'Reset' },
    { id: 4, status: 'done', assignee: 'Bob', points: 8, title: 'Audit' },
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'doing' }, { id: 'done' }],
  swimlaneProperty: 'assignee',
  swimlanes: true,
  pointsProperty: 'points',
  card: { title: 'title' },
});

const ann = board.model.lanes.find((l) => l.id === 'Ann');
const lane = 'Ann ' + ann.count + '/' + ann.points;   // Ann 2/8

board.reorderColumns(['done', 'todo', 'doing']);
const order = board.columns().map((c) => c.id).join(',');  // done,todo,doing

board.setQuickFilter('reset');
const shown = board.model.cardsByKey.size;             // 1 (only "Reset" matches)

return [lane, order, shown].join(' | ');

Sprint, backlog and epic rollup, executed

A sprint view, the backlog, and an epic rollup with progress toward the done column — headless, run on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');

const board = createKanban(null, {
  rows: [
    { id: 's1', status: 'todo', epic: 'E', sprint: 'S1', points: 3, title: 'Story 1' },
    { id: 's2', status: 'done', epic: 'E', sprint: 'S1', points: 5, title: 'Story 2' },
    { id: 's3', status: 'todo', epic: 'E', sprint: null, points: 2, title: 'Story 3' },
  ],
  rowKey: 'id',
  columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'done', done: true }],
  pointsProperty: 'points',
  sprintProperty: 'sprint',
  epicProperty: 'epic',
});

board.setSprint('S1');
const inSprint = board.model.cardsByKey.size;   // 2 (s1, s2 are in S1; s3 has no sprint)
board.showBacklog();
const backlog = board.model.cardsByKey.size;    // 1 (s3)
board.setSprint(undefined);

const e = board.epicRollup()[0];
const rollup = e.points + ' ' + e.progress;      // 10 0.5

return [inSprint, backlog, rollup].join(' | ');

Live, driven by a Data Router, executed

One feed, routed to a board through the same keyed-diff contract a grid uses: a snapshot hydrates it and a delta moves a card. Run headless on every build.

const { createKanban } = await import('../packages/modules/kanban/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const board = createKanban(null, {
  rows: [], rowKey: 'id', columnProperty: 'status',
  columns: [{ id: 'todo' }, { id: 'done' }], card: { title: 'title' },
});

const router = createDataRouter({ key: 'kind', rowKey: 'id' });
router.attach(board, 'task');                // the board is a drop-in router target
router.load([
  { id: 't1', kind: 'task', status: 'todo', title: 'T1' },
  { id: 't2', kind: 'task', status: 'done', title: 'T2' },
]);
const hydrated = board.count('todo') + ',' + board.count('done');   // 1,1

router.apply([{ op: 'upsert', row: { id: 't1', kind: 'task', status: 'done', title: 'T1' } }]);
const afterDelta = board.count('todo') + ',' + board.count('done'); // 0,2

router.destroy();
return [hydrated, afterDelta].join(' | ');

The KPI (stat-tile) view

modules/kpi is an opt-in view of a dataset as a panel of stat tiles — each tile an aggregate over the routed rows: a sum, an average, a min/max, a count, a distinct count, or a host reducer. It is the fourth first-class viewer beside the grid, the kanban and the gantt, in the same shape: a separate bundle that adds no weight to a page that does not load it, changes nothing in grid core, and pulls in no dependency. A KPI panel is just another dataset viewer: it consumes data through the same keyed-diff contract a grid exposes, kpi.rows.apply({ add, update, remove }), so a Data Router can attach(value, kpi) and drive a KPI panel beside a grid, a kanban and a chart off one feed.

import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';

const kpi = createKPI(document.querySelector('#kpis'), {
  rows,                        // or { grid } to follow a live grid's rows
  rowKey: 'id',
  columns: 4,                  // responsive tile columns
  tiles: [
    { id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
    { id: 'avg', label: 'Avg deal', aggregation: 'avg', field: 'amount',
      format: { type: 'currency', decimals: 0 }, baseline: 5000 },   // delta vs a baseline
    { id: 'open', label: 'Open deals', aggregation: 'count',
      filter: (r) => r.stage !== 'won',
      thresholds: { warn: 10, critical: 25, direction: 'lowerIsBetter' } },  // good/warn/critical bands
  ],
  onTileClick: ({ tile }) => drillInto(tile.id),
});

Each tile names an aggregation (sum, avg, min, max, count, countDistinct, or a custom reducer (rows, tile) => value), the field it reads, and an optional filter predicate. Formatting (number/currency/percent/compact, with decimals, currency and locale), a baseline for a delta, and semantic threshold bands (two cut points with a direction, or an explicit bands list) are all per tile; the band is a semantic name (good/warn/critical), separate from any accent colour. An optional sparkline plots a { x, y } series in sequence order. Each tile is a labelled <figure>, focusable and keyboard-activatable, its value announced; the sparkline transition respects prefers-reduced-motion.

A panel with no data says so. A tile's status is good, warn, critical, null (no thresholds) — or unknown, which means the tile measured nothing. Two things cause that: the panel holds no rows at all, or the tile's field names no column on the bound grid, so it never read a cell to reduce over. That fourth status is decided from data presence before any threshold is consulted, because an aggregation over nothing returns the identity of its operation (sum and count return 0) and 0 is a number a threshold grades: under lowerIsBetter cut points it grades good, so an empty panel would otherwise read as a healthy one. An unknown tile reports a null value, renders the nullText placeholder rather than a zero, and is marked with a dashed edge and a visible “No data” caption that also forms part of its accessible name — the state is never carried by colour alone. Because unknown is an explicit status rather than a severity, anything rolling tiles up can surface “not measured” instead of inheriting a false green.

A measured zero is still a measurement. A tile whose filter matches none of the rows the panel does hold is a different thing: no open incidents is genuinely good, so it reads 0 and is graded on its thresholds exactly as before. Only an empty panel is unknown.

Staleness is a different question, and a time-bounded source answers it. unknown is about the absence of rows, not their staleness: a panel over an unbounded store keeps showing the last values it was given when its feed goes quiet, because those rows are still there. Give the underlying source a rolling time window (maxAge with ageBy) and its rows age out while the feed is silent, so the panel drains and reports unknown the next time it reads them — a grid-bound panel as the grid announces the change (or when the host calls refresh()), a routed one as the removals reach rows.apply.

A grid-bound panel follows the grid. A panel given grid reads the grid's rows when it is built and every time what the grid shows changes: after a filter, a sort, a cell edit (edit.setCells or the keyboard), rows.apply or rows.load on the grid, and the grid's pipeline settle after an off-thread sort, the panel agrees with the grid beneath it without the host calling refresh(). It used to read the grid once, at bind, so a KPI rail beside a filtered grid kept showing the unfiltered numbers until the host wired refresh() to the grid's events themselves. The panel now subscribes to the same grid events a createStat tile follows, gated on the same pipeline-settle test, so a model:changed for an expand, a collapse or a page turn re-reads nothing. One re-read per change, not one per event: the grid announces one change several ways (rows.apply fires six events; an edit fires one cell:changed per cell), so the re-read is queued on a microtask and every event of one synchronous turn collapses into one read of the grid — read the panel after the turn ends (after an await, or in its change event), or call refresh() for the numbers now, which re-reads in the same turn and replaces the queued follow rather than doubling it. On a sorted grid past the worker threshold a filter change is two re-reads (one at the end of the turn, one when the settle lands); on a synchronous grid it is one. The grid is the truth, so rows.apply and setRows on a bound panel are still refused, and destroy() stops following. Want a snapshot instead? Do not pass grid as the source: pass the rows (rows: snapshot), with grid alongside if the panel should still adopt the grid's type and density — a panel given both reads rows and follows nothing.

A grid-bound filter reads the columns you project, and says so when it cannot. A panel bound to a grid does not see whole grid rows: it materialises a projection of each row through the grid’s own value pipeline, carrying the row key plus the fields the tiles declare. That is what keeps a refresh over a large grid cheap — and it used to mean a filter reading any other column saw undefined, matched nothing, and reported a confident 0 beside a grid full of rows that matched. Declare the extra columns with fields:

createKPI(el, {
  grid,
  fields: ['priority'],                // project it, so the filter can read it
  tiles: [
    { label: 'P1 jobs', agg: 'count', filter: (r) => r.priority === 'P1' },
  ],
});

Forget to, and the panel tells you rather than quietly reporting a zero: a read of a column the bound grid has but the projection does not resolves to the real cell and warns once — “the ‘P1 jobs’ tile's filter read priority, which is a column on the bound grid but is not projected — add it to fields” — keyed on the tile and the field, so one bad filter over a 100,000-row grid produces one line, not 100,000. undefined on its own is deliberately not the trigger: a blank cell in a column you did project is a legal value and stays silent, because a warning that fires on ordinary sparse data gets muted and then deleted. A tile field naming no column on the grid at all is a different fault, and that tile reports no data rather than a number: reducing over a column that does not exist gives sum and count a 0, which grades good under any lowerIsBetter threshold, so warning in the console while leaving a confident green zero on the dashboard would document the lie rather than fix it — and the reader of a dashboard is not reading the console. It reports the unknown status above, renders nullText, and contributes an explicit unknown to any roll-up. That is deliberately not the same case as a tile with a real field whose filter simply matches nothing: that tile measured, and its zero is still graded. The refusal is checked at first read rather than at bind time, because a dynamic grid's columns can arrive after the panel does, and the verdict is recomputed at every read, so a tile refused while the grid was still loading is measured again the moment its column lands. A panel over a plain rows array has whole rows already and none of this applies to it.

Incremental, not recomputed. Each tile keeps a running accumulator, so a routed rows.apply delta adjusts only the rows it carries — an add contributes, a remove reverses, an update reverses the old row and contributes the new one — rather than re-reading the whole dataset per delta. The two bounded exceptions are honest: an extreme (min/max) removed at its current value triggers a rescan of that tile's own value multiset, and a custom reducer is recomputed over the (filtered) store because an arbitrary function has no inverse.

MemberDescription
createKPI(el, config)Create a KPI panel. Pass a DOM element to render into, or null for a headless panel that computes the same tile model without a DOM.
rows.apply({ add, update, remove })The keyed-diff consumer contract a grid shares, so the panel is a drop-in Data Router target and updates each tile incrementally. Also rows.forEach and rows.count.
tiles() / tile(id) / value(id)Every computed tile model, one tile by id (value, formatted, status, delta, deltaPercent, count, sparkline, bar), or a tile's raw value. status is good, warn, critical, unknown (the panel holds no rows) or null (no thresholds configured); value is null whenever the tile measured nothing.
setRows(rows) / refresh()Replace the source rows, or recompute every tile and re-render. What refresh() re-reads depends on where the rows come from: a grid-bound panel re-reads the bound grid (it follows the grid on its own, so this is for a host that wants the numbers in the same turn); a panel still on its config.rows re-reads that array (a host that mutated it in place sees the change); a routed panel — any row has arrived through rows.apply, typically from a Data Router attach — re-reads nothing: every tile is re-derived from the rows the panel holds and it shows exactly what it showed before, re-rendered. setRows puts a routed panel back on its configured rows.
nodes() / node(key) / visibleNodes()The hierarchy, when tree resolves one: the top-level nodes with their children, one node by key at any depth, or just the nodes on screen. Each node carries label, level, tile (null on a synthesised level), status, rollup (the worst severity at or below it, never unknown), unknown (how many below it measured nothing) and items. Empty on a flat panel, where kpi.tree is false.
expand(key) / collapse(key) / toggle(key)Open or close a branch. A key for a branch the panel does not (yet) hold is retained rather than dropped, so a delta that later introduces it finds it already open.
getState() / setState(snapshot)Serialise and restore the panel's row set, so a headless panel round-trips, plus expanded (the open branch keys) on a hierarchical panel. A snapshot with no expanded key leaves expansion alone rather than resetting it.
on(name, fn) / off(name, fn)Events: tile:click, tile:dblclick, tile:contextmenu, node:toggle (a branch opened or closed), and change (after every update).
destroy()Empty the element and drop the listeners. The host still owns any bound grid.

Interaction is light and host-driven. A tile emits tile:click (also from the keyboard) carrying the tile model, so a host can drill down or, in a demo, filter a routed grid — the wiring lives in the host, not the module. This is deliberately not a dashboard layout engine (that is the parked dashboard generator) and charting beyond a minimal sparkline belongs to the charts module.

The clock tile: the device clock, not an aggregate

{ kind: 'clock', label, timeZone?, locale?, seconds?, date? } in tiles renders a tile that shows the current date and time instead of a figure — the date on one line, the time on the next, e.g. Mon, 21 Apr 2025 over 14:32:18 — styled like any other tile, so a panel of zone clocks beside a panel of figures reads as one visual system with no host CSS.

const kpi = createKPI(document.querySelector('#kpis'), {
  tiles: [
    { kind: 'clock', label: 'London',   timeZone: 'Europe/London' },
    { kind: 'clock', label: 'New York', timeZone: 'America/New_York', locale: 'en-US' },
    { id: 'open', label: 'Open deals', aggregation: 'count', filter: (r) => r.stage !== 'won' },
  ],
});

Where the time comes from. The device clock, read every second in exact alignment with the second boundary — not a fixed setInterval(1000), which drifts — so every clock tile on a panel ticks in the same repaint. One timer serves the whole panel, not one per tile: a panel of three zone clocks runs one shared interval, not three. The timer stops when the panel is destroy()ed or the document goes into the background (document.hidden) and resumes correctly — catching up immediately, then re-aligning — when the tab returns. A panel with no clock tile starts no timer at all.

Zone and format. timeZone is any IANA zone name; omitted, the tile shows the viewer's local time. A name Intl.DateTimeFormat does not recognise is reported through the usual [lattice] diagnostics warning, by name, and the tile falls back to local time rather than rendering nothing. The date and time are formatted for locale — the tile's own, else the panel's locale, else the browser's default — entirely through Intl.DateTimeFormat: a 24-hour clock where the locale uses one, 12-hour with an AM/PM marker where it does not, because that is the locale's own convention rather than a second option to set. seconds: false drops the seconds from the time line; date: false drops the date line entirely. Every formatter is built once, at tile resolution, and reused on every tick.

Inert everywhere a stat tile measures. A clock tile takes none of a stat tile's measurement options — aggregation, field, format, thresholds, bands, target, baseline, sparkline — because a tile that measures nothing has nothing for them to apply to. Supplying any of them is reported as a configuration warning by name and ignored: the tile still renders the clock, nothing else. It contributes nothing to the panel's totals, to a parent's rolled-up status in a tree (its own status is always null, never unknown — a clock tile is never “not measured”, it always has a reading) and nothing to what a host reads out of onChange beyond its own text. It otherwise behaves exactly like any other tile: it sits in tiles()/tile(id) and a tree alongside stat tiles, responds to columns, fires tile:click/tile:dblclick/tile:contextmenu, and its accessible name (aria-label) carries the same date and time text the two visible lines show.

A clock tile, two zones, executed

Structural assertions only — the clock reads the real device clock, so a doc example run on every build cannot pin a literal time without freezing it. The formatting itself is pinned for two locales and two zones with a fixed clock in the test suite. Run headless on every build.

const { createKPI } = await import('../packages/modules/kpi/index.js');

const kpi = createKPI(null, {
  tiles: [
    { kind: 'clock', id: 'london', label: 'London', timeZone: 'Europe/London', locale: 'en-GB' },
    // aggregation/thresholds are measurement options a clock tile refuses (warned, ignored):
    { kind: 'clock', id: 'ny', label: 'New York', timeZone: 'America/New_York', locale: 'en-US',
      aggregation: 'sum', thresholds: { warn: 1, critical: 2 } },
  ],
});

const london = kpi.tile('london');
const ny = kpi.tile('ny');

// kind, status and bar are the same three neutral values on every clock tile,
// whatever was supplied for the ignored options above (String(), because
// Array#join renders null as '' rather than 'null'):
const shapes = [london.kind, ny.kind, String(london.status), String(ny.status), String(ny.bar)].join(' '); // clock clock null null null

// The date and time lines hold the shape the formatting spec promises:
const shaped = [
  /^[A-Za-z]{3}, \d{2} [A-Za-z]{3,4} \d{4}$/.test(london.clock.date),
  /^\d{2}:\d{2}:\d{2}(\s?[AP]M)?$/.test(ny.clock.time),
].join(' '); // true true

// The ignored `aggregation: 'sum'` never took effect:
const ignoredAgg = ny.aggregation; // clock

kpi.destroy();
return [shapes, shaped, ignoredAgg].join(' | ');

The KPI tree: top-level items that expand to the indicators beneath them

Set tree and the panel becomes a rail instead of a grid of tiles: a small number of top-level items, each expanding to the indicators underneath it, with the parent telling you at a glance whether anything below needs attention. Compute expands to psi and cpu; collapsed, it still shows you that one of them is in breach.

const kpi = createKPI(document.querySelector('#rail'), {
  rows, rowKey: 'id',
  tiles: [
    { id: 'compute.cpu',      label: 'cpu',     aggregation: 'max', field: 'cpu',
      thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
    { id: 'compute.memory',   label: 'memory',  aggregation: 'avg', field: 'mem',
      thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
    { id: 'network.latency',  label: 'latency', aggregation: 'max', field: 'latency',
      thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
  ],
  tree: {},                          // the dotted ids are the hierarchy
});

kpi.nodes()[0].rollup;               // 'critical' — even with the branch shut
kpi.expand(kpi.nodes()[0].key);      // open it

Where the shape comes from — two sources, in this order. Declared: tree: { path } or tree: { parentKey } over the tile specs, the same two shapes the grid's tree data and the tree-select editor already take, so a hierarchy you have configured once needs no second vocabulary. A path is the tile's own place, its own segment last — ['System','Compute','cpu'], exactly as ['EMEA','UK','Colchester'] is Colchester's path and not its parent's — and levels no tile represents are synthesised, so System and Compute appear without a tile of their own. Derived: with neither declared, the tile ids are split on separator (default .), so system.compute.cpu files itself. A panel whose ids carry no separator is flat and renders exactly as it always did; tree: false keeps it flat whatever the ids look like. A tile's field is never a source — a dot there already means a nested object property, and overloading it would make field: 'cpu.util' ambiguous.

No value rolls up; severity does. A parent shows no aggregated number. That is not a simplification: the running accumulators expose add/remove/value and no merge, so avg, countDistinct and a custom reducer cannot be composed from their children without rescanning, and a per-aggregation exception list would be a number that is right for a sum and wrong for an average. A parent that has a tile of its own still shows that tile's reading. What does roll up is the status: rollup is the worst severity at or below the node, across as many levels as you have, and it is what a collapsed branch reports.

Nothing measured is not good news, and it does not win the roll-up either. A leaf that measured nothing is unknown (see above), and unknown is deliberately excluded from rollup: ranking “not measured” as the worst thing beneath a parent would hide a real warning under it. It is surfaced separately instead — node.unknown counts the descendants that measured nothing, and the node renders it in words (“2 unknown”) and puts it in its accessible name. So neither way of being wrong is available: silence cannot read as green, and it cannot bury an amber.

Accessible by construction. The rail is a real role="tree" with the APG keyboard model — opens a closed branch and otherwise steps into it, closes an open one and otherwise steps out to its parent, / walk what is visible, Home/End jump to its ends, Enter/Space activate — with a roving tabindex, and aria-level, aria-posinset and aria-setsize on every node, because a reader cannot count what a collapsed branch has left out of the DOM. Status carries a shape as well as a colour (a filled circle, a triangle, a square, a hollow circle), not one dot in three colours, and a parent's rolled-up status is in its accessible name: “Compute, 6 items, worst status critical”, announced as one string. Every phrase is a catalogue key: pass messages (any { t(key, params) }, including a grid's own) to translate the panel, and a key your catalogue lacks falls back to English rather than printing the key.

Every leaf carries a value and a meter. A rail exists to be read at a glance, and a column of numbers is not that, so each leaf draws a small fixed-scale bar beside its reading. The scale comes only from what the tile already declares — there is no new configuration key. A bands list states its own ends and is taken at its word; thresholds states two interior cut points and a lone target states one point, so in those the open end is anchored at the origin: { warn: 70, critical: 90 } measures 0–90, and target: 4000 measures 0 to the target. The scale is never derived from the data — a bar scaled to the values currently in the panel would mean something different on every refresh — so a tile with no bands, no thresholds and no target gets no meter at all, a value and nothing else. The meter is placed by exactly the mapping the grid's own conditional-formatting data bar uses, clamp((x - lo) / span), so a reading past the top of its scale fills the bar rather than overflowing it, and it is coloured by the leaf's own good/warn/critical status — reinforcing the shape glyph, never replacing it. A leaf that measured nothing draws a dashed empty outline with no fill element, which is deliberately not what a measured zero looks like (a solid track with a fill of no length): those are different facts. Each tile model carries the same numbers as bar ({ lo, hi, percent }, or null when no scale is declared) for a host that would rather draw its own. A row that gets no meter reserves the width of one, so the readings stay in a single column whether or not there is a bar beside them — the same reservation a leaf makes on the other side for the +/ control it does not have. A branch draws no meter, because no value rolls up to draw one; and a flat panel of tiles renders exactly the tiles it always did.

The panel follows the grid it belongs to, not the page. Pass grid and the panel takes that grid's resolved type and row rhythm: on a 16px page beside a grid painting its cells at 12.7px, a rail used to draw 16px text. Type reads --lat-kpi-* > --lattice-* > --lat-chrome-* > 13px and row height reads --lat-kpi-row-height > --lat-chrome-row-height > 22px. The --lat-chrome-* rung is mirrored off the mounted grid in JS (modules/shared/chrome.js) because a grid's density lands on the grid's own root, which is a descendant of a panel mounted beside it, and custom properties inherit downward only — no stylesheet can read it. Measured in Chrome: identical type and a 1.00× leaf-row-to-grid-row ratio at compact, comfortable and spacious. It re-mirrors on the grid's config:changed, so grid.set('density', …) moves the panel with it, and destroy() releases everything it wrote. A panel with no grid — a plain rows array, or the router-driven panel — mirrors nothing and renders at the module's own defaults, and a host's --lattice-font-size on an ancestor still out-ranks the mirror.

A heading says it opens. Each top-level item carries a + when shut and a when open, in a small bordered box, on a row with a pointer cursor and a hover state — a disclosure triangle read as decoration rather than as a control. It is purely decoration: aria-expanded on the node is what states the same thing to a screen reader, so the marker is aria-hidden exactly as the status shape is, and the announced name is unchanged. A leaf keeps the marker's width as an empty spacer, so the status shapes and labels stay in one column whether or not the row beside them opens.

A collapsed branch costs data, not paint. Every node's status is computed whether or not you can see it — that is what makes the rail worth having — while a collapsed branch contributes no DOM at all. It is the same division the grid's grouping already makes between its totals walk and its display walk. Expansion is patched in place, keyed on the node, so a live routed feed does not throw a keyboard user off the node they are standing on.

A collapsed parent reports the red leaf underneath it, executed

Two subsystems from dotted tile ids, one of them in breach, with nothing expanded; then the same rail with no data at all. Run headless on every build.

const { createKPI } = await import('../packages/modules/kpi/index.js');

const fewerIsBetter = { warn: 70, critical: 90, direction: 'lowerIsBetter' };
const tiles = [
  { id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: fewerIsBetter },
  { id: 'compute.memory', label: 'memory', aggregation: 'avg', field: 'mem', thresholds: fewerIsBetter },
  { id: 'network.latency', label: 'latency', aggregation: 'max', field: 'latency',
    thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
];

// No `tree` block: the dots in the tile ids are the hierarchy.
const kpi = createKPI(null, { rows: [{ id: 'h1', cpu: 94, mem: 40, latency: 12 }], rowKey: 'id', tiles });

const compute = kpi.nodes()[0];
// Nobody has opened it, and it reports the breach anyway.
const shut = [compute.label, compute.expanded, compute.rollup].join(' ');   // compute false critical
const leaf = [compute.children[0].label, compute.children[0].status].join(' '); // cpu critical

// Nothing delivered: the parent reports the silence rather than a false green.
const quiet = createKPI(null, { rows: [], rowKey: 'id', tiles });
const silent = String(quiet.nodes()[0].rollup) + ' ' + quiet.nodes()[0].unknown; // null 2

return [shut, leaf, silent].join(' | ');

Live, driven by a Data Router alongside a grid, executed

One feed fans out (overlap) to a KPI panel through the same keyed-diff contract a grid uses: a snapshot seeds the tiles, then a delta removes the current max and the min/max rescans. Run headless on every build.

const { createKPI } = await import('../packages/modules/kpi/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');

const kpi = createKPI(null, {
  rowKey: 'id',
  tiles: [
    { id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
    { id: 'max', label: 'Biggest', aggregation: 'max', field: 'amount' },
    { id: 'open', label: 'Open', aggregation: 'count', filter: (r) => r.stage === 'open',
      thresholds: { warn: 1, critical: 3, direction: 'lowerIsBetter' } },
  ],
});

const router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
router.attach(kpi, 'deal');                  // a KPI panel is a drop-in router target
router.load([
  { id: 'd1', kind: 'deal', amount: 100, stage: 'open' },
  { id: 'd2', kind: 'deal', amount: 250, stage: 'won' },
]);
const seeded = kpi.value('total') + ',' + kpi.value('max');       // 350,250

router.apply([{ op: 'delete', row: { id: 'd2' } }]);              // removes the current max
const afterRemove = kpi.value('total') + ',' + kpi.value('max'); // 100,100 (max rescanned)
const band = kpi.tile('open').status;                          // 'good' — 1 open, lowerIsBetter

router.destroy();
return [seeded, afterRemove, band].join(' | ');

An empty panel is unknown, a measured zero is not, executed

The same lowerIsBetter thresholds, three states: nothing delivered yet, rows delivered, and a tile whose filter matches none of the rows the panel holds. Run headless on every build.

const { createKPI } = await import('../packages/modules/kpi/index.js');

const fewerIsBetter = { warn: 10, critical: 25, direction: 'lowerIsBetter' };
const kpi = createKPI(null, {
  rows: [], rowKey: 'id',
  tiles: [
    { id: 'errors', label: 'Errors', aggregation: 'count', thresholds: fewerIsBetter },
    { id: 'sev1', label: 'Sev-1', aggregation: 'count',
      filter: (r) => r.severity === 1, thresholds: fewerIsBetter },
  ],
});

// Nothing has arrived: not a healthy zero, and no number to show.
const empty = kpi.tile('errors').status + ' ' + kpi.tile('errors').value;   // unknown null

kpi.rows.apply({ add: [{ id: 'e1', severity: 3 }, { id: 'e2', severity: 2 }] });
const measured = kpi.tile('errors').status + ' ' + kpi.tile('errors').value; // good 2

// Its filter matched none of those rows — but the panel holds rows, so 0 is a reading.
const realZero = kpi.tile('sev1').status + ' ' + kpi.tile('sev1').value;     // good 0

return [empty, measured, realZero].join(' | ');

The AI narrative / insights layer

modules/ai is an opt-in layer that produces a short, plain-language narrative of the grid's computed figures — a per-KPI / per-chart / per-column “Explain”, or an insights panel over the current (filtered) view. It is a separate bundle that adds no weight to a page that does not load it, changes nothing in grid core, and pulls in no dependency. The grid makes no AI call of its own: createAI never imports a provider SDK, never reads a key, and never makes a network request. It calls one async callback you supply, ask() — your model, your key, your privacy decision — exactly the philosophy of the data adapters, where auth and transport are always the caller's.

import { createAI } from '@toclocoinc/lattice-grid/modules/ai';

const ai = createAI(grid, {
  ask: async ({ system, messages, tools, schema, signal }) => {
    const r = await myProvider.chat({ system, messages, tools, signal });
    return { text: r.text, toolCalls: r.toolCalls };   // or a bare string
  },
  maxRows: 50,                 // cap what any tool result carries to ask()
  redact: ['ssn', 'salary'],   // columns whose values never leave the browser
});

ai.insights(document.querySelector('#insights'));                 // the panel
const { text, flagged } = await ai.explain({ kind: 'column', colId: 'amount' });

Grounded, and reconciled. Where your provider offers tool-use, the model is given a curated read-only tool set (getSchema, getProfile, getStatistics, getForecast, runQuery) and our engine computes what it asks for; where it does not, the module builds a facts packet from the grid's computed results (grid.statistics, the profile, forecasts, view counts) and passes it in the prompt. Either way, every figure in the narrative is reconciled against the values the engine produced this render — an ungrounded number is stripped before the user sees it. The prompt is constrained to narrate-only; the layer is read-only and never mutates data. redact and maxRows bound what the module hands ask(), and the module sends nothing itself. An ask() error surfaces a friendly message and the grid stays fully usable — AI is additive, never load-bearing.

createAI is complementary to grid.ai: grid.ai is the intent/plan skill layer (a question becomes a validated filter/sort plan you preview and apply); createAI is the narrative/insights consumer that explains figures. They can share one host ask() — pass none to createAI and it adopts the grid's configured ai.ask, running the facts-packet path over it.

enable gates only the three DOM-mounting convenience methods: 'narrative'/'insights' is what lets insights() mount, 'query'/'ask' is what lets askBar() mount (see §Ask-your-data), and 'actor' is what lets actorBar() mount (see §AI as a governed actor). All three are allowed when enable is omitted. The programmatic API — explain(), query(), propose(), facts(), riskSummary() and the rest of the controller — is never gated by enable and runs regardless of the allowlist: a host that wants no AI surface at all simply never calls these methods.

MemberDescription
createAI(grid, config)Create an AI narrative / insights controller over a live grid (headless or rendered). Config: ask (the host callback; falls back to the grid's ai.ask), enable (gates only insights()/askBar()/actorBar() — see above), maxRows, redact, tools, locale, reconcile ('strip'/'flag'), element, onNarrative, onError.
explain(target?, opts?) / narrate(...)Produce a grounded, reconciled narrative. Always callable — not gated by enable. target is { kind: 'view' }, { kind: 'column', colId }, { kind: 'forecast', colId, options }, { kind: 'kpi'|'chart', facts }, or { kind: 'risk', gantt, board } for a board / Gantt risk summary. Resolves to { text, facts, grounded, flagged, rounds, mode }.
riskSummary(sources?, opts?)A board / Gantt RISK SUMMARY — “3 tasks at risk on the critical path, SPI 0.67, 2 SLA breaches” — grounded on the separate Gantt / Kanban modules' outputs (gantt, board/sla, or their precomputed earnedValue/schedule/breaches). A convenience over explain({ kind: 'risk' }), through the same reconciliation guard. Always callable — not gated by enable.
insights(el?, opts?)Mount (or re-target) the insights panel into an element, its generate control wired to a view narrative. Keeps the grid usable on an ask() error. Mounts only when enable allows 'narrative'/'insights'.
attachExplain(target, opts?)Build an “Explain” button bound to a target (a KPI tile, a chart datum, a column). Clicking it narrates the target.
facts(target?, opts?)Build the facts packet for a target without calling ask() — the exact grounded set a narrative would use, and what would leave the browser.
on(name, fn) / off(name, fn) / destroy()Events: narrative and error. destroy() tears the controller down and leaves the grid untouched.

Grounded, with the reconciliation guard, executed

A mock ask() returns one grounded figure (the row count) and one invented one. The number-reconciliation guard strips the ungrounded figure and keeps the grounded one. Run headless on every build.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAI } = await import('../packages/modules/ai/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'region' }, { field: 'amount', type: 'number' }],
  rows: [
    { id: 1, region: 'EMEA', amount: 100 },
    { id: 2, region: 'AMER', amount: 300 },
    { id: 3, region: 'APAC', amount: 200 },
  ],
});

// Your model, your key. The grid makes no network call — it awaits this.
// This mock returns one grounded figure (3 rows) and one invented one (900%).
const ask = async () => ({ text: 'There are 3 rows in view. Confidence 900%.' });

const ai = createAI(grid, { ask, tools: false });
const result = await ai.explain({ kind: 'view' });

const kept = result.text.includes('3 rows');      // grounded — survives
const stripped = !result.text.includes('900%');   // hallucinated — removed

ai.destroy();
grid.destroy();
return `${result.flagged.length} flagged | ${kept} kept | ${stripped} stripped`;

Board / Gantt risk summary (modules/ai)

A project manager wants one line: “3 tasks at risk on the critical path, SPI 0.67, 2 SLA breaches.” ai.riskSummary(…) (and the { kind: 'risk' } target of explain) produces exactly that, grounded on figures the separate optional modules have already computed: gantt.earnedValue() for SPI/CPI and the schedule/cost variances, gantt.schedule for the critical path and the tasks at risk on it, and board.sla for the SLA breach and warning counts. Every figure runs through the same number-reconciliation guard as the rest of the narrative — an ungrounded figure is stripped before the user sees it.

The AI bundle imports neither the Gantt nor the Kanban module. You pass the module instances (or their already-computed outputs) on the target, and the layer reads them duck-typed — so a page that loads the AI module without those bundles carries none of their weight. buildRiskFacts(target, opts) is exported to build (and preview) the exact grounded facts a risk summary would use, without calling ask(). Redaction: a risk summary carries aggregates only by default — counts and the EVM indices/variances; it withholds task names and card contents. includeTaskNames adds the at-risk task names (bounded by maxTasks) and includeCost adds the money figures (BAC/PV/EV/AC), each the host's explicit opt-in, reported back in meta.exposed.

const { buildRiskFacts } = await import('../packages/modules/ai/index.js');

// The public outputs a host already holds from the SEPARATE gantt / kanban
// modules. The AI bundle imports neither — it reads these duck-typed.
const earnedValue = { ok: true, project: { spi: 0.8, cpi: 0.9, sv: -1000, cv: -500 } };
const schedule = {
  ok: true,
  order: ['a', 'b', 'c'],
  critical: ['a', 'b', 'c'],                    // all three on the critical path
  tasks: new Map([
    ['a', { id: 'a', name: 'Design', percentComplete: 100, totalFloat: 0 }],
    ['b', { id: 'b', name: 'Build', percentComplete: 40, totalFloat: 0 }],
    ['c', { id: 'c', name: 'Ship', percentComplete: 0, totalFloat: -2 }],
  ]),
};
const breaches = [{ key: 'CARD-1' }, { key: 'CARD-2' }];   // from board.sla.breaches()

const { facts } = buildRiskFacts({ kind: 'risk', earnedValue, schedule, breaches });
const by = Object.fromEntries(facts.map((f) => [f.id, f]));

// Two incomplete tasks (Build, Ship) are on the critical path — at risk.
return `${by['risk.atRisk'].display} at risk | SPI ${by['risk.spi'].display} | ${by['risk.sla.breaches'].display} breaches`;

The tabbed grid

modules/tabs is an opt-in top-of-grid tab strip where each tab is its own full, independently-configured grid instance — "configure each tab as per a normal grid" rather than one grid whose state is swapped. That is a deliberate rejection of the cheaper alternative: grid.state.get()/.apply() only repositions, hides, resizes and sorts existing columns by id (no field, type, editor or row data), so a state-swap only works when every tab shares one column schema and one source — strictly less than the ask. A tab may instead declare from: '<tabId>' plus a narrowing (where, group, join, …), and the module wires a source: { mode: 'derived', from: <the parent tab's live grid>, … } for it — the shipped derived-source mechanism, not a new config-inheritance one. createGrid is injected (the same pattern the React/Vue/Svelte adapters use), so the module imports no engine code regardless of how it is loaded — its own minified ESM build (tabs.esm.min.js) is about 12KB gzipped — the module and nothing else. A module bundle carries its own code plus a small shared runtime, not the engine: the framework adapters are 3–11KB, the KPI module about 50KB, while a module that inlines the whole engine (the web component, htmx) ships at roughly 760KB.

import { createGrid } from '@toclocoinc/lattice-grid';
import { createTabs } from '@toclocoinc/lattice-grid/modules/tabs';

const tabs = createTabs(document.querySelector('#tabs'), {
  createGrid,                     // injected -- see below
  createHeadlessGrid,             // optional: lets an unvisited tab still carry a count
  tabs: [
    { id: 'all', label: 'All', config: { rowKey: 'id', rows, columns } },
    { id: 'open', label: 'Open', from: 'all', where: (r) => r.stage === 'Open',
      follow: 'filtered', refresh: 'live', config: { columns } },
    { id: 'breached', label: 'Breached', from: 'open',       // derives from Open, not All -- a chain
      where: (r) => r.daysOverdue > 0, config: { columns },
      icon: '⚠️', badge: true,                          // "Breached 14" -- and it follows Open's filter
      badgeTone: (count) => (count > 0 ? 'bad' : 'good') },
  ],
  onBeforeTabChange: ({ id }) => !hasUnsavedEdit(),  // veto a switch
});

Lifecycle. A tab's grid mounts on first activation, not up front, and then stays alive — hidden, never destroyed — until the whole strip is. Per-tab scroll, selection, filters, sort, grouping, expansion — and an open cell/row editor — therefore survive a switch away and back natively, by simply not touching that grid instance, rather than through a lossy serialise/restore round-trip: leave a tab mid-edit, switch away, switch back, and the editor is exactly as it was left, uncommitted and undiscarded. Activating a derived tab materialises its whole ancestor chain first (mounted, hidden), and a cyclic from graph is refused — naming the exact cycle — when createTabs is called, not at first click.

A hidden tab costs nothing this module can spend. An inactive panel carries the hidden attribute (display:none); the module runs no timer, observer or repaint of its own against it. Measured with a real browser (bench/tabs-idle.mjs): several mounted-but-hidden, untouched tabs cost the same idle CPU as none at all. The one honestly-reported exception is not this module's: a derived tab's row model still re-derives on every change to its (possibly hidden) parent — by design, so reactivating it is instant rather than a stale flash — and the engine's own repaint listener for a derived source's rows:changed calls the renderer directly, bypassing grid.updates.pause() (which only holds the streaming-ingestion path). A hidden derived tab therefore still runs a read/compute/write pass on every parent change, even though the write phase paints a zero-size viewport; the bench measures and reports the size of that gap rather than leaving it inferred.

A tab can say what is waiting on it. badge: true puts that tab's own live row count on it — and for a derived tab the count follows, so filtering the parent restates the child's badge with it. A number or a string is a static badge; a function is handed the live count and returns what to show (null hides it). The tone is declared, never inferred from a threshold this module would have had to invent: badgeTone takes good, warn, bad or unknown — the same vocabulary a statistic tile grades to, so one data-tone rule in a theme dresses both — or a function of the count. icon adds a leading glyph: a single character or emoji, or an element you built. Never a markup string; nothing in this module parses HTML, and anything it cannot use is warned about once and ignored rather than coerced. Badges are off by default, and a tab that asks for none renders exactly the DOM it did before.

A tab nobody has clicked can still carry a count. Tabs mount lazily, so the tab most worth badging — the one you want to glance at — is precisely the one with no grid behind it. Inject the optional createHeadlessGrid, the same way createGrid is already injected and for the same reason, and an unactivated tab's count is computed with no element and no renderer, correct from first paint and still following its parent's filter. Leave it out and nothing breaks: that tab simply shows no badge until it is first activated, and the module says so once, naming the option. When the strip is narrow it is the label that gives way — it ellipsises while the count and the icon stay whole — and the strip still wraps rather than growing a scroll affordance.

A tab body does not have to be a grid. Give a tab a view — the factory that mounts its body, called as (el, config) => instance — and the tab hosts a kanban board, a KPI strip or a Gantt instead. createKanban and createKPI already have that signature, so they are passed straight in; the Gantt takes a single options object and mounts itself when given an element, so it is adapted in a line: view: (el, config) => createGantt({ ...config, element: el }). The factory is injected, never imported, for the same reason createGrid is. Such a tab's config is that viewer's own config.

{ id: 'board', label: 'Board', from: 'open',       // derives from the Open tab
  where: (r) => r.owner === me, follow: 'filtered', refresh: 'live',
  view: createKanban, badge: true,
  config: { rowKey: 'id', columnProperty: 'stage' } }

And it derives exactly as a grid tab does. That is the point rather than a bonus: from plus the same narrowing (where, group, join, follow, …) applies to a board or a KPI strip just as it does to a grid, so filtering the parent tab restates the board beside it. The mechanism is the derived source you already have: for a non-grid body the module materialises a headless grid carrying that same derived source and pipes its rows into the viewer through rows.apply({ add, update, remove }) — the keyed diff the grid, the board, the KPI panel and the Gantt all already accept from a Data Router. Nothing new is invented, the viewer stays unaware it is in a tab, and the module still imports no engine code. Deriving into a viewer therefore needs createHeadlessGrid injected alongside createGrid; without it the body mounts with its own rows and follows nothing, and the module says so once. A view tab with no from simply holds its own rows.

A body you have never opened is complete the moment you open it. Tabs mount lazily, so a board first shown after its parent has been filtered twice has a lot to catch up on — and because the headless grid holds the rows, there is no feed to join late: the body is seeded with the current set at mount and agrees exactly with a sibling that was mounted earlier. A count badge works on all three the same way, so a Gantt tab can read "Plan 14" as readily as a grid tab.

Accessibility. A real role="tablist"/"tab"/"tabpanel" with aria-selected and a roving tabindex, imitating the grid's own column-header keyboard model rather than the tool panel's tablist (which has the roles but no arrow-key handling). This is manual activation: / and Home/End move the roving tab stop without switching the panel or mounting a grid; Enter/Space, or a click, activates. The newly active tab's label is announced through a polite live region.

MemberDescription
createTabs(el, config)Create a tabbed grid. config.createGrid is required (injected, not imported); config.createHeadlessGrid is optional, and is what gives a never-activated tab a live count and lets a non-grid body derive. config.tabs is a non-empty array of tab descriptors, each an id, a label, a config, optionally an icon and a badge/badgeTone, and optionally from plus the derivation narrowing (where, group, groupBy, bucket, join, unnest, refresh, crossFilter, follow, limit, sort, profile) forwarded onto the derived source built for it.
tab.viewMount something other than a grid in this tab: the factory that builds it, called as (el, config) => instancecreateKanban, createKPI, or a one-line Gantt adapter. The tab's config is then that viewer's own config, and the tab derives from from exactly as a grid tab does (which needs createHeadlessGrid).
tabs() / tab(id) / isMounted(id)The configured tab ids, in order; the live instance mounted in a tab — the grid, or the viewer for a view tab (or null before its first activation); whether a tab has been materialised yet.
activate(id, opts)Switch the active tab, gated by beforeTabChange. Returns true/false synchronously with no handler registered, or a Promise<boolean> when a handler deferred.
on(name, fn) / off(name, fn)Events: tab:changed, the cancellable beforeTabChange (call preventDefault(reason?) or return false to veto), and its paired tabChange:cancelled. Config sugar: onTabChange, onBeforeTabChange, onTabChangeCancelled.
destroy()Tear the whole strip down; destroys every mounted tab's grid (each isolated, so one throwing does not strand the rest).

All / Open / Breached, a two-deep derivation chain, executed

createTabs deliberately has no headless mode — it requires a real host element, the same way createGrid itself does — so this executed example reaches for the same in-tree DOM test double the suite itself runs the renderer against headlessly (packages/dom/src/renderer/testdom.js), rather than a real browser. demo/tabs.html is the browser version of the same chain, with buttons that edit All directly and let Open and Breached follow.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');
const { createTabs } = await import('../packages/modules/tabs/index.js');

const rows = [
  { id: 1, stage: 'Open', daysOverdue: 0 },
  { id: 2, stage: 'Open', daysOverdue: 5 },
  { id: 3, stage: 'Won', daysOverdue: 0 },
  { id: 4, stage: 'Open', daysOverdue: 2 },
];
const columns = [{ field: 'id' }, { field: 'stage' }, { field: 'daysOverdue' }];

const tabs = createTabs(root, {
  createGrid,
  tabs: [
    { id: 'all', label: 'All', config: { rowKey: 'id', rows, columns } },
    { id: 'open', label: 'Open', from: 'all', where: (r) => r.stage === 'Open', config: { columns } },
    // derives from Open, not All -- a two-deep chain
    { id: 'breached', label: 'Breached', from: 'open', where: (r) => r.daysOverdue > 0, config: { columns } },
  ],
});

tabs.activate('breached');                    // materialises 'open' too, automatically
const counts = ['all', 'open', 'breached'].map((id) => tabs.tab(id).rows.count());
tabs.destroy();
return counts.join(',');                       // 4,3,2

Opening on a chosen tab, and vetoing a switch, executed

active picks the tab the strip opens on rather than the first; the three config callbacks are sugar for the same events on() exposes, so onBeforeTabChange can veto a switch with preventDefault(reason) and onTabChangeCancelled is told why. onTabChange fires only for a switch that actually happened — note it does not fire for the initial tab.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');
const { createTabs } = await import('../packages/modules/tabs/index.js');

const rows = [{ id: 1 }];
const columns = [{ field: 'id' }];
const log = [];

const tabs = createTabs(root, {
  createGrid,
  active: 'review',                             // open here, not on the first tab
  tabs: [
    { id: 'draft', label: 'Draft', config: { rowKey: 'id', rows, columns } },
    { id: 'review', label: 'Review', config: { rowKey: 'id', rows, columns } },
    { id: 'locked', label: 'Locked', config: { rowKey: 'id', rows, columns } },
  ],
  onTabChange: (e) => log.push(`changed:${e.id}`),
  onBeforeTabChange: (e) => { if (e.id === 'locked') e.preventDefault('sealed'); },
  onTabChangeCancelled: (e) => log.push(`cancelled:${e.id}(${e.reason})`),
});

const opened = tabs.activeId;                   // 'review' -- config.active won
tabs.activate('draft');                         // allowed, so onTabChange fires
tabs.activate('locked');                        // vetoed, so onTabChangeCancelled fires
const ended = tabs.activeId;                    // still 'draft'
tabs.destroy();
return `${opened} | ${log.join('; ')} | ${ended}`;

A tab nobody has clicked, carrying a live count, executed

A badge needs rows, and rows normally need a mounted grid — so a tab that has never been activated would have nothing to count. Injecting createHeadlessGrid alongside createGrid gives that tab a real, derived count with no DOM and no mount. Without it the tab below shows no badge at all until its first activation, and the module says so once.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createTabs } = await import('../packages/modules/tabs/index.js');

const rows = [
  { id: 1, stage: 'Open', daysOverdue: 0 },
  { id: 2, stage: 'Open', daysOverdue: 5 },
  { id: 3, stage: 'Won', daysOverdue: 0 },
  { id: 4, stage: 'Open', daysOverdue: 2 },
];
const columns = [{ field: 'id' }, { field: 'stage' }, { field: 'daysOverdue', type: 'number' }];

const tabs = createTabs(root, {
  createGrid,
  createHeadlessGrid,                           // what gives an unmounted tab a count
  tabs: [
    { id: 'all', label: 'All', badge: true, config: { rowKey: 'id', rows, columns } },
    { id: 'breached', label: 'Breached', from: 'all', where: (r) => r.daysOverdue > 0,
      follow: 'filtered', refresh: 'live', badge: true, config: { columns } },
  ],
});

// 'breached' has never been activated, so it has no grid of its own.
const button = root.querySelectorAll('[role=tab]')
  .find((b) => b.getAttribute('data-tab-id') === 'breached');
const badge = button.children
  .find((c) => String(c.className || '').includes('lat-tabs__badge'));
const state = tabs.isMounted('breached') ? 'mounted' : 'unmounted';
const text = String(badge.textContent).trim().replace(/\s+/g, ' ');
tabs.destroy();
return `${state} | ${text}`;                     // unmounted | 2 rows

The dashboard layout

modules/layout is an opt-in reconfigurable dashboard surface: a cell grid inside an element, and a set of windows placed on it that a user can move, resize and close — by drag or by keyboard. It is the thing a customer would otherwise reach for GridStack or react-grid-layout to get, which means a second dependency, a second sizing model, and a seam where the viewers in it do not resize properly.

It is payload-agnostic, and that is the whole design. A window body is a div with an id. The module creates it, sizes it, and never reads or writes its contents — it does not import createGrid, does not know what a payload is, and never calls into one. What it does instead is emit window:resized with the measured content box, which is the contract. That rule is what keeps the whole module to about 17,500 bytes gzipped (measured on the built bundle: its own code over a shared module runtime of roughly 2KB) and what makes it usable for a payload we have not written yet.

import { createLayout } from '@toclocoinc/lattice-grid/modules/layout';

const layout = createLayout(document.querySelector('#dash'), {
  columns: 12, rows: 8, gap: 8,
  overflowX: 'static', overflowY: 'scroll', rowHeight: '160px',
  windows: [
    { id: 'pipeline', title: 'Pipeline', xPos: 1, yPos: 1, xSize: 6, ySize: 4,
      movable: true, resizable: true, closable: true },
    { id: 'trend', title: 'Trend', xPos: 7, yPos: 1, xSize: 6, ySize: 4,
      movable: true, resizable: true },
  ],
  onWindowResized: ({ id, width, height }) => redraw(id, width, height),
});

// The module made the container; you fill it and you own what is inside it.
createGrid(layout.payload('pipeline'), { rowKey: 'id', rows, columns });

Two independent overflow axes, not one setting. overflowX and overflowY are each 'static' or 'scroll', because a dashboard that scrolls both ways is ordinary and a single enum cannot express it. The difference is what happens to a track's size. A static axis divides the mounted element with minmax(0, 1fr) — never a bare 1fr, whose implicit auto minimum lets one stubborn payload drag a track past the container. A scrolling axis repeats a fixed track (columnWidth / rowHeight) and the canvas extends past the viewport, which then scrolls. That is the owner's "maintaining their sizing", and it is the difference between this and flex-wrap: measured in a real browser, ten 200px columns in a 600px host paint at 200px each over a 2000px canvas, and shrinking the host to 300px leaves the column at 200px and scrolls further.

Spacing takes a real CSS length. gap, padding, columnWidth and rowHeight each accept a number (pixels), or a string: '200px', '25%', '1fr', '2rem', '10vh'. Percentages that sum past 100 are allowed to overflow and scroll rather than being silently scaled down, which is the honest outcome. Anything outside that vocabulary — including calc() and var() — is refused by name with one warning and replaced by the default, because the value is written into an inline style.

Rearrangement. compact: 'vertical' (the default) pushes displaced windows down and then pulls everything up into whatever space that left, so a window dropped into empty space falls to the top of its column — window:moved carries both to (where it was asked to go) and landed (where it actually ended up). compact: 'none' keeps every window exactly where it is put. compact: 'horizontal' floats windows left into vacated space instead, so dragging a window out of a row closes the hole sideways rather than leaving it open. It is a third value, not a second pass over the other axis: one gravity direction, never two, because two directions each vacate space the other wants and the arrangement would then depend on which ran last. It settles windows in a canonical left-to-right, then top-to-bottom order, exactly mirroring vertical's top-to-bottom, then left-to-right, so the result depends only on where the windows are and never on the order they were declared or dragged in — the same guarantee vertical has always made, now made on both axes and asserted over all 720 declaration orders of a six-window dashboard on each axis. A value that is none of the three warns once, naming what it was given, and falls back to 'vertical'. The axis a mode compacts along is the axis that can overflow: under 'horizontal' a window that genuinely does not fit the columns settles in an implicit track and can be clipped on a static horizontal axis — exactly as a 'vertical' dashboard can be clipped past its rows on a static vertical axis today. overflowX: 'scroll' is to 'horizontal' what overflowY: 'scroll' is to the default.

Keyboard, to the same standard as the drag. Every movable and resizable window carries a focusable handle running the full grab / move / drop / cancel model the kanban board established: Space or Enter grabs, the arrow keys move a tentative placement, Enter drops it through the same beforeWindowMove gate the pointer drag uses, and Escape cancels. A polite live region announces every step — grabbed, each tentative position with its column and row, dropped, cancelled, and reverted when a handler vetoes the drop — and focus returns to the handle afterwards. A window with chrome: false still gets a handle, because a movable window a keyboard user cannot move is not movable.

An “Edit layout” button, without rebuilding the dashboard. closable, movable and resizable also take a layout-level default, so unlocking a twelve-window dashboard is one setting rather than twenty-four, and setInteractive(true|false|{movable, resizable, closable}) changes that default at runtime — unlock, let the user rearrange, lock again and save getLayout(). Nothing is destroyed and nothing is rebuilt, so every grid, chart and board mounted in a window survives the toggle untouched. The asymmetry is deliberate: you can always take a capability away; you can never grant one where the developer said no. setInteractive(false) locks every window, including one whose own spec says movable: true, so a dashboard hard-locks in a single call without auditing twelve window specs; setInteractive(true) unlocks only the windows that never opted out, so a masthead declared movable: false stays pinned. Both halves of the enforcement move together — the handles a window renders and the checks the pointer and keyboard paths make, because removing a handle stops a mouse while only the gesture check stops a keyboard user already standing on one. config.movable: false and setInteractive(false) are deliberately not the same thing: the config states the default for windows that declare nothing — and false is already that default, so it takes nothing away from a window that declared movable: true — while setInteractive(false) is an active lock that pins every window whatever its own spec says. getInteractive() reports all three states rather than two: undefined where no layout-level default is in force, true, or false for a lock. Reporting “unset” as false would read correctly and round-trip wrongly, so setInteractive(getInteractive()) is a no-op in every state, and a key carrying undefined means “leave this capability alone”. Interactivity is a mode, not part of the arrangement: getLayout() does not carry it, setLayout() does not read it, and no event fires. A locked layout is not a read-only dashboard: the module creates the payload container and never reads or writes its contents, so a grid inside a window is made read-only with the grid's own settings — a dashboard that must not be edited is two decisions, not one.

Which payloads re-lay-out on window:resized, honestly. The grid and the chart each own a ResizeObserver and respond correctly; the Gantt does too since 1.52.0; kanban and KPI do no JS work at all on a resize and need none, because they reflow by CSS construction (a kanban column keeps its 280px and the board starts scrolling). Every one of those is measured in test/layout-browser.test.js rather than asserted, including a grid column declared as a percentage (layout: { width: '50%' }), which follows the window (BACKLOG-0001117): half of the viewport at 800px, half of it again at 400px.

Idle cost, measured. A twelve-window dashboard is indistinguishable from a page with no layout module on it at all. Over 8 seconds of real Chrome (bench/layout-idle.mjs), twelve windows with empty payloads, twelve independent live grids in them, and a single lone grid with no layout module all sit in the same few-millisecond band — under a tenth of one percent of a core. They are not separated here because they cannot be: seven runs across two machines land between 3.1ms and 9.0ms and the ordering between them inverts run to run, so a stated delta would be reporting the noise floor. The module adds no timer, no frame loop and no polling, and owns exactly one ResizeObserver for the whole layout rather than one per window. The one figure that is a result rather than noise is not this module's: twelve grids derived from one shared parent filtered at 20Hz cost 3,155–3,409ms over the same 8 seconds — several hundred times the quiet band, and stable across every run — because the engine's repaint listener for a derived source's rows:changed calls the renderer directly and bypasses grid.updates.pause(). The bench reports the size of that gap rather than leaving it inferred.

Closing a window does not destroy its payload. window:closed hands the payload container back; whatever you mounted inside it is yours to destroy. Stated plainly because a leaked grid per closed window is the obvious failure, and this module has no way to know that a div contains something with a destroy().

Not in v1: per-frame drag events; and responsive breakpoints — a twelve-window dashboard on a phone is unsolved, and this does not pretend otherwise. Server-side persistence is the host's, with getLayout().

Nesting is not a limitation, because a payload is an ordinary container. A window's payload is a plain div the module creates, sizes and never reads, so anything composes into it exactly as it would into any other element — including another createLayout dashboard, or a modules/tabs strip for tabbed windows. Neither is a special case this module wires up; both are the ordinary consequence of being payload-agnostic, and both are proved by test rather than asserted from the design (test/layout.test.js's two composition tests).

MemberDescription
createLayout(el, config)Create a dashboard layout. columns/rows (default 12/6) divide the element; overflowX/overflowY are each 'static' or 'scroll'; columnWidth/rowHeight are the fixed track sizes a scrolling axis uses; gap (8px), padding (5px) and compact ('vertical', 'horizontal' or 'none', default 'vertical') complete it. A second mount on the same element is refused by name.
config.windows[]Each window: id (required, unique), xPos/yPos/xSize/ySize in 1-based cells (auto-placed in the first free cell when omitted), title, chrome (default true), and closable/movable/resizable/maximisable/minimisable (all default false, so a dashboard the developer wants fixed is fixed without opting out of anything; each also takes a layout-level default of the same name, which a window's own boolean overrides). padding and payloadId (default `${id}-body`) override per window.
payload(id) / window(id) / windows()The payload container for a window — the div carrying its payloadId, which you fill; a copy of a window's current descriptor; every window id in mount order.
add(spec) / move(id, to) / close(id)Add a window after mount (returns its payload container); move or resize one through the same before-events the drag uses; close one through beforeWindowClose. move and close return true/false synchronously with no handler registered, or a Promise<boolean> when a handler deferred.
getLayout() / setLayout(snapshot)The full current arrangement as plain JSON ({columns, rows, windows: [{id, xPos, yPos, xSize, ySize}]}), and its restore. setLayout never throws on garbage, and an entry naming a window that does not exist yet is retained and applied when that window is added.
getState() / setState(snapshot)The versioned persistence pair, following core's and the Gantt's shape: no arguments in, one plain JSON-safe object out, and setState survives whatever is handed to it.
setInteractive(value) / getInteractive()Lock or unlock the whole dashboard at runtime, without destroying it. A boolean sets movable, resizable and closable together; an object sets only the keys it carries, and a key carrying undefined is treated as absent; getInteractive() returns the layout-level values as a copy, three-valued (undefined for unset, true, or false for a lock) so that setInteractive(getInteractive()) is a no-op in every state. The config keys of the same name state the default; only this method takes a capability away. Locking always wins and unlocking never overrides an opt-out: setInteractive(false) pins a window that declared movable: true, and setInteractive(true) leaves a window that declared movable: false pinned. No event fires and getLayout() is unchanged — a mode is not an arrangement. It does not touch maximisable or minimisable either, for the same reason.
maximise(id) / minimise(id) / restore(id)Maximise fills the layout host — the element you mounted on — not the browser window, and hides every other window for the duration. That is deliberate: filling the viewport means position: fixed, whose containing block is the nearest ancestor carrying a transform, filter, contain or will-change, so the same rule fills the screen on one page and lands in a 300px box on the next; filling the host is a geometry change inside the layout and cannot disturb the page around it. Nothing moves: no compaction runs, no placement changes, and the payload container is the same DOM node throughout, so whatever you mounted in it is untouched. Escape restores it from anywhere inside the layout — a focused grid body cell or column heading included — unless something inside has already claimed the key: an open cell editor, a filter menu or a column menu closes first, and the next Escape restores the window. A grid claims only an Escape it actually used, so a maximised grid never keeps the key (BACKLOG-0001143). Afterwards focus lands on the window's maximise control, so a keyboard user is somewhere they can act rather than wherever the payload left them. minimise(id) draws a window as a single row and hides its payload, keeping the chrome that carries the way back — so the rest of the dashboard closes up around it under whichever compact mode is configured — the windows below pull up under 'vertical', the windows beside it float left under 'horizontal' — which is the point of minimising one. In the arrangement, nothing moves at all: the collapse is a projection of the dashboard, not a change to it, so restore(id) gives back exactly the arrangement that was there — in any order, with any number of other windows still collapsed. All 14,400 minimise/restore orderings of a five-window dashboard are asserted. A window with chrome: false is refused by name: there would be nothing left on screen to restore it with. The controls are opt-in per window (maximisable, minimisable) with a layout-level default of the same name, and setInteractive() does not touch them — a display mode neither moves nor resizes a window in the arrangement, so a locked dashboard can still be blown up to read.
maximised() / minimised()The id of the window filling the host (at most one — maximising a second restores the first), or null; and the ids of every minimised window in mount order. Neither state is part of getLayout(): a mode is not an arrangement, so getLayout() reports the underlying placement in both states — where the window will be when restored — and setLayout() never restores anyone into a mode, moving a minimised window under it instead.
refresh()Re-measure every window and emit window:resized for those that changed. Called automatically; exposed for a host that changed something the module cannot observe, such as revealing an ancestor.
on(name, fn) / off(name, fn)Events: window:moved, window:resized, window:closed, layout:changed; the cancellable beforeWindowMove, beforeWindowResize and beforeWindowClose (call preventDefault(reason?) or return false), each paired with windowMove:cancelled, windowResize:cancelled and windowClose:cancelled. '*' subscribes to every past-tense event and is deliberately never delivered a before-event. Config sugar for all ten. Drag progress is not emitted per frame.
destroy()Stop observing, drop every listener including any left by a gesture in flight, and remove the DOM the module built. Whatever you mounted in a payload is yours to destroy.

Placement, compaction and a saved arrangement, executed

createLayout needs a real host element, the same way createGrid does, so this executed example reaches for the same in-tree DOM test double the suite runs the renderer against headlessly (packages/dom/src/renderer/testdom.js). demo/layout.html is the browser version, with a real grid, chart and KPI rail in three windows that you can drag, resize with the keyboard, close, save and restore.

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createLayout } = await import('../packages/modules/layout/index.js');

const layout = createLayout(root, {
  columns: 4, rows: 4,
  windows: [
    { id: 'a', title: 'A', xPos: 1, yPos: 1, xSize: 2, ySize: 1 },
    { id: 'b', title: 'B', xSize: 2, ySize: 1 },   // no coordinates: auto-placed
  ],
});
const shape = () => layout.getLayout().windows.map((w) => `${w.id}@${w.xPos},${w.yPos}`).join(' ');

const placed = shape();                        // B landed in the first free cell: 3,1
const saved = JSON.parse(JSON.stringify(layout.getLayout()));

layout.move('a', { xPos: 3, yPos: 1 });        // drop A on top of B
const pushed = shape();                        // B is pushed down to 3,2

layout.setLayout(saved);                       // the saved arrangement round-trips
const restored = shape();

const payload = layout.payload('a').id;        // the container you fill: 'a-body'
layout.destroy();
return [placed, pushed, restored, payload].join('|');

The mock socket

modules/mock-socket is a serverless stand-in for a live WebSocket feed, for building and demonstrating a real-time UI with no backend. MockWebSocket presents the same surface as the browser's WebSocket — the same readyState and state constants, the same onopen, onmessage, onclose and onerror, addEventListener, send and close — so the code that reads it does not change when it is swapped for a real one. It fires an initial snapshot the moment it opens, then a stream of deltas on a timer, all from a generator you hand it. It is a dev and test utility: optional, imports nothing from the grid, and is never pulled into the core bundle. It pairs naturally with the data router (one mock stream, partitioned to many grids), but depends on it no more than a real socket does.

import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';

const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }) });
socket.onmessage = (event) => {
  const message = JSON.parse(event.data);
  if (message.kind === 'snapshot') router.load(message.rows);
  else router.apply(message.changes);
};

// Going live is the one line that changes; everything above stays as written:
const socket = new WebSocket('wss://example.com/ops');

The swap is literally one line. Both sockets frame their messages the same way, so the reader parses event.data and switches on kind either way. The feed is seedable and deterministic: the shipped generators carry their own seed and the timing jitter is seeded too, so the same inputs replay the same stream — which is what lets a tutorial and its runnable example show the same thing every time, and what lets a test assert on an exact stream rather than a plausible one. Bring your own generator: a feed is any iterator that yields { kind: 'snapshot', rows } first and then { kind: 'delta', changes } forever — a plain generator function is the easiest form — and rng(seed) is exported so a custom feed can be seeded the same way the shipped ones are.

MemberDescription
new MockWebSocket({ feed, rate?, jitter?, seed?, snapshotDelay?, pauseWhenHidden?, url? })Open a mock socket driven by feed (a generator: snapshot first, then deltas). rate is the ms between deltas (default 1000); jitter a random plus-or-minus ms per gap (default 0); seed seeds that jitter (default 1); snapshotDelay the ms before it opens (default 60); pauseWhenHidden stops the feed while the tab is in the background (default true); url a cosmetic address so socket.url reads like the real thing.
onopen / onmessage / onclose / onerrorThe WebSocket handlers. onmessage receives an event whose data is the JSON-framed FeedMessage; a feed that ends closes the socket cleanly; a feed that throws surfaces as an error event, not an uncaught throw.
addEventListener / removeEventListenerThe EventTarget surface, alongside the on* handlers — both receive every event.
send(data?)Accepted and ignored: there is nothing upstream, so a page that calls send runs unchanged.
close()Close the socket and stop the feed, emitting a clean close.
pause() / resume()Hold the feed and continue it while the socket stays open — a demo and test affordance beyond the WebSocket surface.
opsFeed({ seed?, orders?, shipments?, incidents?, batch? })A mixed operations feed — orders, shipments and incidents across three regions plus a throughput rollup — the kind the data router partitions across several grids and a chart from one source. Yields a snapshot, then deltas forever.
priceFeed({ seed?, symbols?, move?, batch?, spread? })A market-data feed: instruments whose prices random-walk each tick, each record carrying type: 'price', symbol, last, chg and a bid/ask straddling the last. Yields a snapshot, then deltas forever.
rng(seed)A small seeded pseudo-random generator (mulberry32), so a custom feed can be seeded the same way the shipped ones are: the same seed yields the same sequence.

Every record on the shipped feeds carries a type, the property the data router partitions on, and an id (or symbol), its row key — so a mock feed drops straight into a routed screen. The module is plain JavaScript and timers: no dependencies, no eval, safe to paste into a page or a sandbox.

In-cell charts

Seven chart renderers for a cell. Each is a single SVG whose path data is the only thing a repaint writes, so they cost the same as any other cell as rows recycle.

NameShowsReads
lineTrend across a series.An array
areaTrend, with the area beneath filled.An array
columnA bar per point, drawn from zero.An array
winlossOne equal mark per point, up or down.An array
pieHow a set of numbers divides.An array
donutThe same, with a hole.An array
bulletOne measure against a target, over bands.A number
stackedHow one row's total divides, across the cell.An array
rangeThe span a set of values covers, middle marked.An array
gaugeOne value as a dial.A number
deltaDirection and movement over a sampling interval.A number
{ id: 'trend',  field: 'readings', cell: 'line' }
{ id: 'spend',  field: 'monthly',  cell: { render: 'column', props: { min: 0, max: 100 } } }
{ id: 'mix',    field: 'split',    cell: { render: 'donut', props: { hole: 0.55 } } }
{ id: 'sla',    field: 'uptime',
  cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } }

// When the series lives on another property than the cell's value.
{ id: 'trend', field: 'latest', cell: { render: 'line', props: { series: 'readings' } } }
PropApplies toDescription
seriessparklinesProperty name holding the array, when it is not the cell's value.
min / maxallPin the scale so several columns compare like for like.
labelallfalse hides the number beside the chart.
markerline, areafalse hides the dot on the last point.
holedonutInner radius as a fraction, default 0.55.
targetbulletDraws the target marker.
bandsbulletEdges of the qualitative bands, e.g. [60, 85].
intervaldeltaMilliseconds between samples. Default 1000.
modedelta'change' (default) or 'against'.
againstdeltaProperty to compare with in against mode.
showdelta'both', 'arrow' or 'delta'.

Entries that are not numbers are gaps rather than zeroes: a line breaks across them and a bar is omitted. Pin min and max when comparing columns, a sparkline scaled to its own data fills its cell whatever the magnitude.

The chart is aria-hidden and the cell carries a text summary, so a screen reader is told "12 points, 9 to 20, ending 18" rather than each value in turn.

Formulas

A leading = in a numeric cell is a formula. The grid stores what it comes to.

=5 + 5
=quantity * unitPrice
=[Unit Price] * 1.2
=ROUND(quantity * unitPrice, 2)
=IF(quantity > 10, "bulk", "single")
=SUM(readings)              // an array property on the row

References name columns of the same row, not cells, a grid sorts, filters, groups and pages, so A1 would mean a different row from one moment to the next. Matching is on field or title, ignoring case and spacing; bracket a name that contains spaces. A property with no column of its own is reachable too.

GroupFunctions
MathsSUM, AVERAGE/AVG, MIN, MAX, COUNT, PRODUCT, ABS, SQRT, POWER, MOD
RoundingROUND, ROUNDUP, ROUNDDOWN, FLOOR, CEILING
LogicIF, AND, OR, NOT, COALESCE
TextCONCAT, LEN, UPPER, LOWER, TRIM, LEFT, RIGHT
StatisticsMEDIAN, PERCENTILE, QUARTILE1, QUARTILE3, IQR, STDEV, STDEVP, VAR, VARP, COUNTDISTINCT

The statistical functions use R type 7 quantiles, the same definition as the totals row, grid.statistics and the distribution formatting rules, so the four never disagree about what a median is. PERCENTILE reads 90 and 0.9 as the same request. Over an empty set they return a number rather than null, because a formula is arithmetic and has to keep composing.

Operators + - * / ^ with parentheses, comparison for IF, and postfix %. ^ is right-associative and unary minus binds tighter than it, so -2^2 is 4: Excel's answer rather than mathematics'.

// Your own functions, on top of the built-in library.
createGrid(el, {
  formulaFunctions: {
    MARGIN: ([revenue, cost]) => (revenue - cost) / revenue,
  },
});

The result is stored, not the expression. A formula is a way of entering a value: like 1,200, (50) or 12%, and it commits as one undo step with the column's own validation. Persisting a formula and recalculating it when a dependency changes is a separate feature; referencesOf() is exported for anyone building it.

No eval, no new Function. A formula is text a user typed, so evaluating it with the JavaScript engine would let anyone who can edit a cell run code in your page. It is a hand-written parser and the only callable things are the functions above.

Bare arithmetic is not a formula. 2-1 is a plausible product code and 1/2 a plausible date, so both are refused rather than guessed at. Declare a formula with =.

Your own menu items and buttons

The cell menu's function form is handed the cell that was clicked and the built-in items, so adding one entry does not mean reproducing the other thirteen.

createGrid(el, {
  contextMenu: (params, defaults) => [
    ...defaults,
    { separator: true },
    {
      name: `Open ${params.value} in CRM`,
      action: (ctx) => open(`/crm/${ctx.data.accountId}`),
    },
  ],
});

params and the action's argument carry the same cell: { key, colId, value, row, data, column, index, grid }, where data is your original row object. Return the array you want shown: add, remove, reorder or replace. Returning an empty array suppresses the menu; returning nothing at all leaves the defaults alone, so a missing return cannot silently delete the menu.

The same option is accepted on a column definition, so a column's menu is declared where the column is rather than as one more branch inside a single grid-level callback. It takes the same shapes plus a bare array for the common “these items here too” case: boolean | MenuItem[] | (params, defaults) => items.

createGrid(el, {
  columns: [
    { field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },   // appended after the grid's items
    { field: 'amount', contextMenu: (p, defaults) => [...defaults, { name: 'Reprice', action: reprice }] },
    { field: 'ref', contextMenu: () => [{ name: 'Copy reference', action: copyRef }] },   // only this: ignore defaults
    { field: 'nationalId', contextMenu: false },   // no menu on this column, others unaffected
  ],
});

The three levels compose as a chain: built-in defaults, then the grid-level contextMenu, then the column's — each handed the previous result as its defaults, so a column adding one item never restates the built-ins. The array form chains too: contextMenu: [items] on a column is exactly contextMenu: (p, defaults) => [...defaults, ...items], so the built-ins and every grid-level item stay and the column's items follow them, in the order written. To replace a column's menu instead, use the function form and ignore defaults: contextMenu: () => items. (Earlier releases let an array replace the grid-level items; the two forms now agree.) Suppression follows the same order and the more specific level wins: false on a column is a statement about that column alone. The reverse holds too, and is worth knowing before you rely on grid-level contextMenu: false as a safety property: a column that declares its own contextMenu opens one anyway. Grid-level false is a default, not a lock — it is what makes “no menu anywhere except here” expressible. On a right-click inside a multi-column selection the clicked column's menu is the one that opens — not the intersection, which loses items, and not the union, which offers actions wrong for most of the selection. A group row, pivot group row or full-width row belongs to no column, so the chain has one link fewer and the grid-level menu stands. The keyboard routes (Shift+F10 and the Context Menu key) honour the column exactly as the pointer does. See the guide for the full table of combinations.

The empty tail of a row is the row's. When the columns do not fill the grid's width, each row has an empty area to the right of the last column. A right-click there opens the grid's menu for that row, never the browser's, exactly as a right-click on a group row does: there is no column under the pointer, so the column link is missing from the chain and the grid-level menu stands, and cell:contextmenu (and so a builder's params) carries colId: null, column: undefined and value: undefined with the row, key and index filled in. The built-in items that act on a cell — Paste, Clear, Fill down, Edit cell — are not offered there (there is no cell for them to act on); the row and grid items are. A builder that reads params.column should expect it to be absent there. The area below the last row belongs to no row and keeps the browser's menu. On 1.54 and earlier a right-click in the tail fell through to the browser's menu, which looked as though the grid had none; on those versions give one column layout: { flex: 1 } so the cells reach the edge and there is no tail to click.

Expand all and Collapse all are built-in row/grid items (BACKLOG-0001305): present, in this order, on the row context menu and on every column's header menu (the 3-dot button and a right-click on the heading) whenever the grid is grouped, hidden rather than disabled otherwise. Each drives the public grid.rows.expandAll() / collapseAll(), is matched by its translated name like any other built-in item (catalogue keys menu.expandAll / menu.collapseAll), and passes through the same contextMenu / columnMenu chain above.

columnMenu takes the same form for the header's menu: both the 3-dot button and a right-click on a heading. Its params is { colId, column, grid }. Anything of your own that you put on a column definition is on column.def, so an item can appear on some columns and not others.

createGrid(el, {
  columns: [{ field: 'jan', title: 'Jan', context: { month: 1 } }],
  columnMenu: (params, defaults) => {
    // Your own keys live on the definition you wrote.
    const month = params.column.def.context?.month;
    if (!month) return defaults;
    return [...defaults, { separator: true },
      { name: 'Select quarter', action: () => selectQuarter(month) }];
  },
});

The rail takes host buttons the same way. A string names a built-in and an object is yours, placed where it appears in the list rather than appended after the built-ins.

createGrid(el, {
  toolPanel: {
    side: 'left',
    actions: ['undo', 'redo', {
      name: 'sync',
      title: 'Sync to the server',        // or a function, re-read on every repaint
      icon: 'restore',
      run: ({ grid, keys, cells }) => api.sync(keys),
      enabled: () => grid.history.canUndo(),
    }],
  },
});

An icon naming a sprite the registry does not have draws the blank glyph and logs a [lattice] warning once, naming the icon and how to register it (BACKLOG-0001211) — it does not fail silently as an empty, still-clickable button.

Styling and your page's CSS

Forced colours. In Windows High Contrast Mode the grid translates state that is normally a background tint into borders and system colours: selection takes the system's own selection colours, pinned regions swap their shadow for a rule, and diff states are told apart by border style rather than by hue. A colour swatch and a collaborator's presence colour keep their own colour, because there the colour is the information.

Every selector is namespaced under .lattice, so the grid cannot restyle your page. Since 1.4.0 the reverse holds too: the elements the grid builds are given a floor for the properties a host page commonly sets on a bare tag: margin, padding, border, radius, background, shadow, text transform, letter spacing, and type and colour on form controls. A rule such as section { padding: 5.5rem 0 } no longer reaches inside the grid.

No !important is involved. The reset is specificity (0,1,1); every rule that dresses a grid element is (0,2,0) or higher, and so is any rule of yours aimed at a Lattice class. Deliberate overrides work exactly as before: only bare-tag rules are shut out. The reset covers box model and decoration only, never display, position or any dimension.

Accessibility

Built to WCAG 2.2 level AA. Every operation is reachable without a pointer, including resizing and reordering a column, which have key bindings and column-menu items rather than depending on a drag. The grid reports itself as grid or treegrid following its configuration; rows and cells carry their position in the dataset rather than in the rendered window, so a reader on row 500,000 is told so; and rows in a hierarchy carry their position among their siblings, which a reader cannot count for itself when most of a branch was never rendered.

Focus is real focus rather than aria-activedescendant, and survives row recycling. Tabbing into a grid shows a focus ring around the grid at once; the first arrow key moves focus, and the ring, to a cell, and from then on Tab returns to that cell. Sorting, filtering, selection, grouping, expanding, paging, undo, paste and a refused edit are all announced. In Windows High Contrast Mode state is translated into borders and system colours instead of tints. No information is carried by hue alone.

The full keyboard map, the screen reader support statement and the known limits, including the drag-only pivot zones, are in the guide.

Built-in names

Every registry accepts a custom entry under the same name, which then wins over ours.

Data types

Seven built-in, and seventy-four in the extended catalogue. Inference only ever reaches the built-in names: the candidates are tried in registration order, and every string settles on text and every number on number before an extended type is reached. So a column asks for an extended type by name.

textnumberbooleandatedateStringlookupobject
timedatetimetimestampduration ipv4cidripv6 jsonsecret hexhex8hex16hex32 binarybinary8octaldecibeldecibelAmplitude bytesmegabytesgigabytes bitrategigabits metresmillimetreskilometres gramskilogramstonnes secondsmillisecondshours speedkphmphknots accelerationareahectares volumecubicMetres energykilowattHours powerkilowattsforce pressurebarpsi torquedensity flowlitresPerMinute radiansdegrees voltagecurrentresistance capacitanceinductancecharge conductancefluxDensity luminousFluxilluminancesubstance absorbedDoseequivalentDoseradioactivity frequency luminousIntensitydoseRaterpmangularVelocityppmppbbasisPointsmolaritymassFlowtonnesPerHourviscositykinematicViscositythermalConductivityspecificHeatcelsiusfahrenheitkelvincurrencyusdeurgbpjpy

Editors

texttextareanumberdatecheckboxselectmultiSelect timedatetimedurationipaddresspasswordcode unittemperaturecurrencyradixsliderratingsegmented treeSelectobjectPickericonPickercolour

Cell renderers

groupcheckboxprogresslinkpilliconskeletonratingcolourqrcode

Filters and aggregations

textnumberdatesetmultiadvanced
summinmaxavgcountcountValuesfirstlast

Icons

Inline SVG sprites, overridable by name through registerIcon(name, def).

chevronRightchevronDownchevronUpchevronLeft checkdashcloseplusminus infosuccesswarningdanger clocklocklinkexternalfilter sortAscsortDescmenudrag starheartcircleFilledsquareboltflagthumbUp eyeeyeOffcopypresentblank

Filter grammar

The condition tree is a published wire protocol, not an internal shape. It serialises into saved state and travels to a remote source unchanged.

grid.filters.set({
  op: 'and',
  conditions: [
    { col: 'region', op: 'eq', value: 'EMEA' },
    { col: 'capacity', op: 'between', value: [100, 500], bounds: '[)' },
    { op: 'not', conditions: [
      { col: 'status', op: 'in', value: ['closed'] },
    ] },
  ],
});
GroupOperators
Equalityeq, ne
Orderinglt, lte, gt, gte
Rangesbetween, notBetween, with bounds of '[]', '[)', '(]' or '()'
Setsin, notIn
Textcontains, notContains, startsWith, endsWith, matches
Blanknessblank, notBlank
Multi-valuecontainsAny, containsAll, containsNone, for cells holding an array of ids
Groupingand, or, not

An operator outside this list is refused, not applied (BACKLOG-0001180). filters.set() and state.apply() drop a condition whose op is not one of the operators above rather than installing it — it never reaches filters.get() and the grid is left exactly as filtered as it was before. A [lattice] warning names the operator received and the operators valid for that column's type. In a compound filter only the offending leaf is dropped; every other condition still applies.

One filter, not two. A condition set from a header popup, from the tool panel, or through grid.filters.set() all merge into the same tree. Reading grid.filters.get() always gives the whole truth.

Host predicates: where

Some filters cannot be written as a condition, because what they test is not in any column: whether this user may see the row, whether you hold a rate for its currency, whether it is in the set your last API call returned. Register those as named predicates.

grid.filters.where('visibleToMe', row => row.owner === me);
grid.filters.where('rateKnown', row => rates.has(row.ccy), { deps: ['ccy'], pinned: true });
grid.filters.where('visibleToMe', null);   // remove
grid.filters.where();                      // the registered names
grid.filters.reapply('rateKnown');         // re-run one
grid.filters.reapply();                    // re-run all

Registering is activating. There is no "a filter is present" flag to keep in step, because that flag is the thing that goes wrong: it is a second piece of state describing the first, and when the two disagree the grid either filters while reporting that it is not, or reports a filter while every row passes. A predicate is in force from the moment it is registered until it is removed.

Several are in force at once under their own names, ANDed with each other and with the condition tree; removing one leaves the rest alone. The predicate is handed the data row, the same shape DerivedSourceConfig.where receives.

OptionTypeWhat it does
depsstring[]The columns the predicate reads, in the same spirit as value.deps on a computed column. Declared, the verdict is cached per row and re-run only when one of these columns changes on that row. Omitted, the predicate is treated as reading the whole row and runs on every pass — never stale, and never skipped either.
pinnedbooleanSurvive filters.clear(). For row-level permissions and tenant scoping, where a "clear filters" button must never widen what the user can see.
conditionFilterSetA declarative twin, pushed to the source while the function stays as the residual. It must be implied by the predicate: the grid ANDs both, so a twin wider than the function costs only time, while one narrower than it hides rows the function would have kept.

Coming from AG Grid's external filter? The three pieces map onto two. isExternalFilterPresent() disappears — registration is presence. doesExternalFilterPass(node) becomes the named predicate you pass to where. onFilterChanged() becomes either deps, when what changed is a column the grid can watch, or reapply(name?), when it is something the grid cannot see at all — a rate table arriving late, a permission refresh.

A predicate runs where the whole dataset is. Memory, stream and derived sources hold every row, so the function runs across all of them and the counts it produces are whole-dataset counts. The paged and remote sources hold only what they fetched, and what reaches them is the condition tree rather than the function: a predicate on either of those narrows nothing by itself, and the grid warns once when you register it, naming the predicate and the source kind.

A pushdown source is the exception, up to a point. It can fetch the whole matching set and run the function over it, so it does — while that set is under whereRowLimit (default 50,000 rows). At or past the limit, or when the adapter reports no row total, it refuses: the predicate is not applied, the rows it would exclude stay on screen, and a warning names the adapter and the way out. Honouring it past that point would silently turn a windowed grid into a whole-dataset download, which is the thing a pushdown source exists to avoid.

The twin is the route that always works. Give the predicate a condition twin — it is ANDed into the tree the source is sent, so the engine narrows the fetch itself, at any size, and the grid stays silent because that case genuinely works. That is the supported route on a server-delegated or pushdown source.

Only names are state. filters.get() still returns exactly what the user set. state.get() carries where: string[] — the names in force — because a predicate is your code and cannot be serialised into a saved view or restored from one. state.apply() naming a predicate you have not registered reports the skip rather than installing anything, and never removes a predicate a saved view did not name.

A pinned permission filter and a "my items" toggle on one grid, executed on every build:

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const me = 'ana';
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'team' }, { field: 'owner' }, { field: 'ccy' }],
  rows: [
    { id: '1', team: 'eu', owner: 'ana', ccy: 'USD' },
    { id: '2', team: 'us', owner: 'ana', ccy: 'USD' },
    { id: '3', team: 'eu', owner: 'bo', ccy: 'ZWL' },
  ],
});

// Row-level permission. Pinned, so "clear filters" cannot widen it, and it
// carries a declarative twin a server can push.
grid.filters.where('teamVisible', (row) => row.team === 'eu', {
  pinned: true,
  condition: { col: 'team', op: 'eq', value: 'eu' },
});
// An ordinary "my items" toggle, re-run only when `owner` changes on a row.
grid.filters.where('myItems', (row) => row.owner === me, { deps: ['owner'] });

const both = grid.rows.count();          // permission AND my items
grid.filters.where('myItems', null);      // toggle off
const afterToggleOff = grid.rows.count();
grid.filters.clear();                    // the pinned one survives
const afterClear = grid.rows.count();
const stillOn = grid.filters.where().join(',');
const inState = grid.state.get().where.join(',');
grid.destroy();

return `${both}|${afterToggleOff}|${afterClear}|${stillOn}|${inState}`;

A configuration, executed

This block runs on every build. If a key here stopped being honoured, or was renamed, the build would fail rather than the documentation quietly going stale.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// Every one of these is a documented configuration key, set together so the
// example proves they are accepted and honoured rather than merely spelled.
const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'name', field: 'name' }, { id: 'size', field: 'size', type: 'number' }],
  rows: [{ id: '1', name: 'a', size: 3 }, { id: '2', name: 'b', size: 1 }],
  rowHeight: 32, headerHeight: 40, overscan: 8, autoHeight: false,
  showHeader: true, density: 'compact', theme: 'light',
  locale: 'en-GB', timeZone: 'UTC', title: 'Readings',
  gridLines: 'both', cornerRadius: 4, stripedRows: false, targetSize: 'default',
  sampleSize: 100, quickFilterText: '', maximise: false,
  shortcuts: true, rowReorder: false,
  stickyGroupHeaders: true, groupFooter: false,
  totalFilteredOnly: false, showTotalInHeader: false,
  aggregateChooser: false,
  allowUnsafeTemplates: false, useWorker: false,
  sharedMemory: false, workerThreshold: 100000,
  columnVirtualisationAbove: 40, showColumnFunctions: false,
});

const n = grid.rows.count();
grid.destroy();
return n;

Writing direction and the two alignment vocabularies, executed

direction is a recognised configuration key, and a column's resolved align keeps the spelling it was given: left/right are physical edges, start/end are logical and mirror in a right-to-left grid. A number column with no align of its own still defaults to the logical end.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  direction: 'rtl',
  rowKey: 'id',
  columns: [
    { id: 'l', field: 'l', align: 'left' },   // physical: the left edge in either direction
    { id: 'r', field: 'r', align: 'right' },  // physical: the right edge in either direction
    { id: 's', field: 's', align: 'start' },  // logical: the right edge in this RTL grid
    { id: 'e', field: 'e', align: 'end' },    // logical: the left edge in this RTL grid
    { id: 'n', field: 'n', type: 'number' },  // a number column defaults to the logical end
  ],
  rows: [{ id: '1', l: 'a', r: 'b', s: 'c', e: 'd', n: 1 }],
});
const resolved = ['l', 'r', 's', 'e', 'n'].map((id) => grid.columns.get(id).align);
const out = [grid.config().direction, ...resolved].join('|');
grid.destroy();
return out;

Non-blocking stream ingest, executed

A stream source loaded with ingest.useWorker on. In a browser a chunk that clears ingest.workerThreshold is columnized on a Worker so the main thread is not blocked; here in Node there is no Worker, so it columnizes in-process — the same code, the same result, which is exactly what this asserts. With retainSource:false the grid keeps only the packed columns, so rows.data() returns reconstructed objects rather than the caller's own. This makes stream (and remote) ingest non-blocking; memory and paged sources still read the caller's objects on the main thread.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// A stream source: the producer pushes chunks of rows as it finds them.
async function* open() {
  yield { rows: [{ id: '1', city: 'Oslo', pop: 700000 }, { id: '2', city: 'Bergen', pop: 280000 }] };
  yield { rows: [{ id: '3', city: 'Tromsø', pop: 77000 }] };
}

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'city', field: 'city' }, { id: 'pop', field: 'pop', type: 'number' }],
  source: { mode: 'stream', open },
  // Columnize stream chunks off the main thread when a chunk is large enough,
  // and keep only the packed columns rather than the caller's row objects.
  ingest: { useWorker: true, workerThreshold: 1, retainSource: false },
});

// The stream loads over async frames; wait for it to finish before counting.
await new Promise((resolve) => grid.on('stream:end', resolve));
const n = grid.rows.count();
grid.destroy();
return n;

Source-layer memory reduction, executed

A memory grid loaded with ingest.dropSourceRows on. Once the column store is built, the caller's row objects are released from the source layer and the grid config, so the packed columns are the only resident copy — an order-of-magnitude drop at scale. Reads are served by reconstructing a row from the columns, so the values are unchanged; what is gone is object identity, which is why rows.data() returns a fresh object each call rather than the one you supplied.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const supplied = [{ id: '1', city: 'Oslo', pop: 700000 }, { id: '2', city: 'Bergen', pop: 280000 }];

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'city', field: 'city' }, { id: 'pop', field: 'pop', type: 'number' }],
  source: { mode: 'memory' },
  rows: supplied,
  // Release the caller's objects; keep only the packed columns.
  ingest: { dropSourceRows: true },
});

const back = grid.rows.data();
// Same values, reconstructed from the columns — but not the caller's own object.
const valuesMatch = back[0].city === 'Oslo' && back[0].pop === 700000;
const identityDropped = back[0] !== supplied[0];
grid.destroy();
return valuesMatch && identityDropped;

Events, executed

Fourteen events raised by ordinary calls, asserted on every build. An event that stopped firing, or changed name, fails here rather than in a consumer.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }, { id: 's', field: 's', type: 'number' }],
  rows: [{ id: '1', n: 'a', s: 3 }, { id: '2', n: 'b', s: 1 }],
});

// A wildcard handler receives one event object; `type` says which arrived.
const seen = new Set();
grid.on('*', (event) => seen.add(event.type));

grid.sort.set([{ col: 's', dir: 'asc' }]);        // sort:changed
grid.filters.set({ col: 's', op: 'gt', value: 0 }); // filter:changed
grid.columns.hide('n');                            // column:visible
grid.columns.move('n', 1);                         // column:moved
grid.columns.pin('n', 'left');                     // column:pinned
grid.columns.groupColumns(['n', 's'], { title: 'Both' }); // columngroup:changed
grid.set('rowHeight', 30);                         // config:changed
grid.rows.apply({ update: [{ id: '1', s: 9 }] });  // rows:changed, model:changed
grid.state.reset();                                // state:reset, state:changed

const raised = seen.size;
grid.destroy();
return raised;

View persistence, executed

One event carries every state change and says what caused it, so a save layer subscribes once and skips the restore-to-default — which would otherwise write the default straight back over the view the user had just abandoned.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }, { id: 's', field: 's', type: 'number' }],
  rows: [{ id: '1', n: 'a', s: 3 }, { id: '2', n: 'b', s: 1 }],
});

const causes = [];
let writes = 0;
grid.on('state:changed', (event) => {
  causes.push(event.cause);
  // The one cause a save must ignore: persisting a reset writes the default
  // back over the view the user has just abandoned.
  if (event.cause === 'reset') return;
  // A real host debounces, then writes grid.state.get() — which is
  // permission-sanitised, unlike the raw capture.
  writes++;
});

grid.sort.set([{ col: 's', dir: 'asc' }]);   // cause: 'user', sections: ['sort']
grid.state.apply({ version: 2, sort: [] });  // cause: 'apply', with a report
grid.state.reset();                          // cause: 'reset' — deliberately not saved

const result = `${causes.join(',')}:${writes}`;
grid.destroy();
return result;

Every grid namespace and method, executed

Reading a namespace builds it, so this proves each is reachable rather than declared and absent. The plain methods are called, not merely named.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }],
  rows: [{ id: '1', n: 'a' }],
});

// Every namespace the grid exposes. Reading one builds it, so this proves
// each is reachable rather than declared and absent.
const namespaces = [
  'ai', 'columns', 'comments', 'config', 'crossFilter', 'detail',
  'diagnostics', 'diff', 'edit', 'element', 'export', 'facets',
  'filters', 'form', 'formatting', 'highlight', 'history', 'licence',
  'messages', 'overlay', 'pagination', 'permissions', 'presence', 'presentation',
  'ready', 'redaction', 'rows', 'scroll', 'selection', 'sort',
  'state', 'statistics', 'timeline', 'updates', 'views', 'destroyed',
];
const present = namespaces.filter((name) => name in grid).length;

// And the plain methods, each called rather than merely typed.
grid.set('rowHeight', 30);
grid.setAll({ overscan: 6 });
grid.get('rowHeight');
grid.getVersion();
grid.getPinnedRows();
grid.setPinnedRows({ top: [], bottom: [] });
grid.rendererHost();
const off = grid.on('config:changed', () => {});
grid.once('config:changed', () => {});
grid.emit('config:changed', {}, 'api');
grid.off('config:changed', off);
typeof grid.attachRenderer;

grid.destroy();
return present;

Every core export, executed

Named and resolved against the barrel on every build. A rename or a removal fails here rather than in a consumer's build.

const core = await import('../packages/core/src/index.js');

// Every declared export of the core package, named and checked. A symbol
// that was renamed or dropped fails here, not in a consumer's build.
const declared = [
  'AR', 'AR_SA', 'CS_CZ', 'DA_DK',
  'DEFAULT_LOCALE', 'DE_DE', 'EL_GR', 'EN_GB',
  'EN_US', 'ES_ES', 'FI_FI', 'FR_CA',
  'FR_FR', 'HU_HU', 'IT_IT', 'JA_JP',
  'LOCALES', 'MESSAGE_KEYS', 'NB_NO', 'NL_NL',
  'NO_CAPABILITIES', 'PL_PL', 'PT_BR', 'RO_RO',
  'SV_SE', 'UK_UA', 'UNIT_SYSTEMS', 'applyResidual',
  'auditCatalogue', 'compileRules', 'createPushdownSource', 'createRadixType',
  'createUnitType', 'defineUnit', 'dfqlAdapter', 'duckdbAdapter',
  'evaluateFormula', 'formatList', 'formatUnit', 'getVersion',
  'graphqlAdapter', 'ingest', 'ingestSync', 'licenceInfo',
  'licenceState', 'licenseInfo', 'licenseState', 'looksLikeFormula', 'odataAdapter',
  'parseUnit', 'planQuery', 'referencesOf', 'registerModules',
  'registerUnitSystem', 'resolveCatalogue', 'resolveLocale', 'restAdapter',
  'restoreState', 'serialiseState', 'setLicence', 'setLicense',
  'version',
];

return declared.filter((name) => core[name] !== undefined).length;

The dhtmlx translation, executed

Every translated key in one definition, with the result asserted. A key the wrapper stopped honouring drops out of the output and fails the build.

const { translateColumn } = await import('../packages/modules/dhtmlx-compat/columns.js');

// Every dhtmlx column key the wrapper translates, in one definition. A key it
// stopped honouring would drop out of the result and fail this example.
const translated = translateColumn({
  id: 'size',
  header: 'Size',
  type: 'number',
  width: 120, minWidth: 80, maxWidth: 200,
  resizable: true, hidden: false, draggable: true,
  align: 'right',
  tooltip: 'How big', tooltipTemplate: null,
  template: (v) => String(v), htmlEnable: false,
  sortable: true,
  editable: true, editorType: 'datePicker',
  editorConfig: { min: 0 }, options: [],
  summary: 'sum',
});

// And the constructor keys, which translate at the grid rather than the column:
// rowKey, columns, data, autoHeight, rowHeight, headerRowHeight,
// multiselection, dragItem and rowTransfer.
return [
  translated.id,
  translated.layout.width,
  translated.cell.align,
  translated.sort.enabled,
  translated.edit.editor,
  translated.total,
].join('|');

Cell spans, executed

dhtmlx's imperative addSpan maintains a span table, and that table drives Lattice's own per-cell span functions — installed on each column's cell definition. A span declared through the shim is read back through the very functions the renderer calls.

// Aliased on import: the shim's span factory, bound to a local name.
const { createSpans: installSpans } = await import('../packages/modules/dhtmlx-compat/spans.js');

// A minimal stand-in for a grid: it just holds its columns, which is all the
// span shim touches. `createSpans` installs `cell.spanRows`/`cell.spanColumns`
// on each column by re-setting them through its own wrapper.
let columns = [{ id: 'name', cell: {} }];
const grid = {
  get: () => columns,
  set: (_, next) => { columns = next; },
};

const spans = installSpans(grid);
spans.addSpan('R0', 'name', 3, 2); // rowspan 3, colspan 2

// The column's own cell functions now read the maintained table.
const { cell } = columns[0];
const spanned = { row: { key: 'R0' }, colId: 'name' };
const plain = { row: { key: 'R1' }, colId: 'name' };
return [
  cell.spanRows(spanned),
  cell.spanColumns(spanned),
  cell.spanRows(plain),
  cell.spanColumns(plain),
].join('|');

Every module export, executed

Every shipped module’s exports, resolved against its own barrel on every build.

// Every declared export of every shipped module, resolved against its own
// barrel. A module that stopped exporting something fails here.
const modules = [
  [await import('../packages/dom/src/index.js'), [
    'ContextMenu', 'Messages', 'Registry', 'autoInit',
    'createGrid', 'createLocalViewStorage', 'createMessages', 'createStat',
    'deltaOf', 'gridElementsWithin', 'hydrateTable', 'mountPanel',
    'readTable', 'toneOf', 'LatticeGrid',
  ]],
  [await import('../packages/modules/charts/index.js'), [
    'Chart', 'PALETTE', 'SCHEMES', 'TYPES',
    'createChart', 'chartRange', 'canChartRange', 'deriveRangeSpec', 'regressionPlots',
    'registerScheme', 'resolveScheme', 'schemeNames',
    'setDefaultScheme',
  ]],
  [await import('../packages/modules/htmx/index.js'), [
    'HTML_ROW_WARNING_THRESHOLD', 'QUERY_CHANGED_EVENT', 'SCROLL_NEAR_END_EVENT', 'attach',
    'destroyWithin', 'driveInfiniteScroll', 'driveOobUpdates', 'driveServerMode',
    'ingestResponse', 'initWithin', 'queryParams', 'restoreStateWithin',
    'rowsFromFragment', 'rowsFromJson', 'saveStateWithin', 'warnIfLargeHtmlPayload',
  ]],
  [await import('../packages/modules/webcomponent/index.js'), [
    'ATTRIBUTE_CONFIG', 'EVENT_PREFIX', 'GridElementController',
    'TAG_NAME', 'createLatticeGridElement', 'defineLatticeGrid', 'domEventName',
    'observedAttributeNames',
  ]],
  [await import('../packages/modules/devtools/index.js'), [
    'CONSOLE_ACTIVATION', 'createDevtools', 'expose',
  ]],
  [await import('../packages/modules/react/index.js'), [
    'EVENT_NAMES', 'handlerName',
  ]],
  [await import('../packages/modules/vue/index.js'), [
    'createLatticeGrid', 'dashedName',
  ]],
  [await import('../packages/modules/svelte/index.js'), [
    'createLatticeAction',
  ]],
  [await import('../packages/modules/dhtmlx-compat/index.js'), [
    'Grid',
  ]],
  [await import('../packages/modules/data-router/index.js'), [
    'createDataRouter',
  ]],
  [await import('../packages/modules/mock-socket/index.js'), [
    'MockWebSocket', 'rng', 'opsFeed', 'priceFeed',
  ]],
  [await import('../packages/modules/kanban/index.js'), [
    'createKanban',
  ]],
  [await import('../packages/modules/geo-world-110m/index.js'), [
    'pack',
  ]],
];

let present = 0;
for (const [mod, names] of modules) {
  present += names.filter((name) => mod[name] !== undefined).length;
}
return present;

Nested configuration, executed

Thirteen option blocks, each key written where it belongs. Parsed and evaluated on every build, so a key that was renamed or moved shows up here.

// Placeholders for the things a real page supplies. The point of this block is
// the option names: each one below is a documented key, written where it
// belongs, so a key that was renamed or moved stops matching its interface.
const source = {}, other = {}, provider = {}, compute = {}, adapter = {};
const fetch = async () => ({ rows: [], total: 0 });
const open = () => ({ close() {} });

// A derived grid — DerivedSourceConfig
const derivedSourceConfig = { mode: 'derived', from: source, follow: 'filtered', unnest: 'tags',
    join: { with: other, on: 'id' }, where: (r) => r.size > 0,
    bucket: { of: 'taken', by: 'day' }, groupBy: 'team',
    select: { total: { of: 'size', fn: 'sum' } },
    limit: 10, limitPer: 'team', cumulative: { of: 'size', upTo: 0.8 },
    profile: 'size', orient: 'metrics', refresh: 'idle', crossFilter: true };

// Editing — EditConfig
const editConfig = { enabled: true, commit: 'blur', confirm: false, start: 'dblclick',
    enterMovesDown: true, undoDepth: 50, pendingTimeout: 2000 };

// Selection — SelectionConfig
const selectionConfig = { checkbox: true, headerCheckbox: true, checkboxOnly: true, ranges: true, fill: true, fillHandle: true };

// Tree data — TreeConfig
const treeConfig = { parentKey: 'parentId', path: 'path', orphans: 'root',
    loadChildren: async () => [], hasChildren: (r) => !!r.kids };

// Master detail — DetailConfig
const detailConfig = { isMaster: (r) => true, target: '#detail', cacheLimit: 20,
    onCreate: () => {}, placement: 'below' };

// Presence — PresenceConfig
const presenceConfig = { provider, me: { id: 'u1' }, roster: [], palette: ['#0d6b68'],
    idleMs: 30000, removeMs: 60000, throttleMs: 100, lock: true, lockMs: 5000 };

// Comments — CommentConfig
const commentConfig = { provider, debounce: 300, indexLimit: 5000, markdown: false };

// A paged source — PagedSourceConfig
const pagedSourceConfig = { mode: 'paged', pageSize: 100, maxCachedPages: 5, fetch };

// A streaming source — StreamSourceConfig
const streamSourceConfig = { mode: 'stream', open, coalesceMs: 16, maxRows: 1e6, promoteToMemoryBelow: 5e5,
    // A rolling *time* window beside the count one: keep five minutes, aged by the
    // row's own clock. Omit ageBy and rows age from when they arrived instead.
    maxAge: 5 * 60 * 1000, ageBy: 'ts' };

// A pushdown source — PushdownSourceConfig
const pushdownSourceConfig = { adapter, compute, pageSize: 200 };

// CSV export — CsvExportOptions
const csvExportOptions = { fileName: 'rows.csv', delimiter: ',', lineEnding: '\\n',
    headers: true, download: false, quote: 'minimal', processCell: (v) => v };

// Facets — ColumnFacetConfig
const columnFacetConfig = { cardinalityLimit: 200, aboveLimit: 'search', bucketFn: (v) => v,
    buckets: 20, granularity: 'day', strategy: 'even' };

// Numbers and units — NumberFormat
const numberFormat = { decimals: 2, minDecimals: 0, maxDecimals: 4, scale: 1,
    unit: 'metre', system: 'si', space: true, binary: false, format: 'auto',
    display: 'yesNo', label: 'Size', hint: 'in metres' };

// How rows enter the store — IngestConfig
const ingestConfig = { retainSource: false };

return [derivedSourceConfig, editConfig, selectionConfig, treeConfig, detailConfig,
  presenceConfig, commentConfig, pagedSourceConfig, streamSourceConfig,
  pushdownSourceConfig, csvExportOptions, columnFacetConfig, numberFormat, ingestConfig].length;

The remaining option names, executed

Set on a real grid and checked against its own diagnostics: an unrecognised key raises config.unknown, so a renamed or dropped option fails here.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { reportedWarnings } = await import('../packages/core/src/internal/util.js');

// Top-level configuration keys. Each is set on a real grid, and the grid is
// asked whether it recognised them: an unrecognised key raises
// `config.unknown:<key>`, so a renamed or dropped option fails right here.
const documented = [
  'ai', 'alignedGrids', 'anomalySummary', 'columnDefaults', 'columnGroups', 'columnMenu',
  'columnPresets', 'columnTagFilter', 'comments', 'components', 'context',
  'contextMenu', 'dataTypes', 'detail', 'diff', 'edit',
  'environment', 'facets', 'formatting', 'formulaFunctions', 'fullWidth',
  'grandTotalRow', 'groupPanel', 'highlightOnChange', 'historyBar', 'hostFilter', 'ingest',
  'licence',
  'pagination', 'permissions', 'pinnedBottomRows', 'pinnedTopRows', 'pipes',
  'pivot', 'presence', 'responsive', 'rowClass', 'rowForm',
  'rowStyle', 'rowTemplate', 'rowTransfer', 'selection', 'source',
  'state', 'statusBar', 'toolPanel', 'totalFns', 'totalOnlyChangedColumns',
  'tree', 'typeOptions', 'updates', 'variants', 'views',
  'workerUrl',
];

const before = reportedWarnings().length;
const config = { rowKey: 'id', columns: [{ id: 'n', field: 'n' }], rows: [] };
for (const name of documented) config[name] = undefined;

const grid = createHeadlessGrid(config);
const unknown = reportedWarnings().slice(before)
  .filter((w) => w.key.startsWith('config.unknown:'));
grid.destroy();

// These belong to nested option blocks and are exercised in the block above:
//   announce, apply, at, background, byKey, compute
//   config, count, crossFilter, destroy, group, groupSelectsChildren
//   groupSelectsFiltered, height, loaded, order, pageSizes, refresh
//   reload, render, rowLabel, sort

if (unknown.length) throw new Error(`unrecognised: ${unknown.map((w) => w.key).join(', ')}`);
return documented.length;

Every event name, executed

Each documented event is subscribed to and unsubscribed on every build. A consumer wiring a handler to a renamed event gets silence, which is indistinguishable from an event that has not fired yet — so the name is checked rather than left to be discovered.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

// Every documented event name, checked against the bus that would carry it.
// Subscribing to a name the grid does not know is the failure this catches:
// a consumer wiring a handler to a renamed event gets silence, and silence
// is indistinguishable from an event that simply has not fired yet.
const documented = [
  'cell:changed', 'cell:clicked', 'cell:confirmed', 'cell:conflict',
  'cell:contextmenu', 'cell:dblclicked', 'cell:edit:end', 'cell:edit:start',
  'cell:mouseover', 'cell:mouseout', 'cell:mousedown', 'cell:mouseup',
  'cell:pending', 'cell:reverted', 'clipboard:copy', 'column:filter:open', 'column:profile:open', 'column:grouped',
  'column:menu:open', 'column:pivoted', 'column:resized', 'columns:changed',
  'columns:tagged', 'comment:added', 'comment:deleted', 'comment:edited',
  'comment:failed', 'comment:indexLoaded', 'comment:resolved', 'comment:threadClosed',
  'comment:threadOpened', 'comment:unresolved', 'destroy', 'detail:toggled',
  'diff:changed', 'diff:swapped', 'export:progress', 'facet:computed',
  'facet:expanded', 'facet:failed', 'facet:filtered', 'form:closed',
  'form:error', 'form:opened', 'form:saved', 'formatting:changed',
  'group:toggled', 'header:contextmenu', 'highlight:changed', 'find:changed', 'history:applied',
  'history:changed', 'licence:changed', 'page:changed', 'permissions:changed',
  'presence:failed', 'presence:joined', 'presence:left', 'presence:lockRefused',
  'presence:published', 'presence:updated', 'presentation:captured', 'presentation:changed',
  'presentation:ended', 'presentation:scale', 'presentation:spotlight', 'presentation:started',
  'presentation:view', 'range:changed', 'ready', 'redaction:changed',
  'render:done', 'render:first', 'row:clicked', 'row:copied',
  'row:dblclicked', 'row:edit:end', 'row:edit:start', 'row:moved',
  'row:pending', 'row:confirmed', 'row:reverted', 'row:conflict',
  'row:received', 'row:sent', 'rows:deferred', 'rows:paused',
  'rows:queued', 'rows:resumed', 'scroll', 'scroll:end',
  'selection:changed', 'size:changed', 'source:error', 'stream:chunk',
  'stream:end', 'stream:evicted', 'timeline:attached', 'timeline:detached',
  'timeline:seek', 'timeline:seeking', 'toolpanel:focus', 'tree:loadAborted',
  'tree:loadFailed', 'tree:loaded', 'tree:loading', 'view:applied',
  'view:default', 'view:removed', 'view:renamed', 'view:saved',
  'views:changed',
  'rowDrag:started', 'rowDrag:moved', 'rowDrag:left', 'rowDrag:ended',
];

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ id: 'n', field: 'n' }],
  rows: [{ id: '1', n: 'a' }],
});

// `on` returns its own unsubscribe, so a name it accepts round-trips.
let wired = 0;
for (const name of documented) {
  const off = grid.on(name, () => {});
  if (typeof off === 'function') { off(); wired += 1; }
}

grid.destroy();
return wired;

Module and utility APIs

Everything else the package declares. Each entry is public: it is declared in the type definitions, which is what makes it a promise.

Locale and messages

NameSignatureDescription
DEFAULT_LOCALEstringThe locale used when none is configured and none can be read from the page.
LOCALESRecord<string, object>Every built-in catalogue, by locale name.
resolveLocale(configured?, declared?, fallback?) => stringSettle which locale applies: what you configured, then what the page declares, then the fallback.
createMessages(opts?) => MessagesBuild a message catalogue. A partial set lays over the built-in British English one.
MessagesclassThe catalogue itself. t(key, params) resolves one message; configure() replaces the set at runtime.
formatList(items, locale?, type?) => stringJoin a list the way the locale does, as a conjunction or a disjunction.

Formulas and units

NameSignatureDescription
evaluateFormula(text, params?) => FormulaResultEvaluate one expression. The same closed language the grid uses: no eval, no host access.
looksLikeFormula(text) => booleanWhether a pasted or typed value should be treated as a formula.
parseUnit(text, opts?) => number | nullRead a value with a unit on it back to a number in the base unit. null when it will not parse.
formatUnit(value, opts?) => stringThe inverse: render a base-unit number on the ladder the column asked for.
UNIT_SYSTEMSRecord<string, UnitDescriptor[]>Every registered unit system, by name.

Statistic tiles

NameSignatureDescription
deltaOf(value, baseline) => objectThe change between a value and its baseline, as a tile shows it.
toneOf(direction, goodWhen) => stringWhich way to colour a change, given whether a rise is good news.

Licensing and modules

NameSignatureDescription
licenceInfo() => LicenceInfoWhat the current key says: product, holder, expiry. licenseInfo is the same function under the American spelling.
licenceState() => LicenceInfoWhether the current host is licensed, and why not if it is not. licenseState is its alias.
registerModules(modules, opts?) => voidInstall optional modules once, for every grid on the page.
CONSOLE_ACTIVATIONstringThe console incantation that activates a trial key.

Ingesting rows, and menus

NameSignatureDescription
ingestSync(rows, plan?, opts?) => objectBuild a column store and an inferred schema from raw rows, synchronously. The result records why each column got the type it did.
ContextMenuclassThe menu the grid opens on right-click, reusable for a menu of your own. open(p) places it.

The charts module

NameSignatureDescription
TYPESreadonly ChartType[]Every chart type name createChart accepts.
SCHEMESRecord<string, readonly string[]>The built-in colour schemes, by name.
PALETTEreadonly string[]The default series colours.
registerScheme(name, colours) => voidAdd a colour scheme, or replace one of ours under the same name.
resolveScheme(spec?) => objectSettle which scheme a chart will draw with.
setDefaultScheme(name) => voidChange the scheme every chart uses unless it asks for another.
schemeNames() => string[]Every scheme name available, built-in and registered.

The framework adapters

React, Vue and Svelte build their public surface from the same list, so an event becomes a prop or an emit without either side keeping a second copy.

NameSignatureDescription
EVENT_NAMESreadonly string[]Every event the grid declares. Mirrors the EventName union, and the build fails if the two diverge.
handlerName(event) => stringThe React prop for an event: cell:changed becomes onCellChanged.
dashedName(event) => stringThe Vue and Svelte listener name: cell:edit:start becomes cell-edit-start.

The web component

NameSignatureDescription
defineLatticeGrid(tag?) => voidRegister <lattice-grid>, or your own tag name.
createLatticeGridElement(deps?) => classBuild the element class without registering it, for a custom registry.
GridElementControllerclassThe controller behind the element, if you are wrapping it yourself.
TAG_NAMEstringThe default tag, lattice-grid.
EVENT_PREFIXstringWhat DOM events are prefixed with.
ATTRIBUTE_CONFIGReadonly<Record<string, unknown>>Which attributes map to which configuration keys.
observedAttributeNames() => string[]The attributes the element reacts to.
domEventName(event) => stringThe DOM event name a grid event is dispatched under.

The htmx module

NameSignatureDescription
initWithin(root) => Grid[]Build every grid inside a fragment htmx just swapped in.
destroyWithin(root) => voidTear them down before the fragment goes.
gridElementsWithin(root) => Element[]The grid elements in a fragment, without building them.
rowsFromFragment(fragment) => unknown[]Read rows out of server-rendered markup.
rowsFromJson(text) => unknown[]Read rows out of a JSON payload.
ingestResponse(grid, response) => voidApply an htmx response to a grid, whichever of those two shapes it carries.
saveStateWithin(root) => voidPersist the view state of every grid in a fragment before a swap.
restoreStateWithin(root) => voidPut it back afterwards.
queryParams(grid) => Record<string, string>The grid's sort, filter and page as request parameters.
warnIfLargeHtmlPayload(rows) => voidWarn once when a server-rendered payload is large enough that JSON would serve better.
QUERY_CHANGED_EVENTstringDispatched when the grid's query changes, for htmx to trigger on.
SCROLL_NEAR_END_EVENTstringDispatched as the viewport nears the end, for infinite scroll.
HTML_ROW_WARNING_THRESHOLDnumberThe row count that warning fires at.

The devtools module

NameSignatureDescription
expose(grid, name?) => voidPut a grid on globalThis under a name, so a console session can reach it.

Windowed aggregates

The primitives behind grid.statistics.windowed, for a host that drives a live stream itself: a sliding window over timestamped values that re-reduces on demand and stamps every figure with the window it covered. Reduce over the last N ticks, the last N minutes, or the whole session.

NameSignatureDescription
openWindow(opts, now?) => WindowBuild a window from a spec: { kind: 'count', span } for the last N ticks, { kind: 'time', minutes } for the last N minutes, or { kind: 'session' } for everything since it opened.
WindowclassA sliding window. push(v, t?) adds a value, reduce(fn) returns one named aggregate stamped with the window in over, and aggregate() returns them all at once.
WINDOW_KINDSreadonly ('count' | 'time' | 'session')[]The three window kinds a caller may ask for.
const { openWindow, Window, WINDOW_KINDS } = await import('../packages/core/src/index.js');
// openWindow builds one of the three kinds; here, the last 3 ticks.
const live = openWindow({ kind: 'count', span: 3 });
for (const v of [10, 20, 30, 40]) live.push(v); // 10 is evicted; 20, 30, 40 remain
const avg = live.reduce('avg'); // (20 + 30 + 40) / 3 = 30, stamped with the window it covers
// A Window can be built directly too; a session window never evicts.
const session = new Window('session');
session.push(5);
session.push(15);
return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')}; session ${session.reduce('avg').value}`;

Anomaly detection

Flag the rows that do not belong (BACKLOG-0000749). Interpretable statistics with a written-down cut, never a black box: a robust per-column outlier score, Tukey's fences, and multivariate distance from the joint centre. These are the pure kernels behind grid.statistics.anomalies(...) and the anomalyScore/anomalyFlag shadow columns; a host can score a plain array the same way the grid scores a column.

NameSignatureDescription
ANOMALY_METHODSreadonly ('modifiedZScore' | 'iqr' | 'mahalanobis')[]The three methods a caller may ask for, named so a result can say which produced a flag.
modifiedZScores(values, opts?) => { median, mad, threshold, scores, flags, flagged }Per-row robust outlier score, 0.6745·(x − median)/MAD, flagged past threshold (default 3.5). Built on the median and MAD, so one wild reading cannot inflate the spread and hide — the masking effect that fools an ordinary z-score. A non-finite reading and a zero-MAD column yield a null score, not an invented one.
iqrFences(values, opts?) => { q1, q3, iqr, lower, upper, k } | nullTukey's fences, [Q1 − k·IQR, Q3 + k·IQR] (default k = 1.5), the same fence the box plot draws, on R type 7 quartiles.
mahalanobis(matrix, opts?) => { center, df, cutoff, singular, used, distances, squared, flags, flagged } | nullDistance of every row from the joint centre in the metric of the data's own covariance, cut at a χ² quantile (default the 0.975 point). Catches a row impossible only in combination — heavy and short — that a per-column scan misses. A row with any missing coordinate is left unplaced; a singular covariance is ridge-regularised and reported as singular rather than throwing.
ROLLING_ANOMALY_METHODSreadonly ('rollingModifiedZScore' | 'rollingIqr')[]The rolling (windowed) methods, named alongside ANOMALY_METHODS so a caller can enumerate every detector. Also accepted by grid.statistics.anomalies({ method, windowLen }).
rollingAnomalies(values, opts?) => { method, windowLen, minPeriods, threshold, k, scores, flags, flagged }Rolling (windowed) detection (BACKLOG-0000954): judge every reading against a causal trailing window of windowLen ending at it, so a spike is caught against its recent neighbours and a drift never poisons a global baseline. rollingModifiedZScore (median + MAD) or rollingIqr (Tukey fences). With a window as long as the series the last point's score equals the static modifiedZScores one.
anomalyCondition(opts) => (rows) => false | { method, field, flagged }Build a Data Router alert condition from a detector: a (rows) => signal the router's existing router.alert(value, condition, handler) drives, so live anomaly monitoring reuses the router's partitioning, debounce and rising-edge re-arm rather than duplicating an alert engine. Reads one numeric field per row; latest: true signals only when the newest reading is the anomaly.
const { modifiedZScores, iqrFences, mahalanobis, ANOMALY_METHODS } = await import('../packages/core/src/index.js');
// Univariate: the robust modified z-score flags the 500 among steady readings,
// where the mean and standard deviation an ordinary z uses would be dragged up
// by the outlier until it no longer looked like one.
const z = modifiedZScores([20, 21, 19, 20, 21, 19, 20, 500]);
// Tukey's fences say the same, drawn the way a box plot draws them.
const fence = iqrFences([20, 21, 19, 20, 21, 19, 20, 500]);
// Multivariate: height and weight move together; the last person is tall but
// very light, so the pair is impossible even though neither number is extreme.
const hw = [];
for (let i = 0; i < 12; i++) hw.push([160 + i, 60 + i]);
hw.push([182, 45]);
const m = mahalanobis(hw); // the χ² cut flags the off-line row
return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.upper}; joint ${m.flags[12]}`;

Rolling (windowed) detection and the Data Router bridge (BACKLOG-0000954): judge each reading against its trailing window for live monitoring, and turn any detector into a router.alert(...) condition without a second alert engine.

const { rollingAnomalies, anomalyCondition, ROLLING_ANOMALY_METHODS } = await import('../packages/core/src/index.js');
// A steady stream that suddenly spikes. The trailing window catches the spike
// against its recent neighbours, where scoring against the whole run could let a
// long earlier drift hide it.
const stream = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10, 40];
const roll = rollingAnomalies(stream, { windowLen: 6, minPeriods: 4 });
// The SAME detector, wired as a Data Router alert condition: (rows) => signal,
// exactly the predicate router.alert(value, condition, handler) already drives —
// no second alert engine. `latest` fires only when the newest tick is the one out.
const condition = anomalyCondition({ field: 'temp', method: 'rollingModifiedZScore', orderBy: 't', windowLen: 6, minPeriods: 4, latest: true });
const rows = stream.map((temp, t) => ({ t, temp }));
const signal = condition(rows);
return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; alert ${signal ? signal.flagged.length : 0}`;

Forecasting

Project an ordered series forward, and carry a prediction band where a defensible closed form exists (BACKLOG-0000963). Five methods behind one entry point: movingAverage and ses are flat forecasts (the trailing-window mean, the final smoothed level); holt adds a projected trend, holtWinters a projected trend and an additive seasonal; linear extrapolates an ordinary least-squares fit of the time axis. The exponential-smoothing bands are the innovations state-space forecast variances (Hyndman & Athanasopoulos) at the normal quantile; the linear and moving-average bands are the exact Student-t intervals, and linear also reports the narrower mean-response (confidence) band a trendline draws. These are the pure kernels the chart trendline overlay and the time-series grid forecast from; a host can forecast a plain array the same way.

NameSignatureDescription
FORECAST_METHODSreadonly ('movingAverage' | 'ses' | 'holt' | 'holtWinters' | 'linear')[]The five methods a caller may ask for, named so a result can say which produced it.
forecast(seq, opts?) => ForecastResult | nullForecast an ordered series opts.horizon steps ahead by opts.method (default linear), at opts.confidence (default 0.95). Accepts a plain array of numbers (index is the time axis) or {at, value} rows. A smoothing factor absent from opts (alpha/beta/gamma) is fit by minimising the in-sample one-step SSE; holtWinters needs opts.period (≥ 2) and two whole periods of data. Each point carries mean and, where a band applies, lower/upper; linear adds lowerMean/upperMean. Null when the series is too short for the method.
const { forecast, FORECAST_METHODS } = await import('../packages/core/src/index.js');
// Linear: fit the time axis, project one step, and carry the prediction band.
const lin = forecast([{ at: 1, value: 2 }, { at: 2, value: 4 }, { at: 3, value: 5 }, { at: 4, value: 4 }, { at: 5, value: 5 }], { method: 'linear', horizon: 1 });
const p = lin.points[0];
// Holt-Winters additive: a level, a trend and a two-step season, projected two
// steps ahead — the seasonal swing is carried into the forecast, not smoothed away.
const hw = forecast([10, 20, 30, 40], { method: 'holtWinters', period: 2, alpha: 0.5, beta: 0.5, gamma: 0.5, horizon: 2 });
return `methods ${FORECAST_METHODS.length}; next ${p.mean.toFixed(1)}; r2 ${lin.r2.toFixed(1)}; band ${p.lower.toFixed(2)}..${p.upper.toFixed(2)}; season ${hw.points[1].mean}`;

The same forecast off the standard stats surface: grid.statistics.forecast(colId, opts) reads the column over the filtered rows — ordered by opts.by when the time axis matters, exactly as grid.statistics.series(...) orders — and returns the same ForecastResult, so a host reaches a forecast the way it reaches grid.statistics.anomalies(...) rather than assembling the series itself.

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
  columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
  rows: [[1, 2], [2, 4], [3, 5], [4, 4], [5, 5]].map(([t, v], i) => ({ id: String(i), t, v })),
  rowKey: 'id',
});
// Forecast the 'v' column one step ahead, ordered by 't', with the prediction band.
const f = grid.statistics.forecast('v', { by: 't', method: 'linear', horizon: 1 });
const p = f.points[0];
return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.lower > 0}`;

Type reference

Every interface the library declares, with the type of each member. The sections above describe how the grid is used; this one is the complete surface, generated from the type declarations so that it always matches the release.

AcfResult

Autocorrelation (ACF) and partial autocorrelation (PACF) arrays (BACKLOG-0000873).

MemberTypeDescription
acfnumber[]The autocorrelation at each lag; index 0 is lag 0 and is always 1.
pacfnumber[]The partial autocorrelation at each lag; index 0 is 1, and `pacf[1] === acf[1]`.
bounds{ upper: number; lower: number }The approximate ±1.96/√n white-noise confidence band.
nnumberThe series length the ACF/PACF were computed over.
nlagsnumberThe maximum lag.
approximatebooleanAlways true: the ±1.96/√n band is an approximation.

AdfResult

The Augmented Dickey-Fuller stationarity test result (BACKLOG-0000873).

MemberTypeDescription
statisticnumberThe ADF t-statistic on the lagged level.
usedLagnumberThe number of augmenting lags chosen by AIC.
nobsnumberThe observations the final regression used.
criticalValues{ '1%': number; '5%': number; '10%': number }MacKinnon's constant+trend critical values at the 1%, 5% and 10% levels.
pValuenumberAn approximate p-value, interpolated across the critical-value ladder.
pApproximatebooleanAlways true: the p-value is an interpolation, not the MacKinnon surface.
stationarybooleanWhether the series is stationary at the 5% level.
verdictstringThe plain-language verdict: `'stationary'` or `'non-stationary'`.
regression'ct'The regression form used — always `'ct'` (constant + trend) in v1.

AggregateProvenance

How one aggregate was routed, for `lastPlan()` provenance.

MemberTypeDescription
idstring
colstring
fnstring
class'identical' | 'may-differ' | 'fallback'How the engine result relates to the grid kernel.
reasonstringWhy it is client-side, when it is (config, fallback, or the guard). (optional)
weightstring(optional)
paramsRecord<string, unknown>Parameters the statistic takes, carried through so an adapter emits the matching SQL (e.g. a trim share). (optional)

AggregateRequest

One aggregate the grid asks the source to compute over the matching set. `params` carries e.g. `{ share: 0.1 }` so an adapter emits the matching SQL; `weight` names the second column for a two-column stat like `correlation`.

MemberTypeDescription
idstringKeys the result back to the request.
colstringThe column to reduce.
fnstringThe statistic name, as used in `total: '<name>'`.
weightstringThe second column, for a two-column statistic. (optional)
paramsRecord<string, unknown>Parameters the statistic takes, e.g. a trim share. (optional)

AI

An AI controller over a live grid. It explains the grid's computed figures (Play A), answers questions with validated read-only query specs (Play B), and PROPOSES governed edits a human approves and the grid's own gate applies (Play C). `grid.ai` (in core) is the complementary intent/plan skill layer this consumes.

MemberTypeDescription
elHTMLElement | nullThe mounted insights panel element, or null. (read-only)
readybooleanWhether a usable `ask()` is configured. (read-only)
explain(target?: AITarget, opts?: object): Promise<AINarrative>Produce a grounded, reconciled narrative for a target.
narrate(target?: AITarget, opts?: object): Promise<AINarrative>An alias for {@link AI.explain}.
riskSummary(sources?: {Produce a grounded, reconciled board / Gantt RISK SUMMARY (BACKLOG-0000979): a plain-language reading like "3 tasks at risk on the critical path, SPI 0.67, 2 SLA breaches". A convenience over `explain({ kind: 'risk', ... })`; the module sources go in `sources` (`gantt`, `board`/`sla`, or precomputed outputs). Every figure runs through the same reconciliation guard as {@link AI.explain}.
insights(el?: HTMLElement, opts?: object): AIMount (or re-target) the insights panel into an element.
attachExplain(target: AITarget, opts?: object): HTMLElement | nullBuild an "Explain" button bound to a target.
facts(target?: AITarget, opts?: object): AIFactsPacketBuild the facts packet for a target without calling `ask()`.
query(question: string, opts?: {Ask-your-data: turn a question into a validated, read-only query spec, run it in the engine, and (on apply) fan the answer to router-attached viewers. Returns a result the host reviews; `autoApply` applies a safe read for you.
applyQuery(result: AIQueryResult, opts?: { router?: unknown; onResult?: (rows: object[]) => void }): AIApplyReportApply a reviewed query result (the confirm path); re-gated at the seam.
askBar(el?: HTMLElement, opts?: object): AIMount the ask-your-data bar (input, Ask, auto-apply toggle, preview, Apply/Discard).
propose(instruction: string, opts?: {Governed actor (Play C): ask the model for structured edit PROPOSALS over the current view, validate and resolve them (label -> stored value, locate a named row, reject unknown columns/labels/out-of-range), and return a reviewable {@link AIProposal} with a before/after diff. NOTHING is written — the model proposes; a human approves.
applyProposal(result: AIProposal, opts?: { board?: unknown }): Promise<AIProposalReport>Apply an approved proposal — the human-approval step. Writes ONLY through the gate: a grid cell edit via `grid.edit.setCells({ origin: 'ai' })` (the `beforeEdit` veto), a kanban move via `board.move({ origin: 'ai' })` (the `beforeMove` veto). A vetoing host handler stops the write.
actorBar(el?: HTMLElement, opts?: object): AIMount the governed-actor bar: an instruction input, Propose, a before/after diff preview stating the scope, and Approve/Discard. Approve applies through the gate.
on(name: 'narrative' | 'query' | 'proposal' | 'error' | string, fn: (payload: object) => void): () => void
off(name: string, fn: (payload: object) => void): void
destroy(): void

AiApi

MemberTypeDescription
schema(opts?: { maxColumns?: number; maxRows?: number }): Record<string, unknown>A machine-readable description of the grid, for a model's context.
tool(opts?: { maxColumns?: number; maxRows?: number }): Record<string, unknown>The same schema as a tool definition.
prompt(opts?: Record<string, unknown>): stringThe prompt describing this grid (its columns, types and operators) for sending to a model. It carries no row values. It does not take the user's question: compose that yourself alongside the text this returns, which is what `ask` receives as `schemaText`.
buildPrompt(text: string, opts?: Record<string, unknown>): string
plan(reply: string | Record<string, unknown>, opts?: Record<string, unknown>): Record<string, unknown>Parse what the model returned into a plan.
apply(plan: Record<string, unknown>): Record<string, unknown>Run a plan as one undoable step.

AIApplyReport

The report from applying an ask-your-data query.

MemberTypeDescription
okboolean
appliedstring[]The action types that were applied.
failedArray<{ type: string; reason: string }>Actions that threw while applying.
refusedArray<{ type: string; reason: string }>Actions refused by the read-only gate — a mutation is never applied.
fannedOutnumberHow many answer rows were fanned to a router's viewers.

AIConfig

AI module configuration.

MemberTypeDescription
askAIAskThe host's model callback. Falls back to the grid's `ai.ask` when omitted. (optional)
enablestring[]Restricts which of the three DOM-mounting convenience methods are allowed to mount: `'narrative'`/`'insights'` for `insights()`, `'query'`/`'ask'` for `askBar()`, `'actor'` for `actorBar()`. All three are allowed when `enable` is omitted. This does NOT gate the programmatic API — `explain()`, `query()`, `propose()`, `facts()`, `riskSummary()` and the rest of the controller always run regardless of `enable` — because a host that wants no AI surface at all simply never calls these methods. (optional)
autoApplybooleanAsk-your-data: apply a safe (read-only) query result without a confirm step. Off by default — the resolved query is shown and waits for Apply. (optional)
routerunknownA Data Router instance; on applying a query the answer rows are fanned to its attached viewers (grid + chart + KPI together) via `load()`. (optional)
schemaOptionsobjectBudgets passed to the schema builder for ask-your-data. (optional)
contextunknownExtra context passed through to `ask()`. (optional)
onQuery(result: AIQueryResult) => voidCalled with each ask-your-data result. (optional)
onProposal(result: AIProposal) => voidCalled with each governed-actor proposal (Play C), before any approval. (optional)
boardunknownA Kanban board (from `createKanban`) the governed actor writes moves through: an NL card move applies via the board's own `beforeMove` gate (BACKLOG-0000967), never a kanban-specific write bypass. (optional)
maxRowsnumberCap on rows any tool result carries to `ask()`. (optional)
redactstring | string[] | ((colId: string) => boolean)Columns whose values must never leave the browser. (optional)
toolsbooleanForce tool-use on or off; auto-detected from how `ask` was supplied otherwise. (optional)
localestringLocale for figure formatting. (optional)
maxColumnsnumberColumn cap for a view summary. (optional)
reconcile'strip' | 'flag'What to do with an ungrounded figure: `'strip'` (default) or `'flag'`. (optional)
elementHTMLElementAn element to mount the insights panel into. (optional)
onNarrative(result: AINarrative) => voidCalled when a narrative is produced. (optional)
onError(error: { error: unknown; target: AITarget }) => voidCalled when `ask()` errors; the grid stays usable. (optional)

AIDiffEntry

One before/after change in a governed-actor proposal (BACKLOG-0000967).

MemberTypeDescription
keystringThe target row key.
rowLabelstringA human label identifying the row (a name-like column, else the key).
colIdstringThe target column id.
colTitlestringThe column's title, for the diff header.
oldValueunknownThe current stored value.
oldDisplaystringThe current value as shown (a lookup id mapped to its label).
newValueunknownThe proposed stored value (a label resolved to its option id).
newDisplaystringThe proposed value as shown.

AIFact

A single computed figure a narrative is grounded on.

MemberTypeDescription
idstring
labelstring
valuenumber | nullThe raw numeric value, or null for a context-only fact.
displaystringThe pre-formatted display string the model is told to use verbatim.
kindstring
colIdstring(optional)

AIFactsPacket

The facts packet a narrative grounds on.

MemberTypeDescription
targetAITarget
factsAIFact[]
groundedValuesnumber[]The numeric values seeding the reconciliation registry.
meta{

AINarrative

The result of a narrative: reconciled prose plus what grounded and what did not.

MemberTypeDescription
textstringThe narrative, with every ungrounded figure stripped (or flagged).
factsAIFact[]
groundedstring[]The figures that reconciled against a computed value.
flaggedstring[]The figures removed as ungrounded.
packetAIFactsPacket
roundsnumberHow many ask() rounds ran (>1 only on the tool-use path).
mode'tools' | 'packet'

AIProposal

A governed-actor proposal (Play C, BACKLOG-0000967): the model's structured edits, VALIDATED and resolved against the current view — never written until a human approves. `apply()` writes ONLY through the grid's own gate.

MemberTypeDescription
okbooleanTrue when there is at least one applicable change and nothing needs a pick first.
instructionstringThe user's instruction.
scope'view' | 'all'`'view'` (the filtered set, the default) or `'all'` (an opted-in widen).
scopeCountnumberHow many rows the scope covers.
scopeTextstringThe scope in words, always stated in the confirm/diff.
bulkbooleanWhether any proposal was a bulk (`scope:'view'`) edit.
diffAIDiffEntry[]The before/after diff — exactly what would change. Nothing is written yet.
rejectedArray<{ reason: string; [k: string]: unknown }>Proposals refused before apply (unknown column, unknown label, bad type/range, no match).
ambiguousArray<{ reason: string; candidates: Array<{ key: string; label: string }>; [k: string]: unknown }>Matches needing a human pick (>1 row for one phrase), with candidates.
outOfViewArray<{ reason: string; candidates: Array<{ key: string; label: string }>; [k: string]: unknown }>Named targets found only outside the view, offered for an opt-in widen.
noopsArray<{ reason: string; [k: string]: unknown }>Matches whose value already equals the ask (nothing to change).
appliedAIProposalReport | nullThe apply report once applied, or null.
describe(): stringThe proposal in one human sentence, always stating the scope.
apply(opts?: { board?: unknown }): Promise<AIProposalReport>Apply the approved diff through the gate (`beforeEdit`, or `beforeMove` for a board).

AIProposalReport

The report from applying a governed-actor proposal.

MemberTypeDescription
okbooleanTrue when at least one edit landed.
appliednumberHow many edits landed through the gate.
requestednumberHow many edits were attempted.
vetoednumberHow many were stopped by a before-handler veto.
viastringWhich gated path applied them: `'setCells'`, `'board.move'`, or `'none'`.

AIQueryResult

The result of an ask-your-data question (BACKLOG-0000966): a validated, READ-ONLY query spec — never rows — that the host reviews before applying.

MemberTypeDescription
okbooleanTrue when the spec is safe to apply: at least one read, nothing unsafe.
questionstringThe user's question.
planRecord<string, unknown>The core plan (from `grid.ai.plan`).
actionsobject[]The read-only actions that will run — the validated query spec.
unsafeArray<{ type: string; reason: string }>Actions refused as not read-only (a mutation the model asked for).
rejectedArray<{ at: string; what: string; reason: string }>Parts the core validator dropped (unknown column, bad operator, …).
explainstringThe model's own one-line summary, if any.
spec{ actions: object[] }The validated query spec as data.
appliedAIApplyReport | nullThe apply report once applied, or null.
describe(): stringThe resolved query in one human sentence, from the validated spec.
apply(opts?: { router?: unknown; onResult?: (rows: object[]) => void }): AIApplyReportApply the query (re-gated), fanning the answer to a router if configured.

AIRiskFacts

The risk facts a board / Gantt risk summary grounds on (BACKLOG-0000979), from {@link buildRiskFacts}: the facts plus which module sources resolved and which opt-in exposures (task names, cost) were honoured.

MemberTypeDescription
factsAIFact[]
meta{

AITarget

A narrative target. `view` narrates the current filtered view; `column` narrates one column's profile; `forecast` adds its projection; `kpi`/`chart` narrate figures the caller passes through in `facts`; `risk` assembles a project RISK SUMMARY from the separate Gantt / Kanban modules' public outputs (BACKLOG-0000979).

MemberTypeDescription
kind'view' | 'column' | 'forecast' | 'kpi' | 'chart' | 'risk'(optional)
colIdstring(optional)
optionsobjectForecast options, for `kind: 'forecast'`. (optional)
factsArray<{ id?: string; label: string; value: unknown; display?: string; kind?: string; colId?: string }>Caller-supplied figures for a KPI/chart Explain, grounded like the rest. (optional)
ganttunknownFor `kind: 'risk'`: a Gantt instance (from `createGantt`). Read duck-typed for `earnedValue()` (SPI/CPI/variances) and `schedule` (critical path, float). The AI bundle never imports the Gantt module. (optional)
boardunknownFor `kind: 'risk'`: a Kanban board (from `createKanban`). Read for its `board.sla` monitor (breach / warning counts). The AI bundle never imports the Kanban module. (optional)
slaunknownFor `kind: 'risk'`: an SLA monitor, if not reached through `board`. (optional)
earnedValueobjectFor `kind: 'risk'`: a precomputed `gantt.earnedValue()` result. (optional)
scheduleobjectFor `kind: 'risk'`: a precomputed `gantt.schedule` result. (optional)
breachesobject[]For `kind: 'risk'`: precomputed SLA breach states. (optional)
warningsobject[]For `kind: 'risk'`: precomputed SLA warning states. (optional)
evmOptionsobjectFor `kind: 'risk'`: options passed to `gantt.earnedValue()`. (optional)
includeTaskNamesbooleanFor `kind: 'risk'`: expose the at-risk task NAMES (off by default — a risk summary carries aggregates only unless the host opts in). (optional)
includeCostbooleanFor `kind: 'risk'`: expose the money figures BAC/PV/EV/AC (off by default). (optional)
maxTasksnumberFor `kind: 'risk'`: cap on named at-risk tasks (default 10). (optional)

AnnotationApi

MemberTypeDescription
tool'pen' | 'arrow' | 'rect' | 'highlight' | null(read-only)
countnumber(read-only)
use(tool: 'pen' | 'arrow' | 'rect' | 'highlight' | null, opts?: { colour?: string }): string | null
add(mark: AnnotationMark): numberAdd a durable mark from a descriptor, without synthesising pointer input (BACKLOG-0000813). The mark is painted, survives a presentation ending, and round-trips through `getState`. Returns the mark count.
list(): AnnotationMark[]Every mark on the layer, as descriptors — the shape `getState` persists.
undo(): number
clear(): void
redraw(): void

AnnotationMark

A durable annotation mark descriptor (BACKLOG-0000813) — the shape a host seeds through `state.annotations`, adds through {@link AnnotationApi.add}, and reads back through {@link AnnotationApi.list} and `getState`. `points` are in **content coordinates** (the same space user-drawn marks are stored in), so a mark tracks scroll and resize rather than hanging over the viewport. A `freehand` mark is a trail of points; `arrow` and `rect` are their two endpoints. A `text` mark is a label anchored at a single content point, carrying its `text` string and an optional basic style (BACKLOG-0000875). `pen` is accepted as an alias for `freehand` on input; `list()` reports `freehand`.

MemberTypeDescription
type'freehand' | 'arrow' | 'rect' | 'highlight' | 'text'
points{ x: number; y: number }[]Content coordinates. A `text` mark carries a single anchor point; `arrow` and `rect` carry their two corners, and `freehand` a trail.
colourstring(optional)
textstringThe label of a `text` mark. Required for `text`, ignored for other types. (optional)
fontSizenumberA `text` mark's font size in content pixels (before presentation scale). Defaults to 14. (optional)
backgroundstringAn optional backing colour drawn behind a `text` mark's label. (optional)
region'start' | 'centre' | 'end'Which columns the mark belongs to: a pinned region holds still while the grid scrolls sideways, the centre moves with it. Set from where a stroke began; omitted (the centre) for every mark that is not over a pinned column, so a mark saved before this existed reads unchanged. (optional)

AnomalyReason

MemberTypeDescription
columnstringThe column that put this row over the line.
namestringThe column's display name, or its id.
valuenumberThe row's value in that column.
scorenumberThe modified z-score, for the `modifiedZScore` method. (optional)
lowernumberThe lower fence, for the `iqr` method. (optional)
uppernumberThe upper fence, for the `iqr` method. (optional)
method'modifiedZScore' | 'iqr'Which rule flagged it. (optional)

AnomalyReport

MemberTypeDescription
method'modifiedZScore' | 'iqr' | 'mahalanobis'Which rule produced the report.
knumberThe IQR fence multiplier, for the `iqr` method. (optional)
nnumberHow many rows the scan ran over.
rowsAnomalyRow[]The flagged rows, worst first.
flaggednumberHow many rows were flagged.
skippedstring[]The column ids that were not numeric and so could not be scored.
scorednumberHow many numeric columns were scored (univariate). (optional)
columnsunknownPer-column summaries (univariate): the centre, spread and fence per column. (optional)
dfnumberThe degrees of freedom of the χ² cut (multivariate). (optional)
cutoffnumber | nullThe χ² cut the squared distance is compared against (multivariate). (optional)
centernumber[]The joint centre the distances are measured from (multivariate). (optional)
usednumberHow many complete rows defined the metric (multivariate). (optional)
singularbooleanWhether the covariance was singular and had to be regularised (multivariate). (optional)

AnomalyRow

MemberTypeDescription
rowKeystring | nullThe row key — stable across a sort or a feed, where the index is not.
indexnumberThe physical row index at the time of the call.
scorenumber | nullThe row's headline score: its most extreme modified z-score across the flagging columns (univariate), the Mahalanobis distance (multivariate), or null for the IQR method, which has no single score.
squarednumber | nullThe squared Mahalanobis distance, for the `mahalanobis` method. (optional)
whyAnomalyReason[]Why this row was flagged: the columns and how far, so it is explainable.

ApproximateEntry

One entry of the approximate maintenance tier.

MemberTypeDescription
sketchstringThe sketch that backs this kernel: `HyperLogLog`, `KLL`, `SpaceSaving`.
boundErrorBoundThe error bound the sketch is verified to meet.

BeforeEvent

A cancellable *before*-event (BACKLOG-0000943), delivered to `on('beforeX')` handlers before a user-initiated mutation is applied. A handler cancels the pending action by calling `preventDefault(reason?)`; the mutation is then abandoned and a past-tense `<action>:cancelled` event carries the reason. A handler may be `async` (or return a Promise): the grid awaits every before-handler before deciding, so a confirm dialog or a server check can gate the write. Any one handler preventing cancels the action. The action-specific fields (the edited cells, the target index, the affected rows) are spread alongside these, so a handler decides without reaching into grid internals. `origin` distinguishes a genuine user gesture from a host/module-driven or remote write, which is how a module whose move re-enters core is deduplicated by the host.

MemberTypeDescription
preventDefault(reason?: string): voidCancel the pending action; the optional reason is surfaced on the cancellation event.
defaultPreventedbooleanTrue once any handler has called `preventDefault` or returned false.
reasonstring | nullThe reason given to `preventDefault`, or null; `'stale'` when re-validation failed.

BeforeRowReceiveEvent

The `beforeRowReceive` event (BACKLOG-0001225): a row dragged from another grid is about to be inserted into this one. Fires on the **receiving** grid, before the insert, with the row under the pointer named — so a drop that means "assign this to that" can be recorded by the host and the insert stopped with `preventDefault(reason)`. A veto leaves the source grid untouched: the row stays where it was, and neither `row:sent` nor `row:copied` fires there. The source removes its row only after the target has admitted it, and a veto is a refusal to admit. The paired `rowReceive:cancelled` carries the same context plus the reason. Like every {@link BeforeEvent}, the handler may be `async`; the insert is held until it settles, and is cancelled as `'stale'` (BACKLOG-0001242) if the source row is gone by then, or if the row under the pointer is gone or has moved to a different index — `at` names a slot as "before `overKey`", and once that is no longer where `overKey`'s row sits, `at` is a stale index into a list that changed while the handler was thinking, not the slot the drop meant. `overKey: null` (the drop landed on no row) has no row to drift against and is never stale on that account.

MemberTypeDescription
dataRecord<string, unknown>The row about to be inserted: a shallow copy of the source row's data, and the very object that is inserted if no handler vetoes, so a change made to it here lands with the row.
atnumberThe display index the row would be inserted at: the index of the row under the pointer, or `rows.count()` when the drop landed on no row. When `overKey` names a row, this is guaranteed to still be that row's index at the moment the insert actually runs — an async handler that leaves the named row at a different index causes the drop to be cancelled as `'stale'` (BACKLOG-0001242) rather than inserted at this index regardless.
overKeystring | nullThe key of the row under the pointer when the drop happened — the row the user meant. Null when the drop landed past the last row, on empty space, on the header, or on a pinned row: there is no row to name, and a nearest guess would be wrong in a way that looks right.
sourceGridThe grid the row is being dragged from.

BooleanFormat

MemberTypeDescription
type'boolean'
display'checkbox' | 'switch' | 'text' | 'icon'(optional)
trueLabelstring(optional)
falseLabelstring(optional)
nullLabelstring(optional)
trueIconstring(optional)
falseIconstring(optional)

CapabilityInterval

An interval for a capability index, by Bissell's approximation.

MemberTypeDescription
indexnumber
lowernumber
uppernumber
marginnumber
nnumber
confidencenumber

CaptureOptions

Rendering the grid to a still image. `scale` multiplies the pixel dimensions : 2 for a retina still, 3 or 4 for a slide. `background` fills behind the grid so a PNG dropped into a deck does not show it through.

MemberTypeDescription
scalenumber(optional)
backgroundstring(optional)
downloadboolean(optional)
fileNamestring(optional)

CellMenuParams

What a cell-menu builder and a host item's `action` are handed.

MemberTypeDescription
keystring
colIdstring | nullThe column under the pointer, or `null` when the row belongs to no column: a right-click in the empty tail of a row beyond the last column (BACKLOG-0001153), or on a group row, pivot group row or full-width row. The grid-level menu stands in that case (BACKLOG-0001068).
valueunknownThe cell's value; `undefined` when there is no column.
rowRowThe row wrapper.
dataunknownYour original row object.
columnResolvedColumn | undefinedThe resolved column; `undefined` when `colId` is `null`.
indexnumber
gridGrid

CellParams

MemberTypeDescription
textstring
indexnumber
propsRecord<string, unknown>(optional)
t(key: string, vars?: Record<string, unknown>) => stringFormat a message from the grid's catalogue, for a renderer that wants its own accessible names and labels localised rather than hard-coded (§17, WCAG 4.1.2). The built-in renderers use this; a custom renderer may too. Optional: absent when a renderer is exercised without a grid to ask. (optional)

CellRange

MemberTypeDescription
startRownumber
endRownumber
columnsstring[]

ChangeResult

MemberTypeDescription
addedRow[]
updatedRow[]
removedstring[]
rejectedRejectedRow[]Rows that could not be applied. A batch of a thousand containing three bad ones applies the other 997 and lists the three here. (optional)

Chart

A live chart.

MemberTypeDescription
elementSVGElement(read-only)
draw(): voidRedraw now.
update(spec: Partial<ChartSpec>): voidChange the spec and redraw; unnamed keys keep their values.
data(): object | nullThe data the chart last bound.
ascend(levels?: number): voidGo up one level, on a drillable hierarchy.
on(event: ChartEventName, handler: (payload: unknown) => void): () => void
emit(event: ChartEventName, payload?: unknown): void
toSVG(opts?: object): string
toPNG(opts?: { scale?: number; background?: string }): Promise<Blob>
toCSV(): string
destroy(): void

ChartAnnotation

One declarative annotation (BACKLOG-0000744, extended by BACKLOG-0000953). A reference or target line, a shaded band, a callout, or an `event` marker. Its value is a constant `value` (or `from`/`to` for a band), or a `compute` reduction of the data it annotates — `mean`, `median`, `min`, `max`, or `p95` for a percentile — so it follows the data as the grid is filtered. A band with `orient: 'vertical'` shades an x-range instead — an event window, a maintenance period — and an `event` marker is a labelled vertical rule with a flag at a position on the x axis. Every annotation names the axis it reads, which on a dual-axis chart is what stops it being placed against the wrong scale, and is written into the accessible table as a sentence — a vertical marker and an event stating the position they sit at, because a screen-reader user needs where and when, not only that a marker exists.

MemberTypeDescription
kind'line' | 'target' | 'band' | 'callout' | 'event'The default is a reference line. `event` is a labelled vertical marker with a flag at a position on the x axis, described into the accessible table with that position stated (BACKLOG-0000953). (optional)
valuenumberA constant value, for a line, target or callout's measure position. (optional)
compute'mean' | 'avg' | 'median' | 'min' | 'max' | stringA reduction of the annotated data instead of a constant. (optional)
fromnumber | stringA band's two edges. On a horizontal band each is a measure value, a constant or (with `fromCompute`/`toCompute`) computed. On a vertical band (`orient: 'vertical'`, BACKLOG-0000953) each is an x position — a category or a number — and the band shades the x-range between them: an event window, a maintenance period, a recession. (optional)
tonumber | string(optional)
fromComputestring(optional)
toComputestring(optional)
xunknownA vertical line's, event marker's or callout's x position: a category or a number. (optional)
atunknown(optional)
orient'horizontal' | 'vertical'Force a line vertical rather than horizontal, or shade a `band` across an x-range rather than a measure range (BACKLOG-0000953). (optional)
axis'left' | 'right' | 'y2'Which measure axis the annotation reads. (optional)
seriesstringRestrict a `compute` to one series, by its key. (optional)
labelstring(optional)
colourstring(optional)
opacitynumberA band's fill opacity; the default is 0.12. (optional)
classNamestring(optional)

ChartAxis

One axis's configuration. A bare string is the title.

MemberTypeDescription
titlestring(optional)
minnumberFix the axis rather than taking its extent from the data. (optional)
maxnumber(optional)
ticksnumber | unknown[]A tick count, or the exact values to tick. (optional)
formatstring | ((value: unknown) => string)A format mask, or a function of the value. (optional)
gridbooleanDraw the gridlines this axis owns. Default true for the measure axis. (optional)
labelsbooleanDraw the tick labels. (optional)
scale'auto' | 'linear' | 'time' | 'band' | 'category'Pin the x axis's scale rather than taking it from the column's type (BACKLOG-0001344). The default, `'auto'`, is the rule stated in the charts section: a temporal column type (`date`, `datetime`, `timestamp`, `dateString`) draws a time axis, a numeric one draws a linear axis whatever its distinct count, and everything else draws bands. `'band'` is how a numeric code column — a quarter, a rating, a star count — asks for its bands back; `'linear'` and `'time'` put a column the grid types as text onto a continuous axis. Only the x axis reads it. (optional)
everynumberShow every nth category label, on a crowded category axis. (optional)
rotateboolean | 'auto'Force the category labels' rotation rather than deciding it. (optional)
windowPick<WindowSpec, 'kind' | 'span'>A rolling window for the axis domain (BACKLOG-0001036), in the shipped `WindowSpec` vocabulary that rolling statistics already use. Only `{ kind: 'time', span }` applies to an axis: the domain becomes the last `span` milliseconds ending **now**, so the chart keeps scrolling left while the feed is silent — the thing a count window cannot do, because with no rows arriving nothing changes. Advanced on a low-frequency clock (a quarter of the window, between 50 ms and 1 s), never per frame, and stopped when the chart is destroyed or its document is hidden. Needs a continuous x axis carrying wall-clock times; `{ kind: 'count' }` is the source's `maxRows` and is refused here rather than given a second meaning. (optional)

ChartLabels

Data labels beside each mark.

MemberTypeDescription
position'outside' | 'inside' | 'auto'(optional)
formatstring | ((value: unknown, point?: unknown) => string)A format mask, or a function of the value. (optional)
minGapnumberPixels two labels must leave between them before both are kept. (optional)

ChartMeasure

A measure a chart reduces, when the chart is not given a bare `y`.

MemberTypeDescription
colstring
fnTotalNameA reduction name, as the totals row uses. (optional)
type'bar' | 'line' | 'area'The mark this measure draws with, on a combo chart. (optional)
axis'left' | 'right'Which axis it belongs to, on a combo chart. (optional)
titlestring(optional)

ChartNode

One node of a `network` chart, as the host declares it. `x` and `y` are fractions of the plot, 0 to 1, measured from its top-left. A node giving both is **pinned** there and takes no part in the force simulation; the rest are laid out around it, deterministically. Giving only one of the two is not a position and the node is laid out.

MemberTypeDescription
idstringMatches a value in the `source` or `target` column.
labelstringDrawn beneath the node. The id is used when this is absent. (optional)
iconstringA name in the grid's icon registry, drawn inside the node's disc. (optional)
xnumberWhere to pin it, as a fraction of the plot's width. (optional)
ynumberWhere to pin it, as a fraction of the plot's height. (optional)

ChartSpec

What a chart draws and how. `grid` and `container` are required; everything else describes the chart. A chart reads the grid's *filtered* rows, so it follows the grid without being told to.

MemberTypeDescription
gridGrid
containerElement | string
typeChartType
xstringThe category column. (optional)
ystringThe measure column, for the types that take one. (optional)
seriesstringSplits the measure into one series per distinct value. (optional)
rowsobject[] | ((grid: Grid) => object[])The exact rows to chart, overriding the grid's own walk — an array, or a function returning one at draw time. `chartRange` uses it to bind a chart to the band of rows a selected range covers rather than the whole grid. (optional)
measuresChartMeasure[]Several measures at once, for combo and candlestick. (optional)
sourcestringEndpoints, for sankey, chord and network. (optional)
targetstring(optional)
labelstringRow label and dates, for gantt. (optional)
startstring(optional)
endstring(optional)
titlestring(optional)
schemestring | string[]A named scheme, or an array of colours. (optional)
legendboolean | { position?: 'top' | 'bottom' | 'left' | 'right'; isolate?: boolean }(optional)
labelsboolean | ChartLabels(optional)
axis{Per-axis configuration. Each side is a title string or an object of `{ title, min, max, ticks, format, grid, labels }`. `y2` (or `right`) configures the second measure axis of a dual-axis or combo chart (BACKLOG-0000743); a dual-axis chart labels both axes by default so it cannot silently mislead. (optional)
brushboolean | 'filter' | 'zoom' | 'select'Dragging across the plot. `true` or `'filter'` writes a range condition into the grid; `'zoom'` changes only this chart's own domain; `'select'` selects the rows under the drag. The object form names which axis the drag acts on — `axis: 'y'` or `'y2'` brushes a value axis, which on a dual-axis chart must say which one it means (BACKLOG-0000743). (optional)
fontobject(optional)
marginnumber | { top?: number; right?: number; bottom?: number; left?: number }(optional)
fitboolean | 'line'A least-squares line through a scatter or bubble chart, one per series. `true` draws the line and its R²; `'line'` draws the line alone. Only where the x axis is numeric: on a band scale the positions are categories in an arbitrary order, and a slope through them would be a slope through the order they happened to be listed in. (optional)
trendboolean | ChartTrendMethod | ChartTrend | Array<ChartTrendMethod | ChartTrend>Trend and forecast overlays (BACKLOG-0000952): a least-squares line, a trailing moving average, or exponential smoothing, drawn over a line, area or scatter chart. `true` draws a single linear trend; a method name or a {@link ChartTrend} object configures one; an array draws several. The maths matches the core stats engine to the last digit — the same least-squares fit, rolling window and exponential recursions — but is computed locally in the charts module rather than imported, because the in-tree bundler does not tree-shake and the import would inline the whole statistics closure; a test asserts the parity. A `forecast` count projects the linear line that many steps past the data, drawn dashed so it never reads as a reading; a moving average and a smoothed level have no slope to project, so `forecast` is ignored for them and the fact is stated in the accessible description rather than faked. (optional)
band(RegressionBand & { line?: boolean }) | nullA pointwise confidence band, drawn as a varying-width ribbon beneath the fit line (BACKLOG-0000812). Fed by a fitted model's own interval — the `band` from {@link StatisticsApi.regressionModel}, or as produced by {@link regressionPlots} — so the ribbon and the diagnostics report the one computation rather than a slope redrawn here. `line: false` suppresses the band's own centre line, for a chart that already draws the fit with `fit`. Only where the x axis is numeric, for the same reason `fit` is. (optional)
points{An explicit point set, bypassing the by-column binder (BACKLOG-0000872): a cartesian chart whose values are not a grid column — a scale-location plot's √|standardised residual|, a coefficient forest's per-coefficient estimate — hands its points in directly. Each is `{x, y}` with an optional `label`, `size` (a bubble's third channel) and `lower`/`upper` (interval bounds the error-bar primitive reads). Numeric `x` throughout gives a continuous axis. (optional)
errorboolean | { of?: string; confidence?: number }Whiskers showing the uncertainty in each mark. `true` computes a confidence interval from the readings behind the mark; `of` takes a symmetric margin from another column instead. (optional)
reference{ value: number; label?: string; axis?: 'left' | 'right' }[]Horizontal reference lines. On a dual-axis bar or line chart (see {@link ChartMeasure.axis}) a line naming `axis: 'right'` is placed on the right-hand scale, so it means what the right axis says rather than landing at the same number on the scale it does not belong to. (optional)
annotationsChartAnnotation[]The declarative annotation layer: reference and target lines, shaded bands and callouts, each naming the axis it reads and each described into the accessible table as a sentence. A value may be a constant or `compute`d from the data it annotates, so it follows the chart as the grid is filtered. (optional)
bucketsnumberBins for a histogram; the default is twelve. (optional)
divergingbooleanA diverging colour ramp, for heatmap and geomap. (optional)
shapesunknownCountry outlines, for a geomap drawing countries rather than continents. Either GeoJSON, an object of code to SVG path data, or a geometry {@link GeoPack} imported from an optional `modules/geo-*` package (BACKLOG-0001321) — as the pack itself, or as `{ pack: id }` once its module has been imported and registered. (optional)
codePropertystring(optional)
lonstringThe longitude column, for the types that place a row by where it is rather than by a code: `markermap`, `bubblemap` and `hexmap`. Degrees east, -180 to 180; a row outside that, or with no reading, is left off the map and counted. (optional)
latstringThe latitude column, beside {@link ChartSpec.lon}. Degrees north, -90 to 90, on the same terms. (optional)
valuestringThe measure a `markermap` writes beside each dot and colours it by. Its text is the column's own formatted cell text and its colour is whatever the column's conditional-formatting rules give that value, so a map and the table beside it say the same thing about the same number. (optional)
layerstringWhich layer of a multi-layer geometry pack to draw — the UK pack, for instance, ships `regions`, `local-authorities` and `constituencies` together (BACKLOG-0001321). Ignored for a single-layer pack. (optional)
projection'equalEarth' | 'robinson' | 'mercator' | 'equirectangular' | 'albers'The map projection a geomap draws through (BACKLOG-0001321): `'equalEarth'` (the default for a world), `'robinson'`, `'mercator'`, `'equirectangular'`, `'albers'`, `'transverseMercator'`, or a projection function of the caller's own `(lon: number, lat: number) => [number, number]`. Left unset, a geometry pack draws through the projection it declares. (optional)
projectionOptions{ parallels?: [number, number]; centre?: [number, number] }Parameters for the projections that take them: `parallels` and `centre` for `albers`, `centre` for `transverseMercator` (BACKLOG-0001321). (optional)
graticuleboolean | { step?: number }A lon/lat reference grid under a geomap's regions, off by default (BACKLOG-0001321 part 2). Only drawn over a geometry pack's fitted projection — the schematic continents have no fitted projection to draw one against. `step` is the spacing between lines in degrees (default 30). (optional)
multiplesstringOne chart per distinct value of this column. (optional)
canvasboolean | numberDraw to canvas past this many points. (optional)
downsamplenumber(optional)
emptyTextstring(optional)
subtitlestringA second line under the title. (optional)
footnotestringA note under the plot, a source, a caveat, a unit. (optional)
tooltipboolean`false` turns the hover tooltip off. (optional)
selectionbooleanDraw the grid's selected rows emphasised, and follow the selection. (optional)
drillbooleanClicking a group drills into it. (optional)
filterOnClickbooleanClicking a mark filters the grid to it. (optional)
stackbooleanStack the series rather than drawing them side by side. (optional)
curvebooleanOverlay a kernel density curve on a histogram. (optional)
measurestringAn alias for `y`, where "the measure" reads better than "the y axis". (optional)
sizestringBubble charts: the column driving the radius, and the largest it may be. (optional)
maxRadiusnumber(optional)
minnumberFix the measure axis rather than taking it from the data. (optional)
maxnumber(optional)
codestringA geomap's ISO code column. An alias for `x`. (optional)
columnsstring[]Correlogram: which columns to correlate, how, and whether to print them. (optional)
method'pearson' | 'spearman' | 'kendall'(optional)
valuesboolean(optional)
iterationsnumberNetwork layouts: how many relaxation passes to run. (optional)
nodesChartNode[]The nodes of a `network`, named by the host rather than inferred from the rows: an icon per device, a label, and a position the layout must honour. A node listed here that appears in no row is still drawn. A node in the rows that is not listed here takes the chart's `icon` default and its own id as its label. (optional)
iconstringThe default glyph for a `network` node that names none of its own: any name in the grid's icon registry (see {@link Grid.icons}). Unset, a node with no icon is a plain disc. (optional)
linkWidthnumberA `network` link's stroke width in pixels, fixed. Unset, width follows the link's value as a share of the heaviest link, as it always has. (optional)
spec{ lower?: number; upper?: number; target?: number }Control and capability charts: a tolerance overriding the column's own `spec`, how many leading readings fix the control limits, which rule set the violations are judged against, and the level for the capability interval. (optional)
baselinenumber(optional)
rules'westernElectric' | 'nelson'(optional)
confidencenumber(optional)

ChartTrend

One trend or forecast overlay (BACKLOG-0000952).

MemberTypeDescription
methodChartTrendMethodThe overlay method; `linear` by default. (optional)
forecastnumberFor the linear method, how many steps to project the line past the data as a dashed forecast. Ignored by the moving-average and exponential methods, which have no slope to extrapolate. (optional)
windownumberFor the moving-average method, the trailing window in points; 3 by default. (optional)
periodnumberAn alias for `window`. (optional)
kind'ses' | 'holt'For the exponential method, single smoothing (`ses`) or Holt's level+trend (`holt`). (optional)
alphanumberFor the exponential method, the level factor in `[0, 1]`; omit to fit it. (optional)
betanumberFor Holt's exponential smoothing, the trend factor in `[0, 1]`; omit to fit it. (optional)
bandboolean | 'prediction' | 'confidence'The uncertainty band shaded around a linear `forecast` (BACKLOG-0000975). The Student-t `prediction` band (a future observation) by default; `confidence` shades the narrower mean-response band; `false` opts out and leaves the bare dashed line. Ignored where there is no linear forecast to put a band on. (optional)
confidencenumberThe forecast band's confidence level in `(0, 1)`; 0.95 by default. (optional)
labelboolean`false` suppresses the R² label on a linear trend. (optional)

ChartTypeDefinition

The definition an extension chart type registers (BACKLOG-0000886). `draw` receives the base drawing context — `plot`, `bound`, `groups`, `scheme`, `typography`, `fontSize`, `labels`, `grid`, `spec`, `doc` — plus `ctx.helpers`, the base's own toolkit of primitives (element factory, scales, axes, mark pool, distribution kernels), and appends its marks to the layer groups. `bind` optionally supplies the bound data (default: the by-series binder); `freeform` lays the chart out without axis gutters; `labelled` declares that `labels` applies.

MemberTypeDescription
draw(ctx: object) => object
bind(grid: Grid, spec: ChartSpec) => object(optional)
freeformboolean(optional)
labelledboolean(optional)

Chunk

MemberTypeDescription
rowsunknown[]
progress{ loaded: number; estimated?: number }(optional)
doneboolean(optional)

ClipboardOptions

MemberTypeDescription
headersboolean(optional)
rows'visible' | 'all' | 'selected' | 'range'(optional)
sanitisebooleanApply the CSV/Excel formula-injection guard to copied cells: prefix a field beginning with `=`, `+`, `-`, `@`, a tab or a CR with an apostrophe so a spreadsheet treats it as text. **Off by default** (unlike CSV/Excel export, which default it on), because the clipboard most often round-trips back into a grid or cell range where the apostrophe would corrupt the value. Turn it on when your users paste the clipboard into Excel or Google Sheets. (optional)

Column

MemberTypeDescription
tagsstring | string[]Free-form labels for grouping columns together. A bare string is accepted for a single tag. Used by the column tag bar to show and hide sets of columns: tag sixty monthly columns with their year, and a user can switch to one year. (optional)
idstringThe column's own identity. Defaults to `field`; needed explicitly when two columns read the same field, as a value and its running total do. (optional)
fieldstringThe property to read from each row. Dotted paths reach into nested data. (optional)
titlestringThe heading. Defaults to a readable form of `field`. (optional)
typeTypeName | falseThe data type, which decides parsing, formatting, sorting, the default editor and the default filter together. `false` turns inference off and treats the values as opaque. (optional)
presetstring | string[]Named column presets to merge in first, so a house style is declared once. (optional)
formatFormatSpec | stringHow a value is rendered as text. A string is a shorthand mask. (optional)
lookupLookupSpecDisplay a stored code as a label, and edit it as a list. (optional)
valueColumnValueSpecA computed value, with the columns it depends on, in place of a stored one. (optional)
cellColumnCellSpec | stringThe renderer, and what it is given. A string names a registered renderer. (optional)
editColumnEditSpec | boolean | stringWhether and how the cell can be edited. A string names an editor. (optional)
validationColumnValidationDeclarative edit-validation rules (BACKLOG-0000956). Each is checked against a value before it is written, through the `beforeEdit` before-event: a failing value cancels the commit and marks the cell. Distinct from and complementary to `edit.validate`, which is an imperative function. (optional)
sortColumnSortSpec | booleanWhether the column sorts, and by what comparison. `false` refuses it. (optional)
filterColumnFilterSpec | boolean | FilterNameWhether the column filters, and with which filter. A string names one. (optional)
group{Row grouping by this column. `index` fixes its place among several; `explode` gives a multi-value cell one group per value rather than one group for the combination. `granularity` and `weekStart` apply to a `timestamp` column: it buckets by civil `day` (the default), `week` or `month` in the display zone, or `instant` for one group per exact moment. `weekStart` is the first weekday, 1=Monday (default) to 7=Sunday. (optional)
pivot{ enabled?: boolean; index?: number } | booleanUse this column as a pivot dimension, and where it sits among several. (optional)
totalTotalName | TotalFnThe reduction shown in the totals row and in group footers. (optional)
groupTotalTotalName | TotalFnThe reduction for group subtotals — group footers, tree-node rollups and pivot cells — where it should differ from the grand total. Overrides `total` for those scopes only; when omitted the column's `total` applies to both. Lets a column average within each group while the grand total sums, for example (BACKLOG-0000726). (optional)
grandTotalTotalName | TotalFnThe reduction for the pinned grand-total row, where it should differ from the group subtotals. Overrides `total` for the grand total only; when omitted the column's `total` applies (BACKLOG-0000726). (optional)
shadowShadowKind | {A value the grid maintains about this column's own history, rather than a field in the data. `{of: 'price', kind: 'delta'}`, or the bare kind to shadow the column it sits beside. (optional)
running'total' | 'percent' | 'delta'A running total down the grid **as it is currently ordered**. The one derived value that depends on the display order: sort differently and every value changes. That is why it is not a shadow kind: every shadow reads the same however the rows are arranged. (optional)
spec{ lower?: number; upper?: number; target?: number }The customer's tolerance, for process capability and control charts. Declared here rather than passed to each call so the capability figures, a control chart and any rule marking an out-of-tolerance cell cannot disagree about what the tolerance is. (optional)
layoutColumnLayoutSpec | numberWidth, pinning and flex. A bare number is the width in pixels. (optional)
headerColumnHeaderSpec | stringThe header cell: its text, tooltip, menu and any header chart. (optional)
contextMenuboolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void)The cell right-click menu for this column alone (BACKLOG-0001068), in the same shapes the grid-level `contextMenu` takes plus a bare array for the common "just these items here" case. Declared where the column is declared rather than as another branch inside one grid-level callback: the menu logic for a column belongs beside the column it belongs to. It does not replace the grid-level menu — the three levels compose as a chain, built-in defaults then grid-level then this one, each handed the previous result as its `defaults`, so a column adding one item does not have to restate Paste, Clear and Fill down. `false` suppresses the menu on this column and leaves every other column alone: what a sensitive or read-only column wants. The more specific level wins, so a column may also declare a menu on a grid whose `contextMenu` is `false`. (optional)
headerControls'hover' | 'always' | 'hidden'When this column's header controls — its sort arrow, filter funnel and menu button — are shown, overriding the grid-level `headerControls` default for this column alone (BACKLOG-0000982). `'hover'` reveals them on hover or focus, `'always'` keeps them visible, `'hidden'` draws none of them and leaves them out of the tab order. Omitted, the column follows the grid default, which is itself `'hover'`. (optional)
verticalAlignVAlignVertical alignment of this column's cell content within the row (BACKLOG-0000989). Overrides the grid-level `verticalAlign` for this column alone; `top`, `middle` or `bottom`. Also accepted as `cell.verticalAlign`, the way `align` is. Omitted, the column follows the grid default. (optional)
showWhen'open' | 'closed' | 'always'When this leaf column is shown, the same union `ColumnGroup` declares (BACKLOG-0001279). A leaf reads its own `showWhen` exactly as a group reads its own — `open`/`closed` tie the leaf to an ancestor group's collapsed state, `always` (the default) shows it regardless — so tying a leaf's visibility to a group's open/closed state does not require wrapping it in a `ColumnGroup` of its own just to hold this setting; a wrapper is for grouping columns, not for this. (optional)
exportColumnExportSpecHow the column leaves the grid, where that differs from how it is shown. (optional)
allowGroupbooleanWhether the user may group by this column from the interface. (optional)
allowPivotbooleanWhether the user may pivot on it. (optional)
allowTotalbooleanWhether the user may put a total on it. (optional)
nullablebooleanWhether an empty value is a legitimate value rather than a gap. (optional)

ColumnCellSpec

MemberTypeDescription
decorationDecorationName | DecorationSpec(optional)
variantVariantSpec(optional)
templatestring(optional)
renderstring | RenderFn | RendererCtor(optional)
propsRecord<string, unknown>(optional)
css(p: CellParams) => CellStyle(optional)
classstring | string[] | ((p: CellParams) => string | string[])(optional)
classWhenRecord<string, string | ((p: CellParams) => boolean)>(optional)
styleCellStyle | ((p: CellParams) => CellStyle)(optional)
tooltipstring | ((p: CellParams) => string) | ColumnTooltipSpecA tooltip for this column's cells. A string or a function is the plain-text case and becomes the browser's own `title`. An object is a {@link ColumnTooltipSpec}: a tooltip the grid draws, which can carry structure, markup or live content and which a keyboard user can reach (BACKLOG-0001204). (optional)
alignAlign(optional)
verticalAlignVAlignVertical alignment of this column's cell content, overriding the grid-level `verticalAlign` for this column alone (BACKLOG-0000989). Accepted at the top level of the column too, as `align` is. (optional)
wrapboolean(optional)
autoHeightboolean(optional)
flashboolean(optional)
spanColumns(p: SpanParams) => number(optional)
spanRows(p: SpanParams) => number(optional)

ColumnDifference

How one column differs between the filtered subset and its population.

MemberTypeDescription
columnstringThe column id.
namestringThe column's display name, or its id.
measure'standardizedMeanDifference' | 'categoricalTotalVariation'The effect size reported for this column's family: the standardized mean difference for a numeric column, the total variation of the category mix for a categorical one. Never a p-value.
magnitudenumber | nullThe effect size in its own terms, or null when it has no scale here.
distancenumberThe total variation distance between subset and population, 0 to 1 — the common scale both families reduce to, and what the ranking sorts by.
directionnumber+1 when the subset sits above the population, −1 below, 0 for a mix.
subsetNnumberHow many rows the subset comparison stood on.
populationNnumberHow many rows the population comparison stood on.
reliablebooleanFalse when the subset is too small to read the difference from.

ColumnDistribution

MemberTypeDescription
nnumber
minnumber
maxnumber
meannumber
stddevnumber
mediannumber
q1number
q3number
iqrnumber
sortednumber[]

ColumnEditSpec

MemberTypeDescription
enabledboolean | ((p: CellParams) => boolean)(optional)
editorstring | EditorCtor(optional)
propsRecord<string, unknown>(optional)
popupboolean(optional)
validate(p: ValidateParams) => true | string(optional)

ColumnExportSpec

MemberTypeDescription
lookup'label' | 'value' | 'columns'(optional)
csvboolean(optional)
excelboolean(optional)

ColumnFacetConfig

Per-column histogram settings, layered over the grid's.

MemberTypeDescription
enabledboolean(optional)
bucketsnumber(optional)
strategy'equal' | 'quantile' | 'log'(optional)
granularity'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'(optional)
order'count' | 'alpha'(optional)
cardinalityLimitnumber(optional)
aboveLimit'suppress' | 'topN'(optional)
bucketFn(handle: unknown, indices: Uint32Array | null, count: number) => FacetBoundsReplace the built-in bucketing entirely. (optional)
format(bucket: FacetBucket, count: number, unfiltered: number) => stringLabel a bucket for its tooltip and accessible name. (optional)

ColumnFilterSpec

MemberTypeDescription
enabledboolean(optional)
typeFilterName | FilterCtor(optional)
propsRecord<string, unknown>(optional)

ColumnGroup

MemberTypeDescription
idstring(optional)
titlestring
columns(Column | ColumnGroup)[]
collapsibleboolean(optional)
openByDefaultboolean(optional)
showWhen'open' | 'closed' | 'always'(optional)
marryChildrenboolean(optional)
header{ render?: string | RendererCtor; props?: Record<string, unknown>; class?: string | string[] }(optional)
facetColumnFacetConfig | booleanThis column's histogram. `true` turns it on with the grid's settings. (optional)

ColumnGroupState

A persisted banded-header node (§15, BACKLOG-0000739): a band with a `columns` list whose members are leaf ids or nested bands. This is what round-trips a drag-created group through a saved view.

MemberTypeDescription
idstring
titlestring
collapsibleboolean
openByDefaultboolean
columnsArray<string | ColumnGroupState>

ColumnHeaderSpec

MemberTypeDescription
templatestringNot read by the header renderer; use `render` to draw a custom heading. (optional)
renderstring | RendererCtorA custom heading renderer: a function, or a component (a class with a `render` method). A string names a registered renderer. Either form draws the same two ways and they are interchangeable — it may append to the passed label element itself and return nothing, or return an `Element` (attached for you) or a `string` (used as the heading text). (optional)
propsRecord<string, unknown>Props passed to `render` as `params.props`. (optional)
classstring | string[]A class, or classes, added to the heading cell. A string may hold several space-separated tokens (`'a b'`), each applied individually. (optional)
tooltipstring(optional)
alignAlign(optional)

ColumnLayoutSpec

MemberTypeDescription
widthnumber | stringA pixel width, or a percentage of the grid's inner width as a string, `'25%'`. A percentage is a share of the *whole* grid. `flex` divides only the space left over after fixed columns, so the two are not interchangeable: `flex: 25` on four columns is a quarter of the remainder, which is a quarter of the grid only when nothing else is fixed. (optional)
fit'content'`'content'` sizes the column to what it is actually showing, the way `columns.autoSize()` does, and keeps doing it: on the first paint, and again whenever the rows change, the columns are shown, hidden, reordered or pinned, or the grid is resized. It is the declarative form of the imperative call, so a host no longer has to re-issue `autoSize()` after every data change. Sized to the *visible* content, not to the widest value in the dataset: the measurement reads the rows the renderer has mounted, because measuring a million rows is not a plan. It measures the heading too, so a column whose title is longer than its values widens to show the title. **Anything the caller states outranks it.** A declared `width` wins, and so does a width the user drags to — a resize is recorded as a `width`, so from that moment the column is that wide and the fit no longer touches it. `min` and `max` clamp the fitted width as they clamp any other. `flex` is resolved before this and wins, the two being contradictory instructions: `flex` fits the column to the *grid*, this fits it to the *content*. Not re-measured on scroll, deliberately: different rows mount as the grid scrolls, and re-fitting against them would make the columns jitter under the reader. (optional)
minnumber(optional)
maxnumber(optional)
flexnumber(optional)
pin'start' | 'end' | null(optional)
hiddenboolean(optional)
resizableboolean(optional)
movableboolean(optional)
lockVisibleboolean(optional)
lockPositionboolean | 'start' | 'end'(optional)

ColumnMenuParams

What a column menu's item builder and its actions are handed.

MemberTypeDescription
colIdstring
columnResolvedColumnThe resolved column, including any properties you defined on it.
gridGrid

ColumnProfile

MemberTypeDescription
columnstring
rowsnumber
presentnumber
missingnumber
distinctnumber
minnumber | null
maxnumber | null
meannumber | null
mediannumber | null
q1number | null
q3number | null
iqrnumber | null
stddevnumber | null
outliersnumber
histogramHistogramBin[]
topValuesTopValue[]For a categorical (non-numeric) column, the commonest values, largest first (BACKLOG-0000959). Absent for a numeric column, whose shape the numeric figures and the histogram already carry. (optional)

ColumnsApi

MemberTypeDescription
setTotal(Set or clear a column's totals-row reduction. With no `scope`, `fn` becomes the column's single `total`, applied to both group subtotals and the grand total, and any independent group/grand overrides are cleared — the same one-property behaviour as before (BACKLOG-0000726). Pass `scope: 'group'` or `scope: 'grand'` to set just that scope's reduction independently, leaving the other and the base `total` untouched; the scope that has no override falls back to `total`.
aggregates(id: string): TotalName[]The aggregate names meaningful for a column, honouring its type's `totals.supported` declaration (§9.4). What the aggregate chooser offers.
distinct(id: string): unknown[]Every distinct value in a column, from the dictionary where there is one.
get(id: string): ResolvedColumn | undefined
all(): ResolvedColumn[]
visible(): ResolvedColumn[]
state(): ColumnState[]
apply(state: ColumnState[]): void
tags(): string[]Every distinct column tag, in the order first declared.
showTagged(tags?: string | string[] | null): string[]Show only the columns carrying one of these tags. **Columns with no tags are never hidden.** Pass nothing to show every tagged column again. Returns the ids that were hidden.
activeTags(): string[]The tags currently being shown, empty when all are.
show(ids: string | string[]): void
hide(ids: string | string[]): void
move(id: string, to: number): void
groupColumns(ids: string | string[], opts?: { title?: string; at?: number; groupId?: string; id?: string }): string | nullWrap leaf columns in a banded header, or add them to an existing band (BACKLOG-0000739). Header banding, not row grouping (see {@link group}); the band is a {@link ColumnGroup} node so a drag-, keyboard- or config-built band is the same tree, and it round-trips through a saved view. Emits `columngroup:changed`. Pass `groupId` to add to the band already carrying that id, or `id` (BACKLOG-0000985) to create a new band with a caller-chosen stable id you can reference later; `groupId` wins if both are given and an `id` already in use warns and no-ops.
ungroupColumn(id: string): voidTake a leaf out of its band; a band emptied by the move is dissolved.
renameGroup(groupId: string, title: string): voidRename a banded header.
dissolveGroup(groupId: string): voidDissolve a band, returning its columns to the enclosing level in place.
moveGroup(groupId: string, to: number): voidMove a whole band among its siblings, its columns travelling as a block.
pin(id: string, side: 'start' | 'end' | null): void
resize(id: string, px: number): void
decorate(id: string, decoration: DecorationName | DecorationSpec | null, opts?: { variant?: VariantSpec }): voidSet, change or clear a column's decoration at runtime (§8.7). Pass `null` to clear it back to plain text. Presentation config: it is not on the undo timeline and is not carried in a saved view — use `grid.formatting` for durable, view-persisted conditional styling.
autoSize(ids?: string | string[]): void
fit(): voidSize the visible resizable columns so that every column the grid draws, together, exactly fills the width the cells occupy: the body viewport's client width at the moment of the call, which excludes the vertical scrollbar when the grid draws one and is the full inner width when it does not. Columns it does not size keep their width and are taken out of that width first: `resizable: false` columns and the grid's own selection checkbox, detail expander, group and tree columns. The rest share what is left in proportion to their current widths, within each `min`/`max`. If that leaves less than their minimums, each is set to its minimum (never below), the grid scrolls horizontally, and a `[lattice]` warning says so. Rows given to `createGrid` or `rows.load()` before the call are counted. One-shot: it sets fixed widths once (a `flex` column included) and does not follow later changes; after a resize, or after rows arriving later bring a vertical scrollbar in, call it again.
group(ids: string | string[]): void
pivot(ids: string | string[]): void
totals(ids: string | string[]): void

ColumnSortSpec

MemberTypeDescription
enabledboolean(optional)
direction'asc' | 'desc' | null(optional)
ordernumber(optional)
nullsFirstboolean(optional)

ColumnState

MemberTypeDescription
idstring
widthnumber(optional)
flexnumber(optional)
hiddenboolean(optional)
pin'start' | 'end' | null(optional)
sort'asc' | 'desc' | null(optional)
sortIndexnumber | null(optional)
groupIndexnumber | null(optional)
pivotIndexnumber | null(optional)
totalTotalName | null(optional)
groupTotalTotalName | nullThe group-subtotal override, when one differs from `total`. (optional)
grandTotalTotalName | nullThe grand-total override, when one differs from `total`. (optional)
decorationDecorationName | DecorationSpec | nullThe column's runtime decoration (BACKLOG-0000723), present only when the column carries one, so a `columns.decorate()` survives a saved view and participates in undo/redo. Absent means "not recorded"; an explicit `null` on an undo patch clears a decoration back to plain text. (optional)
variantVariantSpec | nullThe variant set alongside the decoration, when one is present. (optional)

ColumnTooltipSpec

A rich, keyboard-accessible tooltip for a column's cells (BACKLOG-0001204) — the object form of `cell.tooltip`, drawn by the grid rather than handed to the browser as a native `title`. Shown after a delay (`tooltip.delay`, 400ms by default) on hover *and* on keyboard focus; the cell points at it with `aria-describedby`; it can be hovered without closing and Escape dismisses it (WCAG 2.2 AA, 1.4.13). It closes on scroll, because rows are pooled and a bubble left open would be anchored to a node that is now showing a different row.

MemberTypeDescription
render(params: TooltipParams) => HTMLElement | TooltipSpec | { html: string } | string | null | undefinedProduce the content. Four shapes, and the difference between the last two is a security property rather than a style choice: - an **element** — your own DOM, attached as it is; - a **{@link TooltipSpec}** — `{ title, rows, note }`, rendered as text; - **`{ html }`** — the only wrapper that inserts markup, scrubbed of script the same way `allowUnsafeTemplates` output is; - a **string** — *always* text, never markup. The last rule is what makes `render: (p) => p.value` safe: a value comes from row data, and data must not be able to promote itself to HTML. (optional)
mount(el: HTMLElement, params: TooltipParams) => voidPut live content in the tooltip — a sparkline, a KPI tile — by calling into a module bundle your application loaded. The grid core never imports a module, so anything live is mounted here by you. (optional)
unmount(el: HTMLElement) => voidTear down whatever `mount` built. Called every time the tooltip closes, so nothing keeps running behind a hidden box. (optional)

ColumnValidation

Declarative edit-validation rules for a column (BACKLOG-0000956). Rules are checked in a fixed order — `required` first, then the value-shape rules, then the functions — and the first failure wins. A blank but optional value passes everything after `required`: an empty cell is empty, not "below the minimum". A failure vetoes the commit through `beforeEdit` and marks the cell; the cancellation carries `reason: 'validation:<code>'`.

MemberTypeDescription
requiredboolean | stringThe value may not be blank. A string is used as the message. (optional)
minnumberMinimum, for a number or a date. (optional)
maxnumberMaximum, for a number or a date. (optional)
minLengthnumberMinimum text length. (optional)
maxLengthnumberMaximum text length. (optional)
patternstring | RegExpA pattern the whole value must match. A string is a RegExp source. (optional)
oneOfunknown[]The value must be one of these. (optional)
crossField(value: unknown, row: unknown, ctx: { key: string; colId: string; changes: unknown[] }) => true | string | voidA cross-field rule: return `true` to pass, or a message string to fail. The row is passed so a rule can compare against its siblings. (optional)
validate(value: unknown, row: unknown, ctx: { key: string; colId: string; changes: unknown[] }) => true | string | voidA free-form check, the same contract as `crossField`. (optional)
messagestringA default message for any rule without its own. (optional)
messagesRecord<string, string>Per-rule messages, keyed by rule name (`required`, `min`, `pattern`, …). (optional)

ColumnValueSpec

MemberTypeDescription
compute(deps: DepValues, ctx: ValueContext) => unknown(optional)
depsstring[] | '*'(optional)
pureboolean(optional)
format(p: FormatParams) => string(optional)
apply(p: ApplyParams) => boolean(optional)
parse(p: ParseParams) => unknown(optional)
key(p: KeyParams) => string(optional)
compareComparator(optional)
quickFilterText(p: ValueParams) => string(optional)

Comment

One comment in a thread, as the provider returns it.

MemberTypeDescription
idstring
bodystring
author{ name?: string; avatarUrl?: string; initials?: string }Rendered as supplied. The grid does not know who the user is. (optional)
atnumber(optional)
editedboolean(optional)
resolvedboolean(optional)
parentIdstring | null(optional)
valueunknownThe cell's value when this was written, so a later reader is told it moved. (optional)
can{ edit?: boolean; delete?: boolean; resolve?: boolean }What the current user may do. Absent means the grid shows every affordance and relies on the provider to refuse. Hiding a button is a convenience, never a security control. (optional)

CommentConfig

MemberTypeDescription
providerCommentProviderWithout one the feature is inert and no error is raised. (optional)
debouncenumberMilliseconds a viewport change waits before the index is fetched. (optional)
indexLimitnumberCell descriptors held before the oldest are dropped. (optional)
mode'anchored' | 'docked'`'anchored'` floats beside the cell; `'docked'` uses a side panel. (optional)
markdownbooleanRestricted markdown in bodies: emphasis, code and links only. (optional)
rowLabel(row: Row) => stringLabel for the row, so the panel says what is being commented on. (optional)

CommentDescriptor

Counts for one cell. Never bodies: this is consulted on every repaint.

MemberTypeDescription
countnumber
unresolvednumber
updatednumber

CommentIndexEntry

What `loadIndex` returns per commented cell.

MemberTypeDescription
cellKeystring(optional)
rowIdstring(optional)
fieldstring(optional)

CommentProvider

Storage for comments. Every method returns a promise; a rejection surfaces in the panel without disturbing grid state.

MemberTypeDescription
loadIndex(rowIds: string[], fields: string[]): Promise<CommentIndexEntry[]>
loadThread(cellKey: string): Promise<Comment[]>
addComment(cellKey: string, body: string, parentId: string | null,
editComment(commentId: string, body: string): Promise<Comment>
deleteComment(commentId: string): Promise<void>
resolveThread(cellKey: string): Promise<void>
unresolveThread(cellKey: string): Promise<void>

CommentsApi

MemberTypeDescription
enabledboolean(read-only)
openKeystring | null(read-only)
threadComment[] | null(read-only)
loadingboolean(read-only)
completeboolean(read-only)
unavailable(): string | null`'no-provider'`, `'no-row-identity'`, or null when available.
at(rowId: string, colId: string): CommentDescriptor | null
request(rowIds: string[], fields?: string[]): void
open(rowId: string, colId: string): Promise<Comment[] | null>
close(opts?: { reason?: string }): void
add(body: string, opts?: { parentId?: string; author?: object }): Promise<Comment | null>
edit(commentId: string, body: string): Promise<Comment | null>
remove(commentId: string): Promise<boolean>
resolve(): Promise<boolean>
unresolve(): Promise<boolean>
refresh(): void
loadAll(): Promise<boolean>
hiddenUnresolved(): number
filterToCommented(opts?: { unresolvedOnly?: boolean }): boolean

Condition

MemberTypeDescription
colstring
typeTypeName(optional)
opOperator
valueunknown(optional)
bounds'[]' | '[)' | '(]' | '()'(optional)
caseSensitiveboolean(optional)
metaRecord<string, unknown>(optional)

ConfidenceInterval

An interval for an estimated figure, at a stated level.

MemberTypeDescription
meannumber
lowernumber
uppernumber
marginnumber
nnumber
confidencenumberThe level the bounds were computed at, 0 to 1.

CrossFilter

MemberTypeDescription
enabled(): booleanWhether this grid can cross-filter a source.
column(): string | nullThe source column the filter is pushed onto.
get(): string[]The keys currently filtering the source.
set(keys: string | string[] | null): voidFilter the source to these derived rows.
toggle(key: string): voidAdd or remove one key, for click-to-filter.
clear(): voidTake this grid's filter off its source.

CsvExportOptions

MemberTypeDescription
delimiterstring(optional)
quotestring(optional)
lineEndingstring(optional)
headersboolean(optional)
columnsstring[](optional)
rows'visible' | 'all' | 'selected'(optional)
fileNamestring(optional)
processCell(p: CellParams) => string(optional)
downloadboolean(optional)

CurrencyConfig

MemberTypeDescription
codestringThe default currency code for bare numeric input, e.g. `'USD'`. (optional)
displaystringThe currency to render and aggregate in. Omit to keep each cell's own. (optional)
ratesRateSourceThe caller's rate source: a `(from,to)=>rate|null` fn or a rate table. (optional)
rateBasestringThe code a rate *table* is denominated in, when not the one mapping to 1. (optional)
decimalsnumberFixed fraction digits; omit for the code's own convention. (optional)
localestringThe locale for number formatting. (optional)
nullDisplaystringText for a null cell. (optional)
missingRatestringThe loud marker rendered when a needed rate is missing. (optional)
excelstringAn Excel number-format override. (optional)
codesstring[]The code list a currency editor's picker offers. (optional)

DataBarSpec

An in-cell proportional bar (BACKLOG-0000955). Drawn as a CSS gradient on the cell background — no extra element, and it composes with the cell's text. The bar's length is the value's position between `min` and `max`. Give both to pin the scale (0 to 100 for a percentage); otherwise `from` derives them from the column — `'minmax'` (the default) spans the data, `'quantile'` the 5th–95th percentile, `'stddev'` a number of deviations either side of the mean. When the range straddles zero, bars grow from a shared axis: positive right, negative left, each in its own colour.

MemberTypeDescription
minnumber(optional)
maxnumber(optional)
from'minmax' | 'quantile' | 'stddev'(optional)
lownumber(optional)
highnumber(optional)
deviationsnumber(optional)
colourstringThe fill for non-negative values. (optional)
colorstringAmerican spelling of `colour`. (optional)
negativeColourstringThe fill for negative values. (optional)
negativeColorstringAmerican spelling of `negativeColour`. (optional)
direction'ltr' | 'rtl'Which way the bar grows. `'ltr'` (the default) or `'rtl'`. (optional)

DataRouter

A data router: one arriving stream, partitioned by a property (or composite predicate), fanned out to a grid per partition (BACKLOG-0000879). Each grid sees only its slice, updated by keyed diff through the public `grid.rows.apply` path — no grid-core change, no cross-references between grids. Snapshots apply keyed diffs (unchanged rows never repaint); deltas add, update or remove in place by `rowKey`, preserving selection and scroll.

MemberTypeDescription
attach(grid: unknown, predicate: RoutePredicate, opts?: RouteOptions): DataRouterAttach a grid behind a predicate; `opts` may reshape, filter, sort, summarise or throttle the route.
attachDefault(grid: unknown, opts?: RouteOptions): DataRouterAttach the "rest" sink for records no explicit route matched. A second call replaces the first.
subscribe(predicate: RoutePredicate, handler: (change: RouterChange) => void, opts?: RouteOptions): DataRouterRoute a partition slice to any non-grid view (v5): the handler receives the same keyed diff a grid would.
alert(predicate: RoutePredicate, condition: (rows: RouterRecord[]) => unknown, handler: (signal: unknown, rows: RouterRecord[]) => void, opts?: AlertOptions): DataRouterWatch a slice and emit on a rising edge of `condition` rather than render (v5). Removed only by `destroy`.
configure(spec?: RouterConfig): DataRouterTake the whole routing graph as one declarative spec (v5); desugars to the calls above and composes with them.
link(source: unknown, target: unknown, relation: SelectionRelation): DataRouterLink a source grid's selection to what a target grid receives (v2, BACKLOG-0000880): the target shows the subset of its partition the `relation` admits, re-pushed through the keyed-diff path. No selection shows the full partition; changes are debounced.
relate(edges: RouterEdge[]): DataRouterDeclare a relationship graph (v3): multi-hop, several-into-one and mutual edges — the scalable form of `link`.
flush(): DataRouterApply any debounced selection refilter synchronously (for tests/determinism).
detach(grid: unknown): DataRouterDetach a grid — or a `subscribe` handler — and drop any link it is part of; the host still owns and destroys it.
load(snapshot: RouterRecord[]): RouteDiff[]Apply a full snapshot as a keyed diff per grid; returns per-route counts. Resets `unrouted`.
apply(deltas: RouterDelta[]): voidApply incremental deltas, routed and applied in place by `rowKey`; ordered and de-duplicated when `seq` is on.
push(delta: RouterDelta | RouterDelta[]): DataRouterEnqueue deltas for batched or coalesced application (v3); applies at once when no batching mode is on.
flushStream(): DataRouterApply the buffered deltas now as a single `apply` (v3) — a deterministic point, and for tests.
flushBackpressure(): DataRouterRefresh every backpressured route to the latest state now (v13); a no-op with nothing pending.
addSource(feed: string | RouterSourceOptions, opts?: RouterSourceOptions): RouterSourceHandleRegister a source feed for fan-in (v9): its rows are normalised and namespaced into the one keyed store.
removeSource(ref: string | RouterSourceHandle): DataRouterRemove a source feed by id or handle (v9): delete exactly its rows from every route, then unregister it.
sources(): string[]The registered source ids (v9).
metrics(): RouterMetricsA cheap point-in-time snapshot of the router's runtime (v10); throughput is measured since the previous read.
on(event: 'metrics', handler: (snapshot: RouterMetrics) => void): () => voidSubscribe to the periodic `metrics` emit (v10) — the only event; the timer runs only while a listener is registered. Returns the unsubscribe.
mountDevtools(el: unknown): RouterDevtoolsPanelMount the live devtools panel into `el` (v10); it re-renders on each `metrics` emit.
unroutednumberHow many records matched no route since the last `load` or `query`, running for deltas. (read-only)
droppednumberHow many stale or duplicate deltas the dedupe gate dropped since creation (v3). (read-only)
lastSeq(): number | undefinedThe highest seq applied — the resume point to request the feed from after a dropped socket (v3).
checkpoint(): Map<string, number>A copy of the per-record resume checkpoint: record identity → last applied seq (v3).
seenThrough(mark: Map<string, number> | Record<string, number>): DataRouterPrime the resume checkpoint from a persisted one, so replayed deltas at or below those seqs are dropped (v3).
persist(opts?: RouterPersistOptions): DataRouterTurn on durable persistence of the router's state (v12).
restore(): Promise<boolean>Resume from the durable snapshot (v12); resolves true when one was found and applied.
flushPersist(): Promise<DataRouter>Flush any pending durable write now (v12); resolves once it has settled.
persistingbooleanWhether durable persistence is on and not degraded to in-memory (v12). (read-only)
buffer(opts?: { window?: number; max?: number }): DataRouterRecord the stream into a bounded ring for time travel (v4): a time `window` in ms and/or a `max` delta count.
scrubTo(target: number, opts?: { by?: 'seq' | 'time' }): DataRouterScrub the attached grids to a past seq or timestamp (v4).
replay(from: number, to: number, opts?: { speed?: number; by?: 'seq' | 'time' }): Promise<void>Replay a buffered range step by step (v4); resolves when it completes or is superseded.
pause(): DataRouterPause an in-flight replay at the current step (v4); a no-op when nothing is replaying.
resume(): DataRouterResume a paused replay from where it stopped (v4); a no-op when not paused.
live(): DataRouterReturn to live (v4): rebuild the head from the base plus every buffered delta.
travelingbooleanWhether the grids are currently showing a reconstructed past (v4). (read-only)
bufferednumberHow many deltas the bounded buffer currently holds (v4). (read-only)
broadcast(opts: { channel: string }): DataRouterMirror the ordered, de-duplicated deltas to other tabs over a BroadcastChannel (v6), with no echo loop.
broadcastingbooleanWhether the router is mirroring to a BroadcastChannel (v6). (read-only)
query(adapter: RouterQueryAdapter, request?: Record<string, unknown>): Promise<DataRouter>Source the router from a pushdown adapter (v7): each `where` route is planned against the adapter's capabilities.
lastQueryPlan(): RouterQueryPlanEntry[] | nullThe pushed/residual split of the last `query()` (v7), per fetch, or null before any.
destroy(): voidDetach every grid and drop every link (the host destroys the grids themselves).

DataRouterOptions

Options for `createDataRouter`. `key` is the partition property or `fn(row)`; optional, since a router whose routes all use `fn(row)` predicates never reads it. `rowKey` is the identity within a grid; `overlap` fans a record to every matching route (default: first match wins); `onUnrouted` receives what matched no route — the row on `load` and `query`, the whole delta on `apply`; `selectionDebounce` is the ms debounce for cross-grid selection refilters (default 16; `0` is synchronous). `seq` names the per-record version that orders and de-duplicates a feed (v3), `dedupe` (default on with `seq`) drops stale and duplicate deltas; `batch` (ms, or `{ intervalMs }`) and `coalesce` buffer a high-frequency feed for `push`; `time` reads a row's timestamp for time-domain scrubbing and `now` overrides the clock (v4); `config` is a declarative routing graph applied at construction (v5); `onWrite` and `onConflict` are the defaults for every writable route (v8); `metricsInterval` is the ms between `metrics` emits (default 1000; `0` disables the timer) (v10).

MemberTypeDescription
keyRouterKey(optional)
rowKeyRouterKey(optional)
overlapboolean(optional)
onUnrouted(item: RouterRecord | RouterDelta) => void(optional)
selectionDebouncenumber(optional)
seqRouterKey(optional)
dedupeboolean(optional)
batchnumber | { intervalMs: number }(optional)
coalesceboolean(optional)
timeRouterKey(optional)
now() => number(optional)
configRouterConfig(optional)
onWrite(change: RouterWrite, ctx: { route: unknown; source: unknown }) => unknown(optional)
onConflict(change: RouterWrite, ctx: { serverRow: RouterRecord }) => void(optional)
metricsIntervalnumber(optional)

DatasetColumnDifference

MemberTypeDescription
columnstringThe column id, present on both grids.
namestringThe column's display name, or its id.
measure'pooledStandardMeanDifference' | 'categoricalTotalVariation'The effect size reported for this column's family: the pooled standardised mean difference (Cohen's d) for a numeric column, the total variation of the category mix for a categorical one. Never a p-value.
magnitudenumber | nullThe effect size in its own terms, or null when it has no scale here.
distancenumberThe total variation distance between the two datasets, 0 to 1 — the common scale both families reduce to, and what the ranking sorts by.
directionnumber+1 when dataset A sits above dataset B, −1 below, 0 for a mix.
nAnumberHow many rows the first grid's side stood on.
nBnumberHow many rows the second grid's side stood on.
reliablebooleanFalse when either side is too small to read the difference from.

DatasetComparison

MemberTypeDescription
rankedDatasetColumnDifference[]Every shared column, largest difference first.
nAnumberHow many rows the first grid contributed (its filtered set).
nBnumberHow many rows the second grid contributed (its filtered set).
unmatched{ onlyA: string[]; onlyB: string[] }Columns present on only one side, which cannot be compared.
measures{ numeric: string; categorical: string; common: string }The measure each family reports, and the common scale, named for a legend.

DataType

MemberTypeDescription
base'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object'
extendsTypeName(optional)
matches(value: unknown) => boolean(optional)
format(p: FormatParams) => string(optional)
parse(p: ParseParams) => unknown(optional)
compareComparator(optional)
defaults{(optional)
storage'float64' | 'int32' | 'bitset' | 'dictionary' | 'object'(optional)
totals{Which aggregates are meaningful for this type, and how. Omit it and every aggregate is allowed, which is what every type that shipped before this does. (optional)
excelstring(optional)
toClipboard(v: unknown) => string(optional)
fromClipboard(s: string) => unknown(optional)

DateFormat

MemberTypeDescription
type'date'
patternstring(optional)
dateStyle'short' | 'medium' | 'long' | 'full'(optional)
timeStyle'short' | 'medium' | 'long'(optional)
timeZonestring(optional)
relativeboolean | { threshold?: number }(optional)
nullDisplaystring(optional)
localestring(optional)

DecorationSpec

MemberTypeDescription
typeDecorationName
size'sm' | 'md' | 'lg'(optional)
shape'pill' | 'rounded' | 'square'(optional)
outlineboolean(optional)
edgeboolean(optional)
position'start' | 'end'(optional)
nameIconName | Record<string, IconName>`icon` decoration only: either a single glyph name (see {@link IconName}) used for every value, or a value -> glyph name map for exact-value icons. Omit both `name` and `bands` to use `iconSet`/its default instead. (optional)
iconSetIconSetNameicon only: a built-in threshold icon set, expanded to `bands`. (optional)
bandsIconBand[]icon only: value bands mapped to glyphs, first match by descending `min`. (optional)
minnumber(optional)
maxnumber(optional)
originnumber(optional)
showValueboolean(optional)
trackboolean(optional)
rampstring(optional)
midpointnumber(optional)

DerivedCorrelation

Pearson's correlation across N columns, pairwise. Rows, `orient: 'pairs'` (the default): one per unordered pair, `{ a, b, coefficient, n }` — the long form, because that is what a grid sorts, filters and charts well, and "the three most correlated pairs" is then a sort and a `limit` on the derived grid. Only the upper triangle is emitted: r is symmetric, so `(a,b)` and `(b,a)` are one finding, and a column against itself is 1 by definition. Rows, `orient: 'matrix'`: one per column, carrying a field per other column plus `column` and `n` — the classic square, for a heat map. The diagonal is 1 and both triangles are filled.

MemberTypeDescription
fn'correlation'
columnsstring[]The columns to correlate pairwise. At least two, or the source is refused.
orient'pairs' | 'matrix'`pairs` (default) for one row per pair; `matrix` for the square. (optional)

DerivedDatasetComparison

How this grid differs from another, ranked by effect size, as rows: `{ column, measure, magnitude, distance, direction, nA, nB, reliable, unmatched }`, largest difference first. The two-grid shape: one grid is the data, a second *is* the analysis of it. Both sides are read over their filtered rows, and the peer is watched — an edit or a filter on it re-derives the comparison, because a comparison whose other side has moved is wrong rather than merely late. A column present on only one side cannot be compared. It is still reported, as a row with a null `magnitude` and `unmatched` set to `'A'` or `'B'`, so a reader sees that it was skipped and why rather than finding it absent.

MemberTypeDescription
fn'datasetVsDataset'
withGridThe second grid to compare this one against.
columnsstring[]Restrict the comparison to these columns. All shared columns by default. (optional)

DerivedJoin

MemberTypeDescription
withGridThe grid holding the other side.
onstring | { left?: string; right?: string }The shared key: one field name when both sides use it, or one each.
type'inner' | 'left'`inner` keeps only rows that matched; `left` keeps them all. (optional)
selectstring[]Which of the partner's fields to bring across. All of them by default. (optional)
prefixstringRename the brought-across fields, when both sides have one worth keeping. (optional)
follow'all' | 'filtered'Which of the partner's rows to read. `all` by default. (optional)

DerivedSelect

One reduced column of a derived grid.

MemberTypeDescription
ofstringThe column to reduce, as a field name or a dotted path. Omit for `count`. (optional)
fnTotalNameA key of `TOTAL_FNS`: `sum`, `avg`, `median`, `p95`, `distinct` and the rest. (optional)

DerivedSeries

A `grid.statistics.series` summary, as one row per metric: `{ metric, value, n }`. One row per *metric*, not per point: `series` returns a `SeriesStats` summary object — `n`, `first`, `last`, `change`, `changePercent`, `volatility`, `annualisedVolatility`, `growth`, `maxDrawdown`, `maxDrawdownFrom`, `maxDrawdownTo`, `autocorrelation`, `upDays`, `downDays` — and not a value per row. The shape is deliberately the one `profile`'s `orient: 'metrics'` already emits rather than a third convention for the same idea.

MemberTypeDescription
fn'series'
ofstringThe column to summarise.
bystringThe column that orders it. Required and never guessed.
periodsPerYearnumberAnnualise volatility and growth against this many periods per year. (optional)

DerivedSourceConfig

A grid whose rows are derived from another grid: aggregated, unnested, filtered, ranked or profiled. Read-only: write to the source instead.

MemberTypeDescription
mode'derived'
fromGrid | UnionSourceOptions[]The grid to read, or several to combine into one row set before the rest of the pipeline runs (BACKLOG-0001045). A bare `Grid` is shorthand for a `UnionSourceOptions` with no `label`/`follow`/`map` override, so an existing `from: <grid>` keeps meaning exactly what it always has. Given an array, every source is read (each narrowed by its own `follow`, defaulting to `'filtered'` as a lone `from` does today), concatenated in **declaration order** — deterministic, not interleaved — and only then does `unnest`/`join`/`where`/`bucket`/`groupBy`/`select`/`sort`/`limit`/ `limitPer`/`cumulative` run, over the combined set, so "the worst performers across both" is one derivation rather than a hand-merge. The output carries the **union of the sources' fields**: a field present on only one source is `undefined` on rows from the others. Sources are **not** type-reconciled — if two disagree on what a field means or holds, that is not resolved for you; give each source a `map` to project it into a common shape first. Every row also carries `__source` (the entry's `label`, or its declaration index when unlabelled), which is required — not optional — because without it a combined list cannot be read, filtered or grouped by where it came from; it is an ordinary field to `where`, `groupBy` and `select`. And because the derived key (`__key`) would otherwise collide across sources sharing the same identifiers, it is namespaced by the same source tag when nothing is grouped (a grouped union's `__key` is the group value, exactly as today, and rows from different sources correctly land in the *same* group when their group values agree — that merging is the point of grouping a union, not a collision to guard against). This is **not** a join: there is no dedup or merge-on-key, and it draws no UNION/UNION ALL distinction — overlapping rows from two sources simply both appear. Reach for `join` when two sides share a key and you want them matched rather than stacked. An empty source contributes nothing and the rest still combine; a source that fails to read is named in a `warnOnce` and skipped for that pass rather than silently dropped, because a silently missing source would make "worst across both" quietly wrong. A source list that includes the grid being derived, directly or through a chain, is refused when the source is built (naming the offender) rather than recursed into. `crossFilter` has no single target once there is more than one parent, so it is not supported alongside a union `from` (ignored, with a `warnOnce`, rather than guessing which parent to push onto).
follow'filtered' | 'all' | 'selected' | 'grouped'Which of its rows to read. `filtered` by default. Ignored — with a `warnOnce` — when `from` is a union array: each entry there carries its own `follow` instead (BACKLOG-0001045). (optional)
unneststringAn array property to expand, one row per element, before anything else. (optional)
joinDerivedJoinMatch each row against a second grid on a shared key, and bring some of its fields across. Runs after `unnest` and before `where`, so a condition: and a grouping, and a total: can read a field the join produced. (optional)
where(row: unknown) => booleanA row predicate, applied before grouping. (optional)
bucket{ of: string; by: 'day' | 'week' | 'month' | 'quarter' | 'year' }Round a date column down to a period, and group on that. (optional)
groupBystring | string[]The dimension, or dimensions, to group by. Omit to pass rows through. (optional)
selectRecord<string, DerivedSelect>The reduced columns, by output id. (optional)
sort{ col: string; dir?: 'asc' | 'desc' }[]How to order the derived rows before limiting them. (optional)
limitnumberKeep at most this many rows. (optional)
limitPerstringApply `limit` within each distinct value of this column, not overall. (optional)
cumulative{ of: string; upTo: number }Keep rows until their running share of the total reaches `upTo`, 0 to 1. (optional)
profilestring | string[]One row per column, with the statistics as columns. Replaces the pipeline. (optional)
orient'columns' | 'metrics'With `profile`, emit one row per statistic instead of one per column. (optional)
statisticsDerivedStatisticsProject a **relational** statistic into rows (BACKLOG-0001046): the figures that need two or more columns, or a second grid, and so cannot be reached through `select`. Every *single-column* statistic already has a route and this is not it — the derived `select` reduces a group by any kernel the totals row uses, and that table is a superset of the statistics one, so `select: { p95: { of: 'amount', fn: 'p95' } }` (or `gini`, `stddev`, `median`, `trimmedMean`, …) works today. Reach for `statistics` only when the answer is a correlation, a series summary or a comparison against another dataset. **A terminal producer, like `profile`, not a pipeline stage.** A correlation is one row per column *pair*, a series summary one row per *metric*, a comparison one row per compared *column* — none of which is one row per group, so there is no position in `unnest → where → bucket → groupBy → select → sort → limit` for it to occupy. It replaces the pipeline, and those keys are ignored with a warning naming them (BACKLOG-0001092) rather than silently discarded. Sort, filter or limit the derived grid itself instead, or chain a second derived grid whose `from` is this one. **`profile` and `statistics` are mutually exclusive** and declaring both is refused, by name, when the source is built. **Not supported alongside a union `from`** — a relational statistic reduces one grid's own columns and a union has no single set of them; also refused by name. **Cost.** Like every terminal producer this never patches incrementally: a change on the parent re-derives the whole thing. `correlation` additionally scans the rows once *per pair*, so N columns cost N·(N−1)/2 passes. Use `refresh` (`'idle'` is the default; `'manual'` or a debounce in ms for an expensive analysis over a live feed; under `'manual'` the host re-derives by calling `rows.load()` on the derived grid) — see `docs/api-detail.html` for the measured figures. Every row carries `n`, the rows the figure covered, because a derived statistic travels into an export or a chart without its grid and "r = 0.98 over eleven rows" is a different claim from the same number over eleven thousand. It does NOT carry a windowed/approximate flag: whether a source held fewer rows than matched its filters is decided from the source's own counters, which a derived source cannot reach, so that signal stays where it already works - the `stat.windowed:*` console warning the parent grid emits. (optional)
refresh'live' | 'idle' | 'manual' | numberWhen to re-derive. `idle` by default: coalesced to a frame. A number debounces by that many milliseconds; `live` re-derives on every change. `manual` never re-derives on its own: the host triggers it by calling `rows.load()`, with no argument, on the derived grid - from a Refresh button, say. Each call re-reads `from` there and then and replaces the rows; a derived grid takes its rows from `from`, so anything passed to `load` is not used. Executed example: `docs/api-detail.html#derived-manual-refresh`. (optional)
crossFilterboolean | string | { col?: string }Let this grid filter the grid it derives from. `true` cross-filters through whatever it groups by; a string names a different source column. (optional)

DetailApi

MemberTypeDescription
enabled(): boolean
isMaster(target: string | Row): boolean
isOpen(key: string): boolean
open(key: string): void
close(key: string): void
toggle(key: string): boolean
closeAll(): void
keys(): string[]
active(): string | null
placement(): 'inline' | 'target' | null
config(): DetailConfig | null

DetailConfig

MemberTypeDescription
enabledboolean(optional)
renderstring | RendererCtor(optional)
configGridConfig(optional)
rows(row: Row) => unknown[] | Promise<unknown[]>(optional)
heightnumber | 'auto' | ((row: Row) => number)(optional)
cacheLimitnumber(optional)
isMaster(data: unknown, row: Row) => boolean(optional)
targetstring | HTMLElementRender the detail into this element instead of into a row beneath its master. A selector or an element. Exactly one detail is open at a time in this placement. (optional)
onCreate(grid: Grid, masterRow: Row) => voidHanded the nested grid as it is created, for whatever the forwarded events do not cover. (optional)
pathstringThe property of the master's record the detail rows live on, so an edit in the detail is reported as a path on the master: `ports.1.vlan`. Inferred by identity when `rows(row)` returns an array already on the record, which is the usual shape; set this when it does not. (optional)

DiagnosticsApi

MemberTypeDescription
snapshot(): Record<string, unknown>
renders(): Record<string, unknown>`dom.cellWrites` is the figure a DOM-write assertion reads.
store(): Record<string, unknown>
operations(): Record<string, unknown>
providers(): Record<string, unknown>
events(): Record<string, number>
config(): { effective: Record<string, unknown>; supplied: string[]; defaulted: string[] }
warnings(): DiagnosticWarning[]
dismiss(id: string): void
bundle(): Record<string, unknown>Contains no row data, cell values or column values.
checkOptions(options: unknown): boolean
record(kind: string, detail: { rows?: number; ms?: number; worker?: boolean }): void
render(cause: string, phases?: Record<string, number>): void
recordEvents(on: boolean, limit?: number): voidOff by default; recording times every emit.
eventLog(): Array<{ type: string; origin: string; listeners: number
clearEventLog(): void
mark(): Record<string, unknown>Keep current store statistics so growth can be measured against them.
since(): Record<string, unknown> | null
reset(): void

DiagnosticWarning

One thing the grid has flagged as probably a mistake.

MemberTypeDescription
idstringStable identifier, nameable in a support conversation.
messagestring
valuesRecord<string, unknown>The specific values involved, so the warning is actionable.
countnumber
firstnumber
lastnumber
source'check' | 'reported' | 'info'`'check'` raised by a diagnostic check, `'reported'` from `warnOnce`.

DiffApi

MemberTypeDescription
swap(): booleanExchange the baseline and the current rows. Returns false with nothing to swap.
enabledboolean(read-only)
setSnapshot(rows: unknown[] | null): voidSet the baseline every row is compared against.
clear(): void
summary(): { added: number; removed: number; changed: number; unchanged: number }
statusOf(key: string): 'added' | 'removed' | 'changed' | 'unchanged'
cellStatus(key: string, colId: string): 'changed' | 'unchanged'
isChanged(key: string, colId?: string): boolean
changedColumns(key: string): string[]
before(key: string, colId: string): unknownThe value a cell held in the baseline.
beforeRow(key: string): unknown
removedKeys(): string[]
removedRows(): unknown[]
report(): Record<string, unknown>

EditApi

MemberTypeDescription
start(key: string, colId: string): boolean
stop(cancel?: boolean): void
undo(): void
redo(): void
setCells(Write several cells as one undoable step (§12). `opts.origin` defaults to `'api'` — the ungated seam every existing caller uses (a fill, a paste, a kanban move), unchanged. Pass `{ origin: 'ai' }` (or `'user'`) to route the write through the cancellable `beforeEdit` gate, exactly as an interactive edit is (BACKLOG-0000967): the AI writes through this so a host `beforeEdit` handler can veto it and nothing persists when it does. With a gated origin and an async (deferring) before-handler, the return is a `Promise<number>`.
bulkSet(value: unknown, opts?: { cells?: { key: string; colId: string }[] }): numberSet one value across a block of cells as a single undoable step (§12, card 740). Defaults to the selected range; read-only and non-editable cells are skipped and every write runs the normal parse/validate path.
fill(opts?: { direction?: 'down' | 'up' | 'left' | 'right'; series?: boolean; range?: CellRange }): numberFill a selected range from its leading edge as one undoable step (§12, card 740). The default copies the anchor across the range (Excel's Ctrl+D and its natural siblings); `series: true` extrapolates a numeric or date series from the first one or two cells of each line, falling back to a copy for types with no series. `direction` defaults to `'down'`.
pasteInto(anchor: { key: string; colId: string }, text: string, extent?: { rows?: number; columns?: number }): number
pastePreviewbooleanWhether a bulk paste is previewed before it commits (`edit.pastePreview`, §12). (read-only)
previewPaste(anchor: { key: string; colId: string }, text: string, extent?: { rows?: number; columns?: number }): {Compute what a paste would change, without committing (§12). The engine behind `edit.pastePreview`: `changes` are the accepted writes with their old and new values (and whether each actually differs), `rejected` are the cells a commit would refuse, each with a reason.
settle(Report the outcome of an in-flight write (§18.3; §5.1-5.2 reconcile). `reconcile` carries server truth on a successful settle: `value` is a server-authoritative value written back before `cell:confirmed` (`returning: 'row'`); `conflict.serverRow` surfaces a last-write-wins conflict via `cell:conflict`. Omit both to keep the optimistic value.
pending(): OpenWrite[]
status(key: string, colId: string): 'pending' | null
addRow(row: object): string | nullAppend a row to a remote source optimistically and persist it (§5.3), the structural analog of the cell edit path. The row shows immediately under a client temp key, and `adapter.mutate({ kind: 'append', rows: [row] })` is asked to persist it; when the server returns the real key the row is rekeyed everywhere the grid tracks it and `row:confirmed` fires, while a refused append is removed and fires `row:reverted`. Only wired when the source declares `mutate.append`; otherwise it warns once and returns null.
deleteRow(key: string): string | nullDelete a row from a remote source optimistically and persist it (§5.3). The row is tombstoned immediately and `adapter.mutate({ kind: 'delete', keys: [key] })` is asked to remove it; on confirmation the row is purged and `row:confirmed` fires, on refusal it is restored and `row:reverted` fires. Only wired when the source declares `mutate.delete`; otherwise it warns once and returns null.
deleteRows(keys?: string | string[], opts?: { origin?: string }): string[] | Promise<string[]>Delete rows on a user gesture, through the cancellable `beforeDelete` event (§18.4, BACKLOG-0000968) — what the built-in Delete-key and "Delete row" gestures call. Unlike {@link deleteRow}, `beforeDelete` fires on a memory-source grid too, so the row can be confirmed or vetoed there. Off until `config.rowDelete` opts in; the keys default to the row selection. Returns the keys removed (empty on a veto or when disabled), or a Promise of them when a `beforeDelete` handler deferred.
settleRow(id: string, ok: boolean, reason?: string, reconcile?: { key?: string; row?: unknown; conflict?: { serverRow?: unknown } }): booleanReport the outcome of an optimistic structural write (§5.3), the counterpart to {@link settle} for `edit.confirm: 'manual'` over a backend that acknowledges an append/delete on a separate channel. The id arrives on `row:pending`.
rowStatus(key: string): 'pending' | nullWhether a row has a structural op in flight (§5.3).
pendingRows(): OpenRowOp[]Every structural op still awaiting an outcome (§5.3), oldest first; always empty when the source cannot append or delete.

EditConfig

MemberTypeDescription
enabledboolean(optional)
mode'cell' | 'row'(optional)
start'single' | 'double' | 'key'(optional)
enterMovesDownboolean(optional)
undoDepthnumber(optional)
commit(write: PendingWrite) => unknown(optional)
confirm'auto' | 'manual'(optional)
pendingTimeoutnumber(optional)
pastePreviewbooleanShow a preview of what a bulk paste will change before it commits (§12), with confirm/cancel. Off by default: a paste commits straight away, exactly as it always has. When on, a paste into more than one cell first opens a dialog listing every cell that changes (old → new) and every cell that would be rejected (permission, data-type, read-only); confirm commits precisely that set through the ordinary edit path, cancel commits nothing. (optional)

Editor

MemberTypeDescription
init(p: EditorParams): void
element(): HTMLElement
value(): unknown
attached(): void(optional)
cancelBeforeStart(): boolean(optional)
cancelOnClose(): boolean(optional)
popupboolean(optional)
destroy(): void(optional)

EditorParams

MemberTypeDescription
stop(cancel?: boolean): void
keystring(optional)
charPressstring(optional)

ErrorBound

How an approximate reduction's error bound holds, and what it measures.

MemberTypeDescription
kind'deterministic' | 'probabilistic' | 'exact'`deterministic` every run, `probabilistic` in expectation, `exact` to float rounding.
metric'absolute' | 'relative' | 'rank' | 'none'What the number measures. `rank` is a fraction of the rank, for quantiles.
valuenumberThe bound itself, in the unit `metric` names.
statementstringA one-line human reading of the guarantee.

ExcelBorderSpec

A cell border, per edge. `true` means a thin line; a string names the style.

MemberTypeDescription
leftboolean | string(optional)
rightboolean | string(optional)
topboolean | string(optional)
bottomboolean | string(optional)

ExcelCellStyle

A conditional-formatting rule's rendering, returned by `cellStyle`.

MemberTypeDescription
boldboolean(optional)
italicboolean(optional)
colourstringFont colour as 6- or 8-digit hex/ARGB, e.g. 'FFFF0000'. `color` is an alias. (optional)
colorstring(optional)
fillstringSolid fill colour as 6- or 8-digit hex/ARGB. (optional)

ExcelExportOptions

MemberTypeDescription
sheetNamestring(optional)
freezePanesboolean(optional)
variantFillsboolean(optional)
bordersboolean | string | ExcelBorderSpecDraw cell borders on the data grid. `false` (default) is borderless; `true` draws a thin box; a string names the line style; an object picks edges. (optional)
hiddenColumns'omit' | 'hidden'What to do with grid-hidden columns. `'omit'` (default) drops them; `'hidden'` keeps them as Excel-hidden columns for round-trip fidelity. (optional)
mergesstring[]Explicit merged body ranges in A1 form, e.g. ['A3:A4']. (optional)
onProgress(p: { written: number; total: number }) => void(optional)

ExportApi

MemberTypeDescription
rangeText(opts?: object): stringThe selected range as tab-separated text, the shape a spreadsheet pastes.
csv(opts?: CsvExportOptions): string | Promise<Blob>
excel(opts?: ExcelExportOptions): Promise<Blob>
clipboard(opts?: ClipboardOptions): Promise<void>
print(): void

FacetBounds

Where a column's buckets are, and how they were chosen.

MemberTypeDescription
kind'numeric' | 'date' | 'category' | 'boolean' | 'none'
bucketsFacetBucket[]
suppressed'type' | 'cardinality' | 'rows' | 'streaming' | 'no-provider' | 'disabled'Set when no histogram was drawn, naming why. (optional)
cardinalitynumberDistinct values, on categorical columns. (optional)
granularity'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'The time unit chosen, on date columns. (optional)
strategy'equal' | 'quantile' | 'log'The numeric strategy actually applied, which may differ from the request. (optional)
minnumber(optional)
maxnumber(optional)

FacetBucket

One bucket of a column's distribution.

MemberTypeDescription
fromnumberLower edge, for ordered columns. Half-open `[from, to)` except the last. (optional)
tonumberUpper edge, for ordered columns. Inclusive on the last bucket only. (optional)
valueunknownThe value, for categorical and boolean columns. (optional)
nullbooleanTrue on the terminal bucket holding nulls, NaN and empty values. (optional)
remainderbooleanTrue on the aggregated tail bucket under `aboveLimit: 'topN'`. (optional)
labelstringA ready-made label, where one is more useful than the raw value. (optional)

FacetConfig

Grid-level histogram settings.

MemberTypeDescription
enabledbooleanOff unless asked for: header space is tight and this doubles its height. (optional)
collapsedbooleanStart as a one-line density strip that opens on hover or click. (optional)
heightnumberBand height in pixels. (optional)
rowCeilingnumberRows above which histograms are suppressed. (optional)
debouncenumberMilliseconds a filter change waits before charts recount. (optional)
whilePausedbooleanWhether a paused stream re-enables histograms. Defaults to true. (optional)
provider(request: {Bucket counts for a source the client cannot compute over. (optional)

FacetsApi

MemberTypeDescription
get(colId: string): FacetState | null
suppression(colId: string): string | null
config(colId?: string): FacetConfig
refresh(opts?: { immediate?: boolean }): void
isExpanded(colId: string): boolean
toggle(colId: string, open?: boolean): boolean
select(colId: string, from: number, to?: number,
clear(colId: string): boolean
selected(colId: string): number[]
expanded(): string[]

FacetState

A column's computed distribution.

MemberTypeDescription
boundsFacetBounds | null
countsUint32Array | nullCounts under every filter except this column's own. Aligned to `buckets`.
unfilteredUint32Array | nullCounts with no filter applied, for the "40 of 200" reading.
stalebooleanTrue while a recount is outstanding; draw the previous counts faded.
suppressedstring | null

FeedMessage

A message on the wire. A snapshot carries the full opening set; a delta carries the changes since. The reader parses `event.data` and switches on `kind`, exactly as against a real feed that framed its messages the same way.

MemberTypeDescription
kind'snapshot' | 'delta'
rowsFeedRow[]Present on a snapshot: the full opening set of rows. (optional)
changesFeedChange[]Present on a delta: the changes to apply. (optional)

Filter

MemberTypeDescription
init(p: FilterParams): void
active(): boolean
passes(p: { row: Row; data: unknown }): boolean
get(): unknown
set(state: unknown): void
element(): HTMLElement
onRowsChanged(): void(optional)

FilterGroup

MemberTypeDescription
op'and' | 'or' | 'not'
conditionsFilterSet[]

FilterParams

MemberTypeDescription
columnColumn
colIdstring
gridGrid
contextunknown
propsRecord<string, unknown>(optional)
changed(): void

FiltersApi

MemberTypeDescription
quickState(): { text: string; mode: string }The quick filter's text and match mode, for restoring a control.
get(): FilterSet
set(filters: FilterSet): void
clear(): voidDrop the condition tree, the quick filter, and every `where` predicate that was not registered `{ pinned: true }`.
quick(text: string): void
where(): string[]The names of the `where` predicates in force, in registration order.
where(name: string, predicate: ((row: any) => boolean) | null, opts?: WhereOptions): voidRegister, replace or remove a named row predicate composed with the filter set (BACKLOG-0001202). Registering *is* activating: there is no companion "a predicate is present" flag to keep in sync, which is the failure mode this replaces. Several may be in force at once under their own names, ANDed with each other and with the declarative set, and removing one leaves the rest alone. The predicate is handed the **data row**. grid.filters.where('visibleToMe', row => row.owner === me); grid.filters.where('rateKnown', row => rates.has(row.ccy), { deps: ['ccy'], pinned: true }); grid.filters.where('visibleToMe', null); // remove Only the names reach `filters.get()` and `state.get()`; the functions never do.
reapply(name?: string): booleanRe-run `where` predicates whose inputs changed where the grid could not see it — a rate table that arrived late, a permission set that refreshed. The out-of-band half of re-evaluation; `deps` is the half the grid observes for itself. Together they replace the manual "filter again" call.

FindApi

In-grid find (BACKLOG-0001018): locate text and step through where it occurs without filtering anything away. Matches are a visual overlay — no row is reordered, removed or edited — and coexist with the quick filter.

MemberTypeDescription
open(text?: string): voidShow the bar with focus in its input, optionally seeding the text.
close(): voidHide the bar and clear every match.
clear(): voidClear the query and the highlights, leaving the bar as it is.
next(): FindMatch | nullThe next match, wrapping from the last to the first, scrolled into view and made the active cell unless an edit is open.
prev(): FindMatch | nullThe previous match, wrapping from the first to the last.
goTo(index: number): FindMatch | nullMake the match at a position in `matches()` current.
matches(): FindMatch[]Every match, in display order: pinned-top rows, then the body, then pinned-bottom rows.
count(): FindCount
current(): FindMatch | null
state(): FindState
stateFor(key: string, colId: string): 'current' | 'match' | nullHow a cell is painted: the current match, another match, or nothing.

FindConfig

The in-grid find bar's settings (BACKLOG-0001018). `find: true` or an omitted key mounts the bar with these defaults; `find: false` removes the bar and its shortcut while `grid.find` keeps working programmatically.

MemberTypeDescription
shortcutbooleanBind Ctrl+F (Cmd+F on a Mac) while focus is in the grid. The browser's own find is untouched while focus is anywhere else on the page. Default true. (optional)
debouncenumberMilliseconds of typing quiet before the bar searches. Default 120. (optional)

FindCount

How many matches there are and which is current. `windowed` is the honest scope flag: over a paged pushdown source only the loaded rows are searched, so `total` counts matches in `loaded` rows out of the `rows` the source reports for the whole matching set.

MemberTypeDescription
currentnumber1-based position of the current match; 0 when there is none.
totalnumber
completebooleanFalse while the bar's sliced scan is still running, so a partial count is never read as final.
windowedboolean
loadednumberRows the search actually read; a windowed source's not-yet-fetched placeholders are not counted.
rowsnumberThe rows the source reports for the whole matching set, when it can say.

FindMatch

One matching cell.

MemberTypeDescription
keystring
colIdstring
indexnumberThe display index, or -1 for a row pinned to an edge.
pinned'top' | 'bottom' | nullWhich sticky strip a pinned row is in; null for a body row.

FindQuery

How `grid.find(text, opts)` matches. Defaults: case-insensitive, substring, every visible column, starting from the first row. Find matches the **formatted display text** — what the cell shows, a column `format` included — never a raw value; there is no regular-expression mode.

MemberTypeDescription
caseSensitivebooleanMatch letter case exactly. Default false. (optional)
wholeCellbooleanThe whole cell text must equal the search text rather than contain it. Default false. (optional)
columnsstring[] | string | nullSearch only these column ids. Omitted searches every visible column. (optional)
fromnumberThe display index to start from: the first match at or after it becomes current. Default 0. (optional)

FindState

The current query and whether the bar is showing.

MemberTypeDescription
textstring
caseSensitiveboolean
wholeCellboolean
columnsstring[] | null
openboolean

ForecastPoint

One forecast step: the point estimate and, where a band applies, its interval.

MemberTypeDescription
stepnumberThe step ahead, `1 … horizon`.
atnumberThe time-axis position the step is stamped at, extrapolated at the mean spacing.
meannumberThe point forecast.
lowernumber | nullThe prediction-interval lower bound (a future observation), or null when none applies.
uppernumber | nullThe prediction-interval upper bound, or null when none applies.
lowerMeannumber | nullThe mean-response (confidence) lower bound — `linear` only, the band a trendline draws. (optional)
upperMeannumber | nullThe mean-response (confidence) upper bound — `linear` only. (optional)
senumber | nullThe prediction standard error the band was built from, or null when none applies.

ForecastResult

A forecast: the chosen model, its parameters, and the projected points.

MemberTypeDescription
method'movingAverage' | 'ses' | 'holt' | 'holtWinters' | 'linear'Which method produced it.
horizonnumberHow many steps ahead were projected.
confidencenumberThe band level, e.g. 0.95.
nnumberHow many finite readings the fit used.
sigmanumber | nullThe residual standard deviation the bands were built from, or null when there was none.
r2numberThe fit's coefficient of determination — `linear` only. (optional)
params{The model parameters: `slope`/`intercept` (linear), `alpha`/`beta`/`gamma`/`period`, or `windowLen`.
pointsForecastPoint[]The forecast, one entry per step.

FormattingApi

MemberTypeDescription
list(scope?: FormattingScope): FormattingRule[]
all(): Record<FormattingScope, FormattingRule[]>
scopes(): FormattingScope[]
add(scope: FormattingScope, rule: FormattingRule, opts?: { at?: number }): FormattingRule | null
remove(scope: FormattingScope, which: string | number): boolean
update(scope: FormattingScope, which: string | number, patch: FormattingRule): FormattingRule | null
move(scope: FormattingScope, which: string | number, to: number): boolean
set(scope: FormattingScope, rules: FormattingRule[]): FormattingRule[]
replaceAll(rules: Record<FormattingScope, FormattingRule[]>): void
clear(scope?: FormattingScope): void
styleFor(colId: string, value: unknown): CellStyle | null
restat(): voidRe-derive the thresholds of distribution rules from the data as it stands.
distribution(colId: string): ColumnDistribution | nullThe five numbers a distribution rule resolves against for one column.

FormattingCondition

MemberTypeDescription
opOperator | DistributionOpA filter operator compared against `value`, or a distribution operator whose threshold comes from the column itself: `{op: 'topPercent', value: 10}`, `{op: 'outlier'}`. Distribution thresholds are pinned when the rules compile; `grid.formatting.restat()` moves them.
valueunknown(optional)
value2unknown(optional)

FormattingRule

One rule. A condition and the styling it produces, a colour scale, a data bar or an icon set. A rule held as runtime state must be JSON, so `style` may not be a function there (config-time `cell.style` still accepts one) and a data bar / icon set / scale is the JSON way to say the same visual intent.

MemberTypeDescription
idstring(optional)
whenFormattingCondition(optional)
styleCellStyle | ((p: CellParams) => CellStyle | null)(optional)
scaleFormattingScale(optional)
dataBarDataBarSpecAn in-cell proportional bar (BACKLOG-0000955). (optional)
iconSetIconSetSpecA per-band glyph beside the value (BACKLOG-0000955). (optional)
stopIfTrueboolean(optional)
enabledboolean(optional)
labelstring(optional)

FormattingScale

MemberTypeDescription
from'minmax' | 'quantile' | 'stddev'Where the bounds come from when `min` and `max` are not given. `'minmax'` spans the data, `'quantile'` spans `low` to `high` (5th to 95th percentile by default), `'stddev'` spans `deviations` either side of the mean. (optional)
minnumber(optional)
maxnumber(optional)
midnumber(optional)
lownumber(optional)
highnumber(optional)
deviationsnumber(optional)
coloursstring[](optional)

FullWidthParams

What `fullWidth.render` is handed.

MemberTypeDescription
rowRow
dataunknownYour original row object.
indexnumberDisplay index of the row.
gridGrid
elementHTMLElementThe element to fill. Write into it directly, or return content instead.

Gantt

A headless Gantt controller: holds the model, recomputes on edits, emits changes.

MemberTypeDescription
tasksGanttTask[](read-only)
dependenciesGanttDependency[](read-only)
scheduleGanttSchedule | null(read-only)
criticalstring[](read-only)
conflictsGanttConflict[]Constraints the latest schedule could not honour (empty when all are satisfied). (read-only)
autoScheduleboolean(read-only)
gridunknown(read-only)
overAllocationsGanttOverAllocation[]The over-allocations from the latest schedule (BACKLOG-0000948). (read-only)
resourceLoadGanttResourceLoad | nullThe latest resource-load report, or null before a successful schedule (BACKLOG-0000948). (read-only)
setTasks(tasks: GanttTask[]): GanttSchedule
setDependencies(deps: GanttDependency[]): GanttSchedule
applyEdit(patch: { id: string | number; start?: number; end?: number; duration?: number; percentComplete?: number; work?: number | Array<{ date: number | string | Date; hours: number }> }, editOpts?: { writeBack?: boolean }): GanttScheduleApply one task edit and recompute — the single gated choke point every drag, keypress, table cell and workload cell commits through. A `work` ARRAY is the task's per-day contour (BACKLOG-0001282). Given without an explicit `start`/`end`/`duration` it SETS the span: the task starts on the contour's first day and runs through its last, so booking hours beyond the bar extends it and clearing an edge bucket pulls it back. Conversely, a `start` or `duration` in the patch re-times an existing contour rather than discarding it — a move keeps its shape, a resize stretches it across the new span at the same daily levels.
compute(): GanttSchedule
findViolations(): GanttViolation[]
resources(loadOpts?: { resources?: GanttResourceSpec; defaultCapacity?: number }): GanttResourceLoadCompute the resource load and over-allocations on demand (BACKLOG-0000948), optionally overriding the capacities for this call.
level(levelOpts?: {Resolve resource over-allocation by shifting tasks later — resource leveling (BACKLOG-0000948). Honours the CPM dependencies and the working-time calendar. Mutates the model unless `{ dryRun: true }`; with `{ writeBack: true }` and a bound grid the moved tasks are pushed through the grid's edit surface.
toCSV(csvOpts?: { dates?: boolean }): stringExport the scheduled tasks as CSV; `{ dates: true }` writes ISO dates.
toMSPDI(xmlOpts?: { hoursPerDay?: number; projectName?: string }): stringExport the current plan as Microsoft Project (MSPDI) XML (BACKLOG-0000950): tasks, dependencies, constraints, baseline, resources and assignments, plus the working-time calendar, serialised with the computed schedule.
rows{The live consumer surface, mirroring `grid.rows.apply`, so a Data Router can drive the Gantt like any other view. Keyed by the controller's rowKey. (read-only)
on(event: 'schedule' | 'error', fn: (payload: unknown) => void): () => void
off(event: 'schedule' | 'error', fn: (payload: unknown) => void): void
mount(container: unknown, options?: {Render the plan into a container as an SVG timeline (bars, dependency arrows, critical-path highlight, today line, non-working shading, milestones, progress). The view redraws when the schedule recomputes.
mountSplit(container: unknown, options?: {Mount the JOINED split view (BACKLOG-0000938): one continuous, row-aligned surface with a left task-grid panel — by default the Task Name tree with expand/collapse, start, finish, duration, assignee avatars and a circular % ring (BACKLOG-0001285), plus any host columns — and the right timeline, sharing a single vertical scroll so every grid row lines up exactly with its bar row. The timeline scrolls horizontally on its own. Composes the controller's schedule; makes no change to grid core. The plan is editable from BOTH panes (BACKLOG-0001280): every gesture `mount` has — pointer drag to move, drag on the right edge to resize, arrow-key move, Shift+arrow resize, `l` to link, Delete — works on the timeline here, and a `start`/`end`/`duration`/`progress`/`name` column in the left panel is inline-editable on a double-click. Both routes commit through the same `applyEdit` choke point, so `beforeTaskMove`, `beforeTaskResize`, `beforeProgressChange` and `beforeTaskEdit` stay the single veto whichever pane the edit came from. The three switches that govern it carry the same meaning and the same defaults as `mount`'s: `editable` (default true) turns every edit on or off, both panes at once; `keyboard` (default true) turns off the focusable bars, the arrow-key gestures and the ARIA announcements while leaving pointer editing alone; and `resizeZone` (default 6) is how many pixels in from a bar's right edge begin a resize rather than a move. `workload` adds the resource band beneath the plan (BACKLOG-0001281), which is display-only — it reports hours, it does not accept them.
captureBaseline(): Array<{ id: string; baselineStart: number; baselineEnd: number; baselineDuration: number }>Capture a baseline (planned) snapshot of the current schedule as HOST data (this does not mutate the tasks). Store it and feed it back as `baselineStart`/`baselineEnd` task fields to get variance and ghost bars.
earnedValue(evmOpts?: { statusDate?: number | string | Date; costField?: string; actualCostField?: string }): GanttEarnedValueCompute earned-value (EVM) metrics for the current plan at a status date (BACKLOG-0000958): PV/EV/AC and the derived SV/CV/SPI/CPI per task, rolled up to summaries and the project. Budget (BAC) is the task's `cost`, or its duration when no cost is given; AC comes from `actualCost`.
unmount(): voidDetach the mounted view, if any. The host still owns the container.
viewunknownThe mounted view, or null. (read-only)
destroy(): void

GanttConflict

An unhonourable scheduling constraint, reported rather than obeyed.

MemberTypeDescription
idstring
typestring
atnumber | null
earliestFeasiblenumber

GanttDependency

A typed dependency between two tasks (by id), with optional lag/lead. `type` defaults to `'FS'`; either endpoint may be a leaf or a summary. `type` also accepts the MS Project string shorthand — `'FS+2'`, `'SS-1'` (BACKLOG-0001072). It is normalised to the structured form on the way in, so `gantt.dependencies` always reads back `{ type, lag }` and there is no second internal representation. Giving both a shorthand lag and a conflicting `lag` field warns; the explicit field wins.

MemberTypeDescription
fromstring | number
tostring | number
typeGanttLinkType | `${GanttLinkType}${'+' | '-'}${number}`(optional)
lagnumber(optional)

GanttEarnedValue

The earned-value result at a status date (BACKLOG-0000958).

MemberTypeDescription
okboolean
error{ code: string; message: string }(optional)
statusDatenumberThe status date the metrics were evaluated at (day-number). (optional)
byTaskMap<string, GanttEarnedValueRow>Every task keyed by id (leaf, summary and derived). (optional)
rowsGanttEarnedValueRow[]The same rows in schedule order. (optional)
projectGanttEarnedValueRowThe project total, rolled up as money sums of the leaves. (optional)

GanttEarnedValueRow

Earned-value metrics for one task or the whole project (BACKLOG-0000958).

MemberTypeDescription
idstring
namestring
isSummaryboolean
isMilestoneboolean
percentCompletenumber | null
hasBaselinebooleanWhether a baseline (not the fallback scheduled window) drove PV.
hasActualCostbooleanWhether any actual cost fed AC (else AC/CV/CPI are null).
bacnumberBudget at completion (the task's cost, or its duration when no cost).
pvnumberPlanned Value (BCWS): budgeted cost of the work scheduled by the status date.
evnumberEarned Value (BCWP): budgeted cost of the work performed (BAC × %complete).
acnumber | nullActual Cost (ACWP): what the work performed actually cost, or null.
svnumberSchedule Variance (EV − PV); positive is ahead of schedule.
cvnumber | nullCost Variance (EV − AC); positive is under budget; null without AC.
spinumber | nullSchedule Performance Index (EV / PV); null when PV is zero.
cpinumber | nullCost Performance Index (EV / AC); null without AC or when AC is zero.

GanttLevelResult

The result of resource leveling: the shifted tasks and what moved (BACKLOG-0000948).

MemberTypeDescription
okboolean
resolvedboolean(optional)
tasksGanttTask[](optional)
scheduleGanttSchedule(optional)
movesArray<{ id: string; from: number; to: number; delay: number }>(optional)
remainingGanttOverAllocation[](optional)
error{ code: string; message: string }(optional)

GanttMSPDIModel

The model {@link importMSPDI} returns and {@link exportMSPDI} takes.

MemberTypeDescription
tasksGanttTask[]
dependenciesGanttDependency[](optional)
resourcesGanttResourceSpec(optional)
projectStartnumber | string | Date(optional)
calendarGanttCalendar | null(optional)
scheduleGanttSchedule(optional)

GanttOverAllocation

A resource booked beyond its capacity across concurrent tasks (BACKLOG-0000948).

MemberTypeDescription
resourcestring
capacitynumber
startnumber
endnumber
loadnumber
taskIdsstring[]

GanttResourceLoad

The per-resource load and the over-allocations across a schedule (BACKLOG-0000948).

MemberTypeDescription
okboolean
resourcesArray<{ resource: string; capacity: number; peak: number; segments: GanttResourceSegment[] }>
overAllocationsGanttOverAllocation[]
byResourceMap<string, { capacity: number; peak: number; segments: GanttResourceSegment[] }>

GanttResourceSegment

One contiguous load segment for a resource: how many units are booked over a span.

MemberTypeDescription
startnumber
endnumber
loadnumber
taskIdsstring[]

GanttSchedule

A CPM schedule result: per-task dates/float and the critical path, or an error.

MemberTypeDescription
okboolean
error{ code: string; message: string; cycle?: string[] }(optional)
tasksMap<string, GanttScheduledTask>(optional)
orderstring[](optional)
criticalstring[](optional)
criticalPathsstring[][](optional)
projectStartnumber(optional)
projectFinishnumber(optional)
projectDurationnumber(optional)
conflictsGanttConflict[]Constraints a predecessor made infeasible (empty when all are satisfied). (optional)
calendarbooleanWhether a working-time calendar was applied. (optional)
overAllocationsGanttOverAllocation[]The resource over-allocations for this schedule (BACKLOG-0000948). (optional)
resourceLoadGanttResourceLoadThe full resource-load report for this schedule (BACKLOG-0000948). (optional)

GanttScheduledTask

The computed CPM values for one task (a leaf is scheduled, a summary derived).

MemberTypeDescription
idstring
namestring
durationnumber
esnumber
efnumber
lsnumber
lfnumber
totalFloatnumber
criticalboolean
percentCompletenumber | null
parentstring | null
isSummaryboolean
isMilestoneboolean
childrenstring[]
baselineStartnumber | nullThe planned (baseline) window, present only when the task carries a baseline. (optional)
baselineEndnumber | null(optional)
startVariancenumber | nullVariance vs the baseline (actual − planned, day-numbers); a positive value is a slip. (optional)
finishVariancenumber | null(optional)
durationVariancenumber | null(optional)

GanttTask

A task in a Gantt plan. Give a `duration` or a `start`+`end` (a day-number, ISO date string or `Date`; one is derived from the other). `milestone: true` (or `duration: 0`) is a zero-duration point. `parent` nests a task under a summary, whose window and progress are DERIVED from its children. `baselineStart`/`baselineEnd` (host-stored) drive planned-vs-actual variance; `constraint` pins or pulls the task; `assignee` and `height` feed the split view's grid panel.

MemberTypeDescription
idstring | number
namestring(optional)
startnumber | string | Date(optional)
endnumber | string | Date(optional)
durationnumber(optional)
percentCompletenumber(optional)
milestoneboolean(optional)
parentstring | number(optional)
baselineStartnumber | string | Date(optional)
baselineEndnumber | string | Date(optional)
baseline{ start?: number | string | Date; end?: number | string | Date }(optional)
constraintGanttConstraintType(optional)
constraintDatenumber | string | Date(optional)
assigneestring | string[](optional)
assigneesstring[](optional)
ownerstring(optional)
assignmentsArray<{ resource?: string; name?: string; id?: string; units?: number }>Explicit resource assignments with fractional units (BACKLOG-0000948): `units` is a multiplier where 1 is a full-time booking. Use this when a task books a resource at less (or more) than 100%; a bare `assignee` is `units: 1`. (optional)
worknumber | Array<{ date: number | string | Date; hours: number }>The task's effort, in one of two forms (BACKLOG-0001281/1282). A **number** is the task's TOTAL hours; the workload band divides it between the assignments in proportion to their units and spreads each share evenly over the working days the task spans. (`hours` is accepted as the same field under its other common name.) An **array** is an explicit per-day contour — what a planner types into a workload cell — and states each day's hours itself: the task's total is the sum of the entries, nothing is spread, and the contour is authoritative for the span, so `applyEdit` derives the task's `start` and `duration` from its first and last day. An EMPTY array means "no hours booked", which is how clearing every bucket is expressed without reviving the even spread. A bar move re-times the contour onto the new days unchanged; a resize stretches it across the new span at the same daily levels. `date` is an ISO date, a `Date` or a plan day-number; the module writes ISO dates back. (optional)
prioritynumberLeveling priority: a higher value is delayed last (default 0). (optional)
heightnumberAn explicit row height (px) for the split view; applied to both panels. (optional)
costnumberThe budgeted cost (BAC) for earned-value analysis (BACKLOG-0000958). When omitted the task's duration is used as the budget, giving schedule-only EVM. (optional)
actualCostnumberThe actual cost incurred (ACWP) for earned-value analysis (BACKLOG-0000958). Left out, the task's cost variance/CPI are `null`. (optional)

GanttViolation

A placement violation flagged by `findViolations`.

MemberTypeDescription
idstring
placedStartnumber
earliestStartnumber
bynumber

GeoPack

An optional geometry pack for a geomap, as one of the `modules/geo-*` packages exports (BACKLOG-0001321). Generated at build time from a named public source; `source`, `licence` and `attribution` record where the geometry came from and what its licence requires. A single-layer pack carries `topology` directly; a multi-layer pack (the UK) carries `layers` instead, keyed by layer name, each with its own `topology`.

MemberTypeDescription
idstring
titlestring
kindstring
projectionChartSpec['projection'](optional)
projectionOptionsChartSpec['projectionOptions'](optional)
source{ name: string; url: string; version: string; retrieved: string }
licence{ name: string; url: string }
attributionstringThe attribution line the licence requires, verbatim, or `''` when it asks for none.
topologyobject(optional)
layersRecord<string, { name: string; topology: object }>(optional)
defaultLayerstring(optional)

Grid

MemberTypeDescription
rowsRowsApiThe data: reading it, changing it, walking it. (read-only)
columnsColumnsApiThe columns: order, width, visibility, grouping and pivoting. (read-only)
selectionSelectionApiWhat is selected, and the range the user has marked. (read-only)
filtersFiltersApiThe filter tree, however it was set. (read-only)
sortSortApiThe sort, in priority order. (read-only)
editEditApiEditing sessions: starting, committing and cancelling them. (read-only)
scrollScrollApiWhere the viewport is, and moving it. (read-only)
exportExportApiCSV, Excel and clipboard. (read-only)
importImportApiBringing rows in from CSV/TSV text, a file, the clipboard or a drop. (read-only)
stateStateApiEverything the user arranged, as a serialisable object. (read-only)
overlayOverlayApiThe loading, empty and error surfaces drawn over the grid. (read-only)
historyHistoryApiUndo and redo over edits and structural changes. (read-only)
viewsViewsApiSaved arrangements the user can switch between. (read-only)
diffDiffApiWhat changed against a baseline, cell by cell. (read-only)
permissionsPermissionsApiWho may see, edit and export what. (read-only)
aiAiApiA machine-readable description of the grid, for a model to read. (read-only)
messagesMessagesApiTranslation: the catalogue and the active locale. (read-only)
licenceLicenceApiLicence state, and setting a key after construction. (read-only)
paginationPaginationApiPages, where the grid is paged rather than scrolled. (read-only)
highlightHighlightApiTransient emphasis on a row, column or cell. (read-only)
findFindApiIn-grid find: locate text without filtering, and step through the matches. (read-only)
redactionRedactionApiValues hidden from view and from export. (read-only)
capture(opts?: CaptureOptions): Promise<Blob>An image of the grid as drawn, where the module is installed. (optional)
annotateAnnotationApiDrawing over the grid, where the module is installed. (optional)
presentationPresentationApiFull screen, scaling and chrome suppression. (read-only)
pivotViewPivotViewApiExpand and collapse the pivot presentation's axes; the state a view carries. (read-only)
updatesUpdatesApiThe live feed: pausing it, flushing it, and what it has done. (read-only)
timelineTimelineApiReplaying the changes the grid has seen. (read-only)
crossFilterCrossFilterCross-filtering, a derived grid filtering the grid it derives from. (read-only)
facetsFacetsApiHeader distributions, and the filters clicking one creates. (read-only)
detailDetailApiThe expandable panel beneath a row. (read-only)
commentsCommentsApiThreads attached to rows and cells. (read-only)
presencePresenceApiWho else is looking, and where. (read-only)
diagnosticsDiagnosticsApiWhat the grid is doing, for when it is doing it slowly. (read-only)
statisticsStatisticsApiReductions, profiles, correlations, capability and intervals. (read-only)
formattingFormattingApiFormatting a value as the grid would, outside a cell. (read-only)
validationValidationApiDeclarative column validation: why a write was refused, and clearing marks. (read-only)
maximiseMaximiseApiFull-screen control, where it is enabled. (read-only, optional)
elementHTMLElement | nullThe element you passed to `createGrid`, not the grid's own root. The grid builds its `.lattice` root *inside* that element, so `el.closest('.lattice')` never matches this, and a theme attribute set on it has no effect, the theme is read from the root within. Use `element.querySelector('.lattice')` for the grid's own root. (read-only)
destroyedbooleanWhether `destroy` has run. Every other member is inert afterwards. (read-only)
readybooleanFalse until the first render has been laid out. (read-only)
config(): GridConfigThe resolved configuration, as one object.
setAll(values: Partial<GridConfig>): voidApply several configuration changes as one update rather than several.
on(event: EventName, handler: EventHandler): UnsubscribeListen. Returns the function that stops listening.
once(event: EventName, handler: EventHandler): UnsubscribeListen until it fires once.
off(event: EventName, handler: EventHandler): voidStop listening.
emit(event: string, payload?: Record<string, unknown>): voidRaise an event of your own on the grid's bus.
setPinnedRows(rows: unknown[], opts?: { edge?: 'top' | 'bottom' }): voidPin rows above or below the scrolling body. The rows render through the ordinary column pipeline but are not part of the data: not counted, sorted, filtered, grouped, selectable or exported. Pass a new array rather than mutating the one you passed before: array identity is how the grid knows the pinned rows have changed.
getPinnedRows(opts?: { edge?: 'top' | 'bottom' }): unknown[]The objects currently pinned at one edge, as a copy.
formRowFormApiThe row form. Declines when `rowForm` is not configured. (read-only)
iconsIconRegistryApiThe grid's icon registry, read-only. The same sprite set `registerIcon` writes to and every cell paints from, reachable from the grid instance so that code outside the grid bundle — an optional module drawing its own glyph, a network chart putting a `router` on a node — draws from the one registry rather than a second, empty copy of it. Register with `registerIcon` or `config.icons`, as before. (read-only)
getVersion(): stringThe library version.
destroy(): voidRelease everything: listeners, timers, workers and the DOM the grid made.

GridConfig

MemberTypeDescription
columns(Column | ColumnGroup)[]The columns, in order. A group nests columns under one heading. (optional)
columnGroupsColumnGroup[]Header groups declared separately from the columns they contain. (optional)
rowsunknown[]The data, for a memory grid. Use `source` for anything fetched. (optional)
rowKeystring | string[] | ((row: unknown) => string | string[])What identifies a row. Everything that survives a refresh (selection, expansion, and edits in flight) is keyed on it, so it must be stable and unique. A derived grid defaults to its own derived key. Three shapes: a field name (`'id'`, dot paths allowed); an array of field names, joined into one composite key (`['tenantId', 'circuitId']`); or a function of the row (`row => \`${row.tenantId}#${row.circuitId}\``), itself allowed to return an array to the same effect. (optional)
sourceSourceConfigWhere rows come from: memory, paged, remote, stream or derived. (optional)
ingestIngestConfigHow rows are ingested into the column store. (optional)
columnDefaultsColumnApplied to every column before its own settings. (optional)
columnPresetsRecord<string, Column>Named bundles of column settings, referenced by a column's `preset`. (optional)
dataTypesRecord<string, DataType>Your own data types, alongside the built-in catalogue. (optional)
sampleSizenumberValues sampled per undeclared column when inferring its type. Default 100. (optional)
targetSize'default' | 'large'Raise every interactive target to a comfortable size for touch, without changing the type. `'large'` asks for it; `'default'` opts out of the coarse-pointer rule that would otherwise apply it. (optional)
componentsRecord<string, RendererCtor | EditorCtor | FilterCtor>Your own renderers, editors and filters, registered by name. (optional)
pipesRecord<string, (value: unknown, ...args: string[]) => string>Named text transforms usable from a format mask or a template. (optional)
totalFnsRecord<string, TotalFn>Your own reductions, alongside the built-in ones. (optional)
variantsRecord<string, VariantDefinition>Named appearance variants a row or cell can be switched into by a rule. (optional)
iconsRecord<string, IconDefinition>Your own SVG glyphs, registered by name before the first paint. The same registry `registerIcon` writes to and every cell, header control, rail button and chart glyph is painted from, so a name given here is usable anywhere a glyph name is: a column's `icon` decoration, a rail action's `icon`, a network node's `icon`. Registering a built-in name overrides it. Read the result back through {@link Grid.icons}. (optional)
treeTreeConfigHierarchical rows: where the parent link or the path lives. (optional)
detailDetailConfigThe expandable panel beneath a row. (optional)
selectionSelectionConfig | 'single' | 'multiple' | 'none'What the user may select, and how selection behaves across groups. The `'none'` shorthand is `{ mode: 'none' }` and behaves identically: no row selection, and no cell ranges or fill handle either. (optional)
editEditConfig | booleanEditing, and how a change is committed and validated. (optional)
paginationPaginationConfig | booleanPage the rows rather than scrolling them. (optional)
localestring(optional)
direction'ltr' | 'rtl' | 'auto'Writing direction. Omit it, or say `'auto'`, to settle it from the element's own computed `dir` and then from `locale`: `ar`, `he`, `fa` and the rest resolve to `rtl`. In a right-to-left grid the logical alignments `start`/`end` mirror while the physical `left`/`right` do not (see {@link Align}). (optional)
timeZonestringIANA zone every date column formats in, e.g. 'Europe/London' or 'UTC'. Omit to use each viewer's own zone. A column's own `format.timeZone` wins. (optional)
themeThemeThe visual theme. (optional)
densityDensityRow height and padding as a named step, rather than pixel by pixel. (optional)
gridLinesboolean | 'both' | 'horizontal' | 'vertical' | 'none' | 'rows' | 'columns'Which rules are drawn between cells. `'both'` by default. The two axes are separate decisions: horizontal rules help the eye track along a row, vertical ones stop adjacent values running together. `false` or `'none'` draws neither. Only the rules *between data* are affected, the header's underline, the pinned seams and the totals separator are structure, not grid lines. (optional)
cornerRadiusboolean | number | stringRound the grid's outer corners. Square by default. `true` adopts the theme's own radius; a number is pixels; a string is used as written, so a host can pass its own token or a relative unit. (optional)
stripedRowsbooleanShade alternate data rows (zebra striping). Off by default, and strictly opt-in: an existing grid must look exactly the same on upgrade. When `true`, every other data row takes the theme's `--lattice-surface-alt` background, which every palette already defines, so dark, high-contrast and terminal stripe correctly without extra work. Parity follows the row's *logical* index, not its position in the DOM, so a row keeps its stripe across a scroll even though the rows are recycled. Structural rows — group headings, group footers and the grand total — are never striped, and both selection and hover still win over the stripe. (optional)
verticalAlignVAlignVertical alignment of cell content within a row, as a default for every column (BACKLOG-0000989). `top`, `middle` or `bottom`; a column's own `verticalAlign` overrides it for that column. The horizontal counterpart is the per-column `align`. Omitted, the grid keeps its historical placement — content centred in a fixed-height row and top-aligned in an `autoHeight` row — so an existing grid is unchanged on upgrade. Setting a value aligns every column uniformly, including `autoHeight` rows, unless a column opts out. (optional)
tooltipTooltipConfigDefaults for the rich cell tooltip (BACKLOG-0001204). The tooltip itself is declared per column, on `cell.tooltip`; this only carries the settings that are a house style rather than a per-column decision. It switches nothing on: a column with no `cell.tooltip` has no tooltip whatever is set here. (optional)
scrollbarsScrollbarMode | { x?: ScrollbarMode; y?: ScrollbarMode }How the scroll viewport's scrollbars are drawn (BACKLOG-0000990, BACKLOG-0001288). `'auto'` (the default) is the platform's native behaviour, where overlay scrollbars fade when idle. `'always'` keeps that native bar shown whether or not the pointer is over the grid. `'custom'` makes the grid draw its own bar on each axis instead — always visible, the same in every browser, and sized by `--lattice-scrollbar-size` / `--lattice-scrollbar-thumb-min` rather than by the platform. Scrolling itself is unchanged in every mode. The object form controls each axis on its own — `{ y: 'always' }` pins the vertical bar while the horizontal one stays native. Note that `'custom'` on one axis hides the native bar on both, because no browser offers per-axis control of that; the grid warns once if the two axes disagree. Omitted, the grid is unchanged on upgrade. (optional)
columnTagFilterboolean | { multiple?: boolean; label?: string }Show a bar above the column headings for filtering columns by tag. Off by default, and it draws nothing unless some column carries a `tags` entry. `multiple: true` lets more than one tag be chosen at once. Only tagged columns are ever hidden, so an untagged account or total column stays visible whatever is selected. (optional)
anomalySummaryboolean | { column?: string; label?: string }Show a small chip in the grid chrome that reads how many rows an anomaly shadow column has flagged, and filters the grid to exactly those when it is clicked (BACKLOG-0000799). Off by default, and it draws nothing unless a column declares a `shadow: { kind: 'anomalyFlag' }`. The count and the filter both read that one shadow column, so the number on the chip is the number of rows the click reveals. `column` names the base column to summarise when more than one anomaly-flag shadow is present; `label` overrides the chip's wording. (optional)
typeOptionsRecord<string, {Per-column options a data type reads. `ratio` and `percentRate` use `{ weight }` to name the column their average is weighted by. A unit type reads `{ significantFigures }` to render to a fixed precision rather than a fixed number of decimals. (optional)
rowTemplatestring | {(optional)
galleryboolean | {Present rows as a gallery of tiles (§7.12). The tiled card layout with a size-driven column count: tiles as wide as `tileWidth` allows, as many across as the container holds, laid out by the same 2-D virtualisation the grid already runs. `true` draws a tile per row generated from the columns; an object sizes them or supplies a template. Presentation only — sort, filter, group and the data pipeline are unchanged. (optional)
recordCardboolean | {Present each row as a record card — a form of label/value pairs (§7.11). For a screen where reading one record matters more than comparing many. `true` draws a card per row generated from the columns, each column a labelled line in display order, showing the same text the table shows. An object supplies a template or sizes the card. A card list underneath, so it inherits the virtualisation and every interaction a card carries. Presentation only — sort, filter, group and the data pipeline are unchanged. (optional)
boardboolean | {Present rows as a board — a kanban of grouped lanes of cards (§7.14). The top-level group becomes a lane and every leaf under it becomes a card stacked in that lane: a pipeline by stage, a task list by status, a backlog by owner. `true` draws a card per row generated from the columns; an object sizes the lanes and cards or supplies a template. Group the grid to give the board its lanes; an ungrouped board is a single lane of every card. A card is drawn through the same code the other card presentations use, so a board card is still a row: it clicks, selects and drags through the grid's own handlers, masks protected columns, and shows the same text the table shows. Both axes are virtualised — the lanes across and the cards down each — so a board of many long lanes renders only what is on screen. Presentation only: sort, filter, group and the data pipeline are unchanged. (optional)
pivotViewboolean | {Present the grid as a pivot — a cross-tab drawn as a matrix (§10, BACKLOG-0000738). The row dimensions (the grid's `group`) go down the left gutter, the column dimensions (the grid's `pivot`) go across the top, and each totalled column fills a cell with its reduction. `true` draws the matrix with the default geometry; an object sizes the cells and gutter or names the breakpoint below which it degrades to cards. **The numbers are the grid's own.** Every cell — body, subtotal, grand total — is the same aggregate kernel the totals row uses, run over the rows that feed the cell, so a pivot subtotal equals the grid's group total for that set by construction rather than being re-derived. Both axes expand and collapse, both are virtualised, and a cell click emits `pivot:drill` with the keys of the contributing rows. **Narrow-screen fallback.** A matrix cannot be read on a phone, so at or below `maxWidth` (the container width, not the viewport) the pivot degrades to a card list — the record card by default — exactly as the table does under `responsive`. Presentation only: sort, filter, group, pivot and the data pipeline are unchanged. (optional)
responsive{Present rows as cards when the grid's container is too narrow to be a table honestly, a phone, or a narrow panel on a wide screen. Measured on the container, not the viewport, so a grid in a sidebar collapses and a grid filling a small tablet does not. Sorting, filtering and export continue to work; the tool panel is where they live when there are no column headings to click. Emits `presentation:changed`. (optional)
rowFormboolean | {(optional)
showColumnFunctionsbooleanDraw the sort, filter and menu controls in the column headings. `true` by default. `false` leaves each heading as its label alone, which is what a dense grid wants: three affordances take roughly fifty pixels, and on an eighty-pixel column that leaves the heading nothing and the label disappears entirely. Only the furniture goes. Sorting, filtering and the column menu are still reachable through the API, the keyboard and the tool panel. (optional)
headerControls'hover' | 'always' | 'hidden'When the per-column header controls — the sort arrow, the filter funnel and the menu button — are shown, as a default for every column (BACKLOG-0000982). - `'hover'` (the default) reveals them when the heading is hovered or a keyboard user focuses into it, which is the historical behaviour: a wide header does not read as a row of identical icons. - `'always'` keeps them visible unconditionally, for a grid where the controls are the point and the discoverability of hover is not wanted. - `'hidden'` draws none of them, for a clean read-only heading; they leave the tab order with the elements that carried them. An active filter and a live sort are still reflected by the heading's state attributes, but no control furniture is built. A column's own `headerControls` overrides this default for that column. Distinct from `showColumnFunctions: false`, which also drops the furniture but keeps sorting, filtering and the menu reachable from the keyboard; `'hidden'` is the read-only choice that removes them outright. (optional)
rowHeightnumber | ((row: Row) => number)Row height in pixels, or a function of the row. A function makes the grid measure rather than assume, which costs a pass over what is on screen: worth it for wrapped text, wasteful for a uniform grid. (optional)
titlestringA caption for the grid, drawn above the column headings. Inside the grid rather than an element the host places above it: a title outside does not scroll with the grid, is not in the region a screen reader announces, and is left behind by image capture and print. (optional)
showHeaderbooleanDraw the column headings at all. `true` by default. `false` removes the row, and removes it from the accessibility tree rather than only from view, a heading a screen reader still announces is invisible, not hidden. What a small dashboard tile wants when its `title` already says what the panel is. Distinct from `showColumnFunctions`, which keeps the headings and drops only the sort, filter and menu controls inside them. (optional)
headerHeightnumberHeader height in pixels. Omitted, the header takes its height from the density-scaled `--lattice-header-height` token, so `density` sizes the header as it sizes the rows. A number names one explicitly and outranks the token. (optional)
overscannumberHow many rows to render beyond the viewport. More costs memory and smooths fast scrolling; fewer is lighter and can show a gap. (optional)
autoHeightboolean | 'visible'Size rows to their content rather than to the density token. Only rows that are actually rendered are ever measured, in both settings: the grid does not lay out rows you cannot see. The difference is what happens on a large grid: `true` gives up above ten thousand rows and falls back to fixed heights, because a cumulative offset array being patched as you scroll a million rows is not worth the result. `'visible'` keeps measuring at any size, accepting that the scrollbar shifts as rows are measured on the way past. The name is historical and reads as though it were about which rows are measured; it is about whether the ceiling applies. (optional)
stateGridStateSort, filters, grouping, widths and the rest, restored at construction. Takes precedence over a saved view flagged `isDefault`: when both are present, this wins outright and the default view is never applied — the active view id stays `null`. (optional)
licencestringYour licence key. Without one the grid renders in full and watermarks off localhost. (optional)
maximisebooleanOffer a full-screen control. (optional)
formulaFunctionsRecord<string, (args: unknown[]) => unknown>Extra functions a formula may call, on top of the built-in library. (optional)
allowUnsafeTemplatesbooleanPermit raw HTML from a template without sanitising it. Off, and worth leaving off: a template usually interpolates data, and data is where injected markup arrives from. (optional)
updates{Caps on the change log behind `grid.updates` and `grid.timeline`. Two caps, because an entry is not a fixed size: `logLimit` bounds how many changes are kept (default 2000) and `logRows` bounds the rows they account for between them (default 100,000). A feed delivering large batches reaches the second long before the first, and without it the log is unbounded in bytes while looking bounded in entries. (optional)
commentsCommentConfigThreaded comments on individual cells. Requires a stable `rowKey`: comments outlive the values they annotate, and index identity would reattach every thread on the next sort. (optional)
presencePresenceConfigCollaborative presence. A display feature over a transport the grid does not own; without a provider it is inert. (optional)
environment() => Record<string, unknown>Host environment for a support bundle. Supplied by the DOM layer; core cannot read `navigator` or `window` itself. (optional)
facetsFacetConfig | booleanColumn header histograms and the filters clicking them creates. Off by default: the band roughly doubles header height, which is a cost no grid should pay without asking. Per-column settings layer over these. (optional)
hostFilter{ active(): boolean; passes(row: Row): boolean }A filter your application owns, applied alongside the grid's own and invisible to its filter UI. (optional)
contextunknownAnything of yours, passed untouched to renderers, editors and sources. (optional)
workerThresholdnumberRow count above which eligible work is computed in a Worker: column distributions, and a portable sort (a built-in collation with no custom comparator). Below it, everything runs on the main thread. (optional)
useWorkerbooleanCompute eligible work off the main thread: column distributions, and a portable sort above {@link GridConfig.workerThreshold} (a re-sort recomputes off-thread while the grid keeps showing the prior order, then swaps to the new one when it lands). Filtering and grouping still run on the main thread. (optional)
workerUrlstringWhere to load the worker kernel from, when hosting it yourself. (optional)
sharedMemorybooleanUse a shared buffer for the worker, where the page's headers allow it. (optional)
groupFooterbooleanA totals line at the foot of each group as well as the grid. (optional)
groupRenderer(params: GroupRowParams) => string | Node | voidDraw the group row yourself. The grid's own group row is an expander, a label and a count. A host that needs more — a section header with a points rollup, a done/total count and a progress bar — supplies this instead, and owns the whole row: it is drawn as one band across every column, and no ordinary cells are mounted for it. Return an HTML string, or a node, or write into `params.element` and return nothing. Unlike `fullWidth.render`, a string here **is** inserted as markup, on the same footing as the board's `cardRenderer`: this is your own template for a row the grid synthesised, not a value out of your data. The chevron is yours to draw and yours to wire: give any element in your markup `data-lat-group-toggle` and a click on it expands or collapses the group, or call `params.toggle()` from a node you built yourself. (optional)
groupDefaultExpandedboolean | number | ((group: GroupInfo) => boolean)Which groups start expanded, before anyone has opened or closed one. `true` (the default) opens every group, `false` closes every group, a number opens the first N levels (`0` closes everything, a negative opens every level), and a predicate answers per group — the current sprint's section open while the rest start closed. Only ever consulted for a group nobody has touched: once the user or your code expands or collapses one, that decision stands. (optional)
grandTotalRowboolean | 'bottom'Where the grand total goes. `true` adds it as the last display row, counted by `rows.count()` like any other. `'bottom'` pins it beneath the viewport instead, so it stays in view while the rows scroll and is *not* part of `rows.count()`. Omitted or `false` means no grand total row. (optional)
pinnedTopRowsunknown[]Rows pinned above the scrolling body. The objects are rendered through the ordinary column pipeline but are not part of the data: not counted by `rows.count()`, not sorted, filtered, grouped, selectable or exported. Use it for a totals line or a units row that must stay against the header. (optional)
pinnedBottomRowsunknown[]Rows pinned below the scrolling body. As `pinnedTopRows`, at the other edge. (optional)
fullWidth{Rows drawn as a single band across every column instead of being divided into them, a section banner, a note, a "load more" affordance. `when` picks the rows; `render` fills them. A full-width row is still one of your data rows: counted by `rows.count()`, sorted, filtered and exported like any other. Only its presentation changes. For a row that should *not* be part of the data, use `pinnedTopRows`. (optional)
totalFilteredOnlybooleanTotal what the filters left rather than the whole set. (optional)
totalOnlyChangedColumnsbooleanOn a change, recompute only the totals whose column moved. (optional)
showTotalInHeaderbooleanPut the total in the header rather than a footer row. (optional)
aggregateChooserbooleanLet the user pick a column's reduction from the column menu. On, the totalling entry becomes an "Aggregate" submenu offering the aggregates the column's type says are meaningful (§9.4); off, the menu keeps its plain "Total this column" toggle. Off by default, so an existing grid is unchanged. (optional)
columnVirtualisationAbovenumberRender only the visible columns once there are more than this many. (optional)
statusBarboolean | { panels?: string[] }The bar beneath the grid, and which panels it carries. (optional)
contextMenuboolean | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void)The cell right-click menu. A function supplies custom items; `false` suppresses it entirely, which is what a read-only grid wants, the default menu offers Paste, Clear and Fill down. (optional)
importboolean | ImportSettingsBringing rows in from a file, the clipboard or a drop (§14, the mirror of export). `true` adds a "Import rows from CSV…" item to the cell menu, makes the grid a drop target for `.csv`/`.tsv` files, and reads a pasted spreadsheet block, each opening a preview the user confirms. An object tunes the affordances. Off by default; the `grid.import` API is always present. Import is a client-side data operation, so it applies to a memory grid. (optional)
rowDeletebooleanEnable the built-in row-delete gesture (§18.4, BACKLOG-0000968) — the Delete/Backspace key on selected rows and a "Delete row" cell-menu item — and the `grid.edit.deleteRows` API. Off by default, because deleting data on a keystroke is destructive and opt-in. Every deletion flows through the cancellable `beforeDelete` event, so a handler can confirm or veto it, on a memory-source grid as well as a remote one. (optional)
columnMenuboolean | ((p: ColumnMenuParams, defaults: MenuItem[]) => MenuItem[] | void)The header's 3-dot menu, and the right-click menu on a column heading. `false` suppresses both. A function supplies custom items, receiving the grid's own so it can add to them rather than reproduce them. Default true. (optional)
rangeChartChart a selected cell range — the spreadsheet "chart this selection" gesture. Off by default, so a grid opts in. The DOM layer draws no charts itself — the charts module is optional and loaded by the host — so this is where the host wires the two together: a function, or an object carrying `onChart`, is called with the grid and the selected range when the reader chooses "Chart selection" from the cell menu. The handler typically calls `chartRange` from `lattice-grid/modules/charts`. `true` offers the item and emits nothing extra; supply a handler to have it actually draw. (optional)
shortcutsbooleanThe `?` keyboard shortcut overlay. `false` suppresses it, for a host that wants `?` for itself. Default true. (optional)
findboolean | FindConfigThe in-grid find bar (BACKLOG-0001018): Ctrl+F / Cmd+F with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; Enter and Shift+Enter step through the matches. `false` removes the bar and its shortcut; the `grid.find` API still works. Default true. (optional)
rowReorderboolean | { column?: string }Let a user reorder rows by dragging a handle, or with Alt+Shift+Up/Down. `true` puts the handle in the first visible column; `{ column }` names a different one. The move reorders your data and emits `row:moved`; persisting it is yours, and `rows.data()` afterwards is the new order. Refused, with a reason announced, while a sort, filter or grouping is active, the position a row is dropped at has no single meaning in the underlying order then. (optional)
rowTransferboolean | {Let rows be dragged out of this grid, into it, or both. Off by default: rows leaving a grid is a data change a host has to want, and a mis-drag that silently removed one has no gesture a user would think to undo. `send` and `receive` are both on when the option is present, so one-way is expressed by turning off the direction you do not want, a source grid is `{ receive: false }` and a target is `{ send: false }`. `mode: 'copy'` leaves the row where it was. `group` restricts exchange to grids sharing the same name, so two unrelated grids on a page do not accept each other's rows. The source needs `rowReorder` as well, since that is what draws the handle a drag starts from. (optional)
alignedGridsunknown[]Other grids to stay column-aligned with. Column widths, order, visibility and pinning are shared, and horizontal scrolling moves them together. Sort, filters, selection, grouping and the rows themselves stay independent: sharing those would make one grid with extra steps rather than two aligned ones. Declared on the grid created last, since it is the only one that can name the others; the link is peer-based once made. (optional)
stickyGroupHeadersboolean | number | { depth?: number }Keep the enclosing group headings pinned above the viewport while scrolling inside a group. Off by default — a deliberate product default; sticky group headers are opt-in. `true` turns it on, stacking at most two; a number, or `{ depth }`, sets how many may stack: each costs a row of viewport, so a deep grouping would otherwise spend the screen describing itself. `false` is off, the same as leaving it unset. (optional)
highlightOnChangeboolean | string | {Flash a cell when its value changes. `true` takes the defaults; an object names a colour, a duration in milliseconds, or both. (optional)
formattingRecord<string, FormattingRule[]>Conditional formatting rules the grid holds as runtime state, keyed by column id or `'*'` for every column (spec 8.12). Seeds `grid.formatting`, which an end user can then change; the rules travel in saved views and undo like any other change. Config-time `cell.style` is unaffected. (optional)
rowClassstring | string[] | ((p: RowStyleParams) => string | string[])A class, or classes, for every row. Re-evaluated on each repaint. (optional)
rowStyleCellStyle | ((p: RowStyleParams) => CellStyle)Inline styles for every row. Camel-case or hyphenated property names. (optional)
toolPanelboolean | {(optional)
groupPanelboolean | {A drag-and-drop group-by strip above the column header — the pattern AG Grid calls the row-group panel. Drag a column heading into it to group by that column; the active groups show as removable, reorderable chips, and reordering the chips changes the nesting order. It is keyboard-operable (arrows navigate, Shift+arrow reorders, Delete ungroups, and an add control groups any column), and every change is announced through the live region, which is why it also addresses the drag-only complaint (BACKLOG-0000429). Off by default and non-breaking, matching `toolPanel`. It drives the same grouping model as `grid.columns.group()`; it reimplements nothing. (optional)
kpisArray<Omit<StatConfig, 'grid' | 'container'>>A built-in KPI/stat strip: a labelled band of {@link createStat} tiles the grid places for you, above the column header. Each entry is a stat spec — the same fields {@link StatConfig} takes, minus `grid` and `container`, which the grid supplies — so a strip tile and a hand-placed one are the same object. The tiles follow the grid's filters, recomputing on every change exactly as a stand-alone stat does. Off by default and non-breaking, matching `groupPanel`: no `kpis` means no band and no cost. It reuses `createStat` and reimplements no compute. (optional)
quickFilterTextstringThe quick filter's initial text. (optional)
permissionsPermissionPolicyPer-column read/write/hidden policy. A usability control, not a security boundary: hidden data is still resident in the store. Enforce the same policy server-side with `permittedColumns` / `permittedExport`. (optional)
diff{Prior state for diff and audit mode. (optional)
views{ storage?: { read(): unknown[]; write(views: unknown[]): void }; saved?: unknown[] }Saved views: a storage adapter and any pre-loaded views. (optional)
historyBarboolean | { element?: HTMLElement; timeline?: boolean }The undo toolbar. `element` mounts it into the host's own chrome. (optional)
ai{The AI skill layer. The grid makes no network call of its own: `ask` is the host's, and owns the model, the key and the privacy decision. (optional)
pivot{(optional)

GridEvent

MemberTypeDescription
typestring
origin'api' | 'user' | 'init' | 'ai'Who caused the action. `'ai'` (BACKLOG-0000967) tags a write an AI proposed and a human approved, applied through `grid.edit.setCells(writes, type, { origin: 'ai' })`; it fires the same cancellable `beforeEdit` gate a `'user'` edit does, so a host can policy-gate AI writes distinctly.
gridGrid

GridModule

MemberTypeDescription
namestring
versionstring(optional)
install(ctx: ModuleContext): void
uninstall(ctx: ModuleContext): void(optional)

GridState

MemberTypeDescription
versionnumber
columnsColumnState[](optional)
columnOrderstring[](optional)
columnGroupsColumnGroupState[]The banded-header tree, when the grid has one (BACKLOG-0000739). (optional)
filtersFilterSet(optional)
wherestring[]The `where` predicates that were in force, as names only (BACKLOG-0001202). A predicate is host code: it cannot be serialised into a view or restored from one. `apply` reconciles these against what the host has registered and reports every name it cannot honour rather than restoring a view that silently shows more rows than the one that was saved. Absent when none is registered. (optional)
quickstring(optional)
sortSortEntry[](optional)
groupstring[](optional)
pivot{ enabled: boolean; columns: string[] }(optional)
pivotView{ rowsCollapsed: string[]; columnsCollapsed: string[] }The pivot presentation's collapse state (§10, BACKLOG-0000738): which row-axis and column-axis nodes are collapsed. Absent when the matrix is fully expanded, and tolerated as "expand all" when applied. (optional)
formattingRecord<string, FormattingRule[]>(optional)
annotationsAnnotationMark[]Durable annotation marks (BACKLOG-0000813): seeded from here on first paint, and written back by `getState` so a host can persist and restore them. In content coordinates, so they track scroll and resize. (optional)
expandedstring[](optional)
selectionstring[](optional)
scroll{ top: number; left: number }(optional)
pagination{ page: number; pageSize: number }(optional)

GroupComparison

The result of {@link StatisticsApi.compareGroups}: how big *and* how sure, as data to interpret. Carries no significance verdict — the p-value is a number, never a flag or a badge.

MemberTypeDescription
test'welch' | 'mannWhitney' | 'chiSquare'The test used, named so it is never hidden.
chosenBy'auto' | 'override'Whether the test was chosen automatically or forced by the caller.
reasonstringWhy this test — the column family, a normality screen, or the override.
statisticnumberThe test statistic.
statisticNamestringWhat the statistic is: `t`, `U`, or `chiSquare`.
dfnumber | nullThe degrees of freedom, where the test has them; null for Mann-Whitney.
pValuenumberThe two-sided p-value, returned as data for the caller to interpret. Never thresholded into a verdict here.
intervalGroupDifferenceInterval | nullThe confidence interval on the difference, or null when there is none.
effectSizeGroupEffectSizeThe paired effect size, so the p-value is never read on its own.
nAnumberHow many rows the first group stood on.
nBnumberHow many rows the second group stood on.
groups[unknown, unknown]The two group values compared, as keys.
reliablebooleanFalse when either group is under the reliability floor.
methodstringThe test's method, named per the reference-suite honesty rule.

GroupDifferenceInterval

A confidence interval on the difference a two-sample test measured.

MemberTypeDescription
estimatenumberThe point estimate of the difference the interval is around.
lowernumber
uppernumber
confidencenumberThe level the bounds were computed at, 0 to 1.
methodstringThe method, named for honesty: `welch-t`, `hodges-lehmann`, `newcombe`.
categoryunknownFor a chi-square interval, which category's share the difference is of. (optional)

GroupEffectSize

The effect size paired with a two-sample test — the "how big" half.

MemberTypeDescription
namestringThe named measure: `pooledStandardMeanDifference` (Cohen's d) for the numeric tests, `categoricalTotalVariation` for chi-square.
valuenumber | nullThe effect size in its own terms, or null when it has no scale here.

GroupInfo

Which group `groupDefaultExpanded` is being asked about.

MemberTypeDescription
keystringThe group's key, the same string `Row.key` carries and `rows.expand` takes.
columnstringThe id of the column this level groups on. (optional)
valueunknownThe value this group stands for. (optional)
levelnumberDepth of the group. Zero is the outermost level. (optional)
pathstring[]The group path from the root down to this group. (optional)

GroupRowParams

What `groupRenderer` is handed.

MemberTypeDescription
rowRowThe group row itself.
keystringThe group's key, as `rows.expand`/`rows.collapse` take it.
columnstringThe id of the column this level groups on. (optional)
valueunknownThe value this group stands for.
levelnumberDepth of the group. Zero is the outermost level.
expandedbooleanWhether the group is currently open.
leafCountnumberHow many records sit beneath it, at any depth.
totalsRecord<string, unknown>The group's own reductions, by column id — whatever `total` asked for. (optional)
leaves(): Row[]The rows beneath this group, computed when you call it. A function rather than an array because a group is unbounded and this runs per paint: a host that only needs the count should read `leafCount` and never call this.
toggle(): voidExpand the group if it is closed, collapse it if it is open.
gridGrid
elementHTMLElementThe element to fill. Write into it directly, or return content instead.

Heteroscedasticity

The Breusch–Pagan heteroscedasticity test result.

MemberTypeDescription
statisticnumber
dfnumber
pnumber
heteroscedasticbooleanTrue when the test rejects homoscedasticity at the 0.05 level.

HighlightApi

MemberTypeDescription
clear(target?: { key?: string; colId?: string } | string): booleanClear one target, or every highlight when called with nothing.
list(): { scope: string; key: string | null; colId: string | null; colour: string; duration: number }[]
colourFor(key: string, colId: string): string | null

HistogramBin

MemberTypeDescription
fromnumber
tonumber
countnumber

HistoryApi

MemberTypeDescription
undo(): HistoryEntry | null
redo(): HistoryEntry | null
canUndo(): boolean
canRedo(): boolean
peek(direction?: 'undo' | 'redo'): HistoryEntry | nullWhat undo or redo would apply next, for labelling a button.
list(): HistoryEntry[]
transaction(label: string, fn: () => void): HistoryEntry | nullGroup everything `fn` does into one undoable step.
clear(): void

HistoryEntry

MemberTypeDescription
seqnumberMonotonic sequence number, in the order actions were recorded.
typestringWhat kind of action it was, e.g. `'sort'`, `'column:pin'`, `'edit'`.
labelstringHuman text for a button, e.g. `'sort by Region'`.
targetstring | nullThe column or row the action was aimed at, where there was one.
atnumberWhen it was recorded, on the high-resolution clock.
delegatedbooleanTrue when the edit model owns the undo rather than the history stack.
undonebooleanSet once the entry has been undone. (optional)

IconBand

One band of a threshold icon set. A value clears a band when it is at least `min`; the highest band it clears wins. Omit `min` on the last band to make it the catch-all. `label` is what assistive technology announces for the glyph, so a screen-reader user hears the band's meaning, not only the value.

MemberTypeDescription
minnumber(optional)
iconIconNameA glyph name from the icon registry (see {@link IconName}).
labelstring(optional)
variantVariantName(optional)

IconGlyph

One sprite: its view box, its path data, and how it is painted.

MemberTypeDescription
viewBoxstring
pathsstring[]
paint'stroke' | 'fill'

IconRegistryApi

Read access to the grid's icon sprite set (see {@link Grid.icons}).

MemberTypeDescription
get(name: string): IconGlyph | nullOne glyph, as a copy, or null when the name is not registered.
names(): string[]Every registered name, in registration order.

IconSetSpec

An icon set (BACKLOG-0000955): a glyph placed beside the value by the band it falls in. Drawn as a `background-image` with padding, so it too needs no extra element and stays a plain style value. `set` names a built-in — `'arrows'`, `'trafficLights'` or `'ratings'` (see {@link ICON_SETS}) — or supply your own ordered `icons` (SVG documents, data URIs or `url(...)` values). Bands are split at `thresholds` (ascending, one fewer than the icons); without them the column's distribution is cut into equal-count bands. `reverse` flips the order so a high value can read as red.

MemberTypeDescription
set'arrows' | 'trafficLights' | 'ratings' | string(optional)
iconsstring[]Your own glyphs, low value first: SVG documents, data URIs or `url(...)`. (optional)
countnumberHow many bands, where the set's size is not fixed (e.g. `'ratings'`). (optional)
thresholdsnumber[]Band edges, ascending; one fewer than the number of icons. (optional)
reversebooleanReverse the glyph order, so the highest band takes the first icon. (optional)
sizenumberGlyph height in pixels. Default 16. (optional)

ImportApi

Bringing rows in — the mirror of {@link ExportApi} (§14, BACKLOG-0000949).

MemberTypeDescription
preview(text: string, opts?: object): ImportPreviewParse delimited text into a preview, changing nothing.
csv(text: string, opts?: object): Record<string, unknown>[]Parse delimited text into coerced records — the inverse of `export.csv`.
previewXlsx(bytes: Uint8Array | ArrayBuffer, opts?: object): Promise<ImportXlsxPreview>Parse an `.xlsx` file's bytes into a preview, changing nothing (§14, BACKLOG-0000970). Async: the archive is inflated with `DecompressionStream`.
xlsx(bytes: Uint8Array | ArrayBuffer, opts?: object): Promise<Record<string, unknown>[]>Parse an `.xlsx` file's bytes into coerced records — the inverse of `export.excel`.
apply(Add or replace the grid's rows from text, a preview or records.

ImportColumn

One source column as understood by the importer, after type inference (§14).

MemberTypeDescription
sourcestringThe heading as written in the file.
indexnumberThe column's position in each row.
fieldstringThe grid field this column maps onto; empty to exclude it from the import.
typestringThe inferred (or grid-dictated) type used to coerce the column's values.
samplesstring[]A few non-blank sample values, for the preview.
matchedbooleanWhether the heading matched one of the grid's own columns.

ImportPreview

What a preview carries — everything a confirm dialog needs (§14).

MemberTypeDescription
delimiterstringThe delimiter that was used, detected or supplied.
headerstring[]The source column headings.
columnsImportColumn[]The per-column mapping and inference the user may edit before confirming.
recordsRecord<string, unknown>[]Every mapped, coerced record the import would add.
sampleRecord<string, unknown>[]The leading records, for a preview table.
rowCountnumberHow many data rows the file holds.
warningsstring[]Anything worth flagging before confirming — a ragged file, a bad quote.

ImportSettings

How `config.import` tunes the DOM import affordances (§14).

MemberTypeDescription
filebooleanAdd the cell-menu item and open a file picker for CSV/TSV. Default true. (optional)
dropbooleanMake the grid a drop target for `.csv`/`.tsv` files. Default true. (optional)
pastebooleanRead a pasted spreadsheet block into a preview. Default true. (optional)
mode'append' | 'replace'How a confirmed import lands: append (default) or replace the dataset. (optional)

ImportXlsxPreview

What an `.xlsx` preview carries — an {@link ImportPreview} plus the sheet read (§14).

MemberTypeDescription
sheetstring | nullThe archive path of the worksheet that was read, e.g. `xl/worksheets/sheet1.xml`.
headerstring[]The source column headings.
columnsImportColumn[]The per-column mapping and inference the user may edit before confirming.
recordsRecord<string, unknown>[]Every mapped, coerced record the import would add.
sampleRecord<string, unknown>[]The leading records, for a preview table.
rowCountnumberHow many data rows the sheet holds.
warningsstring[]Anything worth flagging before confirming.

IngestConfig

How rows are ingested into the column store.

MemberTypeDescription
retainSourcebooleanRetain the caller's row objects by reference so identity round-trips. Default `true`, the historical behaviour: `rows.data()` returns the exact objects you supplied, `row === sourceObject` holds, and a custom renderer reading `row.sourceObject` works. Set `false` to keep only the packed columns and reconstruct a plain row object from them on demand. This drops roughly half the resident footprint, but changes three behaviours: `rows.data()` returns freshly reconstructed objects (new object each call, so `row === sourceObject` no longer holds), a custom renderer that reaches for `row.sourceObject` gets a reconstruction rather than the original, and equality against a row becomes value-based. The stored values are unchanged, so `get()`, `byKey()`, `value()` and `values()` are unaffected. (optional)
dropSourceRowsbooleanRelease the caller's row objects from the *source layer* once the column store has been built, so the columns become the sole resident copy of the data. Default `false`, which keeps today's behaviour. `retainSource:false` stops the {@link https://en.wikipedia.org/wiki/Column-oriented_DBMS column store} from holding the caller's objects, but the memory source and the grid config still retain the supplied array by reference — so the objects stay alive and the resident footprint does not actually fall. This flag closes that gap: it clears `MemorySource`'s retained array and drops the array from the grid config, leaving nothing on the heap but the packed columns. That is where the large reduction comes from (roughly an order of magnitude at a million rows), not from `retainSource` on its own. Implies `retainSource:false`: dropping the caller's objects while the store still expects to read through them would leave the source with no data at all, so setting this on forces the store to reconstruct rows from columns. Every read is therefore served from the columns — `at()`, `byKey()`, `get()`, `value()`, `values()`, filtering, sorting, grouping, totals and export are all unaffected in their values. What changes is the same three identity behaviours `retainSource:false` documents: `rows.data()` returns freshly reconstructed objects (so `row === sourceObject` no longer holds), a custom renderer reaching for `row.sourceObject` gets a reconstruction, and equality against a row becomes value-based. One consumer cannot be served from the columns: an *impure computed column* (a shadow, or a rank/positional column) is deliberately never materialised into the store, so its handle is built by reading the source objects. Under `dropSourceRows` those objects are gone, so such a column reduces over nothing and warns once rather than returning a silently wrong figure. Do not enable `dropSourceRows` on a grid that sorts, filters, groups or totals on a shadow or a positional column. (optional)
useWorkerbooleanColumnize `stream`-source ingest on a Worker so a large load does not block the main thread. Default `false`. When on, an arriving chunk that clears {@link IngestConfig.workerThreshold} is packed into typed column buffers on the Worker; the main thread merges the finished buffers into the store and renders, without running the per-field extraction pass that otherwise dominates ingest. This makes **stream** ingest non-blocking (remote sources already are). Memory and paged sources cannot be made non-blocking this way — the main thread must read the caller's own row objects — and are unaffected. The effect composes with `retainSource: false`: with it off the source keeps no caller-object array on the main thread at all, so the load is both non-blocking and lighter on memory. A column that reads through a closure — a `date` column's storage conversion, or a computed column — cannot cross the Worker boundary, so a grid with any such column columnizes on the main thread and says so once. Falls back silently to the main thread wherever a Worker cannot be created. (optional)
workerThresholdnumberRow count in a single stream chunk at or above which columnization is offloaded to the Worker when {@link IngestConfig.useWorker} is on. Default `10000`. A smaller first chunk is packed on the main thread, where the cost is trivial and the postMessage round trip would only add latency to time-to-first-row. (optional)

Kanban

A board instance: a kanban view of grid rows as cards grouped into columns. It consumes data through the same keyed-diff `rows.apply` contract a grid exposes, so `dataRouter.attach(value, board)` drives it like any other viewer.

MemberTypeDescription
elunknown | null(read-only)
rowKeystring | ((row: KanbanRow) => unknown)(read-only)
rowsKanbanRows
slaKanbanSlaThe card-aging / SLA monitor, present only when a `sla` config was supplied (BACKLOG-0000960). (optional)
columns(): KanbanColumn[]
column(id: string): KanbanColumn | undefined
count(id: string): number
points(id: string): number
cards(): KanbanCard[]
card(key: unknown): KanbanCard | undefined
on(name: string, fn: (event: KanbanEvent) => void): () => void
off(name: string, fn: (event: KanbanEvent) => void): void
readonly(scope?: { column?: string; card?: unknown }): boolean
move(keys: unknown | unknown[], toColumn: string, toIndex?: number | null, toLane?: string): Promise<{ moved: unknown[]; reverted: boolean }>Move one or more cards to a column (and, with an order property, to a position within it), through the `onBeforeMove` veto and the grid's shipped write-back path. The single entry point behind drag-and-drop and keyboard move.
selection(): unknown[]The selected card keys.
isSelected(key: unknown): booleanWhether a card is selected.
select(keys: unknown | unknown[], mode?: 'set' | 'add' | 'toggle' | 'remove'): KanbanChange the selection: `set` (replace), `add`, `toggle` or `remove`.
clearSelection(): KanbanClear the selection.
collapseColumn(id: string, collapsed?: boolean): KanbanCollapse, expand or toggle a column (emits `column:collapse`).
collapseLane(id: string, collapsed?: boolean): KanbanCollapse, expand or toggle a swimlane (emits `swimlane:collapse`).
reorderColumns(order: string[]): KanbanReorder the columns to the given id order (emits `column:reorder`).
moveColumn(id: string, beforeId: string | null): KanbanMove one column before another (or to the end); emits `column:reorder`.
reorderLanes(order: string[]): KanbanReorder the swimlanes to the given id order (emits `swimlane:reorder`).
moveLane(id: string, beforeId: string | null): KanbanMove one swimlane before another (or to the end); emits `swimlane:reorder`.
filtersKanbanFiltersNamed card predicates, composed with AND (BACKLOG-0001229). See {@link KanbanFilters}.
setFilter(fn: ((row: KanbanRow, card: KanbanCard) => boolean) | null): KanbanSet a predicate filter over cards, or clear it with null. Sugar for `filters.where(filters.DEFAULT, fn)`.
setQuickFilter(text: string): KanbanSet the quick-filter text matched across card fields. Independent of every `filters.where` predicate.
facets(property: string): { value: unknown; count: number }[]Distinct values of a property with card counts — the raw material for a facet control.
BACKLOGunknownThe sentinel `setSprint` value that selects the backlog (cards with no sprint). (read-only)
setSprint(sprint: unknown): KanbanSelect the shown sprint (`BACKLOG` for the backlog, undefined for all); emits `sprint:changed`.
showBacklog(): KanbanShow only the backlog (cards with no sprint).
setEpic(epic: unknown): KanbanSelect the shown epic (undefined for all); emits `epic:changed`.
sprints(): unknown[]The distinct sprint values (the switcher's options); a configured `sprints` dataset pins the order.
sprintDefs(): { id: unknown; title: string }[]The sprint dataset as `{ id, title }` descriptors — the configured list plus any data-only sprint.
epics(): unknown[]The distinct epic values.
rollup(property: string): { value: unknown; count: number; points: number; doneCount: number; donePoints: number; progress: number }[]Roll rows up by a property: per-bucket count, points, done and progress.
epicRollup(): { value: unknown; count: number; points: number; doneCount: number; donePoints: number; progress: number }[]The epic rollup (empty when no epic property is configured).
canExpand(card: KanbanCard): booleanWhether a card can be expanded to a child pop-out.
expand(key: unknown): Promise<object | null>Open a card's children in a pop-out (drawer/modal/inline); emits `card:expand`/`card:drill`.
closeDetail(): KanbanClose any open card pop-out.
isFieldEditable(name: string): booleanWhether a mapped card field is opted into inline edit and writable.
editCard(key: unknown, name?: string): object | nullStart inline editing a card's field (the grid's own field editor when bound); no-op headless.
applyEdit(key: unknown, name: string, value: unknown): Promise<boolean>Commit an inline edit through the write-back path (grid.edit.setCells when bound); emits `card:edit`.
addCard(columnId: string, seed?: KanbanRow): unknown | Promise<unknown>Add a card to a column and open it in inline edit; emits `card:add`. Returns the new key directly, or a Promise of it when `onAddCard` returns a Promise or a `beforeAdd` handler defers (BACKLOG-0001230); a rejected `onAddCard` Promise resolves this to `null` with no card added.
getState(): objectSerialise the restorable state: collapsed columns/lanes, order, filter, sprint/epic, selection.
setState(snapshot: object): KanbanRestore a state snapshot from {@link Kanban#getState}.
setLoading(loading: boolean): KanbanMark the board loading (renders a host-localised loading state).
setError(message: string | null): KanbanSet (or clear with null) an error state, rendered as a host-supplied message.
setRows(rows: KanbanRow[]): Kanban
setColumns(defs: KanbanColumnDef[]): KanbanReplace the board's configured column set (BACKLOG-0001228). Keeps card placement and interaction state (collapsed columns, column order, quick filter, selection) for every column id that survives; a dropped id is not specially handled — a card whose value has nowhere configured to go re-derives an ad hoc column rather than becoming `unplaced` (the same "never silently drop a card" rule an unconfigured value already gets).
refresh(): Kanban
destroy(): void

KanbanCard

A card model — one row as it appears on the board. `fields` holds the resolved display text for each mapped card field; `columnId` is the column the card sits in; `points` is the numeric points value (0 when absent). `swimlane`/`sprint`/`epic`/`order` are read from their configured properties and carried for the later cycles that render them.

MemberTypeDescription
keyunknown
rowKanbanRow
columnIdstring | null
pointsnumber
hasPointsboolean
orderunknown(optional)
swimlaneunknown(optional)
sprintunknown(optional)
epicunknown(optional)
fieldsRecord<string, string>

KanbanCardMap

The field-to-property mapping that drives the card template.

MemberTypeDescription
titleKanbanFieldMap(optional)
subtitleKanbanFieldMap(optional)
labelsKanbanFieldMap(optional)
assigneeKanbanFieldMap(optional)
dueKanbanFieldMap(optional)
coverKanbanFieldMap(optional)
progressKanbanFieldMap(optional)
badgesKanbanFieldMap(optional)
accentKanbanFieldMap(optional)

KanbanChildren

Card pop-out configuration. The child view is a full composed grid (via `factory`, a `createGrid`), a nested board (`asBoard`), or a custom `render`. The child set is the rows whose `property` equals the card key, or the `load(card)` result. Recursion falls out: a nested board can pop its own children.

MemberTypeDescription
propertystringParent-id property linking child rows to a card within the same dataset. (optional)
load(card: KanbanCard) => KanbanRow[] | Promise<KanbanRow[]>Per-card child rows, sync or async — an alternative (or addition) to `property`. (optional)
hasChildren(card: KanbanCard) => booleanWhether a card can be expanded, overriding the property/load inference. (optional)
present'drawer' | 'modal' | 'inline'Where the pop-out appears (default `drawer`). (optional)
factory(container: HTMLElement, options: object) => { destroy?: () => void }The grid factory (a `createGrid`) that builds the child grid. (optional)
asBoardbooleanMake the child a nested board (recursive) instead of a grid. (optional)
gridOptionsobject | ((card: KanbanCard) => object)Options for the child grid/board — an object or `fn(card)`. (optional)
render(container: HTMLElement, ctx: { card: KanbanCard; rows: KanbanRow[]; board: Kanban; depth: number }) => (void | (() => void))Fully custom child render; returns a cleanup function. (optional)
title(card: KanbanCard) => stringThe pop-out title (default the card title). (optional)

KanbanColumn

A column with its cards and aggregates. `over` is true when `count` exceeds `wipLimit`.

MemberTypeDescription
idstring
titlestring
colorstring | null
wipLimitnumber | null
collapsedboolean
cardsKanbanCard[]
countnumber
pointsnumber
overboolean

KanbanConfig

Kanban configuration. Every structural property is named here so the same board maps DemandFlow (a status field, `points`, `sprint`, `epic`, a swimlane property) and any customer schema without code change.

MemberTypeDescription
rowsKanbanRow[](optional)
gridunknown(optional)
rowKeystring | ((row: KanbanRow) => unknown)(optional)
columnPropertystring(optional)
columnsKanbanColumnDef[](optional)
columnOrderstring[](optional)
pointsPropertystring(optional)
showPointsboolean(optional)
orderPropertystring(optional)
swimlanePropertystring(optional)
swimlanesbooleanRender the 2D swimlane layout using `swimlaneProperty` (default false). (optional)
lanes(string | { id: string; title?: string })[]Explicit lane definitions; otherwise lanes come from the distinct swimlane values. (optional)
laneOrderstring[]An explicit lane order by id (also set by a lane-header-drag reorder). (optional)
enforceWipbooleanEnforce `wipLimit` as a hard gate: a move that would exceed it is refused (default false). (optional)
cardRenderer(card: KanbanCard, ctx: { column: KanbanColumn; readonly: boolean; el: HTMLElement; doc: Document }) => string | Node | voidA custom card template: return an HTML string or a DOM node to own the whole card body. (optional)
sprintPropertystring(optional)
epicPropertystring(optional)
sprints(string | { id: unknown; title?: string })[]A configurable sprint dataset: the canonical sprint list (order + titles), shown even when empty. (optional)
sprintunknownThe initially selected sprint id, `Kanban.BACKLOG`, or undefined for all. (optional)
epicunknownThe initially selected epic id, or undefined for all. (optional)
doneColumnsstring[]Column ids that count as "done" for a rollup's progress (also a column def's `done: true`). (optional)
childrenKanbanChildrenCard pop-out: a nested child grid or board (master-detail by composition). (optional)
virtualizeboolean | { rowHeight?: number; overscan?: number; threshold?: number; viewport?: number }Card virtualization for tall columns: true, or `{ rowHeight, overscan, threshold, viewport }`. (optional)
slaKanbanSlaConfigCard aging / SLA highlighting (BACKLOG-0000960): warn/breach thresholds (globally, per column and/or per lane) that age each card and fire `card:sla` on a rising crossing. Opt-in; reached at runtime as {@link Kanban#sla}. See {@link KanbanSlaConfig}. (optional)
stateobjectA saved board state (from `getState`) to restore on construction. (optional)
addCardbooleanShow a per-column add-card affordance. (optional)
onCardEdit(event: { card: KanbanCard; key: unknown; field: string; fieldPath: string; value: unknown }) => boolean | void | Promise<boolean | void>Persist a standalone inline edit; return false or a rejected promise to revert. (optional)
onAddCard(columnId: string) => KanbanRow | Promise<KanbanRow> | voidCreate a card for a column on add-card; return the row to create (with its key), a Promise of that row, or nothing to auto-generate. A rejected Promise creates no card and leaves the board unchanged (BACKLOG-0001230). (optional)
filter(row: KanbanRow, card: KanbanCard) => booleanA predicate filter over cards; only matching cards are shown. (optional)
quickFilterstringQuick-filter text matched case-insensitively across card fields. (optional)
cardKanbanCardMap(optional)
readonlyKanbanReadonly(optional)
ariaLabelstring(optional)
emptyTextstring(optional)
selectablebooleanWhether card selection is enabled (default true). (optional)
labelsRecord<string, string>Host-localised words for the move announcements (grabbed/moved/dropped/reverted/cancelled). (optional)
onBeforeMove(card: KanbanCard, from: string | null, to: string, index: number | null) => boolean | Promise<boolean>Veto/confirm a move before any write. Return `false` (or a promise of it) to refuse; `from`/`to` are column ids, `index` the target position. (optional)
onCardMove(event: KanbanMoveEvent) => boolean | void | Promise<boolean | void>Persist a move on a standalone (non-grid) board. Return `false` or a rejected promise to revert the optimistic move. On a grid-bound board the grid's write-back pipeline persists instead and this is not called. (optional)
contextMenuKanbanMenuItem[] | ((card: KanbanCard, selected: KanbanCard[]) => KanbanMenuItem[])A per-card context menu: items, or `fn(card, selectedCards)` returning items. Suppresses `card:contextmenu`. (optional)
onCardClick(event: KanbanEvent) => void(optional)
onCardDblClick(event: KanbanEvent) => void(optional)
onCardContextMenu(event: KanbanEvent) => void(optional)

KanbanEditor

A card field editor handle returned by a host editor factory.

MemberTypeDescription
elHTMLElement
focus() => void(optional)
destroy() => void(optional)

KanbanEvent

The payload every board event carries.

MemberTypeDescription
cardKanbanCard
columnstring | null
elunknown(optional)
originalEventunknown(optional)

KanbanFilters

Named card predicates, composed with AND (BACKLOG-0001229), following the grid's `filters.where` convention (BACKLOG-0001202). Several may be registered under different names at once; each can be replaced or removed without touching the others. `setFilter(fn)` is unchanged sugar for `where(DEFAULT, fn)` / `where(DEFAULT, null)`.

MemberTypeDescription
DEFAULTstringThe reserved name `board.setFilter` registers/removes under. (read-only)
where(): string[]The registered names, in registration order.
where(name: string, predicate: (row: KanbanRow, card: KanbanCard) => boolean): KanbanRegister or replace the predicate under `name`.
where(name: string, predicate: null): KanbanRemove whatever is registered under `name`; a no-op if nothing was.
reapply(name?: string): booleanRe-run every named predicate (or one, by name) and re-render.

KanbanMenuItem

One context-menu item. `action` receives the card, the selected cards, and the board.

MemberTypeDescription
labelstring
action(ctx: { card: KanbanCard; cards: KanbanCard[]; board: Kanban }) => void(optional)
disabledboolean(optional)

KanbanMoveEvent

The payload of a `card:move` (and `card:reverted`) event.

MemberTypeDescription
keysunknown[]
cardsKanbanCard[]
from(string | null)[]
tostring
indexnumber | null
ordersnumber[] | null

KanbanRows

The keyed-diff consumer surface a board shares with a grid, so a Data Router routes to it directly.

MemberTypeDescription
apply(change: { add?: KanbanRow[]; update?: KanbanRow[]; remove?: unknown[] }): void
forEach(fn: (row: KanbanRow, key: unknown) => void): void
countnumber(read-only)

KanbanSla

The card-aging / SLA monitor (BACKLOG-0000960), reached as {@link Kanban#sla} when a `sla` config is supplied. Pure and DOM-free: it computes each card's ageing state from the board's card model and the flow transition log, and the view paints it.

MemberTypeDescription
configobjectThe normalised SLA config (read-only). (read-only)
sync(): KanbanSlaRecompute every card's SLA state without emitting anything.
evaluate(opts?: { emit?: boolean }): KanbanSlaState[]Recompute and fire `card:sla`/`onWarn`/`onBreach` on each rising crossing.
start(): KanbanSlaEstablish the baseline, notify on the current state, and start the optional tick.
stateFor(cardOrKey: KanbanCard | unknown): KanbanSlaState | nullThe SLA state of one card (by card model or key), or null when unknown.
states(): KanbanSlaState[]Every card's current SLA state.
breaches(): KanbanSlaState[]The cards currently at breach level.
warnings(): KanbanSlaState[]The cards currently at warn level (not yet breached).
destroy(): voidStop the tick and drop the board subscriptions.

KanbanSlaConfig

Card-aging / SLA configuration (BACKLOG-0000960). A card is measured against a `warn` and a `breach` threshold; the view puts an age chip on aged cards and a highlight on breached ones, and a rising crossing fires the `card:sla` event and the matching `onWarn`/`onBreach` callback (signature `(level, rows)`, the Data Router alert handler's). Thresholds resolve most-specific-first: lane → column → global. Reached at runtime as {@link Kanban#sla}.

MemberTypeDescription
warnKanbanSlaThresholdThe global warn threshold. (optional)
breachKanbanSlaThresholdThe global breach threshold. (optional)
columnsRecord<string, KanbanSlaThreshold | { warn?: KanbanSlaThreshold; breach?: KanbanSlaThreshold }>Per-column overrides by column id (each a threshold or a `{ warn, breach }` pair). (optional)
lanesRecord<string, KanbanSlaThreshold | { warn?: KanbanSlaThreshold; breach?: KanbanSlaThreshold }>Per-swimlane overrides by lane id (each a threshold or a `{ warn, breach }` pair). (optional)
basis'column' | 'board'Where the ageing clock starts: `'column'` (default) measures time in the card's current column; `'board'` measures age since the card arrived/was created. (optional)
enteredPropertystringA row property holding the wall-clock time the card entered its column. (optional)
createdPropertystringA row property holding the wall-clock time the card was created. (optional)
ignoreDonebooleanWhether cards in a done column are exempt from ageing (default true). (optional)
useTransitionLogbooleanWhether the flow transition log drives the ageing basis when present (default true). (optional)
showAge'always' | 'threshold'Show the age chip on every aged card (`'always'`), or only on warn/breach (`'threshold'`, default). (optional)
now() => numberA wall-clock epoch clock, injectable for deterministic tests (default `Date.now`). (optional)
ticknumberA re-check interval in ms so a card breaching by sitting still still lights up (0 = off). (optional)
onWarn(level: 'warn' | 'breach', rows: KanbanRow[]) => voidCalled on a rising crossing to warn level, `(level, rows)` — the router alert handler's shape. (optional)
onBreach(level: 'warn' | 'breach', rows: KanbanRow[]) => voidCalled on a rising crossing to breach level, `(level, rows)` — the router alert handler's shape. (optional)

KanbanSlaState

The computed SLA state of one card (BACKLOG-0000960).

MemberTypeDescription
keyunknown
columnIdstring | null
laneunknown(optional)
startnumber | nullThe ageing-clock start epoch (ms), or null when no time source could be resolved.
ageMsnumber | nullThe card's age in ms, or null when unknown.
ageTextstringA short human age label (`2d`, `5h`, …), '' when unknown.
warnMsnumber | nullThe resolved warn threshold in ms, or null.
breachMsnumber | nullThe resolved breach threshold in ms, or null.
level'ok' | 'warn' | 'breach' | nullThe classified level, or null when the card cannot be aged.
breachedbooleanTrue when `level` is `'breach'`.

KPI

A KPI / stat-tile panel: a grid of aggregate tiles over a dataset. It consumes data through the same keyed-diff `rows.apply` contract a grid exposes, so `dataRouter.attach(value, kpi)` drives it like any other viewer, updating each tile incrementally from the routed delta.

MemberTypeDescription
elunknown | null(read-only)
rowKeystring | ((row: KPIRow) => unknown)(read-only)
treebooleanWhether the panel renders as a hierarchy rather than a flat tile grid. (read-only)
rowsKPIRows
tiles(): KPITileModel[]
tile(id: string): KPITileModel | undefined
value(id: string): unknown
nodes(): KPINodeModel[]The top-level nodes of the hierarchy. Empty on a flat panel.
node(key: string): KPINodeModel | undefinedOne node by its key, at any depth.
visibleNodes(): KPINodeModel[]The nodes on screen: the roots, plus the children of every open branch.
expand(key: string): KPI
collapse(key: string): KPI
toggle(key: string): KPI
setRows(rows: KPIRow[]): KPI
refresh(): KPI
getState(): object
setState(snapshot: object): KPI
on(name: string, fn: (event: KPIEvent) => void): () => void
off(name: string, fn: (event: KPIEvent) => void): void
destroy(): void

KPIBand

An explicit band: the `status` of the first band whose half-open `[min, max)` contains the value.

MemberTypeDescription
minnumber(optional)
maxnumber(optional)
status'good' | 'warn' | 'critical'

KPIClockTile

A clock tile: the device clock, not an aggregate (BACKLOG-0001640) — the date on one line and the time on the next, ticking once a second from one shared panel timer. It takes none of a stat tile's measurement options (`aggregation`, `field`, `format`, `thresholds`, `bands`, `target`, `baseline`, `sparkline`): supplying any of them is reported as a configuration warning by name and ignored, because a tile that measures nothing has nothing for them to apply to.

MemberTypeDescription
kind'clock'Discriminates a clock tile from an aggregate stat tile.
idstringA stable identity for the tile (defaults to the label, then the index). (optional)
labelstringThe tile's accessible label (e.g. the city or zone it names). (optional)
timeZonestringAny IANA zone name (`'Europe/London'`). Omitted, the tile shows the viewer's local time. A name `Intl.DateTimeFormat` does not recognise is reported through the usual diagnostics warning and the tile falls back to local time rather than rendering nothing. (optional)
localestringThe locale the date and time are formatted in — the tile's own, else the panel's `KPIConfig.locale`, else the browser's default. A 24-hour clock or a 12-hour one with an AM/PM marker follows from the locale itself (`Intl.DateTimeFormat`'s own convention), never a separate option. (optional)
secondsbooleanShow the seconds on the time line. Default `true`. (optional)
datebooleanShow the date line at all. Default `true`. (optional)

KPIConfig

KPI panel configuration.

MemberTypeDescription
rowsKPIRow[](optional)
gridunknown(optional)
rowKeystring | ((row: KPIRow) => unknown)(optional)
fieldsstring[]Extra columns of the bound `grid` to project onto the rows a tile `filter` sees, beyond the fields the tiles themselves declare. A grid-bound panel hands a filter a projection, not a whole grid row, so a filter over a column no tile names would otherwise read `undefined` and report a confident zero. Ignored on a panel over a plain `rows` array. (optional)
tilesKPITile[](optional)
columnsnumber(optional)
ariaLabelstring(optional)
nullTextstring(optional)
localestringThe default locale a clock tile formats in when the tile itself declares none (BACKLOG-0001640); falls back to the browser's default. No effect on a stat tile, which takes its own `format.locale`. (optional)
treeKPITreeConfig | falseArrange the tiles as a hierarchy; `false` keeps the panel flat. (optional)
messages{ t(key: string, params?: Record<string, unknown>): string }The catalogue the panel's own text is read from. A panel routinely has no grid to borrow one off — two of its three input modes have none — so this is the first-class way to translate it. A grid's own `messages` satisfies the shape; a key it does not carry falls back to English. (optional)
onTileClick(event: KPIEvent) => void(optional)
onTileDblClick(event: KPIEvent) => void(optional)
onTileContextMenu(event: KPIEvent) => void(optional)
onNodeToggle(event: { key: string; expanded: boolean; node?: KPINodeModel }) => void(optional)
onChange(event: { model: { tiles: KPITileModel[]; nodes?: KPINodeModel[] } }) => void(optional)

KPIEvent

The payload every tile event carries.

MemberTypeDescription
tileKPITileModel
idstring
originalEventunknown(optional)

KPINodeModel

One node of the rail. **No value rolls up.** `value` and `formatted` are the node's own tile's reading, and are `null` on a level the hierarchy synthesised, because the running accumulators cannot be composed without a rescan. **Severity does.** `rollup` is the worst status at or below the node, which is what a collapsed branch reports. `unknown` is excluded from it on purpose — ranking "nothing was measured" as the worst would hide a real warning underneath it — and is surfaced as `unknown`, a count of the descendants that measured nothing, so neither can pass unnoticed.

MemberTypeDescription
keystringThe node's stable identity: the tile id, or the path of a synthesised level.
idstring | nullThe tile id, or null on a synthesised level.
labelstring
levelnumberDepth, 0 at the top level.
posinsetnumberIts place among its siblings, from 1, and how many there are.
setsizenumber
hasChildrenboolean
expandedboolean
childrenKPINodeModel[]
tileKPITileModel | nullThe node's own tile, or null on a synthesised level.
valueunknown
formattedstring | null
status'good' | 'warn' | 'critical' | 'unknown' | nullThe node's own status.
rollup'good' | 'warn' | 'critical' | nullThe worst status at or below the node. Never `unknown`.
unknownnumberHow many tiles at or below the node measured nothing.
itemsnumberHow many tiles are at or below the node.

KPIRows

The keyed-diff consumer surface a KPI panel shares with a grid, so a Data Router routes to it directly.

MemberTypeDescription
apply(change: { add?: KPIRow[]; update?: KPIRow[]; remove?: unknown[] }): void
forEach(fn: (row: KPIRow, key: unknown) => void): void
countnumber(read-only)

KPISparkline

An optional sparkline series: the `y` field plotted in order of the `x` field (or insertion).

MemberTypeDescription
xstring(optional)
ystring | ((row: KPIRow) => unknown)

KPIStatTile

An aggregate stat tile: the routed rows reduced to one number, with optional filter, format, threshold and trend.

MemberTypeDescription
kind'stat'Absent, or `'stat'`: the default tile kind. (optional)
idstringA stable identity for the tile (defaults to the label, then the index). (optional)
labelstringThe tile's accessible label. (optional)
aggregationKPIAggregation | ((rows: KPIRow[], tile: object) => unknown)The aggregation kind, or a reducer `(rows, tile) => value` for a custom tile. (optional)
compute(rows: KPIRow[], tile: object) => unknownThe reducer for a `custom` aggregation, when `aggregation` is the string `'custom'`. (optional)
fieldstring | ((row: KPIRow) => unknown)The field the aggregation reads (a path or accessor). Ignored by `count`. (optional)
filter(row: KPIRow) => booleanA predicate limiting the rows this tile aggregates. (optional)
formatKPIFormatValue formatting. (optional)
targetnumberA comparison target rendered alongside the value. (optional)
baselinenumberA baseline the tile's delta is measured against. (optional)
thresholdsKPIThresholdsThreshold bands, either two cut points or an explicit band list. (optional)
bandsKPIBand[]Explicit status bands (an alternative to `thresholds`). (optional)
sparklineKPISparkline | stringA trend sparkline series. (optional)

KPIThresholds

A semantic threshold: two cut points and a direction. `higherIsBetter` (the default) makes a value at/above `warn` good, at/above `critical` a warning, below it critical; `lowerIsBetter` mirrors it. Colour is a host concern.

MemberTypeDescription
warnnumber
criticalnumber
direction'higherIsBetter' | 'lowerIsBetter'(optional)

KPITileModel

A computed tile, as it appears in the model.

MemberTypeDescription
idstring
labelstring
kind'stat' | 'clock'`'stat'` for an aggregate tile, `'clock'` for a clock tile (BACKLOG-0001640).
aggregationstring
fieldstring(optional)
valueunknownFor a clock tile, the read instant as epoch milliseconds.
formattedstringFor a clock tile, the date and time text joined by a space (the same text `clock.date` and `clock.time` carry separately).
clock{ date: string | null; time: string }Present only on a clock tile: the date and time lines rendered separately. `date` is `null` when the tile was given `date: false`. (optional)
status'good' | 'warn' | 'critical' | 'unknown' | nullThe tile's semantic band, or `unknown` when the tile measured nothing. `unknown` is decided from data presence before any threshold is consulted: an aggregation over nothing returns the identity of its operation (`sum` and `count` return 0), and 0 is a number a threshold grades, so without it an empty panel would report as a healthy one. Two things make a tile `unknown`: the panel holds no rows at all, or the tile's `field` names no column on the bound grid, so it never read a cell to reduce over. A tile whose `filter` matches none of the rows the panel *does* hold is neither — it has measured a real zero and is banded normally. `null` means the tile has no thresholds or bands configured.
targetnumber(optional)
baselinenumber(optional)
deltanumber | null
deltaPercentnumber | null
deltaFormattedstring(optional)
countnumber
sparklinenumber[] | null

KPITreeConfig

The hierarchy a KPI panel arranges its tiles into (BACKLOG-0001059): a rail of top-level items that expand to the indicators beneath them, each parent highlighted with the worst status below it. The shape is declared with `path` or `parentKey` — the same two shapes the grid's tree data and the tree-select editor take — over the **tile specs**, not the rows. With neither declared, one is derived by splitting the tile ids on `separator`, so `system.compute.cpu` files itself under Compute under System. A panel whose ids carry no separator stays flat, and `false` keeps it flat whatever they look like. A tile's `field` is never a source: a dot there already means a nested object property.

MemberTypeDescription
path(tile: KPITile) => (string | number)[]The tile's own place in the hierarchy, its own segment last. (optional)
parentKeystring | ((tile: KPITile) => unknown)The id of the tile this one sits under, or a reader for it. (optional)
orphans'root' | stringThe heading tiles whose parent is not in the panel are gathered under. (optional)
separatorstringThe separator a derived hierarchy splits a tile id on. Defaults to `.`. (optional)
expandedtrue | string[]Which branches start open: every one (`true`), or these node keys. (optional)

LatticeGridHandle

The live instance a `<LatticeGrid>` ref exposes; `null` before mount.

MemberTypeDescription
gridGrid | null(read-only)

LatticeTabSpec

One tab of a `<LatticeTabs>`; `content` makes it React's rather than the module's.

MemberTypeDescription
idstring
labelstring(optional)
contentunknown | (() => unknown)A React element, or a function returning one, rendered through a portal. (optional)

LatticeViewerCommonProps

What every viewer component takes beyond its own configuration (BACKLOG-0001307): the grid it binds to, which published grid to take when that is left off, the lifecycle callbacks, and the host-element props.

MemberTypeDescription
gridGrid | nullThe grid this viewer is built against; taken from context when absent. (optional)
gridNamestringWhich published grid to take from context; `'default'` when absent. (optional)
onReady(instance: Instance) => voidTold when the viewer exists. (optional)
onDestroy() => voidTold just before it is destroyed. (optional)
classNamestringApplied to the host element rather than to the viewer. (optional)
styleRecord<string, unknown>Applied to the host element rather than to the viewer. (optional)
idstringApplied to the host element rather than to the viewer. (optional)

LatticeViewerHandle

The live instance a viewer component's ref exposes; `null` before mount.

MemberTypeDescription
instanceInstance | null(read-only)

Layout

A reconfigurable dashboard: a cell grid inside an element, and a set of windows on it that a user can move, resize and close by pointer or by keyboard (BACKLOG-0001108). The module is **payload-agnostic**: a window body is a container with an id, which this module creates and sizes and never reads. It tells a payload it was resized by emitting `window:resized`; it never calls into one, because it cannot know what one is.

MemberTypeDescription
elHTMLElement(read-only)
windows(): string[]The window ids, in mount order.
payload(id: string): HTMLElement | nullThe payload container for a window, or `null`.
window(id: string): LayoutWindow | nullA copy of one window's current descriptor, or `null`.
add(spec: LayoutWindow): HTMLElementAdd a window after mount; returns its payload container.
move(id: string, to: Partial<LayoutPlacement>): boolean | Promise<boolean>Move or resize a window, through the same before-events the drag uses.
close(id: string): boolean | Promise<boolean>Close a window through `beforeWindowClose`; the payload is not destroyed.
maximise(id: string): booleanBlow one window up to fill the layout host, hiding the rest. It fills the **host element**, not the browser window, so there is no `position: fixed` (whose containing block is the nearest ancestor carrying a `transform` or a `contain`, which is why the same rule fills the screen on one page and lands in a 300px box on the next), no reparenting and nothing that can disturb the page around the dashboard. **Nothing moves**: no compaction runs, no placement changes, and the payload container is the same DOM node throughout. **Escape restores it**, from anywhere inside the layout — a focused grid body cell or column heading included — unless a payload has already claimed the key: an open cell editor, filter menu or column menu closes first, and the next Escape restores the window. Afterwards focus lands on the window's maximise control. A minimised window is expanded first, and maximising a second window restores the first.
minimise(id: string): booleanCollapse one window to a single row: its payload is hidden and its chrome stays, carrying the control that brings it back. On screen it becomes one row and the windows below pull up into the space under `compact: 'vertical'`. In the arrangement nothing moves at all — the collapse is a projection of it — so `restore()` gives back exactly the arrangement that was there, in **any** order and with any number of other windows still collapsed. A window with `chrome: false` is refused, with a warning naming it.
restore(id: string): booleanLeave whichever display mode a window is in; `false` when it was in none.
maximised(): string | nullThe id of the window filling the host, or `null`. At most one.
minimised(): string[]The ids of every currently minimised window, in mount order.
getLayout(): LayoutSnapshotThe full current arrangement. **A mode is not an arrangement**: this reports the *underlying* placement of a maximised or minimised window — where it will be when restored — never the geometry it is drawn at.
setLayout(incoming: LayoutSnapshot | LayoutWindow[]): numberRestore an arrangement; never throws on garbage.
getState(): { version: number; layout: LayoutSnapshot }A versioned snapshot, following core's and gantt's shape.
setState(snapshot: unknown): numberRestore a `getState()` snapshot; never throws on garbage.
setInteractive(value: boolean | Partial<LayoutInteractive>): LayoutInteractiveLock or unlock the dashboard at runtime — the "Edit layout" button. A boolean sets all three capabilities; an object sets only the keys it carries. Nothing is destroyed, so every payload survives the toggle. The asymmetry is deliberate: **you can always take a capability away; you can never grant one where the developer said no.** `setInteractive(false)` locks every window, including one whose own spec says `movable: true`; `setInteractive(true)` unlocks only the windows that never opted out. `config.movable: false` and `setInteractive(false)` are deliberately not the same thing: the config states the *default* for windows that declare nothing (and `false` is already that default, so it takes nothing away from a window that opted in), while this is an *active lock*. A key carrying `undefined` is treated as absent, so `setInteractive(getInteractive())` is a no-op in every state. A locked layout is not a read-only dashboard: this module never reads or writes a payload, so a grid inside a window is made read-only with the grid's own settings.
getInteractive(): LayoutInteractiveThe layout-level interactivity now in force, as a copy — `undefined` where no layout-level default is set, so the result round-trips through `setInteractive`.
refresh(): numberRe-measure every window and emit `window:resized` for those that changed.
on(
off(name: string, fn: (event: any) => unknown): void
destroy(): voidTear the layout down; whatever the host mounted in a payload is the host's to destroy.

LayoutChangedEvent

The payload of `layout:changed`: the whole arrangement, plus what moved it.

MemberTypeDescription
causestring

LayoutCloseEvent

The payload of `window:closed` and `beforeWindowClose`.

MemberTypeDescription
idstring
payloadIdstring
payloadHTMLElementThe payload container, handed back so the host can destroy what it mounted. (optional)
origin'api' | 'user'(optional)
reasonstring | null(optional)
preventDefault(reason?: string) => void(optional)
defaultPreventedboolean(optional)

LayoutConfig

Dashboard layout configuration.

MemberTypeDescription
columnsnumberCell columns across the mounted element (default 12). (optional)
rowsnumberCell rows down the mounted element (default 6). (optional)
overflowX'static' | 'scroll'Horizontal overflow (default `'static'`). (optional)
overflowY'static' | 'scroll'Vertical overflow (default `'static'`). (optional)
columnWidthnumber | stringFixed column track size, used only when `overflowX` is `'scroll'` (default `'240px'`). (optional)
rowHeightnumber | stringFixed row track size, used only when `overflowY` is `'scroll'` (default `'160px'`). (optional)
gapnumber | stringThe gap between cells (default `'8px'`). (optional)
paddingnumber | stringThe default padding inside a window (default `'5px'`). (optional)
compact'vertical' | 'horizontal' | 'none'Rearrangement (default `'vertical'`). One gravity direction, never two: `'vertical'` pushes displaced windows down and then floats everything up, `'horizontal'` pushes them right and then floats everything left — so dragging a window out of a row closes the hole sideways — and `'none'` leaves every placement exactly where it was put. An unrecognised value warns once, naming what it got, and falls back to `'vertical'`. (optional)
movablebooleanThe default `movable` for every window that does not declare its own (default `false`). This states a default, so `false` takes nothing away from a window that declared `movable: true`; `setInteractive(false)` is the active lock that does. (optional)
resizablebooleanThe default `resizable` for windows that declare none (default `false`); see `movable`. (optional)
closablebooleanThe default `closable` for windows that declare none (default `false`); see `movable`. (optional)
maximisablebooleanThe default `maximisable` for windows that declare none (default `false`). Not touched by `setInteractive()`: a display mode neither moves nor resizes a window in the arrangement, so a locked dashboard can still be blown up to read. (optional)
minimisablebooleanThe default `minimisable` for windows that declare none (default `false`); see `maximisable`. (optional)
windowsLayoutWindow[]The windows, in mount order. (optional)
layoutLayoutSnapshotAn arrangement to apply at mount, as produced by `getLayout()`. (optional)
ariaLabelstringThe layout region's accessible name. (optional)
messages{ t(key: string, params?: Record<string, unknown>): string }A message catalogue, e.g. `grid.messages`; built-in English seeds otherwise. (optional)
onWindowMoved(event: LayoutMoveEvent) => void(optional)
onWindowResized(event: LayoutResizeEvent) => void(optional)
onWindowClosed(event: LayoutCloseEvent) => void(optional)
onLayoutChanged(event: LayoutChangedEvent) => void(optional)
onBeforeWindowMove(event: LayoutMoveEvent) => boolean | void | Promise<boolean>(optional)
onBeforeWindowResize(event: LayoutMoveEvent) => boolean | void | Promise<boolean>(optional)
onBeforeWindowClose(event: LayoutCloseEvent) => boolean | void | Promise<boolean>(optional)
onWindowMoveCancelled(event: LayoutMoveEvent) => void(optional)
onWindowResizeCancelled(event: LayoutMoveEvent) => void(optional)
onWindowCloseCancelled(event: LayoutCloseEvent) => void(optional)

LayoutInteractive

The three capabilities a layout-level default and `setInteractive()` cover. These are the layout **defaults**, not the per-window resolution: a window that declared `movable: false` stays pinned whatever these say. Three values, not two. `undefined` means no layout-level default is in force and each window's own flag decides; `true` unlocks everything that did not opt out; `false` is an active lock. Reporting `undefined` as `false` would read correctly and round-trip wrongly, so it is reported as it is.

MemberTypeDescription
movableboolean | undefined
resizableboolean | undefined
closableboolean | undefined

LayoutMoveEvent

The payload of `window:moved`, `beforeWindowMove`, `beforeWindowResize`.

MemberTypeDescription
idstring
fromLayoutPlacement
toLayoutPlacementWhere the window was asked to go.
landedLayoutPlacementWhere it actually ended up, which under `compact: 'vertical'` may differ. (optional)
origin'api' | 'user' | 'init'(optional)
reasonstring | null(optional)
preventDefault(reason?: string) => voidCancel the action (only meaningful on a `before*` event). (optional)
defaultPreventedboolean(optional)

LayoutPlacement

A cell placement, as carried on the move and resize events.

MemberTypeDescription
xPosnumber
yPosnumber
xSizenumber
ySizenumber

LayoutResizeEvent

The payload of `window:resized` — the measured **content box** of the payload container, not a cell count. Emitted when the container genuinely changes size, including on the opening frame; never with a zero box.

MemberTypeDescription
idstring
payloadIdstring
payloadHTMLElementThe payload container itself, so a host can act on it directly.
widthnumber
heightnumber
xPosnumber
yPosnumber
xSizenumber
ySizenumber

LayoutSnapshot

The plain, JSON-safe arrangement `getLayout()` returns and `setLayout()` takes.

MemberTypeDescription
columnsnumber
rowsnumber
windows{ id: string; xPos: number; yPos: number; xSize: number; ySize: number }[]

LayoutWindow

One window on the cell grid. Deliberately **not** named `WindowSpec`: that name is already taken by the rolling-statistics window (`{ kind: 'count'|'time'|'session', span, size }`) and reusing it would put `kind: 'session'` next to a dashboard pane.

MemberTypeDescription
idstringA stable, unique id. Required.
xPosnumberThe 1-based column the window starts in. Auto-placed when omitted. (optional)
yPosnumberThe 1-based row the window starts in. Auto-placed when omitted. (optional)
xSizenumberHow many columns it spans (default 1). (optional)
ySizenumberHow many rows it spans (default 1). (optional)
titlestringThe title shown in the chrome bar, and the name every control takes. (optional)
chromebooleanWhether to draw the title bar (default `true`). (optional)
closablebooleanWhether to offer a close button (default `false`). (optional)
movablebooleanWhether the window can be moved by drag or keyboard (default `false`). (optional)
resizablebooleanWhether the window can be resized by drag or keyboard (default `false`). (optional)
maximisablebooleanWhether to offer a maximise control in the chrome (default `false`). Maximising fills the **layout host**, not the browser window, and hides every other window for the duration. Escape restores it, unless a payload has already claimed the key. (optional)
minimisablebooleanWhether to offer a minimise control in the chrome (default `false`). A window with `chrome: false` cannot be minimised whatever this says: there would be nothing left on screen to restore it with. (optional)
paddingnumber | stringPadding inside the window; the layout's `padding` (default `'5px'`) otherwise. (optional)
payloadIdstringThe `id` given to the payload container (default `` `${id}-body` ``). (optional)
ariaLabelstringThe window's accessible name, when the title alone is not enough context. (optional)

LicenceApi

MemberTypeDescription
set(key: string): LicenceInfo
info(): LicenceInfo
state(): 'licensed' | 'localhost' | 'trial'
watermark(): boolean
readyPromise<LicenceInfo>Settles when the licence check finishes. (read-only)

LicenceInfo

MemberTypeDescription
validboolean
productstring(optional)
issuedTostring(optional)
expiresstring(optional)
reasonstring(optional)

LookupSpec

MemberTypeDescription
optionsOption[] | (() => Option[] | Promise<Option[]>)(optional)
valueKeystring(optional)
labelKeystring(optional)
groupKeystring(optional)
multipleboolean(optional)
allowCustomboolean(optional)
unknownLabelstring | ((v: unknown) => string)(optional)
search(query: string, signal: AbortSignal) => Promise<Option[]>(optional)
sortBy'label' | 'value' | 'optionOrder' | 'count'(optional)
separatorstring(optional)

MaintenanceTier

The maintenance label for one kernel across both tiers.

MemberTypeDescription
statstringThe kernel name.
exact'maintained' | 'rescan' | nullIts exact tier, or null when it is not an exact kernel.
approximateApproximateEntry | nullThe approximate alternative and bound, or null when none exists.

MaximiseApi

MemberTypeDescription
enter(): boolean
exit(): boolean
toggle(): boolean
active(): boolean

MemorySourceConfig

MemberTypeDescription
mode'memory'
columnarBelownumber(optional)
rowsunknown[]The rows a memory source opens with; equivalent to top-level `rows`, which wins if both are given. (optional)

MenuItem

MemberTypeDescription
namestring(optional)
iconstringAn icon shown in the slot before the label. Three forms, told apart without a second option so existing definitions keep working: a registered sprite name (`'download'`), a single character or emoji (`'↑'`), or author-trusted element markup (`'<i class="fa-light fa-download"></i>'`), which is rendered as an element rather than shown as text. Markup is inserted into the icon slot only — never the label — at the same trust as `action`. (optional)
shortcutstring(optional)
action() => void(optional)
disabledboolean(optional)
separatorboolean(optional)
childrenMenuItem[](optional)

MessagesApi

The resolved message set for a grid: every user-visible string, in the grid's locale.

MemberTypeDescription
t(key: string, params?: Record<string, unknown>): stringFormat a message.
list(items: string[], type?: 'conjunction' | 'disjunction'): stringJoin parts the way this locale joins lists.
number(value: number, opts?: Intl.NumberFormatOptions): stringFormat a number for this locale.
localestringThe resolved BCP 47 tag. (read-only)
keysReadonlyArray<string>Every key the catalogue defines. (read-only)

ModuleContext

MemberTypeDescription
registryRegistry
gridGrid(optional)

Money

A stored currency value: an amount in a named currency. `{amount:10,code:'USD'}` is a different value from `{amount:10,code:'EUR'}` — currency is a real type, not a display format, so the code rides on every cell.

MemberTypeDescription
amountnumber
codestring

MutateCapability

What an adapter can persist back to its source — the write-back capability (§4.1), declared on `AdapterCapabilities.mutate`. `false` (the default) is read-only by declaration; a resolved block turns every kind off unless the adapter opts in.

MemberTypeDescription
appendbooleanThe adapter can insert new rows, bridged by the structural engine (`edit.addRow`, §5.3). (optional)
updatebooleanThe adapter can patch existing rows, bridged by the cell edit path (§4.3 Option A). (optional)
deletebooleanThe adapter can remove rows, bridged by the structural engine (`edit.deleteRow`, §5.3). (optional)
returning'row' | 'key' | 'none'The reconcile contract — what the server hands back after a successful mutation (§5.1). `'row'`: the authoritative row (id, computed columns, timestamps), reconciled before confirm. `'key'`: only the assigned key. `'none'` (the default): nothing — the optimistic value stands (last-write-wins). (optional)

MutationOp

One mutation handed to `adapter.mutate(op, request)` (§4.2). Cell-scoped `update` is the only kind wave 1 synthesises; `append`/`delete` are part of the shape so it survives into a later structural build (card 770).

MemberTypeDescription
kind'append' | 'update' | 'delete'
rowsunknown[]append: the new rows (may lack a server-assigned key). (optional)
keystringupdate: the row key. (optional)
patchRecord<string, unknown>update: the changed columns only, matching `PendingWrite` semantics. (optional)
keysstring[]delete: the row key(s). (optional)
originstringProvenance, carried through for auth / audit. (optional)
requestIdstringStable id for idempotent retry / dedupe. Reserved; retry is a non-goal in wave 1. (optional)

MutationResult

The result of a mutation (§4.2) — the reconcile payload. A cell-update commit flows this back through `PendingWrites`: `ok: false` reverts and surfaces `reason`; `rows` (`returning: 'row'`) reconciles server truth before confirm; `conflict` surfaces a last-write-wins divergence via `cell:conflict`.

MemberTypeDescription
okboolean
rowsunknown[]`returning: 'row'` — the authoritative row(s) to reconcile to. (optional)
keysstring[]`returning: 'key'` — server-assigned key(s) for appended rows, in order. (optional)
reasonstringOn rejection — surfaced on `cell:reverted`, never swallowed. (optional)
conflict{ key: string; serverRow?: unknown }The server's current value, for a surfaced last-write-wins conflict. (optional)

NumberFormat

MemberTypeDescription
type'number'(optional)
style'decimal' | 'currency' | 'percent'(optional)
currencystring(optional)
currencyDisplay'symbol' | 'code' | 'name' | 'narrowSymbol'(optional)
decimalsnumber(optional)
minDecimalsnumber(optional)
maxDecimalsnumber(optional)
thousandsSeparatorboolean | string(optional)
decimalSeparatorstring(optional)
notation'standard' | 'compact' | 'scientific'(optional)
negative'minus' | 'parentheses' | 'suffix'(optional)
negativeClassstring(optional)
signedbooleanShow a leading `+` on a positive value (`+5`, `+£5.00`, `+12%`). A negative value keeps whatever `negative` says regardless of this flag, and zero shows no sign either way (BACKLOG-0001095). Off by default. (optional)
prefixstring(optional)
suffixstring(optional)
zeroDisplaystring(optional)
nullDisplaystring(optional)
localestringThe locale for number, date and text formatting. The page's by default. (optional)
messagesRecord<string, string | Record<string, string>>A partial message catalogue laid over the built-in British English one. Every valid key is listed in `MESSAGE_KEYS`; a key that is not is ignored with a warning. Import a bundled locale (`FR_FR`, `AR`, …) or supply your own object. Merged rather than replacing, so an incomplete translation leaves the remainder in English rather than showing raw keys. (optional)
scalenumber(optional)

OpenRowOp

A structural op (append or delete) still awaiting an outcome (§5.3).

MemberTypeDescription
idstring
kind'append' | 'delete'
keystring
state'pending' | 'superseded'
agenumber

OpenWrite

MemberTypeDescription
idstring
keystring
colIdstring
valueunknown
beforeunknown
state'pending' | 'superseded'
agenumber

Option

MemberTypeDescription
idunknown
labelstring
disabledboolean(optional)
variantVariantName(optional)
iconIconNameA glyph name from the icon registry (see {@link IconName}), shown before the label. (optional)
groupstring(optional)

OverlayApi

MemberTypeDescription
show(kind: 'loading' | 'empty' | (string & {}), message?: string): void
hide(): void

PagedSourceConfig

MemberTypeDescription
mode'paged'
pageSizenumber(optional)
maxCachedPagesnumber(optional)
fetch(req: {

PaginationApi

MemberTypeDescription
get(): { page: number; pageSize: number; total: number; pageCount: number }
set(next: { page?: number; pageSize?: number }): void
applyPage(next: { page?: number; pageSize?: number }): void

PaginationConfig

MemberTypeDescription
enabledboolean(optional)
pageSizenumber(optional)
pageSizesnumber[](optional)

Peer

One peer, as the grid holds them.

MemberTypeDescription
idstring
namestring
colourstringAssigned deterministically from the id when the provider supplies none.
avatarUrlstring | null(optional)
initialsstring | null(optional)
cursor{ rowId: string; colId: string } | nullRow key and column, never an index.
rangesArray<{ rowIds: string[]; columns: string[] }>
editing{ rowId: string; colId: string } | null
atnumberLocal receipt time, not the sender's clock.
sentAtnumber | nullThe sender's own timestamp, for inspection only. Nothing decides on it. (optional)
idleboolean(optional)
silentMsnumber(optional)
hiddenbooleanTrue when the peer's cursor is on a row this view is not showing. (optional)

PendingWrite

MemberTypeDescription
idstring
keystring
colIdstring
valueunknown
beforeunknown
rowRow(optional)

PermissionsApi

MemberTypeDescription
levelOf(column: string | ResolvedColumn): PermissionLevel
isHidden(column: string | ResolvedColumn): boolean
isReadable(column: string | ResolvedColumn): boolean
isEditable(column: string | ResolvedColumn): boolean
isSecret(column: string | ResolvedColumn): booleanTrue only at `writeOnly`: writable, never shown or exported.
isExportable(column: string | ResolvedColumn): boolean
levels(): Record<string, PermissionLevel>
setContext(context: unknown): voidChange the context permissions are evaluated against, and re-evaluate.
invalidate(): void

PivotViewApi

Controls for the pivot presentation (§10, BACKLOG-0000738): expand or collapse an axis node, and read the collapse state a saved view carries. Every method is a no-op on a headless grid, which has no matrix to collapse.

MemberTypeDescription
expand(axis: 'row' | 'column', path: string): voidExpand a collapsed node on the row or column axis.
collapse(axis: 'row' | 'column', path: string): voidCollapse a node on the row or column axis, hiding its descendants.
toggle(axis: 'row' | 'column', path: string): voidToggle a node's collapse on the row or column axis.
state(): { rowsCollapsed: string[]; columnsCollapsed: string[] }The collapsed row-axis and column-axis paths, as a saved view carries them.

PresenceApi

MemberTypeDescription
enabledboolean(read-only)
meRecord<string, unknown> | null(read-only)
publishingboolean(read-only)
peers(): Peer[]
hiddenCount(): number
editorOf(rowId: string, colId: string): Peer | null
lockedBy(rowId: string, colId: string): Peer | nullAdvisory. Reduces collisions; does not eliminate them.
jumpTo(peerId: string): boolean
publish(): void
setPublishing(on: boolean): void
setPaused(paused: boolean): void
connect(provider: PresenceProvider | null): void
stats(): Record<string, number>

PresenceConfig

MemberTypeDescription
providerPresenceProviderWithout one the feature is inert and raises nothing. (optional)
me{ id: string; name?: string; colour?: string; avatarUrl?: string; initials?: string }The local identity, echoed in everything published. (optional)
throttleMsnumberMilliseconds between published updates. Throttled, not debounced. (optional)
idleMsnumberSilence after which a peer is shown idle. (optional)
removeMsnumberSilence after which a peer is dropped. (optional)
lockMsnumberSilence after which a peer's edit claim is disregarded. (optional)
lockbooleanRefuse local editing of a cell a peer is editing. Advisory only: the authoritative resolution is the conditional write in `edit.commit`. (optional)
palettestring[]Override the peer colour palette. (optional)
rosterboolean | { side?: 'start' | 'end' }Suppress the roster, or place it. (optional)
announcebooleanSuppress join and leave announcements to assistive technology. (optional)

PresenceProvider

Transport for presence. The grid never opens a connection: it subscribes to what the provider delivers and hands it what changed locally.

MemberTypeDescription
subscribe(onMessage: (message: Peer | Peer[]) => void): (() => void) | voidReturns an unsubscribe function, if it has one.
publish(state: Record<string, unknown>): void

PresentationApi

MemberTypeDescription
activeboolean(read-only)
scalenumber(read-only)
options{ scale?: number; chrome?: string[]; views?: string[]; from?: number; autoAdvance?: number }(read-only)
viewsstring[](read-only)
indexnumber(read-only)
viewIdstring | null(read-only)
start(options?: {
stop(): boolean
setScale(value: number): number
nudge(steps?: number): number
step(by?: number): number
goTo(index: number): number
reset(): boolean
spotlight{ keys: string[]; colIds: string[] } | null(read-only)
setSpotlight(target?: { keys?: string[]; colIds?: string[] } | null): boolean

ProcessCapability

MemberTypeDescription
nnumber
meannumber
lowernumber | null
uppernumber | null
targetnumber | null
sigmaWithinnumber | nullShort-term variation, from the moving range: what Cp and Cpk use.
sigmaOverallnumber | nullOverall variation: what Pp and Ppk use.
cpnumber | nullPotential capability. Null for a one-sided specification.
cpknumber | nullCapability allowing for where the process is centred.
ppnumber | nullCp over the overall spread: what the process actually delivered.
ppknumber | nullCpk over the overall spread. Well below Cpk means the process drifted.
outOfSpecnumber
defectRatenumber | null
limits{ centre: number; upper: number; lower: number; sigma: number } | nullThree sigma either side of the process mean, from the moving range.
baselinenumberHow many leading readings set the limits. (optional)
ruleSet'westernElectric' | 'nelson'Which rule set `violations` were judged against, they number differently. (optional)
violations{ index: number; rule: number; description: string }[]
intervalCapabilityInterval | nullA confidence interval for `cpk`. A study that reports the point estimate alone overstates itself: 1.35 from thirty parts has a lower bound below 1. (optional)
intervalPpCapabilityInterval | nullThe same, for `ppk`. (optional)

ProportionInterval

A Wilson score interval for a rate. Stays inside 0 to 1 at the extremes.

MemberTypeDescription
proportionnumber
lowernumber
uppernumber
nnumber
confidencenumber

PushdownAdapter

An engine the grid can query, and what it is able to answer.

MemberTypeDescription
namestringUsed in diagnostics and in the message when work cannot be pushed. (optional)
capabilitiesPushdownCapabilities(optional)
execute(query: RemoteRequest, request?: RemoteRequest):Run the part of the query the adapter declared it could handle.
executeGroupLevel(Answer one level of a grouped grid (BACKLOG-0001325). Present only when `capabilities.group` opts in. The level is `query.groupValues.length`: the root asks for the outermost grouping column's distinct values, expanding a group asks for the next column's values within it, and past the last grouping column the children are the leaves (`leaves: true`). A group row comes back in the shape the remote source already reads from a grouping server: the grouping column's own id carries the key, `leafCount` the group's row count, `totals` the subtotals keyed by column id. `total` is how many group rows the level holds. At the root, `matchCount` and `grand` carry the whole-set figures a grouped window cannot derive — the rows the filter matched, and the grand total over them. `aggregates` is the subtotal list the source routed to the engine; anything it could not route is named in `PushdownPlan.aggregates.client` and left absent from the group row rather than computed over the wrong set. (optional)
unfilteredCount(): Promise<number | null>The row count before any filter (BACKLOG-0001325) — the denominator of "1,204 of 100,000" under grouping, where the display count is group headers rather than rows. Optional; a source falls back to the display count. (optional)
mutate(op: MutationOp, request?: RemoteRequest): Promise<MutationResult>Persist one mutation (§4.2). Present only when `capabilities.mutate` opts in. `createPushdownSource` synthesises an `edit.commit` that calls this for cell updates (§4.3 Option A); `request` threads the abort signal through the way `execute` receives it, and auth already lives on the adapter. (optional)

PushdownAggregatesConfig

Design-time aggregate-pushdown policy for a pushdown source (BACKLOG-0000730 Part B). The developer chooses, at grid setup before render, whether each statistic is computed by the engine (fast, over the matching set) or client-side (the grid's exact definition, needs a full-dataset pull). It is fixed for the life of the grid, never a runtime toggle, and never surfaced to an end user. Absent, every aggregate is computed client-side — today's behaviour, so no existing caller regresses. `engine-if-identical` is the recommended setting for a windowed DuckDB source: it pushes only the statistics whose engine result is verified identical to the grid kernel, keeping the documented MAY-DIFFER stats (e.g. `mode`) client-side. The engine is used only when the filter is fully pushed; a residual filter forces every aggregate client-side, so an engine figure and a client figure never mix in one result set.

MemberTypeDescription
defaultAggregateModeThe default policy for stats the engine can express. `'engine'` pushes everything expressible (using the engine's method for MAY-DIFFER stats); `'engine-if-identical'` pushes only the verified-identical ones; `'client'` computes everything client-side. Default `'client'`. (optional)
overridesRecord<string, 'engine' | 'client'>Per-stat overrides, winning over `default`. A stat the engine cannot express (`weightedQuantile`) is always client-side regardless. (optional)

PushdownCapabilities

What a pushdown adapter can answer. Everything is off unless declared.

MemberTypeDescription
filterfalse | 'term' | 'flat' | 'tree'`false`, a single field and term, a flat conjunction, or a full tree. (optional)
operatorsstring[]Which comparison operators the engine understands. (optional)
sortfalse | 'single' | 'multi'`false`, one column only, or many. (optional)
quickbooleanWhether a free-text search across columns can be pushed. (optional)
rangebooleanWhether the engine can return a window rather than the whole result. (optional)
totalbooleanWhether it can report the count of matching rows. (optional)
groupbooleanWhether it can answer the grid's grouped view — group rows, their counts, their subtotals and their order — one level at a time, instead of returning the leaves for the grid to group in the browser (BACKLOG-0001325). All or nothing, unlike `filter`. A filter splits because the engine narrowing a superset and the grid narrowing what is left reach the same set; a grouping cannot, because group rows counted over the wrong set are wrong rows, not slow ones. So the push router refuses the whole grouped level — and says why in `PushdownPlan.groupReason` — whenever anything else in the query failed to push. An adapter declaring this must implement `executeGroupLevel`; one that declares it without the method is re-planned without grouping and warned about, rather than half-pushed. (optional)
mutatefalse | MutateCapabilityWhat the adapter can persist back — the write-back contract (§4.1). `false` (the default) is read-only by declaration. A declared block opts kinds in; `capabilitiesOf` resolves it to a full `MutateCapability` (or `false`). (optional)

PushdownFullDatasetConfig

Opt-in, sticky full-dataset pull for a pushdown/remote source (BACKLOG-0000730). Off by default. When enabled, the source materialises the entire matching set client-side once per query signature and serves every window, total and statistic from it, so those figures are computed over the whole set rather than the loaded window. A set past either limit is refused with a visible `source:error` — never silently truncated.

MemberTypeDescription
enabledbooleanSticky: hold the whole matching set client-side. Default `false`. (optional)
maxRowsnumberRefuse (visible error) past this many rows. Default `1_000_000`. (optional)
maxBytesEstimatenumberRefuse past this estimated heap cost, in bytes. Default `512 * 1024 * 1024`. (optional)

PushdownPlan

How one request was divided between the engine and the grid.

MemberTypeDescription
pushedRemoteRequestThe query the adapter was given.
residual{What the grid applied afterwards. `where` is the host predicate runtime when one survived the `whereRowLimit` gate, and `null` when none was registered or the gate refused it (BACKLOG-0001268).
needsAllbooleanWhether the whole result had to be fetched rather than a window.
unpushedstring[]Which parts could not be pushed: `filter`, `sort`, `quick`, `where`, `group`.
groupedbooleanWhether the engine answered the grid's grouped view for this request (BACKLOG-0001325). False for an ungrouped query and for a grouped one the engine was refused — `groupReason` says which.
groupLevelnumberWhich grouping level a pushed grouped request asked for: 0 at the root, 1 inside a group, and so on. Zero when nothing was grouped.
groupReasonstringWhy a grouped request was *not* pushed, in a sentence, or `''` when it was pushed or when nothing was grouped. Grouping is all or nothing, so this is the whole story rather than a residual.
fullbooleanWhether the whole result was fetched because `fullDataset` is on, rather than only because residual work forced it. When true, totals and statistics reduce over the whole matching set and the windowed-stat warning is silent.
aggregates{Per-aggregate provenance, present only when the last request computed aggregates (BACKLOG-0000730 Part B): which statistics the engine computed and which the client did, with the class the pushdown map assigned each. Under grouping it also carries the `groupBy` the subtotals were computed over. Build-time inspection, not a runtime per-figure marker. (optional)

PushdownSourceConfig

MemberTypeDescription
adapterPushdownAdapter
computeobjectThe compute barrel, for applying whatever the engine could not. (optional)
pageSizenumber(optional)
fullDatasetPushdownFullDatasetConfigOpt-in full-dataset pull. Off unless `fullDataset.enabled` is set. See {@link PushdownFullDatasetConfig}. (optional)
aggregatesPushdownAggregatesConfigDesign-time aggregate-pushdown policy. Absent = client-side (today's behaviour). See {@link PushdownAggregatesConfig}. (optional)
allowPartialResultsbooleanAccept a partial/paged result to a whole-set request when residual work (a filter, sort or quick search) will run over it client-side. Off by default: such a shortfall is refused with a thrown error, because filtering or sorting a fraction of the result presents the wrong rows as the whole filtered set — a wrong answer, not a slow one. Set `true` only when you knowingly accept that risk (e.g. an adapter that cannot page and a result small enough not to matter); the old warn-once-and-proceed behaviour is then kept. It never changes the fullDataset memory-guard or the no-residual short-return warning. (optional)
whereRowLimitnumberThe most rows the source will fetch and hold in order to run a twinless `where` predicate as the residual (BACKLOG-0001268). Defaults to `50_000`, the same anchor as the grid's `workerThreshold` — the size at which this codebase already judges a dataset big enough to need different handling. A `where` predicate is a host function no engine can evaluate, so the only way to honour one is to fetch every matching row and filter here. That silently turns a windowed grid into a whole-dataset download, which is the thing a pushdown source exists to avoid. So it is a gate, not a free upgrade: at or past this many matching rows the predicate is **refused and warned about** — the rows it would exclude stay on screen — rather than the download being taken on the host's behalf. An adapter that reports no row total counts as over the limit, because guessing the other way is guessing your way into the download. Raise it when you want that download; the `{ condition }` twin is the route that narrows the fetch itself and works at any size. (optional)

RailAction

MemberTypeDescription
namestring
titlestring | (() => string)
iconIconName | (() => IconName)A glyph name from the icon registry (see {@link IconName}) — a built-in name, or one registered with `registerIcon`/`registerIcons`, `config.icons` or `grid.icons`. A function form is re-read on every repaint, the same as `title`, so a toggle can swap its glyph with its state. When omitted, the rail tries `name` as the icon name instead (so an action named after a built-in, e.g. `'undo'`, needs no separate `icon`); an unrecognised name — from either `icon` or the `name` fallback — draws a blank glyph, and only an explicitly-given unrecognised `icon` warns once in the console. (optional)
run(params: RailActionParams): void
enabled(): boolean(optional)
active(): booleanMarks the action as a toggle and reports whether it is currently on. When present the rail renders `aria-pressed` and a pressed style, re-read on every repaint; a one-shot action omits it and is unchanged. This is the hook the native annotation tools use, and it is available to a host button that is itself a toggle. (optional)

RailActionParams

What a host rail action's `run` is handed.

MemberTypeDescription
gridGrid
keysstring[]
cells{ key: string; colId: string }[]

RedactionApi

Redaction obscures a column's values on screen. It is presentational: the values stay in the model, the DOM, the clipboard and every export. Use `permissions` with `writeOnly` for a value that must not be readable.

MemberTypeDescription
has(colId: string): boolean
list(): string[]
toggle(colId: string): boolean
add(colId: string): void
remove(colId: string): void
set(ids: string[]): void
clear(): void
activeboolean(read-only)

Registry

MemberTypeDescription
modules(): GridModule[]
has(name: string): boolean
renderer(name: string): RendererCtor | RenderFn | undefined
editor(name: string): EditorCtor | undefined
filter(name: string): FilterCtor | undefined
dataType(name: string): DataType | undefined
totalFn(name: string): TotalFn | undefined
pipe(name: string): ((v: unknown, ...a: string[]) => string) | undefined
register(kind: string, name: string, impl: unknown): void

RegressionBand

A pointwise confidence band for the mean response of a single-predictor fit.

MemberTypeDescription
confidencenumber
points{ x: number; yhat: number; lower: number; upper: number }[]

RegressionCoefficient

One fitted coefficient, with the uncertainty around it.

MemberTypeDescription
namestring`(intercept)` or the predictor's column id.
estimatenumber
stdErrornumber
tnumberestimate ÷ standard error.
pnumberTwo-sided Student-t p-value; a number with a documented method, not a verdict.
lowernumber | nullThe Wald confidence interval at the model's confidence level (BACKLOG-0000872) — the whiskers a coefficient forest plot draws. Null when there is no residual degree of freedom to form a critical value.
uppernumber | null

RegressionFit

MemberTypeDescription
slopenumber
interceptnumber
r2numberThe square of Pearson's r: how much of the response the fit accounts for.
stdErrornumberStandard error of the slope, which is what says it differs from zero.
nnumberPairs that survived pairwise deletion, not rows scanned.

RegressionModel

A fitted multi-predictor linear model and its diagnostics (BACKLOG-0000792).

MemberTypeDescription
methodstring
coefficientsRegressionCoefficient[]
r2number
adjR2number
nnumber
dfnumberResidual degrees of freedom, n − p.
sigma2numberResidual variance, RSS ÷ df.
fittednumber[]
residualsnumber[]
leveragenumber[]Hat-diagonal leverage per row.
cooksD(number | null)[]Cook's distance per row; null where it cannot be computed.
vifnumber[]Variance-inflation factor per predictor; Infinity when exactly collinear.
heteroscedasticityHeteroscedasticity | null
bandRegressionBand | null
weightsnumber[] | nullPer-row weights actually used (robust/WLS), or null for OLS.
predictorsstring[]
responsestring
rowsnumber[]The physical rows the diagnostics are aligned to, in order.

RegressionSpec

The specification of a multi-predictor model (BACKLOG-0000792).

MemberTypeDescription
predictorsstring[]The predictor column ids.
responsestringThe response column id.
method'ols' | 'wls' | 'robust' | 'quantile'`ols` (default), `wls` or `robust`. `quantile` is reserved (coming next). (optional)
weightsstringA weights column id, required for `wls`. (optional)
confidencenumberThe confidence level for the band; 0.95 by default. (optional)

RejectedRow

A row a change could not apply, and why. Reported, never thrown.

MemberTypeDescription
operation'add' | 'update' | 'remove'
idstring
reason'unknown-id' | 'duplicate-id'`unknown-id`, no row with that key. `duplicate-id`, a row with that key already exists; admitting a second would corrupt every structure that resolves one key to one row.

RemoteRequest

MemberTypeDescription
protocol1
range{ start: number; end: number }
groupPathstring[]
groupValuesunknown[]The same ancestry as `groupPath`, but as the values the server returned rather than their display strings (BACKLOG-0001325). Always present, empty at the root, so a source can tell "no ancestors" from "a host that does not send this". `groupPath` is stringified because it is a stable *identity* for expansion state, and that is what it must stay: a numeric key `3` is `'3'` there and an absent key is `''`, indistinguishable from a group whose key really is the empty string. Useless for narrowing a query, then — which is what a grouping engine needs it for — so the typed values travel beside it.
groupByColumnRef[]
totalsColumnRef[]
totalFnsRecord<string, string>The named statistic each totalled column reduces with — `{ amount: 'sum' }` (BACKLOG-0001325). `totals` has always said *which* columns want a subtotal and never *what*, because the client reads the reduction off the column model and a server had no way to. Only string reductions appear: a column totalling with a host function has no name to send, and naming one that merely resembles it would put a plausible wrong number on every group row. `groupTotal` wins over `total`, the same precedence the client applies for the group scope.
pivotByColumnRef[]
pivotModeboolean
filtersFilterSet
quickstring(optional)
sortSortEntry[]
contextunknown
signalAbortSignal
whereWhereRuntimeThe `where` predicates in force, as a runtime the source can evaluate but not mutate (BACKLOG-0001268). Present **only when at least one predicate is registered**, so a grid that does not use `where` sends the request it always sent, field for field. A host `fetch` may ignore it, and every existing one does: it is a host function, so there is nothing to serialise and no engine can evaluate it — `passes` is dropped by `JSON.stringify` the way `signal` already is. It is carried for the one reader that can act on it, `createPushdownSource`, which runs it as the residual over the matching set when that set is under `whereRowLimit`. The `{ condition }` twin remains the route that narrows the fetch itself, at any size. (optional)

RemoteResult

MemberTypeDescription
rowsunknown[]
countnumber(optional)
pivotFieldsstring[](optional)

RemoteSourceConfig

MemberTypeDescription
mode'remote'
pageSizenumber(optional)
maxCachedPagesnumber(optional)
fetch(req: RemoteRequest): Promise<RemoteResult>

Renderer

MemberTypeDescription
init(p: CellParams): void
element(): HTMLElement
refresh(p: CellParams): boolean(optional)
attached(): void(optional)
destroy(): void(optional)

ResolvedColumn

A column after presets, type defaults and grid defaults are folded in.

MemberTypeDescription
idstring
fieldstring | null
titlestring
typeTypeName
dataTypeDataType
nullableboolean
alignAlign
verticalAlignVAlignThe resolved vertical alignment (BACKLOG-0000989), or `undefined` when neither the column nor the grid set one — in which case the cell keeps the grid's historical vertical placement (centred, or top for `autoHeight`). (optional)
valueRequired<Pick<ColumnValueSpec, 'pure'>> & ColumnValueSpec
cellColumnCellSpec
editColumnEditSpec
sortColumnSortSpec
filterColumnFilterSpec
group{ enabled: boolean; index: number; explode: boolean; granularity?: 'day' | 'week' | 'month' | 'instant'; weekStart?: number }
pivot{ enabled: boolean; index: number }
totalTotalName | TotalFn | null
groupTotalTotalName | TotalFn | nullThe group-subtotal override, or null when group subtotals follow `total` (BACKLOG-0000726).
grandTotalTotalName | TotalFn | nullThe grand-total override, or null when the grand total follows `total` (BACKLOG-0000726).
layoutColumnLayoutSpec
headerColumnHeaderSpec
contextMenuboolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void) | nullThis column's own cell-menu declaration (BACKLOG-0001068), or null when it makes none and the grid-level menu stands alone. Carried onto the resolved column so a column preset or `columnDefaults` can supply one.
exportColumnExportSpec
lookupLookupSpec | null
allowGroupboolean
allowPivotboolean
allowTotalboolean
formatValue(value: unknown, row?: Row, data?: unknown): stringCompiled display-text producer.
getValue(data: unknown, row?: Row): unknownResolve the value for a row, through the computed-value graph.
defColumn

RouteOptions

Per-route options, shared by `attach`, `attachDefault` and `subscribe`. `rowKey` overrides the router default for this route. `transform` reshapes each row before the viewer sees it; `filter` admits a subset; `sort` orders what the viewer receives; `rollup` summarises the slice (v3). A `transform` or `rollup` route is derived and cannot be `writable`. `where` is read only by `query()` (v7). `writable` routes the grid's committed edits to `onWrite`, reverting on reject, with `onConflict` for a last- write-wins conflict (v8); both default to the router's own. `label` names the route in metrics and the devtools panel; `backpressure` throttles its refresh under load (v13).

MemberTypeDescription
rowKeyRouterKey(optional)
transform(row: RouterRecord) => RouterRecord(optional)
filter(row: RouterRecord) => boolean(optional)
sortRouteSort(optional)
rollupRouteRollup(optional)
whereRouteWhere(optional)
writableboolean(optional)
onWrite(change: RouterWrite, ctx: { route: unknown; source: unknown }) => unknown(optional)
onConflict(change: RouterWrite, ctx: { serverRow: RouterRecord }) => void(optional)
labelstring(optional)
backpressureRouteBackpressure(optional)

RouterConfig

A declarative routing graph (v5, BACKLOG-0000910): the same routes, links, relationship edges and buffer the imperative calls would make, as one data spec. Desugars to those calls and composes with them.

MemberTypeDescription
routesRecord<string, unknown>[](optional)
links{ from: unknown; to: unknown; on?: SelectionRelation; relation?: SelectionRelation }[](optional)
relateRouterEdge[](optional)
buffer{ window?: number; max?: number }(optional)

RouterJoin

A fan-in source's lookup join (v11): `from` is the lookup source's id; `localKey` (alias `on`) reads the joining value off this source's row; `foreignKey` (alias `fromKey`) reads it off the lookup row, defaulting to a string `localKey`; `fields` (alias `select`) picks the lookup fields to carry — a list, a rename map, or a function of both rows; `missing` says what to do while the lookup row has not arrived: `hold` the row back, `passthrough` it unjoined, or fill the fields with `null`.

MemberTypeDescription
fromstring
localKeyRouterKey(optional)
onRouterKey(optional)
foreignKeyRouterKey(optional)
fromKeyRouterKey(optional)
fieldsstring[] | Record<string, string> | ((lookupRow: RouterRecord | null, leftRow: RouterRecord) => RouterRecord)(optional)
selectstring[] | Record<string, string> | ((lookupRow: RouterRecord | null, leftRow: RouterRecord) => RouterRecord)(optional)
missing'hold' | 'passthrough' | 'null'(optional)

RouterMetrics

A `metrics()` snapshot (v10): per-route and per-source counts and throughput (rows/sec since the previous read), and the global unrouted, dropped (duplicate), buffered and lag figures.

MemberTypeDescription
routesRouterRouteMetrics[]
sourcesRouterSourceMetrics[]
unroutednumber
droppednumber
bufferednumber
lagnumber
throughputnumber

RouteRollup

A rollup route's summary spec (v3): one summary row per `groupBy` group, each `aggregate` a named reducer over the group's rows or a `{ op, field }` shorthand (`sum`, `avg`, `min`, `max`, `count`).

MemberTypeDescription
groupByRouterKey | RouterKey[]
aggregateRecord<string, ((rows: RouterRecord[]) => unknown) | { op: string; field?: string }>(optional)

RouterPersistOptions

Durable persistence options (v12): `key` names the snapshot, `debounce` (ms) coalesces writes, `storage` is a `{ get, set }` pair of your own, or `indexedDB` / `dbName` / `storeName` select the browser store.

MemberTypeDescription
keystring(optional)
debouncenumber(optional)
storage{ get: (key: string) => Promise<unknown>; set: (key: string, value: unknown) => Promise<void> }(optional)
indexedDBunknown(optional)
dbNamestring(optional)
storeNamestring(optional)

RouterQueryAdapter

A pushdown adapter `query()` can source the router from (v7): anything with an `execute(query, request)` returning rows, and optional `capabilities` the planner consults to decide what it may push down.

MemberTypeDescription
capabilitiesRecord<string, unknown>(optional)
execute(query: Record<string, unknown>, request?: Record<string, unknown>) => Promise<{ rows: RouterRecord[]; total?: number }>

RouterRouteMetrics

One route's figures in a `metrics()` snapshot (v10).

MemberTypeDescription
labelstring | null
rowsnumber
shownnumber
throughputnumber

RouterSourceHandle

The handle `addSource` returns for one feed (v9). Its `load` is a per-source snapshot — a keyed diff over this feed's rows only, other feeds untouched; `apply` and `push` take this feed's deltas through the router's ordinary and batched paths; `remove` deletes exactly the rows it holds and unregisters it, returning the router.

MemberTypeDescription
idstringThe source id. (read-only)
sizenumberHow many rows this source currently holds live. (read-only)
load(rows: RouterRecord[]): RouterSourceHandleApply a per-source snapshot: upsert its current rows, delete the ones it no longer has.
apply(deltas: RouterDelta[]): RouterSourceHandleApply per-source deltas through the router's ordinary apply path.
push(delta: RouterDelta | RouterDelta[]): RouterSourceHandleEnqueue per-source deltas through the router's stream path (batching honoured).
remove(): DataRouterRemove this source: delete exactly the rows it holds from every route, then unregister it.

Row

MemberTypeDescription
keystringWhat identifies the row. Selection, expansion and edits are all keyed on it.
dataunknown | nullThe object you supplied. Null on a group heading, which is a product of the grouping rather than a record.
levelnumberDepth in a tree or a grouping. Zero at the top.
parentRow | nullThe row above it in a tree or grouping, or null at the top.
childrenRow[]Every child, before filtering. (optional)
filteredChildrenRow[]The children the filters left. (optional)
sortedChildrenRow[]The children in display order. (optional)
groupbooleanWhether this is a group heading rather than a record. A heading carries no data and must be skipped when totalling.
expandedbooleanWhether its children are showing.
leafCountnumberHow many records sit beneath it, at any depth.
totalsRecord<string, unknown>The group's own reductions, by column id. (optional)
detailbooleanWhether this row is the expanded detail panel of the one above. (optional)
masterbooleanWhether this row has a detail panel. (optional)
heightnumberThe row's height in pixels, as measured or configured.
indexnumber | nullPosition in the display order, or null when off screen.
selectedboolean | 'partial'Selection state. `partial` is a group some but not all of whose children are selected.
physicalnumber | nullPhysical index into the ColumnStore. Null for synthetic rows. (optional)
groupColumnstringGroup rows only: the column id this level groups on, and the group value. (optional)
groupValueunknownThe value this group heading stands for. (optional)
groupPathstring[]Stable path of group keys from root to this row. (optional)
hasChildrenbooleanWhether children exist, which a lazily loaded tree knows before it has them. (optional)
pinned'top' | 'bottom'Which sticky strip this row is pinned in, when it is one the host pinned through `setPinnedRows`. Absent on every row that is part of the data. (optional)

RowChange

MemberTypeDescription
addunknown[](optional)
atnumber(optional)
updateunknown[](optional)
removeunknown[] | string[](optional)

RowDragEvent

The row-drag lifecycle events (BACKLOG-0001224): `rowDrag:started`, `rowDrag:moved`, `rowDrag:left` and `rowDrag:ended`, which report a row drag *as it happens* rather than once it has settled. Before them a host got the handle the grid draws and then one settled event, with nothing in between to highlight a candidate target, drive a custom drop indicator, or react when the pointer left the grid. **All four fire on the grid the drag started in**, whether the row is being reordered within that grid or dragged into another one. A drag is one gesture with one owner, and the source grid is the only grid present for the whole of it — the pointer may cross several others, or none. `over` names whichever grid the event is about, so a single subscription can drive decoration on any of them. **Notifications, not gates.** None of these is cancellable and none carries `preventDefault`. The drop is already vetoable twice over — `beforeRowMove` for a reorder, `beforeRowReceive` for a drop into another grid — and a third veto on the same gesture would be a third place to look when a drop does not happen. **What is safe to do in a handler.** Read, measure and draw: highlight a candidate row, move an indicator, update a side panel. Do not mutate rows, columns, sort, filters or grouping from one of these. The drag resolves where it would land against the display order, so changing that order mid-gesture moves the ground under the drop; and `data` is the source row's own object rather than a copy, so writing to it edits the row that is still in the grid without announcing it. Work that changes the grid belongs in `beforeRowReceive`, which is asked before the insert, or in the settled events afterwards. **`rowDrag:moved` is coalesced to one event per animation frame**, carrying the latest pointer position of that frame, so a handler runs at the display's rate rather than the pointer's several hundred events a second. The other three fire on the transition itself. The sequence for any gesture is `rowDrag:started`, then `rowDrag:moved` and `rowDrag:left` as the pointer travels, then exactly one `rowDrag:ended` — including when the pointer is released outside every grid. No `rowDrag:moved` is delivered after `rowDrag:ended`. A press that never passes the drag threshold is a click and raises none of them; a grid destroyed mid-drag raises no `rowDrag:ended`.

MemberTypeDescription
keystringThe key of the row being dragged.
dataRecord<string, unknown> | nullThe dragged row's data as it stands in the source grid — that row's own object, not a copy. Null if the row has left the source during the drag.
overGrid | nullThe grid the event is about: the grid under the pointer for `rowDrag:started`, `rowDrag:moved` and `rowDrag:ended`, and the grid just left for `rowDrag:left`. Null when the pointer is over no grid at all.
atnumber | nullWhere the row would land in `over`: the display index it would take. Null when there is no candidate to report — the pointer is over no grid, over a grid that will refuse the row, or over a header; and on `rowDrag:left`, which is about a grid the pointer has already gone from.
overKeystring | nullThe key of the row under the pointer in `over`, or null where there is no row to name: past the last row, on empty space, on a header, on a pinned row, on a grid that will refuse the drop, or on `rowDrag:left`.
droppedboolean`rowDrag:ended` only: whether the release is being acted on — a transfer the target accepts, or a same-grid reorder that is a real move and is not refused by a sort, filter or grouping. False when the row was released over no grid, over a grid that refuses it, or back where it started. What became of an acted-on drop is reported by `row:moved`, `row:sent`, `row:received` and `rowReceive:cancelled`. (optional)

RowFormApi

MemberTypeDescription
open(key: string): booleanOpen the form for a row. False when the form is not configured.
close(): void
save(): boolean
isOpen(): boolean

RowReceiveCancelledEvent

The `rowReceive:cancelled` event (BACKLOG-0001225): a `beforeRowReceive` was vetoed, or went stale during an async handler. Nothing was inserted and the source grid is untouched.

MemberTypeDescription
dataRecord<string, unknown>The row that was not inserted, as the handler saw it.
atnumberThe display index it would have taken.
overKeystring | nullThe key of the row under the pointer, or null.
sourceGridThe grid the row would have come from; it still holds the row.
reasonstringThe reason given to `preventDefault`, `'prevented'` when none was given, or `'stale'` when the row under the pointer or the source row was gone by the time an async handler settled.

RowsApi

MemberTypeDescription
load(rows: unknown[]): voidReplace the data. Sort, filters, grouping and column layout are kept.
apply(change: RowChange): ChangeResult
queue(change: RowChange): Promise<ChangeResult>
get(index: number): Row | undefined
byKey(key: string): Row | undefined
count(): number
totalCount(): numberRows in the source before filtering; under pagination, across every page.
matchCount(): numberData rows matching the filters, excluding group, footer and total rows.
coverage(): StatCoverageHow much of the data a figure computed from this grid covers, so a statistic over a windowed source can say it is approximate.
data(): unknown[]
forEach(fn: (row: Row, index: number) => void): void
forEachAll(fn: (row: Row, index: number) => void): voidEvery row in the data, before any filter. Leaf rows, in physical order.
forEachExcept(colId: string, fn: (row: Row, index: number) => void): voidVisit the rows surviving every filter except one column's own: the faceting question, asked of the rows.
value(key: string, colId: string): unknown
text(key: string, colId: string): string
values(key: string): Record<string, unknown>
refresh(opts?: { rows?: string[]; columns?: string[]; force?: boolean }): void
move(key: string, to: number): { moved: boolean; from: number; to: number; reason?: string }Move a row to another position in the data. Refuses, with a reason, while a sort, filter or grouping is active.
groupHeadings(index: number): Row[]The group headings enclosing a display row, outermost first. Empty when the grid is not grouped.
leavesOf(key: string): Row[]The leaf rows beneath a group heading: the members it counts in `leafCount`, as rows, so you can roll up a field the grid was never told to total. Filtered members in display order. Computed per call, so call it when you draw a group row rather than in a loop over every row.
expand(key: string, deep?: boolean): void
collapse(key: string): void
expandAll(): void
collapseAll(): void

RowStyleParams

MemberTypeDescription
rowRow
keystring
indexnumber
dataunknown
gridGrid
contextunknown

SavedView

MemberTypeDescription
idstring
namestring
descriptionstring
sharedboolean
isDefaultbooleanApplied on load when no `config.state` is given. `config.state` wins outright over this flag: with both present, the default view is never applied and the active view id stays `null`.
builtinbooleanSupplied in `config.views.saved`: listed apart, and not renamable or deletable.
createdAtnumber
updatedAtnumber
stateGridStateA partial `GridState`; only the sections it names are applied.

ScrollApi

MemberTypeDescription
toRow(row: string | number, align?: 'start' | 'center' | 'end' | 'auto'): voidA row key, or a display index. A key survives a sort and is usually what a caller holds; resolving one scans the display order, so prefer an index when scrolling a very large grid repeatedly. The row lands fully visible in the part of the body the pinned strips (pinned rows, sticky group headings, a bottom grand total) do not cover: `end` puts it just above the bottom strip, `start` just below the top one.
toColumn(id: string): void
toCell(row: string | number, colId: string, align?: 'start' | 'center' | 'end' | 'auto'): voidScroll a cell into view, both axes in one call.
position(): { top: number; left: number }
to(at: { top?: number; left?: number }): void`left` is the logical offset, zero at the content's start in either direction.

SelectionApi

MemberTypeDescription
clearRange(): voidDrop every range, leaving the row and cell selection alone.
statistics(): object | nullEverything worth knowing about the selected cells: what `summary()` reports plus median, quartiles, deviation, distinct and outliers. Over the cells rather than a column, so a rectangle spanning three columns is one set of numbers. Null with nothing selected.
rows(): Row[]
keys(): string[]
set(keys: string[]): void
all(): void
clear(): void
headerState(): boolean | 'partial'
cells(): { key: string; colId: string }[]
ranges(): CellRange[]
setRange(range: CellRange): void
addRange(range: CellRange): void
startRange(rowIndex: number, colId: string, opts?: { additive?: boolean }): void
extendRange(rowIndex: number, colId: string): void
corner(): { row: number; colId: string } | null
inRange(rowIndex: number, colId: string): boolean

SelectionConfig

MemberTypeDescription
mode'none' | 'single' | 'multiple'`'none'` also turns off `ranges` and `fillHandle` unless either is set explicitly alongside it. (optional)
checkboxboolean(optional)
headerCheckboxboolean(optional)
checkboxOnlybooleanOnly the `checkbox` column may change row selection — a click anywhere else in the row, and Space with focus anywhere but the checkbox, leave selection untouched. Range and cell selection are unaffected either way. For a host whose row click is bound to its own action (opening a record): without this, that click also selects the row, so a later bulk action can reach rows nobody chose. Off by default. `mode: 'none'` already refuses every selection path regardless of this flag. (optional)
groupSelectsChildrenboolean(optional)
groupSelectsFilteredboolean(optional)
rangesboolean(optional)
fillHandleboolean(optional)
fill(p: { source: unknown[]; target: { row: Row; column: ResolvedColumn }[]; direction: string }) => unknown[](optional)

SeriesStats

MemberTypeDescription
nnumber
firstnumber
lastnumber
changenumber
changePercentnumber | null
volatilitynumber | nullStandard deviation of period-on-period returns.
annualisedVolatilitynumber | nullThe same, times the root of `periodsPerYear`; null unless one was given.
growthnumber | nullCompound growth per period, annualised when `periodsPerYear` is given.
maxDrawdownnumber | nullThe largest peak-to-trough fall, as a fraction.
maxDrawdownFromnumber
maxDrawdownTonumber
autocorrelationnumber | nullLag-1: positive is momentum, negative is mean reversion.
upDaysnumber
downDaysnumber

SortApi

MemberTypeDescription
get(): SortEntry[]
set(entries: SortEntry[]): voidReplace the sort model; an entry naming no known column is dropped with a warning.
clear(): void

SortEntry

MemberTypeDescription
colstring
dir'asc' | 'desc'
nullsFirstboolean(optional)

Source

MemberTypeDescription
mode'memory' | 'paged' | 'remote' | 'stream'(read-only)
count(): number
at(index: number): Row | undefined
byKey(key: string): Row | undefined
loaded(index: number): boolean
hint(start: number, end: number): void
apply(change: RowChange): ChangeResult
reload(opts?: ReloadOptions): void
destroy(): void(optional)

Stat

The handle `createStat` returns.

MemberTypeDescription
element(): HTMLElement | null
value(): unknown
refresh(): void
destroy(): void

StatConfig

A statistic block: a label, a value, its change, and what it is compared with. Reads the grid, so it cannot disagree with the table beneath it, and formats through the column's own type, so the tile and the table cannot drift.

MemberTypeDescription
gridGrid(optional)
containerHTMLElement | stringAn element, or a CSS selector resolved against the grid's document.
titlestring(optional)
iconstringAn optional leading icon beside the title and value, using the same value contract as a menu item: a registered sprite name, a single character or emoji, or author-trusted element markup (`'<i class="fa-light fa-bolt"> </i>'`, an `<img>`). It lays out to the side without disturbing the change indicator, threshold bands or confidence interval; omit it for the plain tile layout. (optional)
valueunknown | StatValueSpec | ((grid: Grid) => unknown)A literal value, a spec to reduce, or a function of the grid. (optional)
footerstring | ((value: unknown, grid: Grid) => string)Text under the value, or a function of it. (optional)
baselinenumber | ((grid: Grid) => number)What the value is compared against, for the change indicator. (optional)
goodWhen'up' | 'down' | 'neither'Whether a rise is good news. `up` by default. (optional)
bands{ good?: number; warn?: number; direction?: 'up' | 'down' }Thresholds the value itself is judged against, setting `data-tone` on the tile. Separate from `goodWhen`, which judges the *change*: a Cpk of 0.9 is bad news whether it rose or fell to get there. (optional)
interval(value: unknown, grid: Grid) =>An interval to show under the value: how much to trust it. Return whichever of the grid's intervals belongs to this tile. (optional)
scope'filtered' | 'all' | 'selected'Which rows feed the value. `filtered` by default. (optional)
liveboolean`false` stops the tile following the grid; `refresh()` still works. (optional)
format(value: unknown, grid: Grid) => stringOverride the formatting the column's type would apply. (optional)
emptystringShown when there is no value. `, ` by default. (optional)
decimalsnumberFraction digits for a value whose reduction changed the unit. 2 by default. (optional)
classstringExtra class names for the tile's root. (optional)

StatCoverage

How much of the data a computed figure actually covers. `covered < total`, or `total === null`, means the figure is approximate.

MemberTypeDescription
coverednumberRows the figure was computed over.
totalnumber | nullRows the source knows about, or `null` when it cannot know — never a guess.
windowedbooleanTrue when a window bounded the computation, so the figure covers part of the data.

StateApi

MemberTypeDescription
get(): GridState
apply(state: GridState, opts?: { skip?: (keyof GridState)[] }): StateApplyReport
baseline(): GridState | nullThe grid as configured, without `config.state` — captured once, before that seed is applied, so a view opened through `config.state` is never itself mistaken for the default `reset()` returns to.
reset(): StateApplyReport | nullPut the grid back the way it started, as one undoable step.
modified(): booleanWhether anything has changed since construction.

StateApplyReport

MemberTypeDescription
appliedstring[]
skipped{ key: string; reason: string }[]

StateChangedEvent

The `state:changed` event (BACKLOG-0001182). Fires once per logical state change, whether it began as a user gesture or as a programmatic call, so view persistence is built on this one event rather than on the ten individual ones — `reset()` raises those too, which made a debounced save write the reset arrangement back. **Exactly one event per change.** A change that internally routes through `state.apply()` — applying a saved view, an undo, a reset — announces itself once, carrying the outermost cause rather than the inner mechanism's. A host predicate registered, replaced or removed through `filters.where(name, fn)`, and a `filters.reapply()` that re-runs one, go through the same tracked door as `sort` and `filters`: each fires this event once, `cause: 'user'`, with `'where'` in `sections` (BACKLOG-0001235).

MemberTypeDescription
causeStateChangeCauseWhy the state changed. `'reset'` is the one a save should ignore.
sectionsStateSection[]Which sections moved, sorted and de-duplicated. For `'apply'` and `'reset'` these are the sections the report applied; for `'user'`, the sections the change touches.
stateGridState | nullThe state that was applied — present for `'apply'` and `'reset'`, null for `'user'`. A full capture on every gesture would put an unsanitised copy of the state, hidden column ids and widths included, on the bus for every listener; a host calls `grid.state.get()` when it decides to write, which is permission-sanitised.
reportStateApplyReport | nullWhat an apply could not restore; null for `'user'`.

StatisticsApi

MemberTypeDescription
shadow(colId: string, kind: ShadowKind, rowKey: string,One shadow value for one row, by the column it shadows and the kind. For `kind: 'specStatus'`, `spec` carries the `{lower, upper, warnLower, warnUpper}` limits to judge the row's value against; other kinds ignore it.
fitShadow(kind: 'fitPredicted' | 'fitResidual' | 'fitInfluence'One regression shadow value for a row, by key (BACKLOG-0000812): the predicted value, residual, or Cook's-distance influence flag from the fitted model, over the filtered rows. Null for a row outside the fit.
rowKeystring, spec: RegressionSpec): number | boolean | null
running(colId: string, kind: 'total' | 'percent', rowKey: string): number | nullA running total at one row, down the grid as it is currently ordered.
rebase(colId?: string): voidMake the current values the new baseline: "mark all".
tracking(): { columns: string[]; rows: number; forgotten: number }What the shadow histories are costing.
reduce(colId: string, fn: string): unknownReduce a column by a named kernel over the filtered rows.
profile(colId: string): ColumnProfile | nullEverything worth knowing about one column, in one pass each.
anomalies(opts?: { columns?: string[]The rows that do not belong (BACKLOG-0000749): anomaly detection over the filtered rows by the robust modified z-score (`modifiedZScore`, the default), Tukey's IQR fences (`iqr`), or multivariate Mahalanobis distance over the chosen columns (`mahalanobis`). Every flagged row carries the score behind it and the reason for it, so a flag is explainable rather than a verdict from nowhere. Non-numeric columns are returned under `skipped`.
subsetVsPopulation(opts?: { columns?: string[] }): SubsetComparisonWhich columns differ most between the filtered subset and the whole population it was drawn from, ranked by effect size — never by a p-value. The measure is stated per column; a numeric and a categorical column are put on one bounded scale so they rank against each other.
datasetVsDataset(other: Grid, opts?: { columns?: string[] }): DatasetComparisonWhich columns differ most between this grid and another, ranked by effect size — never by a p-value (BACKLOG-0000735). The generalisation of {@link subsetVsPopulation} from subset-vs-population to dataset-vs-dataset: two independent grids, yoked by passing one in, no shared store. A numeric column reports a pooled standardised mean difference (Cohen's d, symmetric in the two peers where Glass's delta is not); a categorical column the total variation of its category mix; both land on one bounded scale. Both sides are read over their filtered rows. Only shared columns are ranked; a column on one side alone is returned under `unmatched`.
compareGroups(colId: string, opts: TwoSampleSpec): GroupComparison | nullIs the difference between two groups real? A two-sample test returned as data to interpret — never a verdict (BACKLOG-0000750). The significance boundary the comparison story (653, 735) stopped short of: those rank by how *much* columns differ and return no p-value; this answers *how sure* for one chosen pair of groups and hands the p-value back as data. There is no `significant` flag, no badge, and no multiple-comparison correction. The rows are split by `opts.by`, the test is chosen by the column's family and named in the result (overridable with `opts.test`): Welch's t or Mann-Whitney U for a numeric column, chi-square for a categorical one. Every result pairs a confidence interval on the difference with the effect size, so it is always "how big and how sure".
correlation(a: string, b: string): number | nullPearson's correlation between two columns.
covariance(a: string, b: string, opts?: { population?: boolean }): number | nullCovariance, a correlation before the scales are divided out.
regression(a: string, b: string): RegressionFit | nullLeast-squares fit of `b` on `a`: in finance, beta and alpha.
regressionModel(spec: RegressionSpec): RegressionModel | nullFit a multi-predictor linear model over the filtered rows and return the full diagnostic set — coefficients with standard errors, t and p; R² and adjusted R²; per-row fitted values, residuals, leverage and Cook's D; VIF per predictor; a Breusch–Pagan heteroscedasticity flag; and, for a single predictor, a pointwise confidence band. `method` is `ols`, `wls` (needs a `weights` column) or `robust`; `quantile` is reserved and the regularised families refuse. Null on degenerate input (BACKLOG-0000792).
adf(spec: { of: string; orderBy: string; maxlag?: number }): AdfResult | nullThe Augmented Dickey-Fuller stationarity test over the `of` series in `orderBy` order (BACKLOG-0000873), constant+trend form with the lag order chosen by AIC up to an optional cap. Returns the statistic, the lag used, MacKinnon's critical values, an approximate (interpolated) p-value and a plain-language verdict at the 5% level — a scalar readout, not a column.
acf(spec: { of: string; orderBy: string; maxlag?: number }): AcfResult | nullThe autocorrelation (ACF) and partial autocorrelation (PACF) of the `of` series in `orderBy` order out to `maxlag` (BACKLOG-0000873), with the approximate ±1.96/√n band. A short-series readout; feed the arrays to a bar chart over explicit points with the band as reference lines. The lag-1 autocorrelation matches `series(...).autocorrelation`.
spearman(a: string, b: string): number | nullSpearman's rank correlation, which one outlier cannot drag.
kendall(a: string, b: string): number | nullKendall's tau-b. Null past 5,000 rows: it is quadratic.
weightedQuantile(colId: string, weightId: string, p?: number): number | nullA quantile of one column weighted by another; the median by default.
capability(colId: string, opts?: {Process capability against the column's `spec`, with control limits and the Western Electric rule breaks. `baseline` fixes the limits over the first N readings, which is how a shift is found rather than hidden by the limits it widened.
interval(colId: string, opts?: {A confidence interval for what a column measures, the range the estimate pins the figure down to, not a verdict about it. Reads the rows the filters left, so an interval narrows as the grid does: it describes the filtered population, not the whole table.
series(colId: string, opts: { by: string; periodsPerYear?: number }): SeriesStats | nullHow a column varies along an ordering. `by` is required and never guessed: kernels see rows in the order they arrived, which is not the grid's sort.
forecast(colId: string, opts?: {Forecast one column forward (BACKLOG-0000963): the stats-surface face of the {@link forecast} kernel. The column is read over the filtered rows in arrival order, or ordered by `opts.by` (a date or numeric column, as {@link series} orders) when the time axis matters, then projected `opts.horizon` steps ahead by `opts.method` (default `linear`) with a prediction band where one applies. Every kernel option passes through; returns the same {@link ForecastResult}, or null when the column is unknown or too short.
weightedAverage(colId: string, weightId: string): number | nullA weighted average of one column by another.
keyOf(data: unknown): string | nullThe key a row's data resolves to.
maintenanceReadonly<Record<string, 'maintained' | 'rescan'>>Which reductions can be maintained against a change, and which rescan. (read-only)
approximateReadonly<Record<string, ApproximateEntry>>The approximate tier: kernels a sketch maintains in constant time per tick, keyed by kernel name, each carrying the sketch that backs it and the error bound that sketch is verified to meet. (read-only)
maintenanceTier(fn: string): MaintenanceTierThe honest tier for one kernel across both the exact and approximate maps: its exact tier and, when one exists, the approximate alternative and bound.
windowed(colId: string, fn: WindowedFn, opts: {A windowed aggregate — "the average lately" (BACKLOG-0000654) — over one column, stamped with the window it covers (`over`), so a windowed figure is never read without its window. Exact over the values inside the window. `kind: 'count'` takes the last `span` values in arrival order. `kind: 'time'` takes the values within the last `span` ms (or `minutes`) and `kind: 'session'` takes every value; both need a timestamp column, so `by` is required for them and never guessed. Returns null when the column, or the `by` column, is unknown.

StatValueSpec

How a statistic block finds the number it reports.

MemberTypeDescription
ofstringThe column to reduce, as a field name or a dotted path. Omit for `count`. (optional)
fnTotalNameA key of `TOTAL_FNS`: `sum`, `avg`, `median`, `p95`, `gini` and the rest. (optional)
showstringReport this column from the row holding the extreme, rather than the extreme itself: `{ of: 'sales', fn: 'max', show: 'rep' }` is the *name* of the best rep. Needs `min` or `max`, no single row holds an average. (optional)

StreamSourceConfig

MemberTypeDescription
mode'stream'
open(req: {
maxRowsnumberThe most rows to keep. A stream has no end, so an unbounded grid dies overnight; this makes it a sliding window and the oldest rows are dropped. Omit for no limit. Set on the source, not passed to `open`, it bounds what the grid retains rather than what the producer sends. (optional)
maxAgenumberThe longest a row is kept, in milliseconds — a rolling *time* window, sitting beside `maxRows` as a second, independent bound (BACKLOG-0001036). Rows older than the span are evicted through the same path, the same `evicted` counters and the same `stream:evicted` event as the count bound, so an existing readout keeps working. Set both and whichever bites first applies. Eviction continues on a low-frequency timer while the feed is idle, so "the last five minutes" keeps shrinking through a silent period rather than freezing — which is the thing `maxRows` cannot do. Retention is a *bound, not a guillotine*: rows live a little past the span before a block is dropped. Two things add to it. First the eviction slack, ten per cent of the span, exactly as `maxRows` overshoots its count, so the row permutation is rebuilt once per block rather than once per row. Second, when the feed is idle, up to one tick of the eviction timer, which runs at a quarter of the span clamped to between 50 ms and one second. So the real ceiling is roughly `span * 1.1 + tick`, and because the tick has a floor it is proportionally larger the shorter the window: negligible at a five-minute window (about 10%), around 1.25x at ten seconds, and as much as ~1.35x at three. That is the deliberate trade for an idle grid that costs no CPU. Omit for no age limit. (optional)
ageBystring | ((row: unknown) => unknown)Which clock `maxAge` reads: a column id (or dotted path), or a function of the row returning a `Date`, epoch milliseconds, or an ISO string (BACKLOG-0001036). Given, the window follows the **data's own** clock, so it means what the producer means — and inherits the producer's clock skew. Omitted, `maxAge` falls back to **arrival time**: when the row reached this source. Arrival time needs no timestamp column and cannot be skewed, but it is not event time — a row delayed in transit counts as young. A row whose time value cannot be read is never aged out. (optional)
promoteToMemoryBelownumber(optional)
coalesceMsnumber(optional)

SubsetComparison

The subset-vs-population ranking (BACKLOG-0000653).

MemberTypeDescription
rankedColumnDifference[]Every compared column, largest difference first.
subsetNnumberHow many rows the filtered subset holds.
populationNnumberHow many rows the whole population holds.
filteredbooleanWhether a filter is actually narrowing the set.
measures{ numeric: string; categorical: string; common: string }The measure each family reports, and the common scale, named for a legend.

TabChangeEvent

The payload every tab-change event carries.

MemberTypeDescription
idstring
previousIdstring | null
origin'api' | 'user' | 'init'(optional)
reasonstring | null(optional)
preventDefault(reason?: string) => voidCancel the switch (only meaningful on `beforeTabChange`). (optional)
defaultPreventedboolean(optional)

TabDescriptor

One tab: an id, a display label, a grid config, and — for a derived tab — the parent tab id plus the narrowing forwarded onto the derived source built for it (`source: { mode: 'derived', from: <parent's grid>, ... }`). The derivation keys are the ones `packages/core/src/source/derive.js` already understands; this module invents none of its own.

MemberTypeDescription
idstringA stable, unique id. Required.
labelstringThe tab button's text. Defaults to `id`. (optional)
configobjectThe config for this tab's body: the grid config passed to `createGrid` (merged with the derived `source`, when `from` is set), or — with `view` — that viewer's own config. (optional)
view(el: HTMLElement, config: object) => unknownMount something other than a grid in this tab: the factory that builds it, called as `(el, config) => instance`. `createKanban` and `createKPI` have that signature already; a Gantt is adapted in a line (`(el, config) => createGantt({ ...config, element: el })`). The factory is injected rather than imported, exactly as `createGrid` is. A `view` tab derives from `from` exactly as a grid tab does: a headless grid carries the derived source and its rows are piped into the viewer through `rows.apply`, so deriving into one needs `createHeadlessGrid` injected too. (optional)
fromstringThe parent tab id to derive from. When set, `config.source` is built for you and any of your own is replaced (with a warning). (optional)
where(row: unknown) => booleanRow predicate forwarded to the derived source. (optional)
groupunknownGroup-by forwarded to the derived source. (optional)
groupByunknown(optional)
bucketunknownTime-bucketing forwarded to the derived source. (optional)
joinunknownJoin spec forwarded to the derived source. (optional)
unnestunknownArray-field unnesting forwarded to the derived source. (optional)
refresh'live' | 'idle' | 'manual' | number`'live' | 'idle' | 'manual' | number` forwarded to the derived source. (optional)
crossFilterunknownCross-filter wiring forwarded to the derived source. (optional)
follow'filtered' | 'all' | 'selected' | 'grouped'Which slice of the parent's rows to derive from: `'filtered' | 'all' | 'selected' | 'grouped'`. (optional)
limitnumberRow limit forwarded to the derived source. (optional)
sortunknownSort forwarded to the derived source. (optional)
profileunknownStatistical-profile derivation, forwarded to the derived source. (optional)
ariaLabelstringThis tab's panel's own `aria-label`, when the label alone is not enough context. (optional)
iconstring | HTMLElementA leading icon: a single character or emoji, or an element you built. Never a markup string — nothing here parses HTML. Decorative, so it is hidden from assistive technology. (optional)
badgetrue | number | string | ((count: number | null, tab: { id: string; label: string; from: string | null }) => unknown)A count badge. `true` shows this tab's own live row count and follows it; a number or string is static; a function is given the live count and returns what to show (`null` hides it). Off when absent. (optional)
badgeTone'good' | 'warn' | 'bad' | 'unknown' | ((count: number | null, tab: { id: string; label: string; from: string | null }) => 'good' | 'warn' | 'bad' | 'unknown' | null)The badge's tone, declared by the host rather than derived from a threshold: `'good' | 'warn' | 'bad' | 'unknown'`, or a function of the live count returning one. (optional)

Tabs

A tabbed grid: a `role="tablist"` strip above a stack of `role="tabpanel"` regions, each hosting its own, independently-configured grid instance (BACKLOG-0001039). A tab's grid mounts on first activation and is kept alive, hidden, until `destroy()`.

MemberTypeDescription
elHTMLElement(read-only)
activeIdstringThe currently active tab id. (read-only)
tabs(): string[]The configured tab ids, in order.
tab(id: string): unknown | nullThe live grid instance for a tab, or `null` before it has been materialised.
isMounted(id: string): booleanWhether a tab's grid has been created yet.
activate(id: string, opts?: { origin?: 'api' | 'user' }): boolean | Promise<boolean>Switch the active tab, gated by `beforeTabChange`.
on(name: 'beforeTabChange' | 'tab:changed' | 'tabChange:cancelled' | string, fn: (event: TabChangeEvent) => void): () => void
off(name: string, fn: (event: TabChangeEvent) => void): void
destroy(): voidTear the whole strip down; destroys every mounted tab's grid.

TabsConfig

Tabbed-grid configuration.

MemberTypeDescription
createGrid(el: HTMLElement, config: object) => unknownThe grid factory to mount each tab with, e.g. `import { createGrid } from '@toclocoinc/lattice-grid'`. Required.
createHeadlessGrid(config: object) => unknownThe headless grid factory, injected the same way and for the same reason. Optional, and only needed for badges: with it, a tab that has never been activated still carries a live count, computed with no DOM. Without it, such a tab shows no badge until its first activation. (optional)
tabsTabDescriptor[]The tabs, in display order. Required, at least one.
activestringThe initially active tab id. Defaults to the first tab. (optional)
ariaLabelstringThe tablist landmark's accessible name. (optional)
messages{ t(key: string, params?: Record<string, unknown>): string }An explicit message-catalogue override; otherwise a mounted tab's own `grid.messages` is used. (optional)
onTabChange(event: TabChangeEvent) => void(optional)
onBeforeTabChange(event: TabChangeEvent) => boolean | void | Promise<boolean>(optional)
onTabChangeCancelled(event: TabChangeEvent) => void(optional)

TextFormat

MemberTypeDescription
type'text'
transform'none' | 'upper' | 'lower' | 'title'(optional)
truncatenumber | { chars: number; ellipsis?: string }(optional)
nullDisplaystring(optional)
emptyDisplaystring(optional)

TimelineApi

Moving the grid through recent data changes. Reads the change log rather than the undo history: history records what the *user* did, and the question on a live grid is what the *data* did. Nothing is scrubbable until `attach()`: what a value used to be is not recoverable after the fact.

MemberTypeDescription
attachedboolean(read-only)
liveboolean(read-only)
positionnumber(read-only)
depthnumber(read-only)
attach(): void
detach(): void
seek(steps: number): number
step(by: number): number
toLive(): number
at(): number | null
span(): { from: number; to: number } | null

TooltipConfig

Grid-level defaults for the rich cell tooltip (BACKLOG-0001204), set once for every column rather than repeated on each. Defaults only: it switches nothing on. A tooltip exists because a column declares `cell.tooltip`, and a grid whose columns declare none has no tooltips whatever is set here.

MemberTypeDescription
delaynumberHow long the pointer or the keyboard cursor must rest on a cell before the tooltip is built, in milliseconds. 400 by default. The delay is why a pointer sweeping across the grid mounts nothing: a tooltip that built a chart on every cell it crossed would be unusable, and `0` asks for exactly that. (optional)
maxWidthnumber | stringHow wide the tooltip may grow. A number is pixels; a string is used as written. (optional)

TooltipParams

What a tooltip's `render` and `mount` are given: the same identification `cell:clicked` carries, plus the cell element itself and the grid. Resolved from the DOM at the moment the tooltip opens rather than when the pointer arrived, so a pooled row re-used in between names the row it is showing now.

MemberTypeDescription
rowRowThe row under the pointer or the keyboard cursor.
keystringThat row's key.
indexnumberIts display index.
colIdstringThe column the cell belongs to.
columnColumnThe resolved column.
valueunknownThe cell's value.
textstringThe cell's formatted text.
cellHTMLElementThe cell element the tooltip is anchored to.
gridGridThe grid.

TooltipRow

One label/value line in a {@link TooltipSpec}. Both halves are written as text by the grid, whatever they contain.

MemberTypeDescription
labelunknownThe line's label, drawn on the leading edge. (optional)
valueunknownThe line's value, drawn on the trailing edge. (optional)

TooltipSpec

Structured tooltip content the grid renders for you (BACKLOG-0001204): a heading, a list of label/value lines, and a closing note. Every field is written as **text**, never as markup, so a spec built out of row values needs no escaping and cannot become HTML by accident. Return `{ html }` from `render` when markup is genuinely wanted.

MemberTypeDescription
titleunknownA heading for the tooltip. (optional)
rowsTooltipRow[]Label/value lines, in order. (optional)
noteunknownA closing note under the lines, drawn quieter than them. (optional)

TopValue

One row of a categorical column's top-values table (BACKLOG-0000959).

MemberTypeDescription
valueunknownThe value itself, as it is stored.
countnumberHow many present rows carry it.
sharenumberIts share of the present values, 0 to 1.

TreeConfig

MemberTypeDescription
path(row: unknown) => string[](optional)
parentKeystring | ((row: unknown) => unknown)(optional)
orphans'root' | string(optional)
hasChildren(row: unknown) => boolean(optional)
loadChildren(row: Row, signal: AbortSignal) => Promise<unknown[]>(optional)
labelstring | ((data: unknown, row: Row) => unknown)Where the generated tree column takes its text from: a field or a function. (optional)
titlestringThe tree column's heading. Defaults to the label column's own title. (optional)

TwoSampleSpec

How {@link StatisticsApi.compareGroups} splits the rows and picks a test.

MemberTypeDescription
bystringThe column whose values split the rows into groups. Required.
groups[unknown, unknown]The two group values to compare. The two most frequent when omitted. (optional)
test'auto' | 'welch' | 'mannWhitney' | 'chiSquare'Force a test rather than choosing by column family. `auto` (the default) picks Welch or Mann-Whitney for a numeric column and chi-square for a categorical one; the choice is always named in the result. (optional)
confidencenumberThe confidence level for the interval, 0 to 1. 0.95 by default. (optional)
categoryunknownThe focal category for a chi-square difference interval, when the column has more than two categories. Without it, a multi-category comparison reports no scalar interval, only the effect size. (optional)

UnionSourceOptions

One member of a union `from` (BACKLOG-0001045): a grid to combine with the others, plus how to read it and reshape it before it joins the rest. A bare `Grid` in the `from` array is shorthand for `{ grid }` with every other field defaulted.

MemberTypeDescription
gridGridThe grid this source reads.
labelstringIdentifies this source: it is what `__source` carries on every row this source contributes, and what namespaces that row's `__key` so two sources sharing the same identifiers do not collide. Defaults to the source's position in the `from` array (`'0'`, `'1'`, …), as a string. (optional)
follow'filtered' | 'all' | 'selected' | 'grouped'Which of this source's rows to read. `filtered` by default, exactly as a lone `from` follows its grid today — set independently per source, so filtering one narrows only its own contribution. (optional)
map(row: unknown) => unknownReshape this source's rows into the common shape before they join the rest — typically a rename or a projection, for a field this source calls something else. Not a type coercion: if a field means something different on two sources, `map` is where you make them agree, because the union itself does not guess. (optional)

UnitConfig

How a column stores, parses and renders a quantity.

MemberTypeDescription
systemstring(optional)
unitstring(optional)
binaryboolean(optional)
decimalsnumber(optional)
minDecimalsnumber(optional)
maxDecimalsnumber(optional)
displaystring(optional)
localestring(optional)
groupboolean(optional)
spacestring(optional)
placement'suffix' | 'prefix'(optional)
compoundstring[]Render one stored number across an ordered subset of the system's units, e.g. `['ft', 'in']` for `5 ft 11 in`. Display and parse only: the stored value stays a single base-unit number, so sort, filter and total are unchanged. Parsing sums the parts. (optional)

UnitDescriptor

One unit descriptor: a symbol and how many base quantities it is worth.

MemberTypeDescription
symbolstring
factornumber
aliasesreadonly string[]
binaryboolean
prefixstring | null
autoboolean

UpdatesApi

MemberTypeDescription
pausedboolean(read-only)
pause(): boolean
resume(): ChangeResult
flush(): ChangeResult
stats(): {
log(opts?: { since?: number }): { at: number; change: RowChange; rows: number }[]

ValidationApi

The runtime face of declarative column validation (BACKLOG-0000956).

MemberTypeDescription
check(colId: string, value: unknown, row?: unknown): { code: string; message: string } | nullRun a column's rules against a value, returning the first failure or null.
errorFor(key: string, colId: string): ValidationError | nullThe recorded error for one cell, or null when it is valid.
errors(): ValidationError[]Every cell that currently holds a validation error.
clear(key?: string, colId?: string): booleanClear errors: one cell, a whole row, or all of them.
define(colId: string, spec: ColumnValidation | null): voidSet or replace a column's rules at runtime; null removes them.
activebooleanWhether at least one column declares a rule. (read-only)

ValidationError

One recorded validation error (BACKLOG-0000956).

MemberTypeDescription
keystring
colIdstring
codestring
messagestring

ValueParams

MemberTypeDescription
valueunknown
dataunknown
rowRow
columnColumn
colIdstring
gridGrid
contextunknown

VariantDefinition

MemberTypeDescription
light{ fill: string; text: string; border: string }
dark{ fill: string; text: string; border: string }

ViewChange

MemberTypeDescription
reason'save' | 'update' | 'rename' | 'remove' | 'default' | 'import' | 'seed' | 'replace'
viewSavedView | nullThe view the change concerns; null for a bulk replace.

ViewsApi

MemberTypeDescription
list(): SavedView[]
get(id: string): SavedView | undefined
activeIdstring | null(read-only)
save(name: string, opts?: { id?: string; overwrite?: boolean }): SavedView
apply(id: string): SavedView | null
rename(id: string, name: string): SavedView | null
duplicate(id: string, name?: string): SavedView | null
remove(id: string): boolean
setDefault(id: string | null): SavedView | nullMark the view applied on load; null clears it.
defaultView(): SavedView | null
diff(id: string): Record<string, unknown> | nullWhat applying the view would change, without applying it.
export(id: string): string
import(json: string): SavedView
reload(): voidRe-read from storage, after another tab or the server changed it.

ViewStorage

MemberTypeDescription
read(): SavedView[]Load the user's views. Called at construction and by `views.reload()`.
write(views: SavedView[], change: ViewChange): voidMirror the views somewhere synchronous: `localStorage`, an in-memory cache. For a server, listen for `view:saved` / `view:removed` and do the write yourself: the grid does not make network calls and does not want to know whether yours succeeded.

WhereOptions

How a `where` predicate is re-evaluated, whether `filters.clear()` may remove it, and what the source may be told about it (BACKLOG-0001202).

MemberTypeDescription
depsstring[]The columns the predicate reads, in the same spirit as `value.deps` on a computed column (§8.4.2). Declared, the verdict is cached per row and re-run only when one of these columns changes on that row. Omitted, the predicate is treated as reading the whole row and is called on every pass — never stale, and never skipped either. (optional)
pinnedbooleanSurvive `filters.clear()`. For a predicate that is not the user's filter — row-level permissions, tenant scoping — where a "clear filters" button must never widen what the user can see. (optional)
conditionFilterSetA declarative twin of the predicate, pushed to the source while the function stays as the residual. On a pushdown engine this narrows the fetch instead of filtering a page client-side. It must be implied by the predicate: the grid ANDs both, so a twin wider than the function costs only time, while one narrower than it hides rows the function would have kept. **The twin is what works at any size.** Without one, a pushdown source can still run the function — but only as the residual over the whole matching set, so it does so only while that set is under `whereRowLimit` (default `50_000`) and refuses loudly past it (BACKLOG-0001268). A paged or remote source cannot run it at all and warns at registration. The twin is pushed to the engine, so it narrows the fetch itself and none of that applies. (optional)

WhereRuntime

The `where` predicates in force, as a source sees them (BACKLOG-0001268). A snapshot rather than the model, so a source can evaluate the predicates but cannot register or remove one through it.

MemberTypeDescription
activebooleanWhether any predicate is registered at all.
namesstring[]The registered names, in registration order — for diagnostics.
versionnumberBumped on every registration or removal, so a cache key can track it.
passes(row: unknown, key?: string): booleanDoes this row survive every registered predicate?

WindowedResult

One windowed figure and the window it covers.

MemberTypeDescription
valuenumber | nullThe reduction, or null when the window held no usable values.
overWindowSpecThe window the figure was computed over — always stated.

WindowSpec

The window a windowed aggregate was computed over.

MemberTypeDescription
kind'count' | 'time' | 'session'Which window: last N ticks, last N ms, or the session.
spannumberThe size: N ticks, N ms, or the session duration in ms.
sizenumberHow many values actually fell inside the window.