Lattice Grid Buy a licence

api reference

Designer API Reference

createDesigner: a dashboard designer whose state is the dashboard spec extended with pages, in edit and view mode, with JSON state in and out.

API reference › The Designer

All 23 pages Everything on one page → Developer guide →

The Designer

modules/designer is a dashboard designer built on dashboard composition. Its state is the dashboard spec, extended with pages, and its view mode is createDashboard: there is no second renderer, so a page looks the same in the designer as it does when you build its spec yourself.

import { createGrid, createHeadlessGrid } from '@toclocoinc/lattice-grid';
import { createLayout } from '@toclocoinc/lattice-grid/modules/layout';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createDesigner } from '@toclocoinc/lattice-grid/modules/designer';

const designer = createDesigner(document.querySelector('#designer'), {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: await loadState(),                     // or omit: one empty page
  factories: { createGrid, createHeadlessGrid, createLayout, createChart },
});
designer.on('state', ({ state }) => saveState(state));
State keyWhat it holds
schemaVersion1. A state saved by an older designer - or a plain createDashboard spec with no version - is migrated to it on load; a state with a newer version is refused by name rather than half-loaded.
pages[]{ id, title, spec, filters? }. spec is a dashboard spec, checked with validateDashboardSpec; it names sources by id, and is built over the designer's sources (its own spec.sources win on a clash).
selectedPageIdThe page on the canvas.
panelsThe designer chrome's own state; features keep theirs here. The editing screen keeps each rail's collapse as { left: { collapsed }, right: { collapsed } }.

The state is plain JSON, and every key survives. The designer copies the state in and out and never drops a key it does not know, whether it sits on the state, a page, a spec or a panel. setState(getState()) gives back the same state, deep-equal, so you can store state from a newer designer and load it into an older one without losing anything. sources hold your live data (rows, adapters, routers), so they are options, not state. factories are handed to createDashboard exactly as its third argument, and the designer imports no viewer.

Saved states keep loading across versions. The state carries schemaVersion, and setState runs every older state through ordered, pure migrations before it is used: a state with no version (a plain createDashboard spec, or an early designer state that forgot the stamp) is stamped and, when it has no pages, wrapped into a one-page state - every key carried, so a migration never drops an option it does not know. A state with a newer schemaVersion is refused by name and nothing is loaded, so a future state is never half-applied. designer.migrate(state) runs the same migrations and returns the report; designer.migrate(state, { dryRun: true }) returns the report without applying. This is the compatibility promise for hosts that store states: save the state getState() returns and load it back into any later designer.

Two modes. mode: 'view' (the default) builds the selected page with createDashboard and nothing else. mode: 'edit' builds the same canvas with its layout made interactive: when a window is moved or resized, its placement is written into the page's spec.layout.windows (every other key of that window entry is kept) and state fires once. setMode(mode) switches mode and announces it in a polite live region. The canvas is a region labelled Dashboard canvas.

The handle. getState() and setState(state); mode and setMode(mode); pages() and selectPage(id); on('state', fn) (or onStateUpdated(fn)), sent once for each change with { state, cause }; problems(), which lists every page's spec problems as { page, path, message } and never throws; getProperty(name) and setProperty(name, value), which reach mode, state, selectedPageId and guardrails by name for a host that binds the designer generically (setProperty('mode', 'view') switches live, exactly as setMode('view')); and destroy(), which removes everything the designer made. The designer puts nothing outside el and leaves no listener on window or document. A setState with an equal state changes nothing and sends no event, so a handler that writes the state straight back cannot loop.

Extension points. designer.chrome is the edit-mode region, shown only in edit mode. It holds the top bar (the toolbar, the page tabs and undo/redo) and the two rails, and the palette, properties and data panels mount in the rails (the editing screen) (follow the chrome rules). designer.commands is a registry: register(name, run), run(name, ...args), has(name) and list(). A command is called with { designer, getState, commit } and changes the state only through commit(state, cause). That is the one path to a state event, so undo can wrap it. selectPage and setMode are built-in commands.

Undo and redo. Every committed change is one undo step, recorded at that single commit - so the change kinds the later cards add (add/move/resize/remove a widget, a property or data edit, a page op, a filter edit, an accepted AI proposal) are undoable for free, and a drag or a slider scrub coalesces into one step. undo() and redo() restore the exact prior state and fire state with cause undo/redo; canUndo()/canRedo() say whether there is a step; historyDepth (default 100) caps how many steps are kept; and a host setState(state) clears the history unless it passes { keepHistory: true }. In edit mode the chrome shows Undo and Redo buttons, and Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y drive them - scoped to the designer, so a grid widget's own Ctrl+Z is never stolen.

The editing screen

In edit mode the designer is a WYSIWYG screen laid out with CSS grid on its root, and styled from the theme tokens alone: a top bar holding the toolbar, the page tabs and undo/redo; a left rail of about 260 px; the canvas, the live dashboard rendered exactly as view mode renders it, which takes every remaining pixel (minmax(0, 1fr)), so no panel can squeeze it; and a right properties panel of about 300 px, shown while a widget is selected. In view mode the canvas fills the whole designer. designer.chrome is still the one element the parts live in (it lays out as display: contents), and every class and region role the earlier panels use is kept.

The rails. designer.rails.left holds two collapsible sections, each scrolling inside itself: a palette slot, where the widget palette mounts (designer.rails.left.slot('palette')), and the data panel (slot('data')). designer.rails.right.slot() is where the properties panel mounts. Each rail has collapse(), expand(), toggle() and collapsed, and a button in its header; a collapsed rail is a 48 px icon strip. The collapse state is kept in state.panels as { left: { collapsed }, right: { collapsed } }, so it round-trips through getState() and setState(); changing it sends a state event with cause panels and is not an undo step.

Narrow designers. The layout follows the designer's own width, not the window's. From 1100 px up both rails are inline. From 900 to 1099 px the left rail is inline and the properties panel is an overlay drawer, so a selected widget never takes the canvas below 600 px at 1000 px wide. Below 900 px both rails are overlay drawers, opened from buttons in the top bar; a drawer's open state is not saved. A drawer covers the canvas but never resizes it. designer.rails.layout says which of 'wide', 'narrow' and 'compact' is in force. A card that adds a panel mounts it in a rail slot rather than appending to the chrome.

The widget palette

designer.palette mounts in the left rail's palette section, beside the canvas: a search box, then the widgets (grid, KPI, text, and a map when a map factory is injected), the filter widgets (list, range, date range, search), and every chart type in collapsible groups by family - comparison, trend, composition, distribution, flow, hierarchy, geo, specialist and statistics. The chart list is the charts module's built-in types plus registeredChartTypes(), read each time, so a chart module a host loads appears with no designer change (and in the properties panel's type list too). A registered type claims its own family with family on its registerChartType definition; one that does not is a specialist. The guardrails hide what an author may not use. Collapsed, the rail is a 48 px icon strip with one button per group; its state is state.panels.palette ({ collapsed }), and it never changes the canvas height.

Adding. Click an entry, press Enter on it (the arrow keys move between entries), or drag it onto the canvas. This runs the paletteAdd(kind, type) command, which builds the default panel from the chosen source's fields, places it in the first free slot of a sensible size, and hands it to addPanel - one undoable step. A bar takes the first categorical field as x and the first numeric field as y (summed); a KPI sums the first numeric field; a grid shows every field; a trend chart takes a date field when there is one. A type whose required roles the source cannot fill is disabled, with the reason beside it (needs a date field). A filter widget is added to the page's filters list as { id, type, source, field }. The text widget's label is “Text”.

The palette lists a chart type registered at run time, disables what the source cannot fill, and adds a bar with its defaults

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createChart, registerChartType } = await import('../packages/modules/charts/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document: doc, root } = createTestDom({ width: 1200, height: 800 });
const el = doc.createElement('div');
root.appendChild(el);

const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rowKey: 'id', rows: [{ id: 1, region: 'north', sales: 10 }],
    fields: [{ id: 'region', type: 'text' }, { id: 'sales', type: 'number' }] } },
  factories: { createGrid, createHeadlessGrid, createChart, createLayout },
});

registerChartType('docs-radial', { draw() {}, freeform: true });
const find = (id) => designer.palette.entries().find((e) => e.id === id);
const seen = [!!find('chart:docs-radial'), find('filter:dateRange').disabled];

const id = designer.palette.add('chart', 'bar');
const panel = designer.getState().pages[0].spec.panels[0];
seen.push(id === panel.id, [panel.options.type, panel.options.x, panel.options.y.col].join(','));
seen.push(designer.getState().pages[0].spec.layout.windows.length);
designer.palette.collapse();
seen.push(designer.getState().panels.palette.collapsed);
designer.destroy();
return seen.join(' | ');                                   // true | needs a date field | true | bar,region,sales | 1 | true

Filters and cross-filtering

designer.filters mounts the filter bar in designer.rails.filterBar - a row above the canvas, a sibling of designer.chrome rather than inside it, so it (and the filter-context chips and the clear-all button) stay visible and interactive in view mode too, unlike the rest of the editing screen. A filter is scoped to every page (state.filters) or to the selected one (page.filters, the slot a palette-added filter widget already writes { id, type, source, field } into): addFilter({ scope, type, source, field }) adds one, setValue(id, value) sets its active value (shaped by its type - a list's is a string array, a range's [min, max], a dateRange's { preset, from, to } with last7/last30/last90/thisMonth/thisQuarter/thisYear/custom presets, a search's a string), clearAll() drops every value but keeps the filters defined, and removeFilter(id) drops one for good - each one undoable commit. Every grid-backed widget reading a filter's source follows it (the viewer contract): applied with grid.filters.where() and a pushed-down twin, exactly as a cross-filter link is. In view mode the filter bar draws only the value controls, the context chips and clear-all - never the add/remove/rebind controls - so a viewer can change what a filter is set to but not the set of filters, the guardrail the card asks for.

Cross-filtering. A grid, chart, KPI, calendar or map widget's own properties panel gets a "clicking this filters…" setting (grid and chart only, since those are the kinds a click meaningfully selects on): no widgets, all widgets, or chosen ones, related by a field both sides share. designer.filters.setClickFilter(panelId, { targets, on }) (or null to clear) recompiles the page's links - the dashboard module's own cross-filter mechanism's D4, widened by this card to start from any panel with a grid to select on, not only a kind: 'grid' one) - from every widget's current setting. Click a bar with options.selection set, or a grid row, and every targeted widget narrows; clear the selection and they widen back.

A global filter and a page filter narrow every widget reading their source, clearing keeps the filters, and a click-filter setting narrows and clears its targets

const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document: doc, root } = createTestDom({ width: 1200, height: 800 });
const el = doc.createElement('div');
root.appendChild(el);

const rows = [
  { id: 'r1', region: 'north', sales: 100, name: 'Acme' },
  { id: 'r2', region: 'north', sales: 50, name: 'Bolt' },
  { id: 'r3', region: 'south', sales: 200, name: 'Acme East' },
  { id: 'r4', region: 'south', sales: 20, name: 'Crate' },
];
const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rowKey: 'id', rows,
    fields: [{ id: 'region', type: 'text' }, { id: 'sales', type: 'number' }, { id: 'name', type: 'text' }] } },
  state: { schemaVersion: 1, selectedPageId: 'p1', panels: {}, pages: [{ id: 'p1', title: 'Sales', spec: { panels: [
    { id: 'table', kind: 'grid', source: 'sales', options: { selection: 'single' } },
    { id: 'chart', kind: 'chart', source: 'sales', options: { type: 'bar', x: 'region', y: 'sales' } },
  ] } }] },
  factories: { createGrid, createHeadlessGrid, createChart, createLayout },
});
const table = () => designer.dashboard.panel('table').grid;
const chart = () => designer.dashboard.panel('chart').grid;
const seen = [];

// A global filter narrows every widget reading `sales`, table and chart alike.
const searchId = designer.filters.addFilter({ scope: 'global', type: 'search', source: 'sales', field: 'name' });
designer.filters.setValue(searchId, 'Acme');
seen.push(table().rows.matchCount(), chart().rows.matchCount());       // the two Acme rows
seen.push(designer.getState().filters[0].value);
designer.undo();                                                       // the value alone, not the filter
seen.push(designer.getState().filters[0].value === undefined);
designer.filters.removeFilter(searchId);

// A page filter does the same, scoped to this page only.
const rangeId = designer.filters.addFilter({ type: 'range', source: 'sales', field: 'sales' });
designer.filters.setValue(rangeId, [60, '']);
seen.push(table().rows.matchCount());                                  // sales >= 60: rows 1 and 3
seen.push(designer.filters.active()[0].scope);
designer.filters.clearAll();
seen.push(table().rows.matchCount());                                  // clear-all keeps the filter, drops its value
designer.filters.removeFilter(rangeId);

// Cross-filtering: clicking the table's row filters every other widget by region.
designer.filters.setClickFilter('table', { targets: 'all', on: 'region' });
const links = designer.getState().pages[0].spec.links;
seen.push(links.map((l) => `${l.from}-${l.to}:${l.on}`).join(','));
table().selection.set(['r3']);
flushFrames();
seen.push(chart().rows.matchCount());                                  // south only
table().selection.set([]);
flushFrames();
seen.push(chart().rows.matchCount());                                  // cleared
designer.filters.setClickFilter('table', null);
seen.push(designer.getState().pages[0].spec.links.length);

seen.push(!!designer.rails.filterBar);
designer.destroy();
return seen.join(' | ');   // 2 | 2 | Acme | true | 2 | page | 4 | table-chart:region | 2 | 4 | 0 | true

The data panel

designer.dataPanel is mounted in chrome: every source and its fields, grouped by type with a search box, each field's type icon and description, and the declared relationships between sources. A source declares its own fields ({ id, label, type, format?, description? }[], the type one of number, currency, percent, date, datetime, boolean, text, list, geometry); without that, fields are inferred - from a rows source's sampled values, from a pushdown source's columns or its adapter's own schema, from a router source's columns, or from a grid:<id> source's referenced panel's live grid columns.

Relationships name two sources' fields, { from: 'orders.customerId', to: 'customers.id', type: 'many-to-one' } (type is many-to-one, one-to-many or one-to-one; many-to-one by default). designer.dataPanel.compiledRelationships() turns each into the Data Router join edge that realises it - a LOOKUP join for many-to-one/one-to-one, a COLLECT join for one-to-many - ready for router.addSource(edge.sourceId, { join: edge.join }). designer.dataPanel.reachable(sourceId) lists every source reachable from one, through the relationship graph, which is what the panel shows under "reachable from" for the selected widget's source.

Guardrails hide what an author may not use: the panel reads the designer's live guardrails handle, so a source outside guardrails.sources, or a field a guardrails.fields rule does not allow, is not shown, and a hidden field cannot be dropped onto a widget even by name.

Dragging a field onto the canvas (native HTML5 drag-and-drop, or Enter/Space on a focused field for the keyboard) adds a sensible default widget - a KPI tile summing a number/currency/percent field or counting a boolean one, a single-column grid otherwise - through the built-in dropField command. designer.dataPanel.registerRoleTarget(el, onDrop) lets a properties panel (a later card) claim the drop for a specific widget role instead.

Declared and inferred fields, a relationship, and which sources are reachable from an order's source

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const designer = createDesigner(el, {
  mode: 'edit',
  sources: {
    // Declared fields, with a type, a format and a description.
    orders: {
      kind: 'rows',
      rows: [{ id: 'o1', customerId: 'c1', amount: 42 }],
      rowKey: 'id',
      fields: [
        { id: 'amount', label: 'Order total', type: 'currency', format: '$0,0.00', description: 'What the order was worth.' },
        { id: 'customerId', label: 'Customer', type: 'text' },
      ],
    },
    // No `fields`: inferred from the sampled rows.
    customers: { kind: 'rows', rows: [{ id: 'c1', region: 'north', since: '2024-05-01' }], rowKey: 'id' },
  },
  relationships: [{ from: 'orders.customerId', to: 'customers.id', type: 'many-to-one', fields: ['region'] }],
  guardrails: { sources: ['orders', 'customers'] },
  state: { pages: [{ id: 'p1', spec: { panels: [{ id: 'win', kind: 'grid', title: 'Orders', source: 'orders' }] } }], selectedPageId: 'p1' },
  factories: { createGrid, createHeadlessGrid, createLayout },
});

const bySource = Object.fromEntries(designer.dataPanel.sources().map((s) => [s.id, s]));
const seen = [
  bySource.orders.fields.map((f) => f.type).join(','),                // declared: currency, text
  bySource.customers.fields.map((f) => f.type).join(','),             // inferred: id (text), region (text), since (date)
  designer.dataPanel.reachable('orders').join(','),                    // customers, via the relationship
];

const edges = designer.dataPanel.compiledRelationships();
seen.push(edges[0].join.foreignKey === 'id' && edges[0].join.from === 'customers');

designer.destroy();
return seen.join(' | ');                                               // currency,text | text,text,date | customers | true

A two-panel page in view mode, the state round-tripping with an unknown key, a second page selected, and edit mode

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', region: 'north', sales: 10 }, { id: 'b', region: 'south', sales: 20 }];
const designer = createDesigner(el, {
  mode: 'view',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    schemaVersion: 1,
    pages: [
      { id: 'p1', title: 'Sales', spec: {
        layout: { columns: 2, rows: 1 },
        panels: [
          { id: 'table', kind: 'grid', title: 'Orders', source: 'sales' },
          { id: 'note', kind: 'html', title: 'Note', options: { text: 'Provisional figures.' } },
        ],
      } },
      { id: 'p2', title: 'Notes', spec: { panels: [{ id: 'only', kind: 'html', options: { text: 'Hello' } }] } },
    ],
    selectedPageId: 'p1',
    panels: {},
    myAppVersion: 3,                                  // a key the designer does not know
  },
  factories: { createGrid, createHeadlessGrid, createLayout },
  relationships: [], guardrails: {}, llm: null,       // kept on designer.context for later features
});
const seen = [designer.mode, designer.dashboard.panels().join(','), designer.problems().length];

// The state comes back as it went in, unknown key and all.
const state = designer.getState();
designer.setState(state);
seen.push(JSON.stringify(designer.getState()) === JSON.stringify(state));

// One event per change: selecting the second page builds it on the canvas.
const causes = [];
designer.on('state', (event) => causes.push(event.cause));
let heard = 0;
designer.onStateUpdated(() => { heard += 1; });
seen.push(designer.pages().map((p) => p.title).join(','));
designer.selectPage('p2');
seen.push(designer.dashboard.panels().join(','), causes.join(','), heard);

// Edit mode: the same canvas, with the windows made movable.
designer.setMode('edit');
seen.push(designer.mode, designer.dashboard.layout.getInteractive().movable);

// Properties by name: setProperty('mode', 'view') switches live.
seen.push(designer.setProperty('mode', 'view'), designer.getProperty('mode'));

designer.destroy();
seen.push(el.children.length === 0 ? 1 : 0);
return seen.join(' | ');                              // view | table,note | 0 | true | Sales,Notes | only | selectPage | 1 | edit | true | true | view | 1

A plain spec migrated to a one-page state, a dry-run report, and a newer version refused

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const designer = createDesigner(el, { factories: { createGrid, createHeadlessGrid, createLayout } });

// A plain createDashboard spec (no pages, no schemaVersion) is a v0 state:
// setState migrates it to a one-page v1 state, keeping an unknown option.
const spec = { panels: [{ id: 'a', kind: 'html', title: 'A', options: { text: 'Hi', custom: 'kept' } }] };
designer.setState(spec);
const pages = designer.getState().pages.length;            // the v0 spec is now one page
const kept = designer.getState().pages[0].spec.panels[0].options.custom; // and its unknown option survived

// designer.migrate reports the migration; dryRun reports without applying.
const dry = designer.migrate({ panels: [{ id: 'b', kind: 'html' }] }, { dryRun: true });
const live = designer.migrate({ panels: [{ id: 'c', kind: 'html', options: { x: 1 } }] });

// A state from a NEWER version is refused by name, not half-loaded.
const refused = designer.migrate({ schemaVersion: 2, pages: [] }, { dryRun: true });

const out = [
  pages, kept,
  dry.applied, dry.state.pages[0].spec.panels[0].id,   // dry run: reported, not applied
  live.applied, live.state.pages[0].spec.panels[0].id, // live run: applied
  refused.ok, refused.reason,                          // a newer version is refused
];
designer.destroy();
return out.join(' | ');                                 // 1 | kept | true | b | true | c | false | newer

Pages

A dashboard holds one or more pages, each a full spec, and the canvas shows the selected one. selectPage(id) builds a page and fires a page event ({ id, page, previousId, type }) whenever the selection changes, and the page tabs drive the same commands from the keyboard - one tab stop, Left and Right move, Home and End jump, Alt+Arrow reorders, F2 renames, Delete deletes. addPage({ title?, duplicate?, id? }) adds a blank page or a copy of another and selects it; renamePage(id, title), movePage(id, delta) (signed positions, clamped to the ends) and removePage(id) rename, reorder and delete, each one undoable state change and each refused by name when guardrails.pages disallows the operation. Deleting a page that has widgets asks for confirmation in the page itself. A page is built only when selected and disposed when left, so only the selected page's grids are alive - unless keepPagesAlive: true keeps every visited page built across selection changes.

Laziness builds only the selected page's grids; add, duplicate, rename, reorder and delete are one state change each; keepPagesAlive keeps a page built

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

// Count the grid builds: laziness means only the selected page's grid lives.
let grids = 0;
const base = { createGrid, createHeadlessGrid, createLayout };
const factories = {
  createGrid: (mount, cfg) => { grids += 1; return base.createGrid(mount, cfg); },
  createHeadlessGrid: base.createHeadlessGrid,
  createLayout: base.createLayout,
};
const rows = [{ id: 'a', region: 'north', sales: 10 }];
const designer = createDesigner(el, {
  mode: 'view',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    pages: [
      { id: 'p1', title: 'Sales', spec: { panels: [{ id: 'table', kind: 'grid', title: 'Orders', source: 'sales' }] } },
      { id: 'p2', title: 'Notes', spec: { panels: [{ id: 'only', kind: 'html', options: { text: 'Hi' } }] } },
    ],
    selectedPageId: 'p1',
  },
  factories,
});

// p1's grid is built once; leaving for p2 disposes it; returning rebuilds it.
const seen = [grids];
designer.selectPage('p2'); seen.push(grids);
designer.selectPage('p1'); seen.push(grids);

// addPage (blank, then a duplicate of p2), rename, move, remove - each one step.
const added = designer.addPage({ title: 'Extra' }); seen.push(added !== null, designer.pages().length);
const dup = designer.addPage({ duplicate: 'p2' }); seen.push(dup !== null, designer.pages().length);
seen.push(designer.renamePage('p2', 'Details'));
seen.push(designer.movePage('p1', 1));
seen.push(designer.removePage(added));
designer.destroy();

// keepPagesAlive keeps a visited page built, so its grid is never rebuilt.
let kept = 0;
const el2 = document.createElement('div');
root.appendChild(el2);
const keepAlive = createDesigner(el2, {
  mode: 'view',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    pages: [
      { id: 'a', title: 'A', spec: { panels: [{ id: 'g', kind: 'grid', title: 'G', source: 'sales' }] } },
      { id: 'b', title: 'B', spec: { panels: [{ id: 'h', kind: 'html', options: { text: 'Hi' } }] } },
    ],
    selectedPageId: 'a',
  },
  factories: {
    createGrid: (mount, cfg) => { kept += 1; return base.createGrid(mount, cfg); },
    createHeadlessGrid: base.createHeadlessGrid,
    createLayout: base.createLayout,
  },
  keepPagesAlive: true,
});
keepAlive.selectPage('b'); keepAlive.selectPage('a'); keepAlive.destroy();
seen.push(kept);
return seen.join(' | ');                              // 1 | 1 | 2 | true | 3 | true | 4 | true | true | true | 1

Statistics widgets

Six widget kinds an author adds and configures in the properties panel, each built on an existing grid.statistics call with no new maths: column profile (count, missing, quartiles, spread, a histogram - grid.statistics.profile), distribution (a histogram, box, violin or ECDF with a Jarque-Bera normality readout - grid.statistics.profile and the jarqueBera reduction), correlation (a Pearson or Spearman matrix over chosen numeric fields - grid.statistics.correlation/spearman), group comparison (difference, confidence interval, p-value, effect size and the test used - grid.statistics.compareGroups), forecast (a series with a forecast band - the 'line'/'area' trend overlay,/0000975, turned on by default) and model error (fit, residuals and influence, from a single-predictor grid.statistics.regressionModel). Each states its method and sample size in the picture - no number without its basis - and over a pushdown source each either computes in the data engine (the column profile, with its provenance shown) or refuses by name (the other five, which read leaf rows the engine does not hold). Every one is a kind: 'chart' panel, so the properties panel's generated editor configures all six from the roles and options capabilities.js declares for their type - no widget-specific designer code. designer.statisticsWidgets.kinds lists the six; designer.statisticsWidgets.add(kind, id, extra?) adds one as a new chart panel with one undoable commit (designer.addWidget).

The six kinds, column profile's generated role, group comparison's options, and an added widget's numbers matching grid.statistics.profile directly

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

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = Array.from({ length: 30 }, (unused, i) => ({
  id: `r${i}`, region: i % 2 === 0 ? 'north' : 'south', month: i, sales: 100 + i * 5 + (i % 4) * 10,
}));

const designer = createDesigner(el, {
  mode: 'edit',
  sources: { orders: { kind: 'rows', rows, rowKey: 'id' } },
  state: { pages: [{ id: 'p1', spec: { panels: [] } }] },
  factories: { createGrid, createHeadlessGrid, createLayout, createChart },
});

// Six widget kinds, each a kind: 'chart' panel of its own type - "add" is one
// undoable commit through designer.addWidget, same as any other widget.
const kinds = designer.statisticsWidgets.kinds.slice().sort();
designer.statisticsWidgets.add('columnProfile', 'w1', { source: 'orders', options: { y: 'sales' } });

// The properties panel's generated editor already offers
// column profile's one role and group comparison's test/confidence options - // no designer code of its own.
const { chartRoles, chartOptions } = await import('../packages/modules/charts/capabilities.js');
const profileRoles = chartRoles('profile').map((r) => r.role);
const groupOptions = chartOptions('groupComparison').map((o) => o.name);

// What it drew matches grid.statistics.profile directly.
const grid = designer.dashboard.panel('w1').grid;
const direct = grid.statistics.profile('sales');

const undone = designer.undo(); // the add is one step

designer.destroy();
return [
  kinds.join(','),
  profileRoles.join(','),
  groupOptions.includes('test') && groupOptions.includes('confidence'),
  direct.rows,
  undone,
  designer.getState().pages[0].spec.panels.length,
].join(' | ');                 // columnProfile,correlation,distribution,forecast,groupComparison,modelError | field | true | 30 | true | 0

Pushdown sources

A source of kind: 'pushdown' (DuckDB, ClickHouse, Elasticsearch, Splunk, REST, OData) is a relation the engine holds, so the designer designs against it without loading it. Fields come from the adapter's describe() or schema with the engine's types mapped (BIGINT, Nullable(UInt64), keyword, Edm.Int32 become number, and so on) and are never read off a sampled page; the grids, charts and KPIs built over the source are given those columns. Every chart, KPI, statistics widget and derived-grid step computes in the engine through source.aggregate() and the pushdown plans, and each widget's info badge says where its figures were computed: the engine, or the client with the reason. What the adapter cannot do is refused by name: the properties panel disables the aggregation with the reason and lists the steps (filter, sort, group, pivot, total) the adapter cannot answer, a chart over a pushdown source is asked of the engine or refused rather than drawn from the loaded window, and a derived step the adapter cannot answer is refused. Editing stays interactive: every edit is still its own commit and undo step, but the engine questions of a rebuilt canvas are held for pushdownDebounceMs (150 by default) after the last edit and the superseded ones are aborted, so a burst of edits asks the engine once, for the final state. The host's factories.createPushdownSource is wrapped to do this and put back by designer.pushdown.destroy().

A REST-like adapter that can only page rows: its fields from describe(), its aggregations and steps refused by name, an edit refused, a derived pivot refused

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createPushdownSource } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

// An adapter that pages rows and nothing else; describe() is its schema.
const adapter = {
  name: 'rest',
  capabilities: { range: true, total: true },
  describe: async () => [{ name: 'id', type: 'Edm.Int32' }, { name: 'name', type: 'Edm.String' }, { name: 'at', type: 'Edm.DateTimeOffset' }],
  execute: async () => ({ rows: [{ id: 1, name: 'a', at: '2024-01-01T00:00:00Z' }], total: 1 }),
};

const designer = createDesigner(el, {
  mode: 'edit',
  pushdownDebounceMs: 150,
  sources: { api: { kind: 'pushdown', adapter, pageSize: 50 } },
  state: { pages: [{ id: 'p1', spec: { panels: [
    { id: 'rows', kind: 'grid', title: 'Rows', source: 'api' },
    { id: 'by', kind: 'chart', title: 'By name', source: 'api', options: { type: 'bar', x: 'name', y: { col: 'id', fn: 'avg' } } },
  ] } }] },
  factories: { createPushdownSource, createGrid, createHeadlessGrid, createLayout, createChart },
});

// Fields: the adapter's own, typed, with no row read to learn them.
const fields = (await designer.pushdown.describe('api')).map((f) => `${f.id}:${f.type}`).join(',');

// Refusals: named, with the reason.
const sum = designer.pushdown.refusal('api', { aggregation: 'sum' });
const steps = designer.pushdown.refusals('api').filter((r) => r.kind === 'step').map((r) => r.name).filter((n) => n !== 'filter' && n !== 'sort');

// The properties panel never commits a refused aggregation ...
designer.properties.selectPanel('by');
const edited = designer.properties.editRole({ role: 'y', aggregated: true, repeatable: false }, 0, 'id', 'sum');

// ... and a derived step the adapter cannot answer is refused, not computed over the window.
const derived = designer.commands.run('addDerivedGrid', { from: 'rows', steps: [] });
const pivoted = designer.commands.run('addDerivedStep', derived, { kind: 'pivot', rows: ['name'], column: 'id', measures: [{ of: 'id', fn: 'sum' }] });

designer.destroy();
return [fields, sum.slice(0, sum.indexOf(',')), steps.join(','), edited, pivoted].join(' | ');

Theme and accessibility

All designer chrome - the toolbar, the page tabs, the shortcuts dialog and every panel a later feature mounts - is styled with the theme token contract and nothing else, so it needs no designer-specific CSS from the host. The designer's root carries the grid's own lattice class, so the four presets (material3.css, antd5.css, bootstrap5.css, fluent2.css) restyle it exactly as they restyle a grid, in light and in dark. The theme option (or designer.setTheme(name), live) writes data-theme on the root: 'light', 'dark', 'high-contrast', or 'auto', which follows the system's prefers-color-scheme live in CSS with no script. With no theme the designer follows the nearest data-theme above it. The chrome's stylesheet is a <style> inside the root, so destroy() removes it. Reduced motion is honoured: the chrome's transitions use --lattice-motion-duration, which the theme zeroes under prefers-reduced-motion, and are switched off outright as well.

Keyboard. Every action is reachable without a pointer. The toolbar and the page tabs are each one tab stop; Left and Right move within them, Home and End jump, and the tabs select as focus moves. F6 and Shift+F6 cycle the regions in the focus order toolbar, palette, canvas, properties (then data and filters). Widgets on the canvas move and resize with the layout's own keyboard model: on a widget's handle, Space or Enter grabs, the arrow keys move or resize one cell and say where, Space or Enter drops, Escape cancels. One map documents every key; designer.shortcuts.list() returns it and Alt+Shift+K shows it in a dialog.

KeysWhat it does
Alt+Shift+MSwitch between edit and view mode, from anywhere in the designer.
Alt+Shift+NAdd a note widget to the selected page.
Alt+Shift+GAdd a grid widget over the first source.
F6 / Shift+F6Move focus to the next or previous region.
Alt+Shift+KShow or hide the list of shortcuts.
Space / Enter, arrows, EscapeOn a widget's drag or resize handle: grab, move or resize, drop, cancel.

Screen readers. Every control has a role and an accessible name, the dialog is modal and labelled, and state changes are announced in one polite live region (role="status"): Chart added: Revenue by region, Page Detail selected, Edit mode, Dashboard canvas region. designer.announce(text) says any message, and says the same text twice. Contrast is at least 4.5:1 for text and 3:1 for the focus ring and the boundaries of controls, in light and dark and in each of the four presets; the focus ring is drawn in the foreground colour with the preset's focus colour as a halo, because Bootstrap's focus token is a translucent glow and Ant Design's accent on white is 4.1:1.

Rules for a feature that adds chrome. Every card that mounts a panel, toolbar or dialog follows these:

  1. Tokens only. Style with var(--lattice-*) and no colour literal. Use the pairs the chrome uses: foreground and foreground-muted on surface or background, selected-foreground on selected-background for a primary control, accent for a boundary. Reuse the classes lattice-designer-button, lattice-designer-tab, lattice-designer-dialog. Put a <style> inside your panel, never in document.head.
  2. Name and role. A control is a <button>, <input> or <select> (or has a role) and a visible-text or aria-label name; an icon-only button has an aria-label. A panel is a role="group" or region with aria-label; a dialog is role="dialog" with aria-modal="true" and a title.
  3. One tab stop per composite. A toolbar, tab list or palette is one tab stop with arrow keys inside it (roving tabindex).
  4. Join the focus order. designer.regions.register('palette', el, { order: 10 }) - palette 10, canvas 20, properties 30, data 40, filters 50.
  5. Every key is in the map. designer.shortcuts.register({ id, keys, description, run }). Use Alt+Shift+<letter>, never a bare letter. A modal dialog traps Tab, closes on Escape and returns focus to what opened it.
  6. Announce every change a pointer user can see, with designer.announce(text) or a command that does: '{kind} added: {title}'.
  7. Strings in messages.js. designer.* labels and a11y.designer.* announcements, English, through t(key, params); never in the core catalogue.
  8. Check it. designer.audit() returns the problems (unnamed control, unnamed dialog, tab outside a tab list, two tab stops in one group, duplicate id, no live region); a test for your panel asserts it is empty in edit and view mode.

Keys only: switch to edit mode, add three widgets, hear each one, and audit the chrome

const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', region: 'north', sales: 10 }, { id: 'b', region: 'south', sales: 20 }];
const designer = createDesigner(el, {
  theme: 'auto',                                        // follows the system, live, in CSS
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  factories: { createGrid, createHeadlessGrid, createLayout },
  state: { pages: [{ id: 'p1', title: 'Sales', spec: { panels: [] } }] },
});
const said = () => el.querySelector('.lattice-designer-status').textContent;
const key = (letter) => designer.canvas.dispatchEvent(new TestEvent('keydown', { key: letter, code: `Key${letter}`, altKey: true, shiftKey: true }));

const seen = [designer.theme, designer.setTheme('dark')];
key('M'); seen.push(designer.mode, said());            // Alt+Shift+M
key('N'); seen.push(said());                           // a text widget
key('G'); seen.push(said());                           // a grid
key('N'); seen.push(said());
seen.push(designer.dashboard.panels().length, designer.audit().length);
seen.push(designer.regions.list().join(','), designer.shortcuts.list().length > 5);
designer.announce('Saved');
seen.push(said());
designer.destroy();
return seen.join(' | ');                               // auto | dark | edit | Edit mode | Text added: Text 1 | Grid added: Grid 1 | Text added: Text 2 | 3 | 0 | toolbar,palette,canvas | true | Saved

Undo and redo: one committed change is one step, undo restores the exact prior state, redo re-applies it, the toolbar and scoped shortcuts drive it, and a host setState clears it

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', region: 'north', sales: 10 }, { id: 'b', region: 'south', sales: 20 }];
const designer = createDesigner(el, {
  mode: 'edit',
  historyDepth: 3,
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    schemaVersion: 1,
    pages: [
      { id: 'p1', title: 'Sales', spec: { layout: { columns: 2, rows: 1 }, panels: [
        { id: 'table', kind: 'grid', title: 'Orders', source: 'sales' },
        { id: 'note', kind: 'html', title: 'Note', options: { text: 'Provisional figures.' } },
      ] } },
      { id: 'p2', title: 'Notes', spec: { panels: [{ id: 'only', kind: 'html', options: { text: 'Hello' } }] } },
    ],
    selectedPageId: 'p1',
    panels: {},
  },
  factories: { createGrid, createHeadlessGrid, createLayout },
});

const seen = [designer.canUndo(), designer.canRedo()];   // false, false

// One committed change: a page op, recorded at the single commit.
const causes = [];
designer.on('state', (event) => causes.push(event.cause));
designer.selectPage('p2');
seen.push(designer.canUndo(), designer.canRedo());       // true, false

// Undo restores the exact prior state and fires state with cause 'undo'...
seen.push(designer.undo(), designer.getState().selectedPageId, designer.canRedo());
// ...and redo re-applies it.
seen.push(designer.redo(), designer.getState().selectedPageId);

// The chrome carries the two buttons, enabled exactly when there is a step.
seen.push(designer.chrome.querySelectorAll('.lattice-designer-tool').length);

// A host setState clears the history unless it passes { keepHistory: true }.
designer.setState(designer.getState());
seen.push(designer.canUndo());

seen.push(causes.join(','));                             // selectPage,undo,redo
designer.destroy();
return seen.join(' | ');

Guardrails (D7). guardrails limits what an author can use, and is live. Its shape is { sources?, fields?, widgets?, chartTypes?, formatting?, pages?, calculatedFields?, derivedGrids?, ai?, maxWidgetsPerPage? } - an allow-list (or, for fields, a per-source allow/deny) - and anything omitted is allowed, so guardrails: undefined restricts nothing. A restricted item is hidden - not disabled - in the palette, properties and data panels, and the API refuses a change that would add one, by the guardrail's name. A state that already contains a restricted panel is never dropped: view mode renders it (exploration only), and edit mode renders it read-only, pinned with a locked badge and no move or resize handles. The live handle is designer.guardrails: its allows* predicates answer what the panels would hide, and refuse(kind, …) names the guardrail that stops a change. It is changeable at run time through setProperty('guardrails', value) or setGuardrails(value), and a non-object is refused by name.

Widgets restricted to grids, the locked note, and a run-time change that clears the guardrails

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

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', region: 'north', sales: 10 }, { id: 'b', region: 'south', sales: 20 }];
const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    schemaVersion: 1,
    pages: [{ id: 'p1', title: 'Sales', spec: {
      layout: { columns: 2, rows: 1 },
      panels: [
        { id: 'table', kind: 'grid', title: 'Orders', source: 'sales' },
        { id: 'note', kind: 'html', title: 'Note', options: { text: 'Provisional figures.' } },
      ],
    } }],
    selectedPageId: 'p1',
    panels: {},
  },
  factories: { createGrid, createHeadlessGrid, createLayout, createChart },
  guardrails: { widgets: ['grid'] },                  // only grids: the html note is locked
});
const seen = [];

// The live handle answers the same questions the palette would ask.
seen.push(designer.guardrails.allowsWidget('grid'), designer.guardrails.allowsWidget('html'));
seen.push(designer.guardrails.refuse('widget', 'html'));  // the guardrail that refuses it, by name
seen.push(designer.guardrails.widgetLimit() === null);

// The restricted note still renders, but edit mode pins it and hides the grips.
const note = [...designer.canvas.querySelectorAll('.lat-layout__window')]
  .find((n) => n.getAttribute('data-window-id') === 'note');
seen.push(note.getAttribute('data-locked'), note.querySelector('.lat-layout__grip') === null);

// Change the guardrails at run time, then clear them again.
seen.push(designer.setProperty('guardrails', { widgets: ['grid', 'html'] }));
seen.push(designer.guardrails.allowsWidget('html'));
seen.push(designer.setGuardrails(undefined));
seen.push(designer.getProperty('guardrails').widgets === null);

designer.destroy();
return seen.join(' | ');              // true | false | widgets | true | widgets | true | true | true | true | true

The canvas, in edit mode. Every panel of the selected page is a layout window a user drags, resizes, closes and selects by pointer or keyboard, and the layout's track grid is the snap grid. A drag, a resize or a keyboard move writes the exact cell placement into spec.layout.windows and fires state once; moving one widget rebuilds none of the others. selectWidget(id) selects a widget (a click, or Tab and Enter, do the same; null clears it) - it is ringed, focused, and given the accessible name 'Chart: Revenue by region, selected' - and selectedWidget() is what the properties panel binds to. removeWidget(id) (and the Delete key, and the close control) removes a widget, addWidget(panel, placement) adds one, each one undoable change. The canvas writes placements on the layout's existing layout:changed event, which already settles once per committed gesture; underneath, the layout gained a programmatic select(id)/selected() pair that rings and focuses a window.

Selecting a widget by id, reading the selection, and adding then removing a widget - each one undoable state change

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

const { document, root } = createTestDom({ width: 1000, height: 600 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', region: 'north', sales: 10 }, { id: 'b', region: 'south', sales: 20 }];
const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: { schemaVersion: 1, selectedPageId: 'p1', panels: {}, pages: [
    { id: 'p1', title: 'Sales', spec: {
      layout: { columns: 2, rows: 2, compact: 'none', windows: [
        { id: 'table', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
        { id: 'chart', xPos: 2, yPos: 1, xSize: 1, ySize: 1 } ] },
      panels: [
        { id: 'table', kind: 'grid', title: 'Orders', source: 'sales' },
        { id: 'chart', kind: 'chart', title: 'Revenue by region', source: 'grid:table',
          options: { type: 'bar', x: 'region', y: 'sales' } } ] } } ] },
  factories: { createGrid, createHeadlessGrid, createLayout, createChart },
});
const out = [];

// Select a widget: it drives selectedWidget() and takes an accessible name.
designer.selectWidget('chart');
const sel = designer.selectedWidget();
out.push(sel.id, sel.panel.title);
out.push(el.querySelector('[data-window-id="chart"]').getAttribute('aria-label'));

// The layout carries the selection too: select(id) is programmatic, selected() reads it.
out.push(designer.dashboard.layout.selected());
designer.dashboard.layout.select(null);
out.push(designer.dashboard.layout.selected() === null);

// Add a widget, then remove one: one undoable state event each.
const causes = [];
designer.on('state', (event) => causes.push(event.cause));
designer.addWidget({ id: 'extra', kind: 'html', title: 'Extra', options: { text: 'x' } }, { xPos: 1, yPos: 2, xSize: 2, ySize: 1 });
designer.removeWidget('table');
out.push(causes.join(','));
out.push(designer.getState().pages[0].spec.panels.map((p) => p.id).join(','));

designer.destroy();
return out.join(' | ');                              // chart | Revenue by region | Chart: Revenue by region, selected | chart | true | addWidget,removeWidget | chart,extra

The properties panel. Selecting a widget shows its properties - its source, its field-to-role mapping (a measure carrying one of sum, average, the extremes, the two counts or the median, or the widget's own reductions where it declares them), a chart type switch, and its formatting - in designer.properties, mounted in the chrome. The editor is generated from capability metadata, never hard-coded. chartEditorModel(type) (charts module) is one call that returns a chart type's roles, its options and the aggregations a measure offers; the non-chart widgets declare the same shape in the registry's widgetEditorModel(kind). A new chart type or a new option therefore appears in the panel with no Designer change - where an option lacks metadata it is added to a registry, not here. Every control commits through the designer's one commit path, so each edit is a single state event and undo wraps it; an invalid value is committed and refused inline by the viewer's own message, not blocked at the control. A chart type switch keeps every role the new type still has and announces, in a live region, which roles it dropped.

A chart's editor generated from the registry: a measure with an aggregation, one event per edit, and a type switch that reports what it dropped

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createChart, chartEditorModel } = await import('../packages/modules/charts/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', region: 'north', sales: 10 }, { id: 'b', region: 'south', sales: 20 }];
const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    schemaVersion: 1,
    pages: [{ id: 'p1', title: 'Page', spec: { panels: [
      { id: 'c1', kind: 'chart', title: 'Chart', source: 'sales', options: { type: 'scatter', x: 'region', y: 'sales', size: 'sales' } },
    ] } }],
    selectedPageId: 'p1',
    panels: {},
  },
  factories: { createGrid, createHeadlessGrid, createChart, createLayout },
});

// The editor is generated from the registry: a scatter's roles, in order.
const roles = chartEditorModel('scatter').roles.map((r) => r.role).join(',');

// One edit is one state event; a measure carries its aggregation.
const props = designer.properties;
let events = 0;
const off = designer.on('state', () => { events += 1; });
const y = chartEditorModel('scatter').roles.find((r) => r.role === 'y');
props.editRole(y, 0, 'sales', 'avg');
const afterEdit = events;
const measure = designer.getState().pages[0].spec.panels[0].options.y;
const yStored = `${measure.col}/${measure.fn}`;

// A type switch keeps the compatible roles and reports the dropped one.
const switched = props.switchType('line');
const type = designer.getState().pages[0].spec.panels[0].options.type;
off();
designer.destroy();

return [roles, afterEdit, yStored, switched.dropped.join(','), type].join(' | ');

What each widget's editor holds. A chart: its fields to roles (x, y/measures with an aggregation, series, size, colour, label, a date bucket), the type switch, and formatting - title, axis titles and number/date format masks (axis.y.format, axis.x.format), legend, data labels, tooltip, colour scheme, a palette theme override - plus the type's own options (stack, curve, a map's projection, …). A grid: its columns, each with its own shown/hidden, width, format, sort, group-by, pivot-on and total, and earlier/later buttons for the order; then the pivot toggle and the totals rows. A KPI: the measure and its aggregation, a comparison (baseline, how the delta is shown, a target), a number format and a sparkline. A map: a geometry column or a lat/lon pair, the measure where that map type reads it, and colour. A control is generated from its descriptor alone (type, enum values, range, default), so a checkbox shows an option's effective default and a default is stored as unset. The panel opens with the widget's fields: drag one onto a role row, or press it to pick it up and press Enter on a role's field list. Every control is a native, focusable form control or button with an accessible name. The chart metadata is read from the charts module the host injected - pass chartEditorModel and registeredChartTypes in factories (or load the charts script, which sets them on LatticeGrid) so an extension type registered by its own bundle is offered.

A grid's columns edited through the generated controls: pick a column, set its width and total, move it first, and turn on the grand total row

const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createChart, chartEditorModel, registeredChartTypes } = await import('../packages/modules/charts/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', region: 'north', sales: 10 }, { id: 'b', region: 'south', sales: 20 }];
const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    schemaVersion: 1,
    pages: [{ id: 'p1', title: 'Page', spec: { panels: [
      { id: 'g1', kind: 'grid', title: 'Orders', source: 'sales', options: { columns: [{ field: 'region' }] } },
    ] } }],
    selectedPageId: 'p1',
    panels: {},
  },
  // The chart metadata comes from the charts module the host loaded.
  factories: { createGrid, createHeadlessGrid, createChart, createLayout, chartEditorModel, registeredChartTypes },
});

// Find a generated control by its accessible name, and change it as a person would.
const panel = designer.properties.el;
const control = (name) => ['select', 'input', 'button'].flatMap((tag) => panel.querySelectorAll(tag))
  .find((n) => n.getAttribute('aria-label') === name);
const change = (name, value) => {
  const c = control(name);
  if (c.type === 'checkbox') c.checked = value; else c.value = value;
  c.dispatchEvent(new TestEvent('change', {}));
};
const columns = () => designer.getState().pages[0].spec.panels[0].options.columns;

change('Columns 2', 'sales');                       // the trailing row adds a column
const picked = columns().map((c) => c.field).join(',');

let events = 0;
const off = designer.on('state', () => { events += 1; });
change('Columns 2 width', '140');                  // one edit, one state event
const oneEvent = events;
off();

const total = control('Columns 2 total');          // an enum: its choices are the registry's values
change('Columns 2 total', total.querySelectorAll('option').find((o) => o.textContent === 'sum').value);
control('Move Columns 2 (sales) earlier').dispatchEvent(new TestEvent('click', {}));
change('Grand total row', true);

const spec = designer.getState().pages[0].spec.panels[0].options;
const first = spec.columns[0];
const minWidth = control('Columns 1 width').getAttribute('min');   // the registry's range is on the control
designer.destroy();

return [picked, oneEvent, spec.columns.map((c) => c.field).join(','), `${first.layout.width}/${first.total}`, spec.grandTotalRow, minWidth].join(' | ');

Calculated fields. In the data panel an author adds an expression column to a source: designer.calculated. The expression is the grid's own formula language - a hand-written parser and evaluator, never eval - with field references (autocompleted, and source.field for a related source reached through a relationship), arithmetic and comparison, IF/CASE/AND/OR (only the branch taken is evaluated, so IF(cost = 0, 0, revenue / cost) never divides by zero), dates (YEAR, MONTH, WEEK, DATEDIFF, DATEADD), text (CONCAT, UPPER, CONTAINS) and COALESCE/ROUND. The editor shows the result type and a five-row live preview, and an error names the token and its position: unknown field "revnue" at 9. A field is stored in state.panels.calculated[sourceId], compiled to an ordinary computed column, and so is a field like any other in a chart, a KPI, a filter, statistics and a derived grid. On a pushdown source an expression is pushed only when the adapter's translateExpression can translate it (the result reports its provenance); otherwise it is refused with the reason, never silently computed over one page. Each change is one commit: one state event, one undo step.

Add a calculated field, see its type and preview, read it in the grid, and get a named error for a misspelt field

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

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ id: 'a', revenue: 100, cost: 60 }, { id: 'b', revenue: 200, cost: 50 }];
const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rows, rowKey: 'id' } },
  state: {
    schemaVersion: 1,
    pages: [{ id: 'p1', title: 'Page', spec: { panels: [{ id: 'g1', kind: 'grid', title: 'Sales', source: 'sales' }] } }],
    selectedPageId: 'p1',
    panels: {},
  },
  factories: { createGrid, createHeadlessGrid, createChart, createLayout },
});

const calc = designer.calculated;
const preview = calc.preview('sales', 'revenue - cost');           // the type and the first rows
const shown = preview.rows.map((r) => r.value).join(',');
const bad = calc.add('sales', { id: 'oops', expression: 'revenue - revnue' });  // refused, by name

let events = 0;
const off = designer.on('state', () => { events += 1; });
calc.add('sales', { id: 'margin', label: 'Margin', expression: 'revenue - cost' });  // one commit
const grid = designer.dashboard.panel('g1').grid;
const margin = grid.rows.value('a', 'margin');                     // a field like any other
off();
designer.undo();                                                   // one undo step removes it
const left = calc.list('sales').length;
designer.destroy();

return [preview.type, shown, margin, `${bad.errors[0].token} at ${bad.errors[0].at}`, events, left].join(' | ');

Derived grids

designer.derived builds a grid from another grid or source with an ordered step list, and the result is both a widget and a source other widgets read. There is no second engine: each step compiles into the dashboard spec using what the grid, the dashboard and the data router already do.

  • filter - conditions, compiled to the grid's own filter state (options.state.filters), which runs before grouping and pushes down on a pushdown source.
  • group and roll up - group-by fields (columns with group: { enabled, index }) and measures (a column's total); the rolled-up rows are the grid's group rows, and the grouping pushes down.
  • pivot - row fields group, the column field carries pivot: { enabled }, the measure its total, and options.pivot.enabled turns pivot mode on; it pushes down.
  • join - a related source by a declared relationship: a many-to-one/one-to-one one compiles to the derived source's join (a lookup that brings chosen fields across), a one-to-many one to a Data Router COLLECT that gathers the related rows into a list column.
  • sort and top-N - the grid's sort state, and the derived source's sort + limit (limitPer for the best N within each value of a column).

The parent is read through a grid:<id> source, so a change to the parent re-derives the grid live, and the derivation chains. The steps and the lineage live in the designer's own panel slot (state.panels[id].derived), so a round-trip is the identity and every step is one undo. designer.derived.lineage(id) is the sentence a data panel shows, and designer.derived.provenance(id) reads which steps a pushdown source answered and which engine ran them (engine is null for a memory parent). A step the adapter cannot do - a group it cannot group, a pivot it cannot pivot - is refused by name in designer.derived.refusals(id) and marked in the data panel's step list.

A derived grid grouped and rolled up from its parent, its lineage, and a step undone

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const designer = createDesigner(el, {
  mode: 'edit',
  sources: {
    orders: {
      kind: 'rows', rowKey: 'id',
      rows: [
        { id: '1', rep: 'Ana', region: 'EU', amount: 100 }, { id: '2', rep: 'Ben', region: 'EU', amount: 40 },
        { id: '3', rep: 'Ana', region: 'US', amount: 60 }, { id: '4', rep: 'Cy', region: 'US', amount: 200 },
      ],
      fields: [{ id: 'rep', type: 'text' }, { id: 'region', type: 'text' }, { id: 'amount', type: 'number' }],
    },
  },
  state: { pages: [{ id: 'p1', spec: { panels: [{ id: 'orders', kind: 'grid', title: 'Orders', source: 'orders' }] } }], selectedPageId: 'p1' },
  factories: { createGrid, createHeadlessGrid, createLayout },
});

// Build a derived grid: the sales, grouped by rep, with each rep's revenue - // a `grid:orders` source re-derived whenever the orders grid changes.
const id = designer.commands.run('addDerivedGrid', {
  from: 'orders', title: 'Top reps', fromLabel: 'Orders',
  steps: [{ kind: 'group', by: 'rep', measures: [{ id: 'amount', of: 'amount', fn: 'sum' }] }],
});

const panel = designer.getState().pages[0].spec.panels.find((p) => p.id === id);
const repCol = panel.options.columns.find((c) => c.field === 'rep');
const amountCol = panel.options.columns.find((c) => c.field === 'amount');
const lineage = designer.derived.lineage(id);

// Provenance and refusals: a memory parent pushes nothing down, so the engine
// is null, every step is client-side, and nothing is refused.
const provenance = designer.derived.provenance(id);
const refusals = designer.derived.refusals(id);

// A second step, then undo it: each step is one undoable change.
designer.commands.run('addDerivedStep', id, { kind: 'sort', by: [{ col: 'amount', dir: 'desc' }] });
const after = designer.derived.steps(id).length;
designer.undo();
const undone = designer.derived.steps(id).length;
designer.destroy();

return [panel.source, repCol.group.enabled, amountCol.total, lineage, provenance.engine, provenance.pushed.length, refusals.length, after, undone].join(' | ');

Data routes from config

designer.dataRoutes compiles each widget's own config row into its own data route - the fields its roles use, its aggregations, its page and global filters, and the joins its fields need across the declared relationships - read through the exact same role metadata the properties panel already generates its editor from, so a route is never a second guess at a widget's shape. A derived grid enters as an ordinary source and a calculated field as an ordinary declared field - one compile step, not three.

Changing one widget's role re-plans only that widget's route (by sameSpec on its own config row) and re-requests only its data - a sibling's route and its data are untouched, by reference. A route also reads its page's filters, which are not on the widget's own row, so a page filter change re-plans and re-requests every widget on that page, not only the row that literally changed; a widget on a different page is left alone. Removing a widget detaches its route. designer.dataRoutes.explain() lists every live route by widget id, the way the Data Router's own explain() reads, and requests() lists every data request issued, in order, by widget id. Over a pushdown source, issueQuery(route, adapter) asks the engine for only the route's own fields and, when it groups, exactly its groupBy and aggregates - never select *, and never a loaded page's worth when an aggregate needs the whole relation; executeRouteOverRows(route, rowsBySource) is the in-memory counterpart, performing the route's own joins so a joined total can be proven correct with no engine at all.

A grid's route carries only its fields and its GROUP BY; changing one widget's aggregation re-plans only that widget; a page filter change re-plans every widget on the page

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const designer = createDesigner(el, {
  mode: 'view',
  sources: { orders: { kind: 'rows', rowKey: 'id', rows: [{ id: '1', region: 'EU', amount: 100 }] } },
  state: {
    pages: [{
      id: 'p1',
      filters: [],
      spec: {
        panels: [
          { id: 'a', kind: 'grid', source: 'orders', options: { columns: [{ field: 'region', group: true }, { field: 'amount', total: 'sum' }] } },
          { id: 'b', kind: 'grid', source: 'orders', options: { columns: [{ field: 'amount', total: 'avg' }] } },
        ],
      },
    }],
    selectedPageId: 'p1',
  },
  factories: { createGrid, createHeadlessGrid, createLayout },
});

// Widget `a`'s route: only the fields its columns bind, and its GROUP BY.
const routeA = designer.dataRoutes.route('a');
const routeBBefore = designer.dataRoutes.route('b');

// Edit only `a`'s aggregation - one state change: re-plans and re-requests only `a`.
const state = designer.getState();
state.pages[0].spec.panels[0].options.columns[1].total = 'avg';
designer.setState(state, { keepHistory: true });

const routeAAfter = designer.dataRoutes.route('a');
const routeBAfter = designer.dataRoutes.route('b');
const requestsBefore = designer.dataRoutes.requests().length;

// A page filter change is not on either widget's own row, yet both read it.
state.pages[0].filters = [{ id: 'f1', source: 'orders', field: 'region', op: 'eq', value: 'EU' }];
designer.setState(state, { keepHistory: true });
const bothStale = designer.dataRoutes.route('a') !== routeAAfter && designer.dataRoutes.route('b') !== routeBAfter;
const requestsAfter = designer.dataRoutes.requests().length;

designer.destroy();

return [
  routeA.fields.join(','),
  routeA.groupBy.join(','),
  routeAAfter.aggregates[0].fn,
  routeBAfter === routeBBefore,
  requestsAfter - requestsBefore,
  bothStale,
].join(' | ');

The AI assistant

The assistant (designer.assist) asks the host's model for a page, checks what comes back, and shows what survives as a highlighted preview the author accepts (one undo step) or rejects. The model is reached through the host's own callback - the llm option is the same ask() the AI module takes, or { ask, sendSampleRows } to opt into sending a bounded sample of the chosen source's rows - so the designer carries no key and no endpoint. The request the callback receives carries the governed system, message, prompt, messages and schema, enriched with the allowed sources, their fields, the relationships, the current page's spec and the guardrails; no row leaves the browser unless sendSampleRows is set. Everything the model returns is untrusted and checked twice: parseProposal/checkProposal drop a panel that names another source, a column the chosen source does not have, a kind the page cannot draw, or markup in an html panel, and the designer's guardrails then drop anything they refuse - a widget kind, a chart type, a source, a field or the maxWidgetsPerPage cap - each drop a warning by name. A reply that is not a proposal is reported, never thrown.

A stub model proposes a grid, the preview is accepted as one undoable step, and undo restores the page

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid, createHeadlessGrid } = await import('../packages/dom/src/index.js');
const { createLayout } = await import('../packages/modules/layout/index.js');
const { createDesigner } = await import('../packages/modules/designer/index.js');

const { document, root } = createTestDom({ width: 1200, height: 800 });
const el = document.createElement('div');
root.appendChild(el);

const rows = [{ region: 'north', sales: 10 }, { region: 'south', sales: 20 }];
// The host's model: a stub that answers with a grid proposal. The designer
// hands it the request; it carries no key and no endpoint.
const ask = async () => ({ panels: [
  { id: 'grid', kind: 'grid', source: 'sales', title: 'Sales by region', options: { columns: [{ field: 'region' }, { field: 'sales' }] } },
] });

const designer = createDesigner(el, {
  mode: 'edit',
  sources: { sales: { kind: 'rows', rows, fields: [{ id: 'region', type: 'text' }, { id: 'sales', type: 'number' }] } },
  state: { schemaVersion: 1, pages: [{ id: 'p1', title: 'Sales', spec: { panels: [] } }], selectedPageId: 'p1', panels: {} },
  llm: ask,
  factories: { createGrid, createHeadlessGrid, createLayout },
});

const seen = [];
const result = await designer.assist.propose('Add a grid of sales by region');
seen.push(result.ok, result.diff.added.join(','), result.warnings.length);      // true, grid, 0
seen.push(designer.assist.pending !== null);                                     // true (a preview is showing)
seen.push(designer.assist.accept());                                             // true (one undo step)
seen.push(designer.getState().pages[0].spec.panels.map((p) => p.id).join(',')); // grid
seen.push(designer.undo());                                                      // true (the accept, undone)
seen.push(designer.getState().pages[0].spec.panels.length);                      // 0
designer.destroy();
return seen.join(' | ');

Type reference

Generated from the type declarations, so it always matches the release. Each surface lists its properties, its methods and the events it raises as three tables; an option or value type lists its members once.

The Designer

DesignerFilter

One filter: a global one lives on `DesignerState.filters` (every page), a page one on `DesignerPage.filters` (that page only). The same shape a palette-added filter widget writes (`id`, `type`, `source`, `field`), with `operator` and a `value` added once it is set: a `list` filter's is `string[]`, a `range`'s `[number|'', number|'']`, a `dateRange`'s `{ preset, from, to }` (ISO calendar days; `preset` one of `last7`, `last30`, `last90`, `thisMonth`, `thisQuarter`, `thisYear`, `custom`), a `search`'s a plain string. No value means the filter is defined but inactive - not filtering anything.

PropertyTypeDescription
idstringA unique id, scoped to the list it is in.
type'list' | 'range' | 'dateRange' | 'search'The filter widget kind, which also decides `operator`'s shape and `value`'s.
sourcestringThe source it reads.
fieldstringThe field it filters on.
operatorstringThe operator the type implies (`in`, `between`, `between`, `contains`). (optional)
valueunknownThe active value, type-shaped as above; absent means not filtering. (optional)
[key: string]unknownAny other key is carried through `getState()` unchanged.

DesignerClickFilter

One widget's cross-filter setting: "clicking this filters…". Kept at `DesignerState.panels[panelId].clickFilter`, not on the panel itself - the dashboard spec's own keys are closed - and compiled to `DesignerPage.spec.links` whenever any widget's setting on that page changes.

PropertyTypeDescription
targets'all' | string[]Every other widget on the page, or the chosen ones' ids.
onstringThe field a click's row(s) are read by, and the target widgets are filtered on (same name both sides).

DesignerPage

One page of a designed dashboard.

PropertyTypeDescription
idstringA unique id.
titlestringThe page's title. (optional)
specDashboardSpecThe page's dashboard spec; its sources are added to the designer's own.
filtersDesignerFilter[]This page's own filters, on top of `DesignerState.filters`. (optional)
[key: string]unknownAny other key is carried through `getState()` unchanged.

DesignerState

The designer's state: the dashboard spec extended with pages. Plain JSON, and every key the designer does not know is carried, so `setState(getState())` is the identity.

PropertyTypeDescription
schemaVersionnumberThe schema version, `1`.
pagesDesignerPage[]The pages, in order.
selectedPageIdstring | nullThe page on the canvas, or null when there is none.
filtersDesignerFilter[]Filters that apply on every page, on top of each page's own. (optional)
panelsRecord<string, unknown>The designer chrome's own state (later cards keep theirs here); may be empty. The editing screen keeps each rail's collapse as `{ left: { collapsed }, right: { collapsed } }`; a widget's cross-filter setting is `panels[panelId].clickFilter`.
[key: string]unknownAny other key is carried through `getState()` unchanged.

DesignerConfigRow

One keyed config row: the unit the Data Router adds, updates and removes. The designer state normalises to these - one per widget, page, filter and relationship - and `configRowsToState` is their exact inverse. A widget row's `kind` is its panel kind (`grid`, `chart`, …); a page/filter/relationship row's `kind` names its sort.

PropertyTypeDescription
idstringThe row's id (a widget's is its panel id); the Data Router's `rowKey`.
pageunknownThe page (or dashboard) the row belongs to; the partition key.
kindstringA panel kind for a widget, else `'page'`, `'filter'` or `'relationship'`.
sourceunknownThe widget's source, or null.
mappingunknownA widget's viewer options (its data mapping), or null.
formatunknownThe rest of the entity's config - a panel's title and so on, or a page's spec.
placementunknownA widget's window placement, or null.
seqnumberA version/sequence the router orders and de-duplicates on.

DesignerConfigWrite

The write path an author's own edits leave through, mirroring the `onWrite` a grid's write-back uses.

PropertyTypeDescription
op'upsert' | 'delete'`'upsert'` for an add or change, `'delete'` for a removal.
rowDesignerConfigRowThe config row the edit produced.
beforeDesignerConfigRow | nullThe row as it was before, or null for a new one.

DesignerConfigRouter

Config routing: route dashboard config to a designer the way the Data Router routes rows to a grid. The designer subscribes to its slice of a router and applies the keyed diff per widget - add creates, update re-renders only that widget, remove deletes - and an author's own edits leave as config rows through the write path. Inert until `connect`.

PropertyTypeDescription
routerDataRouter | nullThe connected router, or null. (read-only)
pageunknownThe connected slice (page or dashboard id), or null. (read-only)
traceunknownThe connected router's `trace`, or null when nothing is connected. (read-only)
MethodSignatureParametersReturnsDescription
rows(): DesignerConfigRow[] - DesignerConfigRow[]The whole state as config rows, one per widget, page, filter and relationship.
toRows(state: Partial<DesignerState> | DashboardSpec): DesignerConfigRow[]state: Partial<DesignerState> | DashboardSpecDesignerConfigRow[]Decompose any designer state into config rows.
fromRows(rows: DesignerConfigRow[]): DesignerStaterows: DesignerConfigRow[]DesignerStateRecompose a designer state from config rows (the inverse of `toRows`, by `sameSpec`).
connect(router: DataRouter, options?: { page?: unknown; filter?: (row: DesignerConfigRow) => boolean; transform?: (row: DesignerConfigRow) => DesignerConfigRow; label?: string; onWrite?: (change: DesignerConfigWrite, context: { route: unknown; page: unknown; source: 'author' }) => void; }): () => voidrouter: DataRouter
options?: { page?: unknown; filter?: (row: DesignerConfigRow) => boolean; transform?: (row: DesignerConfigRow) => DesignerConfigRow; label?: string; onWrite?: (change: DesignerConfigWrite
context: { route: unknown; page: unknown; source: 'author' }) => void; }
() => voidSubscribe to a slice of a Data Router and apply its keyed config diff per widget. `page` names the slice (default: the selected page); `filter` and `transform` are the router's own per-route options, so one dashboard can vary per role or tenant; `onWrite` receives an author's own edits as config rows. Replaces any current connection; returns a disconnect.
disconnect(): void - voidDisconnect the current router, if any.
explain(options?: unknown): unknownoptions?: unknownunknownThe connected router's `explain()`, or null when nothing is connected.
destroy(): void - voidDisconnect and tear the handle down.

DesignerDataRoute

One widget's compiled data route: the fields its roles use, its aggregations, its page and global filters, and the joins its fields need across the declared relationships. Compiled from the widget's own config row alone - unaffected by any other widget's.

PropertyTypeDescription
widgetIdstringThe widget's own id (its panel id).
pageunknownThe page the widget is on.
kindstringThe widget's panel kind (`grid`, `kpi`, `chart`, …).
sourceIdstring | nullThe widget's own source id, or null.
fieldsstring[]Every field the widget's roles read - its own source's fields bare, a joined source's as `'otherSource.field'`. Never wider than the roles bound.
groupBystring[]The subset of `fields` a role marked as a grouping dimension.
aggregates{ id: string; col: string; fn: string; role: string }[]One entry per aggregated role: the engine's own `{id, col, fn}` shape, ready for `executeGroupedAggregates`.
joinsunknown[]The `compileRelationships` edges the route's cross-source fields need, one per related source actually reached.
filters{ page: unknown[]; global: unknown[] }The route's filters: `page` are this page's filters naming this widget's own source; `global` name no source and apply to every widget on the page.
refusalsstring[]One message per role field a declared relationship could not reach; that field is left out of `fields`.

DesignerDataRoutes

Data routes from config: a route per widget, compiled from its own config row and its page's filters, re-planned - and, for a `rows`-kind source, its data re-read in memory - only when that widget's row or its page's filters change; an untouched widget on an untouched page keeps its exact route object. A `pushdown`-kind source's route is compiled the same way but not live-fetched here: that source's own widget rendering is the one path that asks its adapter, debounced - `issueQuery` stays available for a host or a test to call on such a route directly. The handle the designer exposes as `designer.dataRoutes`.

MethodSignatureParametersReturnsDescription
routes(): DesignerDataRoute[] - DesignerDataRoute[]Every live route.
route(id: string): DesignerDataRoute | nullid: stringDesignerDataRoute | nullOne widget's route, or null when it has none (removed, or never a widget row).
result(id: string): unknownid: stringunknownThe last data a widget's `rows`-kind route asked for, or null before its first request, after it is detached, or for a `pushdown`-kind route.
requests(): { widgetId: string; seq: number }[] - { widgetId: string; seq: number }[]Every data request issued, in order, by widget id - a role edit on one widget adds one entry naming it, and no entry for any other.
explain(): Record<string, DesignerDataRoute> - Record<string, DesignerDataRoute>Every live route, by widget id - `explain()[widgetId]` reads the way the Data Router's own report reads.
refresh(): void - voidRe-plan every widget's route from the current state, whether or not its row changed, and re-read every `rows`-kind widget's data.
destroy(): void - voidTear the handle down.

DesignerGuardrails

The guardrails a developer sets to limit what an author can use. Everything omitted is allowed, so `{}` restricts nothing. A value a guardrail does not recognise is ignored rather than guessed. Changeable at run time with `setProperty('guardrails', value)`.

PropertyTypeDescription
sourcesstring[]Only these source ids may be used; omitted = every source. (optional)
fieldsRecord<string, { allow?: string[]; deny?: string[] }>Per-source field allow/deny, e.g. `{ sales: { allow: ['region'], deny: ['cost'] } }`; a deny wins over an allow. (optional)
widgetsstring[]Only these panel kinds (`grid`, `chart`, `kpi`, `calendar`, `map`, `html`) may be added. (optional)
chartTypesstring[] | { families?: string[] }The chart types allowed: concrete names (`['bar', 'line']`), or families (`{ families: ['bar'] }`). (optional)
formatting'full' | 'basic' | 'none' | string[]Formatting allowed: `'full'`, `'basic'` (everyday options), `'none'`, or the names of the options allowed. (optional)
pages{ add?: boolean; remove?: boolean; rename?: boolean; reorder?: boolean }Which page operations an author may do; each defaults to allowed. (optional)
calculatedFieldsbooleanWhether authors may add calculated fields; defaults to true. (optional)
derivedGridsbooleanWhether authors may derive grids; defaults to true. (optional)
aibooleanWhether authors may use the AI assistant; defaults to true. (optional)
maxWidgetsPerPagenumberThe most widgets a page may hold; omitted = no limit. (optional)

DesignerSourceField

One field of a source, declared by the developer or inferred from the source's own data or adapter schema when not declared.

PropertyTypeDescription
idstringThe field's id: a key of a `rows` row, or a `columns` entry's `field`.
labelstringThe label shown in the data panel; a human form of `id` by default. (optional)
typeDesignerFieldType 'number' | 'currency' | 'percent' | 'date' | 'datetime' | 'boolean' | 'text' | 'list' | 'geometry'The field's type, which decides its icon and the type group it sits in.
formatstringHow a value is formatted, as a `Column.format` mask. (optional)
descriptionstringShown in the data panel beneath the field's label. (optional)

DesignerRelationship

A relationship from one source's field to another's, which {@link createDesigner}'s data panel compiles to a data-router join edge: a LOOKUP join (`many-to-one`/`one-to-one`) on `from`'s source, pulling `fields` from `to`'s source; a COLLECT join (`one-to-many`) on `from`'s source (the "one" side), gathering `to`'s matching rows under `as`.

PropertyTypeDescription
fromstring`'sourceId.fieldId'`, the "many" side of a `many-to-one`, the "one" side of a `one-to-many`.
tostring`'sourceId.fieldId'`, the other side.
type'many-to-one' | 'one-to-many' | 'one-to-one'The cardinality; `'many-to-one'` by default. (optional)
fieldsstring[] | Record<string, string> | ((lookupRow: object | null, leftRow: object) => object)A `many-to-one`/`one-to-one` join's fields to pull: an array, a `{ src: dest }` rename map, or a `select` function, as a data-router join's own `fields`. (optional)
asstringA `one-to-many` join's collected-array field name; the `to` source's id by default. (optional)

DesignerRelationshipEdge

One {@link DesignerRelationship} compiled to the data-router join edge that realises it.

PropertyTypeDescription
sourceIdstringThe source the join attaches to (`router.addSource(sourceId, { join })`).
relatedSourceIdstringThe source it joins against.
fieldstringThe field on `sourceId` the join reads.
relatedFieldstringThe field on `relatedSourceId` the join matches.
type'many-to-one' | 'one-to-many' | 'one-to-one'The relationship's cardinality.
joinRecord<string, unknown>The `join` option: `router.addSource(sourceId, { join })`.

DesignerSourceInfo

One source and its fields, as the data panel shows it.

PropertyTypeDescription
idstringThe source's id.
kindstring | undefinedThe source's `kind` (`rows`, `pushdown`, `router` or `grid`).
fieldsDesignerSourceField[]The source's fields, guardrails already applied.

DesignerDataPanel

The data panel: the host's sources, fields, types and relationships, browsable and draggable onto widgets. Mounted in `designer.chrome`.

PropertyTypeDescription
elHTMLElementThe panel's own element, inside `designer.chrome`. (read-only)
MethodSignatureParametersReturnsDescription
sources(): DesignerSourceInfo[] - DesignerSourceInfo[]Every visible source and its visible fields, guardrails applied.
reachable(sourceId: string): string[]sourceId: stringstring[]Every source reachable from `sourceId` through `relationships`, both directions, multi-hop.
compiledRelationships(): DesignerRelationshipEdge[] - DesignerRelationshipEdge[]`relationships` compiled to data-router join edges.
setQuery(text: string): voidtext: stringvoidFilter the field list by free text, against each field's id, label and description.
selectSource(panelId: string | null): voidpanelId: string | nullvoidSet which widget's source drives the "reachable from" list, by panel id.
registerRoleTarget(el: HTMLElement, onDrop: (payload: { sourceId: string; fieldId: string }) => void): () => voidel: HTMLElement
onDrop: (payload: { sourceId: string; fieldId: string }) => void
() => voidRegister a drop target inside the designer (a properties panel's role slot): dropping a field there calls `onDrop({ sourceId, fieldId })` instead of adding it to the canvas. Returns a function that unregisters it.
destroy(): void - voidTear down the panel and its listeners.

DesignerDerivedMeasure

One measure of a derived grid's group, roll-up or top-N step.

PropertyTypeDescription
idstringThe output column id; derived from `of` and `fn` when omitted. (optional)
ofstringThe column to reduce; omitted for a count. (optional)
fnstringThe aggregation: `sum`, `avg`, `min`, `max`, `count`, `countDistinct`, `median`, `p95`, `stddev`, `first` or `last`. (optional)

DesignerDerivedFilterStep

A filter step: conditions compiled to the grid's own filter state, applied before grouping.

PropertyTypeDescription
kind'filter'The step kind.
whereFilterSetThe filter condition tree: a single `{ col, op, value }`, or `{ op: 'and' | 'or', conditions }`.

DesignerDerivedGroupStep

A group-and-roll-up step: group-by fields, each with its measures.

PropertyTypeDescription
kind'group'The step kind.
bystring | string[]The group-by field, or fields (outermost first).
measuresDesignerDerivedMeasure[]The measures rolled up within each group. (optional)

DesignerDerivedPivotStep

A pivot step: the row fields, a column field and a measure.

PropertyTypeDescription
kind'pivot'The step kind.
rowsstring | string[]The row group field, or fields.
columnstringThe field whose distinct values become the pivot columns.
measureDesignerDerivedMeasureThe measure at each row/column cell. (optional)
measuresDesignerDerivedMeasure[]Several measures at each cell, when more than one. (optional)

DesignerDerivedJoinStep

A join step: a related source by a declared relationship, choosing fields.

PropertyTypeDescription
kind'join'The step kind.
tostringThe related source id.
on{ left: string; right: string }The join keys; taken from the declared relationship when omitted. (optional)
cardinality'many-to-one' | 'one-to-one' | 'one-to-many'The relationship's cardinality; `many-to-one` by default. A `one-to-many` join collects into a list column. (optional)
fieldsstring[]The related fields to bring across (all by default). (optional)
asstringThe list column a `one-to-many` collect gathers into; the related source id by default. (optional)

DesignerDerivedSortStep

A sort step: order the derived rows.

PropertyTypeDescription
kind'sort'The step kind.
byArray<{ col: string; dir?: 'asc' | 'desc' }>The sort keys, applied in order.

DesignerDerivedTopNStep

A top-N step: the best rows by a measure, limited.

PropertyTypeDescription
kind'topN'The step kind.
limitnumberKeep at most this many rows.
bystring | string[]When ranking aggregated groups, the group-by field(s). (optional)
measuresDesignerDerivedMeasure[]The measures the ranking aggregates. (optional)
sortArray<{ col: string; dir?: 'asc' | 'desc' }>How to order before limiting. (optional)
perstringApply the limit within each distinct value of this column. (optional)

DesignerDerivedSpec

The specification of a derived grid to add (the `addDerivedGrid` command's argument).

PropertyTypeDescription
fromstringThe parent source or panel id the grid is built from.
idstringThe new panel's id; derived from `from` when omitted. (optional)
titlestringThe widget title. (optional)
fromLabelstringThe parent's display label, for the lineage sentence. (optional)
stepsDesignerDerivedStep[]The initial steps. (optional)
hiddenbooleanWhether the grid is hidden (a source other widgets read, not shown). (optional)

DesignerDerivedProvenance

Which steps of a derived grid a pushdown parent answered /.

PropertyTypeDescription
enginestring | nullThe pushdown adapter's name (`adapter.name`), or null when the parent is in memory.
pushedstring[]The steps the adapter pushed down.
clientstring[]The steps computed client-side.

DesignerDerived

Derived grids.

MethodSignatureParametersReturnsDescription
compile(steps: DesignerDerivedStep[], context: { fromId: string; fromLabel?: string; columns?: Array<{ id?: string; field?: string; type?: string }>; relationships?: DesignerRelationship[] }): { source: string | undefined; options: Record<string, unknown>; collect: Record<string, unknown> | null; lineage: { from: string; related: string[] }; refusals: Array<{ index: number; name: string; reason: string }>; }steps: DesignerDerivedStep[]
context: { fromId: string; fromLabel?: string; columns?: Array<{ id?: string; field?: string; type?: string }>; relationships?: DesignerRelationship[] }
{ source: string | undefined; options: Record<string, unknown>; collect: Record<string, unknown> | null; lineage: { from: string; related: string[] }; refusals: Array<{ index: number; name: string; reason: string }>; }Compile a step list to a panel's `source` and `options`, without committing.
steps(id: string): DesignerDerivedStep[] | nullid: stringDesignerDerivedStep[] | nullThe steps of a derived grid, or null when there is no such derived grid.
lineage(id: string): string | nullid: stringstring | nullThe lineage sentence for a derived grid (`"Top reps ← Orders"`), or null.
provenance(id: string): DesignerDerivedProvenanceid: stringDesignerDerivedProvenanceWhich steps of a derived grid a pushdown parent answered, read from the built grid's `lastPlan()`.
refusals(id: string): Array<{ index: number; name: string; reason: string }>id: stringArray<{ index: number; name: string; reason: string }>Which steps of a derived grid cannot run, by name and index: a pushdown engine's refusal (group/pivot), or a join with no declared relationship and no explicit `on`.

DesignerProposalRequest

The payload handed to {@link DesignerLlm}: what the model may see. `schema` carries the chosen source's columns and the panel kinds the page can draw, plus the designer's allowed sources, their fields, the relationships, the current page's spec and the guardrails. No rows unless `sendSampleRows`.

PropertyTypeDescription
systemstringThe standing instruction the model is given.
messagestringThe prompt and the SCHEMA (and ROWS, when opted in) as one message.
promptstringThe system instruction followed by `message`.
messagesArray<{ role: string; content: string }>The same content as `system` and `message`, as a chat transcript.
schemaRecord<string, unknown>`{ source, columns, kinds, sources, fields, relationships, page, guardrails }`.
rowsobject[]A bounded sample of the chosen source's rows; only when `sendSampleRows` opted in. (optional)
signalAbortSignalAn abort signal a host may pass through. (optional)

DesignerAssistDiff

The change a proposal makes to the current page, by panel id.

PropertyTypeDescription
addedstring[]Panels the proposal adds.
changedstring[]Panels the proposal keeps but changes.
removedstring[]Panels the proposal removes.

DesignerAssistResult

What `assist.propose(prompt)` resolves to: the checked proposal and everything refused, by name. `ok` is true only when a preview is showing and `accept()` can apply it.

PropertyTypeDescription
okbooleanWhether a preview is showing (and `accept()` would apply it).
specDashboardSpec | nullThe checked proposal (`panels`, and `layout`/`links` when the model sent them); null when not `ok`.
warningsstring[]Every part refused or reported, by name and reason.
diffDesignerAssistDiffWhat the proposal changes, by panel id; empty when not `ok`.

DesignerAssist

The AI assistant: an assist box in the left rail that asks the host's model for a page, checks the reply against the spec schema and the guardrails, and shows what survives as a highlighted preview on the canvas for the author to accept (one undo step) or reject. Mounted on `designer.assist`.

PropertyTypeDescription
elHTMLElementThe assist box's element, in the left rail's `assist` section. (read-only)
pendingDesignerAssistResult | nullThe pending proposal, or null when none is showing. (read-only)
MethodSignatureParametersReturnsDescription
propose(prompt: string): Promise<DesignerAssistResult>prompt: stringPromise<DesignerAssistResult>Ask the model for a page and show what survives as a preview. Never throws: a reply that is not a proposal, or a part the checks or guardrails refuse, is a warning in the result. No rows are sent unless `llm.sendSampleRows` is set.
accept(): boolean - booleanApply the pending proposal as one undoable state change and clear the preview. False when there is no pending proposal.
reject(): void - voidClear the pending preview without applying it.
destroy(): void - voidTear down the assist box, the preview and their listeners.

DesignerOptions

The options of {@link createDesigner}.

PropertyTypeDescription
modeDesignerMode 'edit' | 'view'The mode it opens in; `'view'` by default. (optional)
statePartial<DesignerState> | DashboardSpecThe state it opens with (or a plain dashboard spec, migrated on load); one empty page by default. (optional)
historyDepthnumberHow many undo steps to keep; `100` by default. (optional)
keepPagesAlivebooleanKeep every page built after it is left, so switching back to it does not rebuild it. By default a page is built when selected and disposed when left, so only the selected page's widgets are alive. (optional)
sourcesRecord<string, DashboardSource & { fields?: DesignerSourceField[] }>The named sources every page's panels may read, by id: the host's live data, so not state. `fields` declares each source's fields; absent that, the data panel infers them from the source's own rows/columns or adapter schema. (optional)
relationshipsDesignerRelationship[]The relationships between sources' fields, which the data panel compiles to data-router join edges; kept on `context`). (optional)
guardrailsDesignerGuardrailsThe guardrails limiting what an author may use; the data panel hides the sources and fields they do not allow. Also live on {@link Designer.guardrails}. (optional)
pushdownDebounceMsnumberHow long, in milliseconds, the engine questions of a pushdown source are held after the last edit; `150` by default. A burst of edits, each its own commit, asks the engine once, for the final state. (optional)
factoriesDashboardOptions & { /** The charts module's editor model, so the panel reads the host's chart registry. */ chartEditorModel?: (type: string) => ChartEditorModel; /** The charts module's registered extension types, offered in the type switch. */ registeredChartTypes?: () => string[]; }The factories, exactly as `createDashboard`'s third argument takes them, plus the charts module's `chartEditorModel` and `registeredChartTypes`, which the properties panel generates chart editors from (else found on the `LatticeGrid` global, else the built-in types only). (optional)
llmDesignerLlmThe host's model callback for the AI assistant: the same `ask()` `createAI` takes, or `{ ask, sendSampleRows }` to opt into sending a bounded sample of rows. Kept on `context`. (optional)
messages{ t(key: string, params?: Record<string, unknown>): string }A message catalogue (`{ t }`, e.g. `grid.messages`); the designer's own English is used for a key it does not know. (optional)
themestringThe theme written to the designer root's `data-theme`: `'light'`, `'dark'`, `'auto'` (follows the system live), `'high-contrast'` or any theme the host styles. Unset follows the page. (optional)

DesignerShortcut

One key in the designer's documented shortcut map.

PropertyTypeDescription
idstringA stable id; registering the same id replaces the entry.
keysstringThe key combination as documented, e.g. `'Alt+Shift+M'` (a letter is read from `event.code`, so Option on a Mac does not change it).
descriptionstringWhat it does, as the shortcuts dialog shows it.
MethodSignatureParametersReturnsDescription
run(event: KeyboardEvent) => voidevent: KeyboardEvent=> voidWhat it runs; an entry without one only documents a key another module already handles. (optional)

DesignerShortcuts

The designer's documented keyboard shortcut map.

MethodSignatureParametersReturnsDescription
register(entry: DesignerShortcut): () => voidentry: DesignerShortcut() => voidAdd a key to the map; a malformed entry is refused by name. Returns a function that removes it.
list(): Array<Pick<DesignerShortcut, 'id' | 'keys' | 'description'>> - Array<Pick<DesignerShortcut, 'id' | 'keys' | 'description'>>The documented map, in registration order.

DesignerRegions

The focus regions F6 and Shift+F6 cycle.

MethodSignatureParametersReturnsDescription
register(name: string, el: HTMLElement, options?: { order?: number; label?: string }): () => voidname: string
el: HTMLElement
options?: { order?: number; label?: string }
() => voidPut a panel in the focus order (`order`: toolbar 0, palette 10, canvas 20, properties 30, data 40, filters 50). Returns a function that removes it.
list(): string[] - string[]The region names in focus order, whether or not they are showing.
focus(name: string): booleanname: stringbooleanMove focus into a region and say which; false when it is unknown, hidden or has nothing to focus.

DesignerRail

One rail of the editing screen: the left (palette and data) or the right (properties).

PropertyTypeDescription
collapsedbooleanWhether the rail is collapsed to its 48 px strip (or, as an overlay drawer, closed). (read-only)
elHTMLElementThe rail's element. (read-only)
MethodSignatureParametersReturnsDescription
collapse(): boolean - booleanCollapse the rail.
expand(): boolean - booleanExpand the rail.
toggle(): boolean - booleanCollapse an expanded rail, or expand a collapsed one.
slot(name?: string): HTMLElementname?: stringHTMLElementThe element a panel mounts in: for the left rail the body of the section `name` (`'palette'`, `'data'`, or a new one a card adds); the right rail has one body and ignores the name.

DesignerPaletteEntry

One thing the palette can add: a widget kind, a filter, or a chart type.

PropertyTypeDescription
idstring`kind:type`, such as `chart:bar`, `grid:grid` or `filter:range`.
kind'grid' | 'chart' | 'map' | 'kpi' | 'filter' | 'text'`grid`, `chart`, `map`, `kpi`, `filter` or `text`.
typestringThe chart type (`bar`), the filter type (`dateRange`), or the kind again.
labelstringThe name shown, such as `Horizontal bar`.
familystring`widgets`, `filters`, or a chart family: `comparison`, `trend`, `composition`, `distribution`, `flow`, `hierarchy`, `geo`, `specialist` or `statistics`.
disabledstring | nullWhy the chosen source cannot fill it (`needs a date field`), or null when it can be added.

DesignerPalette

The widget palette, in the left rail's `palette` section.

PropertyTypeDescription
elHTMLElementThe palette's element, in the left rail. (read-only)
collapsedbooleanWhether the palette, and with it the left rail, is collapsed to its 48 px icon strip. (read-only)
MethodSignatureParametersReturnsDescription
entries(): DesignerPaletteEntry[] - DesignerPaletteEntry[]Every entry the guardrails allow: the widgets, the filters, and each chart type from the base list and the registry, read at call time.
add(kind: 'grid' | 'chart' | 'map' | 'kpi' | 'filter' | 'text', type?: string): string | nullkind: 'grid' | 'chart' | 'map' | 'kpi' | 'filter' | 'text'
type?: string
string | nullAdd a widget as a click on its entry does: the `paletteAdd` command. Returns the new id, or null when it is disabled, hidden or over the page's widget limit.
setQuery(text: string): voidtext: stringvoidNarrow the entries by free text, as the search box does.
refresh(): void - voidRead the registry, the guardrails and the sources again.
collapse(): boolean - booleanCollapse to the icon strip.
expand(): boolean - booleanExpand from the icon strip.
toggle(): boolean - booleanCollapse an expanded palette, or expand a collapsed one.
destroy(): void - voidRemove the palette from the DOM.

DesignerRails

The editing screen's rails.

PropertyTypeDescription
leftDesignerRailThe left rail, about 260 px, a 48 px icon strip when collapsed: a `palette` and a `data` section, each scrolling inside itself. (read-only)
rightDesignerRailThe right rail, about 300 px: the properties panel, shown while a widget is selected. (read-only)
layout'wide' | 'narrow' | 'compact'`'wide'` (1100 px and up: both rails inline), `'narrow'` (900 to 1099 px: the right rail is an overlay drawer) or `'compact'` (below 900 px: both are). (read-only)
filterBarHTMLElementThe filter bar's element: a row above the canvas, visible and interactive in edit and view mode alike, unlike the rest of the editing screen. (read-only)

DesignerAuditProblem

One problem the chrome audit found.

PropertyTypeDescription
rulestringThe rule: `name`, `dialog`, `tab`, `roving`, `id` or `live`.
elementstringA short description of the element at fault.
messagestringWhat is wrong.

DesignerMigrationReport

The report {@link Designer.migrate} returns: what a migration did, or refused to do.

PropertyTypeDescription
okbooleanFalse only when the state was from a newer version and was refused.
reason'newer' | null`'newer'` when the state was refused for that reason; otherwise `null`.
schemaVersionnumberThe schema version this designer writes.
fromnumberThe version the state claimed before migration (`0` when it had none).
tonumberThe version it now has - `schemaVersion`.
appliedbooleanWhether any migration changed the state.
changesstring[]What each applied migration did, in order.
stateDesignerState | nullThe migrated state (a copy), or `null` when it was refused.

DesignerMigrateOptions

The options of {@link Designer.migrate}.

PropertyTypeDescription
dryRunbooleanReport the migration without applying it to the designer. (optional)

DesignerProblem

One spec problem on one page.

PropertyTypeDescription
pagestring | undefinedThe page's id.
pathstringWhere in the page's spec, e.g. `panels[2].kind`.
messagestringWhat is wrong there.

DesignerStateEvent

What a `state` event carries.

PropertyTypeDescription
stateDesignerStateA copy of the new state.
causestringWhat changed it: `setState`, `selectPage`, `move`, `resize`, `removeWidget`, `addWidget`, `undo`, `redo`, or a command's own cause.
type'state'The event name.

DesignerSelectEvent

What a `select` event carries.

PropertyTypeDescription
idstring | nullThe widget now selected, or null when the selection was cleared.
panelDashboardPanel | nullThe selected widget's panel, or null.
type'select'The event name.

DesignerPageEvent

What a `page` event carries: a page became selected.

PropertyTypeDescription
page{ id: string; title: string | undefined } | nullThe page now selected, or null when there are no pages.
idstring | nullThe selected page's id, or null when there are no pages.
previousIdstring | nullThe previously selected page's id, or null.
type'page'The event name.

DesignerCommandContext

What a command is handed.

PropertyTypeDescription
designerDesignerThe designer.
MethodSignatureParametersReturnsDescription
getState(): DesignerState - DesignerStateA copy of the state.
commit(state: DesignerState, cause: string, options?: { coalesce?: unknown }): booleanstate: DesignerState
cause: string
options?: { coalesce?: unknown }
booleanReplace the state: the one path to a `state` event. Returns whether it changed. A `coalesce` token merges consecutive commits sharing it (a drag or slider scrub) into one undo step.

DesignerCommands

The command registry: the extension point the palette, properties and undo cards build on.

MethodSignatureParametersReturnsDescription
register(name: string, run: (ctx: DesignerCommandContext, ...args: any[]) => unknown): () => voidname: string
run: (ctx: DesignerCommandContext, ...args: any[]) => unknown
() => voidRegister a command, replacing one of the same name; returns its unregister.
run(name: string, ...args: unknown[]): unknownname: string
...args: unknown[]
unknownRun a command by name; undefined when there is none.
has(name: string): booleanname: stringbooleanWhether a command is registered.
list(): string[] - string[]The registered command names.

DesignerGuardrailsHandle

The guardrails handle on {@link Designer.guardrails}: the normalised value plus the predicates every panel reads to hide (not disable) a restricted choice, and the canvas reads to lock a panel already in the state.

PropertyTypeDescription
valueReadonly<{ sources: string[] | null; fields: Readonly<Record<string, Readonly<{ allow: string[] | null; deny: string[] | null }>>> | null; widgets: string[] | null; chartTypes: string[] | null; formatting: Readonly<{ level: 'full' | 'basic' | 'none' | null; options: string[] | null }>; pages: Readonly<{ add: boolean; remove: boolean; rename: boolean; reorder: boolean }>; calculatedFields: boolean; derivedGrids: boolean; ai: boolean; maxWidgetsPerPage: number | null; }>The guardrails, normalised and frozen. (read-only)
MethodSignatureParametersReturnsDescription
allowsSource(id: string): booleanid: stringbooleanWhether the source id is allowed.
allowsField(source: string, field: string): booleansource: string
field: string
booleanWhether a field of a source is allowed.
allowsWidget(kind: string): booleankind: stringbooleanWhether a panel kind is allowed.
allowsChartType(type: string): booleantype: stringbooleanWhether a chart type is allowed.
allowsFormatting(option: string): booleanoption: stringbooleanWhether a formatting option is allowed.
allowsPage(action: string): booleanaction: stringbooleanWhether a page operation (`add`, `remove`, `rename`, `reorder`) is allowed.
allowsCalculatedFields(): boolean - booleanWhether calculated fields are allowed.
allowsDerivedGrids(): boolean - booleanWhether derived grids are allowed.
allowsAi(): boolean - booleanWhether the AI assistant is allowed.
widgetLimit(): number | null - number | nullThe most widgets a page may hold, or null for no limit.
refuse(kind: string, ...args: unknown[]): string | nullkind: string
...args: unknown[]
string | nullThe name of the guardrail that refuses `kind`, or null.
restrictPanel(panel: unknown): string | nullpanel: unknownstring | nullThe name of the guardrail a panel already in the state violates, or null.

DesignerProperties

The properties panel: the editor for the selected widget, generated from capability metadata. Its controls commit through the designer's one commit path, so each edit is a single `state` event and undo wraps it; an invalid value is committed and refused inline by the viewer's own message, not blocked at the control.

PropertyTypeDescription
elHTMLElementThe panel's root element, mounted in the chrome. (read-only)
lastSwitch{ type: string; dropped: string[] } | nullWhat the last type switch kept and dropped, or null. (read-only)
MethodSignatureParametersReturnsDescription
selectedPanelId(): string | null - string | nullThe selected widget's id, or null.
selectPanel(id: string): booleanid: stringbooleanSelect a widget to edit; false when there is no such widget.
setTitle(value: string): booleanvalue: stringbooleanSet the selected widget's title. Returns whether the state changed.
setSource(value: string): booleanvalue: stringbooleanSet the selected widget's source. Returns whether the state changed.
setOption(name: string, type: 'string' | 'boolean' | 'number' | 'enum' | 'list', value: unknown): booleanname: string
type: 'string' | 'boolean' | 'number' | 'enum' | 'list'
value: unknown
booleanSet a formatting option. The selected widget's own descriptor for `name` types the value and knows its default (a default is stored as unset); `type` is used only when the widget has no such option.
setEntryOption(role: ChartRole, index: number, name: string, value: unknown): booleanrole: ChartRole
index: number
name: string
value: unknown
booleanSet one option of one entry of a repeatable role, such as a grid column's `layout.width`.
moveEntry(role: ChartRole, index: number, delta: number): booleanrole: ChartRole
index: number
delta: number
booleanMove one entry of an orderable role earlier (`-1`) or later (`+1`), such as a grid column.
editRole(role: ChartRole, index: number, col: string, fn: string): booleanrole: ChartRole
index: number
col: string
fn: string
booleanBind (or clear, with `''`) a field to a role, carrying its aggregation.
fields(): string[] - string[]The fields the selected widget's source offers: the field list a role is dragged from.
switchType(type: string): { type: string; dropped: string[] }type: string{ type: string; dropped: string[] }Switch a chart or map type, keeping compatible roles and reporting the dropped ones.
refresh(): void - voidRebuild the panel body for the current selection.
destroy(): void - voidRemove the panel and its subscriptions.

DesignerFilters

Filters and cross-filtering: the filter bar (`designer.rails.filterBar`), its global and page filters, the filter widgets the palette adds, the filter-context chips and the clear-all button, and a widget's cross-filter targets. Every grid-backed widget reading a filter's source follows it - the viewer contract - and a cross-filter setting compiles to the page's `links`.

PropertyTypeDescription
elHTMLElementThe filter bar's element, in `designer.rails.filterBar`. (read-only)
MethodSignatureParametersReturnsDescription
active(): Array<DesignerFilter & { scope: 'global' | 'page' }> - Array<DesignerFilter & { scope: 'global' | 'page' }>Every filter active on the current page - global ones first, each tagged `{ scope: 'global' | 'page' }` - with its live value.
addFilter(options: { scope?: 'page' | 'global'; type: DesignerFilter['type']; source: string; field: string }): string | nulloptions: { scope?: 'page' | 'global'; type: DesignerFilter['type']; source: string; field: string }string | nullAdd a filter (`scope` is `'page'` by default), with one undoable commit. Returns its id, or null when refused.
removeFilter(id: string): booleanid: stringbooleanRemove a filter by id, from whichever list holds it, with one undoable commit.
setValue(id: string, value: unknown): booleanid: string
value: unknown
booleanSet a filter's value (shaped by its type); `undefined` clears it alone. Guardrail-free in view mode - only the filter *set* is edit-mode-only.
clearAll(): boolean - booleanClear every active filter's value, keeping the filters themselves (the clear-all button).
setClickFilter(panelId: string, next: DesignerClickFilter | null): booleanpanelId: string
next: DesignerClickFilter | null
booleanSet (or clear, with `null`) a widget's cross-filter targets, recompiling the page's `links` from every widget's current setting.
refresh(): void - voidRedraw the bar and re-apply every active filter to the current page's grids.
destroy(): void - voidUnsubscribe from designer events; the element is removed with the designer root.

CalculatedField

One calculated field: an expression column added to a source.

PropertyTypeDescription
idstringThe field's id: its column id, and the name other expressions and widgets use.
labelstringThe heading shown for it; the id when absent. (optional)
expressionstringThe expression, in the formula language, without a leading `=`.

CalculatedError

One thing wrong with an expression, named by token and position.

PropertyTypeDescription
messagestringThe message, such as `unknown field "revnue" at 9`.
atnumberThe zero-based offset of the token in the expression as typed.
tokenstringThe offending token's text.
suggestionstringThe nearest known field or function name, when one is close enough to be meant. (optional)

CalculatedResult

The outcome of adding, changing or checking a calculated field.

PropertyTypeDescription
okbooleanWhether the expression checks and compiles.
typestringThe inferred result type: `'number'`, `'text'`, `'boolean'`, `'date'` or `'datetime'`. (optional)
via'browser' | 'pushdown'Where it is computed: `'browser'` row by row, or `'pushdown'` by the source's adapter. (optional)
provenancestringFor a pushed expression, what the adapter reports about the translation. (optional)
errorsCalculatedError[]What is wrong with the expression, when it does not check. (optional)
refusedstringWhy the source cannot take it, when the expression is fine but the source refuses it. (optional)

CalculatedSuggestion

One autocomplete choice.

PropertyTypeDescription
labelstringThe text shown.
insertstringThe text inserted: a function name includes its opening parenthesis.
kind'field' | 'related' | 'function'A field of the source, a field reached through a relationship (`source.field`), or a function.
typestringThe field's type, or a function's result type. (optional)
familystringA function's family: arithmetic, condition, null, date or text. (optional)
docstringA function's one-line description. (optional)

DesignerCalculated

Calculated fields: expression columns an author adds to a source, kept in the designer state under `panels.calculated[sourceId]` and compiled to the grid's computed-column engine (sandboxed; never `eval`). Every change commits once, so it is one `state` event and one undo step.

PropertyTypeDescription
elHTMLElementThe editor's root element, mounted in the chrome. (read-only)
MethodSignatureParametersReturnsDescription
list(): Record<string, CalculatedField[]> - Record<string, CalculatedField[]>The calculated fields of every source, keyed by source id.
list(source: string): CalculatedField[]source: stringCalculatedField[]The calculated fields of one source, in the order they were added.
add(source: string, field: CalculatedField): CalculatedResultsource: string
field: CalculatedField
CalculatedResultAdd a field. Refused, changing nothing, with its errors when it does not check or the source cannot take it.
update(source: string, id: string, patch: { label?: string; expression?: string }): CalculatedResultsource: string
id: string
patch: { label?: string; expression?: string }
CalculatedResultChange a field's expression or label.
remove(source: string, id: string): booleansource: string
id: string
booleanRemove a field; false when there is no such field.
analyse(source: string, expression: string): CalculatedResultsource: string
expression: string
CalculatedResultCheck an expression against a source without storing it: its type, or its errors by token and position.
preview(source: string, expression: string): CalculatedResult & { rows: Array<{ value: unknown; error?: string }> }source: string
expression: string
CalculatedResult & { rows: Array<{ value: unknown; error?: string }> }The live preview: the result type and the first five rows' values (or per-row errors).
suggest(source: string, text: string, caret?: number): { from: number; to: number; items: CalculatedSuggestion[] }source: string
text: string
caret?: number
{ from: number; to: number; items: CalculatedSuggestion[] }Autocomplete at a caret (default: the end): the range to replace and the choices.
status(source: string, id: string): CalculatedResult | nullsource: string
id: string
CalculatedResult | nullHow a stored field compiled (in the browser, pushed, or refused and why), or null when there is none.
setSource(id: string): voidid: stringvoidChoose the source the editor adds to.
setExpression(text: string, id?: string): voidtext: string
id?: string
voidSet the editor's expression text, and optionally its field name.
edit(source: string, id: string): booleansource: string
id: string
booleanLoad a stored field into the editor for changing. False when there is no such field.
accept(item: CalculatedSuggestion): voiditem: CalculatedSuggestionvoidInsert an autocomplete choice at the caret.
submit(): CalculatedResult - CalculatedResultCommit the editor's draft as an add (or a save of the field being edited).
refresh(): void - voidRedraw the editor.
destroy(): void - voidRemove the editor and restore the host's sources as they were.

Designer

A designer.

Properties
PropertyTypeDescription
elHTMLElementThe element it is mounted on. (read-only)
modeDesignerMode 'edit' | 'view'The current mode. (read-only)
chromeHTMLElementThe chrome region, shown in edit mode: a `display: contents` wrapper of the top bar (toolbar, page tabs, undo and redo) and the two rails. (read-only)
railsDesignerRailsThe editing screen's rails: the left holds the palette and data sections, the right the properties panel. (read-only)
paletteDesignerPaletteThe widget palette: every widget, filter and chart type, added by click, drag or keyboard with a default configuration. (read-only)
canvasHTMLElementThe labelled canvas region the selected page is built in. (read-only)
dashboardDashboard | nullThe dashboard on the canvas, or null. (read-only)
commandsDesignerCommandsThe command registry. (read-only)
dataPanelDesignerDataPanelThe data panel: the host's sources, fields, types and relationships. (read-only)
propertiesDesignerPropertiesThe properties panel: the generated editor for the selected widget. (read-only)
calculatedDesignerCalculatedThe calculated-fields editor: expression columns with live preview and errors by name. (read-only)
configRouterDesignerConfigRouterConfig routing: route dashboard config to this designer through a Data Router, the way rows are routed to a grid. (read-only)
derivedDesignerDerivedDerived grids: build a grid from another with a step list, chart or analyse it like any source. (read-only)
dataRoutesDesignerDataRoutesData routes from config: a route per widget, compiled from its own config row and re-planned only when that row changes. (read-only)
assistDesignerAssistThe AI assistant: propose a page, preview the checked result, and accept (one undo step) or reject it. (read-only)
contextReadonly<{ sources: Record<string, DashboardSource & { fields?: DesignerSourceField[] }>; relationships: DesignerRelationship[] | undefined; guardrails: DesignerGuardrails | undefined; llm: DesignerLlm | undefined; }>The sources, relationships, guardrails and llm it was given. (read-only)
guardrailsDesignerGuardrailsHandleThe live guardrails handle: the normalised value plus the `allows*` predicates. (read-only)
shortcutsDesignerShortcutsThe documented keyboard shortcut map. (read-only)
regionsDesignerRegionsThe focus regions F6 cycles. (read-only)
themestring | nullThe theme name on the designer root, or null when it follows the page. (read-only)
statisticsWidgetsDesignerStatisticsWidgetsThe statistics widgets: column profile, distribution, correlation, group comparison, forecast and model error. (read-only)
filtersDesignerFiltersFilters and cross-filtering: the filter bar, global and page filters, and a widget's cross-filter targets. (read-only)
pushdownDesignerPushdownDesigning over pushdown sources: schema fields, provenance, refusals and the debounce. (read-only)
Methods
MethodSignatureParametersReturnsDescription
setTheme(name: string | null): string | nullname: string | nullstring | nullSet the theme live (`'light'`, `'dark'`, `'auto'`, `'high-contrast'`, or null to follow the page); a name that is not a string is refused and the theme kept. Returns the theme now in force.
announce(message: string): voidmessage: stringvoidSay a message in the polite live region; the same text twice is still said.
audit(): DesignerAuditProblem[] - DesignerAuditProblem[]Check the chrome against the designer's accessibility rules (names, roles, one tab stop per roving group, unique ids, the live region); an empty list passes.
getState(): DesignerState - DesignerStateA copy of the state.
setState(state: Partial<DesignerState> | DashboardSpec, options?: { keepHistory?: boolean }): booleanstate: Partial<DesignerState> | DashboardSpec
options?: { keepHistory?: boolean }
booleanReplace the state, migrating an older one first; an equal state changes nothing and sends no event. Returns whether it changed. Clears the undo history unless `options.keepHistory` is set.
migrate(state: Partial<DesignerState> | DashboardSpec, options?: DesignerMigrateOptions): DesignerMigrationReportstate: Partial<DesignerState> | DashboardSpec
options?: DesignerMigrateOptions
DesignerMigrationReportMigrate a state to this version and report what changed; with `dryRun`, report without applying. A newer state is refused.
setGuardrails(guardrails: DesignerGuardrails | undefined): booleanguardrails: DesignerGuardrails | undefinedbooleanReplace the guardrails at run time and rebuild the canvas; a non-object is refused. Returns whether they now apply.
setMode(mode: DesignerMode): DesignerModemode: DesignerModeDesignerModeSwitch mode, rebuilding the canvas and announcing it; returns the mode in force.
pages(): Array<{ id: string; title: string | undefined }> - Array<{ id: string; title: string | undefined }>The pages' ids and titles, in order.
selectPage(id: string): booleanid: stringbooleanSelect a page and build it on the canvas; false when there is no such page.
addPage(options?: { id?: string; title?: string; duplicate?: string }): string | nulloptions?: { id?: string; title?: string; duplicate?: string }string | nullAdd a page - blank by default, or a copy of the page `options.duplicate` names - and select it, with one undoable state change. Refused by name when `guardrails.pages.add` is false. Returns the new page's id, or null when it could not be added.
renamePage(id: string, title: string): booleanid: string
title: string
booleanRename a page with one undoable state change. Refused by name when `guardrails.pages.rename` is false. An empty title is refused.
movePage(id: string, delta: number): booleanid: string
delta: number
booleanMove a page earlier (`delta` negative) or later (`delta` positive) by positions, clamped to the ends, with one undoable state change. Refused by name when `guardrails.pages.reorder` is false.
removePage(id: string): booleanid: stringbooleanRemove a page with one undoable state change. Removing the selected page selects its neighbour. Refused by name when `guardrails.pages.remove` is false.
undo(): boolean - booleanUndo the most recent committed change, restoring the exact previous state and firing `state` with cause `undo`.
redo(): boolean - booleanRedo the most recently undone change, firing `state` with cause `redo`.
canUndo(): boolean - booleanWhether `undo()` would change the state.
canRedo(): boolean - booleanWhether `redo()` would change the state.
selectWidget(id: string | null): string | nullid: string | nullstring | nullSelect a widget on the canvas by id, or clear the selection with `null`. The selected widget is ringed, focused and given the accessible name `'Chart: Revenue by region, selected'`, and a `select` event fires. Returns the id now selected, or null - including in view mode, where there is no canvas to select on.
selectedWidget(): { id: string; panel: DashboardPanel | null } | null - { id: string; panel: DashboardPanel | null } | nullThe selected widget and its panel, or null - what the properties panel binds to.
removeWidget(id: string): booleanid: stringbooleanRemove a widget - its panel and its window - with one undoable state change. False when there is no such widget.
addWidget(panel: DashboardPanel, placement?: Partial<LayoutPlacement>): booleanpanel: DashboardPanel
placement?: Partial<LayoutPlacement>
booleanAdd a widget - a panel, and its window when a placement is given - with one undoable state change. False when the panel is invalid or a duplicate.
getProperty(name: 'mode'): DesignerModename: 'mode'DesignerModeRead a property by name; undefined for a name that is not one.
getProperty(name: 'state'): DesignerStatename: 'state'DesignerStateThe current state (as `getState()`).
getProperty(name: 'selectedPageId'): string | nullname: 'selectedPageId'string | nullThe selected page's id, or null when there are no pages.
getProperty(name: 'guardrails'): DesignerGuardrailsname: 'guardrails'DesignerGuardrailsThe guardrails, normalised.
getProperty(name: string): unknownname: stringunknownAny other property by name; undefined for a name that is not one.
setProperty(name: 'mode', value: DesignerMode): booleanname: 'mode'
value: DesignerMode
booleanWrite a property by name, live (`setProperty('mode', 'view')` is `setMode('view')`); whether it now has that value.
setProperty(name: 'state', value: Partial<DesignerState> | DashboardSpec): booleanname: 'state'
value: Partial<DesignerState> | DashboardSpec
booleanReplace the state (as `setState()`); whether it was accepted.
setProperty(name: 'selectedPageId', value: string): booleanname: 'selectedPageId'
value: string
booleanSelect a page by id (as `selectPage()`); false when there is no such page.
setProperty(name: 'guardrails', value: DesignerGuardrails | undefined): booleanname: 'guardrails'
value: DesignerGuardrails | undefined
booleanReplace the guardrails (as `setGuardrails()`); whether they now apply.
setProperty(name: string, value: unknown): booleanname: string
value: unknown
booleanAny other property by name; false for a name that is not one.
problems(): DesignerProblem[] - DesignerProblem[]Every page's spec problems. Never throws.
on(name: 'state', fn: (event: DesignerStateEvent) => void): () => voidname: 'state'
fn: (event: DesignerStateEvent) => void
() => voidEvery state change, once each.
on(name: 'mode', fn: (event: { mode: DesignerMode; type: 'mode' }) => void): () => voidname: 'mode'
fn: (event: { mode: DesignerMode; type: 'mode' }) => void
() => voidA mode change.
on(name: 'select', fn: (event: DesignerSelectEvent) => void): () => voidname: 'select'
fn: (event: DesignerSelectEvent) => void
() => voidA canvas selection change.
on(name: 'page', fn: (event: DesignerPageEvent) => void): () => voidname: 'page'
fn: (event: DesignerPageEvent) => void
() => voidA page became selected.
onStateUpdated(fn: (event: DesignerStateEvent) => void): () => voidfn: (event: DesignerStateEvent) => void() => voidThe same as `on('state', fn)`.
destroy(): void - voidTear down the canvas and the designer's elements.
Events
EventWhenPayloadCancellable
stateDesignerStateEventno

DesignerPushdownField

One field of a pushdown source, read from its adapter's schema or `describe()`.

PropertyTypeDescription
idstringThe column name.
labelstringA readable heading derived from the name.
type'number' | 'currency' | 'percent' | 'date' | 'datetime' | 'boolean' | 'text' | 'list' | 'geometry'The data panel's field type, mapped from the engine's type.
engineTypestringThe engine's own type name (`BIGINT`, `Nullable(UInt64)`, `keyword`, `Edm.Int32`, …), when it reported one. (optional)
declaredfalseAlways `false`: read from the adapter, not declared by the host.

DesignerPushdownRefusal

What a pushdown source's adapter cannot do, named, with the reason.

PropertyTypeDescription
kind'step' | 'aggregation'Whether a derived step or an aggregation is refused.
namestringThe step (`filter`, `sort`, `group`, `pivot`, `total`) or aggregation (`median`, …).
reasonstringWhy, naming the adapter.

DesignerPushdownProvenance

Where a widget's figures were computed, as its info tooltip says.

PropertyTypeDescription
computed'engine' | 'client' | 'pending'`'engine'`, `'client'` (with a `reason`), or `'pending'` while the engine is asked.
reasonstring | nullWhy the client computed it, when it did.
measuresArray<Record<string, unknown>>Per-measure provenance, where the widget reports it.
sourcestringThe pushdown source the widget reads.
textstringThe sentence the info badge shows as its tooltip.

DesignerPushdown

Designing over `kind: 'pushdown'` sources. Fields come from the adapter, never from a sampled page; widgets compute in the engine and show where; what the adapter cannot do is refused by name; and edits stay interactive because the engine is asked once for a burst.

PropertyTypeDescription
debounceMsnumberThe debounce in milliseconds (`pushdownDebounceMs`); assignable at run time.
MethodSignatureParametersReturnsDescription
fields(sourceId: string): DesignerPushdownField[] | nullsourceId: stringDesignerPushdownField[] | nullThe fields of a pushdown source from its adapter's `describe()`/schema, or null until it has answered (or when the id is not a pushdown source).
describe(sourceId: string): Promise<DesignerPushdownField[]>sourceId: stringPromise<DesignerPushdownField[]>Resolves with a pushdown source's fields once its adapter has described them.
isPushdown(sourceId: string): booleansourceId: stringbooleanWhether a source id, or a `grid:<panel>` reference, ends at a pushdown source.
refusals(sourceId: string, aggregations?: string[]): DesignerPushdownRefusal[]sourceId: string
aggregations?: string[]
DesignerPushdownRefusal[]Everything the source's adapter refuses: each step and each aggregation, with the reason.
refusal(sourceId: string, what: { aggregation: string } | { step: string }): stringsourceId: string
what: { aggregation: string } | { step: string }
stringWhy an aggregation or a step is refused for a source, or an empty string when it is not.
provenance(panelId: string): DesignerPushdownProvenance | nullpanelId: stringDesignerPushdownProvenance | nullWhere a panel's figures were computed, with the sentence its info badge shows; null for a panel that does not read a pushdown source.
refreshInfo(): void - voidRedraw the info badges now.
stats(): { queries: number; superseded: number; held: number } - { queries: number; superseded: number; held: number }How many engine questions ran and were superseded, and how many are held.
onChange(fn: () => void): () => voidfn: () => void() => voidBe told when a source's fields arrive. Returns the unsubscribe.
destroy(): void - voidPut the host's factories back and stop holding questions.

DesignerStatisticsWidgets

The statistics widget kinds an author adds and configures in the properties panel. Each is a `kind: 'chart'` panel of its own `type`, so the properties panel's existing generated editor configures all six from `capabilities.js`'s roles and options for that `type` - nothing here renders a control of its own.

PropertyTypeDescription
kindsreadonly string[]The six kind names: `columnProfile`, `distribution`, `correlation`, `groupComparison`, `forecast`, `modelError`. (read-only)
MethodSignatureParametersReturnsDescription
defaults(kind: string): { type: string; options?: Record<string, unknown> } | nullkind: string{ type: string; options?: Record<string, unknown> } | nullThe default panel `{type, options}` for a kind, or null for an unknown one.
add(kind: string, id: string, extra?: Partial<DashboardPanel> & { placement?: Partial<LayoutPlacement> }): booleankind: string
id: string
extra?: Partial<DashboardPanel> & { placement?: Partial<LayoutPlacement> }
booleanAdd one of the six kinds as a new chart panel, with one undoable commit (`designer.addWidget`).
destroy(): void - voidNothing to tear down: the chart types stay registered, like any import.