@toclocoinc/lattice-grid 1.46.1 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +131 -1
  2. package/docs/API.html +197 -7
  3. package/docs/api-detail.html +100 -3
  4. package/lattice-grid.d.ts +344 -8
  5. package/lattice-grid.esm.min.js +1553 -140
  6. package/lattice-grid.min.cjs +1553 -140
  7. package/lattice-grid.min.css +1 -1
  8. package/lattice-grid.min.js +1553 -140
  9. package/modules/ai.esm.min.js +220 -27
  10. package/modules/ai.min.cjs +218 -26
  11. package/modules/ai.min.js +218 -26
  12. package/modules/angular.esm.min.js +3 -3
  13. package/modules/angular.min.cjs +3 -3
  14. package/modules/angular.min.js +3 -3
  15. package/modules/chart-alluvial.esm.min.js +1 -1
  16. package/modules/chart-arc.esm.min.js +1 -1
  17. package/modules/chart-bubblemap.esm.min.js +1 -1
  18. package/modules/chart-bump.esm.min.js +1 -1
  19. package/modules/chart-calendar.esm.min.js +1 -1
  20. package/modules/chart-decomposition.esm.min.js +1 -1
  21. package/modules/chart-diverging.esm.min.js +1 -1
  22. package/modules/chart-dumbbell.esm.min.js +1 -1
  23. package/modules/chart-fan.esm.min.js +1 -1
  24. package/modules/chart-hexbin.esm.min.js +1 -1
  25. package/modules/chart-hexmap.esm.min.js +1 -1
  26. package/modules/chart-icicle.esm.min.js +1 -1
  27. package/modules/chart-parallel.esm.min.js +8 -3
  28. package/modules/chart-ridgeline.esm.min.js +1 -1
  29. package/modules/chart-roc.esm.min.js +1 -1
  30. package/modules/chart-slope.esm.min.js +1 -1
  31. package/modules/chart-splom.esm.min.js +1 -1
  32. package/modules/chart-waffle.esm.min.js +1 -1
  33. package/modules/charts.esm.min.js +294 -19
  34. package/modules/charts.min.cjs +294 -19
  35. package/modules/charts.min.js +294 -19
  36. package/modules/data-router.esm.min.js +4 -4
  37. package/modules/data-router.min.cjs +4 -4
  38. package/modules/data-router.min.js +4 -4
  39. package/modules/devtools.esm.min.js +2 -2
  40. package/modules/devtools.min.cjs +2 -2
  41. package/modules/devtools.min.js +2 -2
  42. package/modules/dhtmlx-compat.esm.min.js +4 -4
  43. package/modules/dhtmlx-compat.min.cjs +4 -4
  44. package/modules/dhtmlx-compat.min.js +4 -4
  45. package/modules/gantt.esm.min.js +13 -4
  46. package/modules/gantt.min.cjs +13 -4
  47. package/modules/gantt.min.js +13 -4
  48. package/modules/htmx.esm.min.js +1553 -140
  49. package/modules/htmx.min.cjs +1553 -140
  50. package/modules/htmx.min.js +1553 -140
  51. package/modules/kanban.esm.min.js +59 -8
  52. package/modules/kanban.min.cjs +59 -8
  53. package/modules/kanban.min.js +59 -8
  54. package/modules/kpi.esm.min.js +4 -4
  55. package/modules/kpi.min.cjs +4 -4
  56. package/modules/kpi.min.js +4 -4
  57. package/modules/mock-socket.esm.min.js +2 -2
  58. package/modules/mock-socket.min.cjs +2 -2
  59. package/modules/mock-socket.min.js +2 -2
  60. package/modules/react.esm.min.js +3 -3
  61. package/modules/react.min.cjs +3 -3
  62. package/modules/react.min.js +3 -3
  63. package/modules/svelte.esm.min.js +3 -3
  64. package/modules/svelte.min.cjs +3 -3
  65. package/modules/svelte.min.js +3 -3
  66. package/modules/vue.esm.min.js +3 -3
  67. package/modules/vue.min.cjs +3 -3
  68. package/modules/vue.min.js +3 -3
  69. package/modules/webcomponent.esm.min.js +1553 -140
  70. package/modules/webcomponent.min.cjs +1553 -140
  71. package/modules/webcomponent.min.js +1553 -140
  72. package/package.json +1 -1
package/docs/API.html CHANGED
@@ -865,6 +865,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
865
865
  <tr><td class="name">columnMenu</td><td class="type">boolean | (p) =&gt; MenuItem[]</td><td class="desc">The header's 3-dot menu, and a right-click on a column heading. The function form is <code>(params, defaults) =&gt; items</code>, with <code>params</code> carrying <code>colId</code>, <code>column</code> and <code>grid</code>: see <a href="#custom-menu">custom items</a>. <code>false</code> suppresses it.</td></tr>
866
866
  <tr><td class="name">rangeChart</td><td class="type">fn | { onChart } | boolean</td><td class="desc">Off by default. Offers <strong>Chart selection</strong> in the cell menu and binds <kbd>Alt</kbd>+<kbd>F1</kbd> when a selected range has a number to plot. The DOM layer draws no charts, so the handler you give — a function, or <code>{ onChart }</code>, called <code>(grid, range)</code> — is where the page wires in <code>chartRange</code> from <a href="#chart-a-range">the charts module</a>.</td></tr>
867
867
  <tr><td class="name">shortcuts</td><td class="type">boolean</td><td class="dflt">true</td><td class="desc">The <kbd>?</kbd> keyboard shortcut overlay. <code>false</code> suppresses it, for a host that wants <kbd>?</kbd> for itself. See <a href="api-detail.html#keyboard">Keyboard</a>.</td></tr>
868
+ <tr><td class="name">find</td><td class="type">boolean | FindConfig</td><td class="dflt">true</td><td class="desc">The in-grid find bar: <kbd>Ctrl</kbd>+<kbd>F</kbd> (<kbd>Cmd</kbd>+<kbd>F</kbd>) with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; <kbd>Enter</kbd> / <kbd>Shift</kbd>+<kbd>Enter</kbd> step through the matches. <code>{ shortcut, debounce }</code>: <code>shortcut: false</code> keeps the bar reachable through <code>grid.find.open()</code> only; <code>debounce</code> is the typing quiet period in ms (120). <code>false</code> removes the bar and the binding; <code>grid.find(text)</code> still searches. See <a href="api-detail.html#find">Find</a>.</td></tr>
868
869
  <tr><td class="name">rowReorder</td><td class="type">boolean | { column }</td><td class="dflt">, </td><td class="desc">Let a user reorder rows by dragging a handle or with <kbd>Alt</kbd>+<kbd>Shift</kbd>+arrows. The handle goes in the first visible column unless <code>column</code> names another. Refused, with a reason announced, while a sort, filter or grouping is active. See <a href="api-detail.html#row-reorder">Row reorder</a>.</td></tr>
869
870
  <tr><td class="name">rowTransfer</td><td class="type">boolean | { send, receive, mode, group }</td><td class="dflt">, </td><td class="desc">Let rows be dragged between grids. Off by default. <code>send</code> and <code>receive</code> are both on when present, so one-way is <code>{ receive: false }</code> or <code>{ send: false }</code>. <code>mode: 'copy'</code> leaves the row behind; <code>group</code> restricts which grids may exchange. See <a href="api-detail.html#row-transfer">Moving rows between grids</a>.</td></tr>
870
871
  <tr><td class="name">alignedGrids</td><td class="type">Grid[]</td><td class="dflt">, </td><td class="desc">Other grids to stay column-aligned with. Widths, order, visibility, pinning and horizontal scroll are shared; sort, filters, selection and rows stay independent. Declare it on the grid created last. See <a href="api-detail.html#aligned-grids">Aligned grids</a>.</td></tr>
@@ -3218,6 +3219,42 @@ createGrid(host, { source, columns: [...] });</code></pre>
3218
3219
  </table>
3219
3220
  </div>
3220
3221
 
3222
+ <p class="section-note">
3223
+ <strong>Typed binding for timestamp and date columns.</strong> A prepared statement binds a
3224
+ filter value with the value's own type, not the column's: the grid sends an instant as an
3225
+ ISO-8601 string, the client binds it as <code>VARCHAR</code>, and DuckDB refuses
3226
+ <code>"ts" &gt;= ?</code> against a <code>TIMESTAMP</code> column (<em>Binder Error: Cannot
3227
+ compare values of type TIMESTAMP and type VARCHAR</em>). The adapter therefore types the
3228
+ <em>placeholder</em>: a comparison or <code>IN</code> member against a <code>TIMESTAMP</code>,
3229
+ <code>TIMESTAMP WITH TIME ZONE</code>, <code>DATE</code>, <code>TIME</code> or
3230
+ <code>TIMESTAMP_S/_MS/_NS</code> column is written <code>CAST(? AS &lt;that type&gt;)</code>,
3231
+ and the value is still bound, never interpolated. The column's type comes from the engine — one
3232
+ <code>DESCRIBE SELECT * FROM &lt;from&gt;</code> on the first query, cached for the adapter's
3233
+ life and exposed as <code>adapter.describe()</code> — so an untyped grid column over a
3234
+ timestamp is covered. When the schema does not name the column (a <code>DESCRIBE</code> that
3235
+ failed, said once), the grid column's declared type on the condition is the fallback:
3236
+ <code>timestamp</code>/<code>datetime</code> cast to <code>TIMESTAMP</code>,
3237
+ <code>date</code>/<code>dateString</code> to <code>DATE</code>, <code>time</code> to
3238
+ <code>TIME</code>. The engine's type wins when both are known. A <code>Date</code> or an
3239
+ epoch-milliseconds number is bound as its ISO instant, because DuckDB has no cast from a number
3240
+ to a timestamp. The same schema fixes <code>blank</code>: <code>= ''</code> is a conversion
3241
+ error on any non-text column, so a typed column's blank test is <code>IS NULL</code> alone.
3242
+ Text and numeric comparisons (<code>VARCHAR</code>, <code>BIGINT</code>, <code>DOUBLE</code>,
3243
+ <code>DECIMAL</code>, <code>HUGEINT</code>) are written exactly as before, with no cast.
3244
+ </p>
3245
+ <p class="section-note">
3246
+ <strong>Time zones, honestly.</strong> The cast is the engine's, so its zone rules apply. Against
3247
+ a naive <code>TIMESTAMP</code> column the wall-clock digits of the bound string are compared;
3248
+ an instant ending in <code>Z</code> — which is what the grid's own date filter sends — therefore
3249
+ matches a column that stores UTC wall time, the usual convention for log and event data. A
3250
+ non-zero offset in the string is engine-version dependent (DuckDB 1.1 converts it to UTC, 1.5
3251
+ keeps the digits as written), so send <code>Z</code> instants, not local offsets. Against a
3252
+ <code>TIMESTAMP WITH TIME ZONE</code> column an offset or <code>Z</code> is honoured exactly,
3253
+ and a string with <em>no</em> zone is interpreted in the engine's session
3254
+ <code>TimeZone</code> (UTC in DuckDB-Wasm unless the ICU extension is loaded and the setting
3255
+ changed). Against a <code>DATE</code> column an instant is truncated to its UTC day.
3256
+ </p>
3257
+
3221
3258
  <h5 id="dfql-options"><code>dfqlAdapter</code></h5>
3222
3259
  <div class="table-wrap">
3223
3260
  <table>
@@ -3857,6 +3894,7 @@ off(); <span class="cmt">// on() returns i
3857
3894
  <tr><td class="name">history:applied</td><td class="type">{ direction, step }</td><td class="desc">An action was undone or redone. Distinct from <code>history:changed</code>, which also fires when a new action is pushed onto the stacks and so cannot tell you anything was reversed.</td></tr>
3858
3895
  <tr><td class="name">state:reset</td><td class="type">{ state }</td><td class="desc">The grid was returned to its baseline.</td></tr>
3859
3896
  <tr><td class="name">highlight:changed</td><td class="type">{ highlights }</td><td class="desc">A highlight was added or cleared.</td></tr>
3897
+ <tr><td class="name">find:changed</td><td class="type">{ text, caseSensitive, wholeCell, columns, open, count }</td><td class="desc">The find query, its matches, the current match or the bar's open state changed. <code>count</code> is a <code>FindCount</code>; while the bar's sliced scan is still running <code>count.complete</code> is false and the figure is partial.</td></tr>
3860
3898
  <tr><td class="name">redaction:changed</td><td class="type">{ columns }</td><td class="desc">A column was redacted or restored.</td></tr>
3861
3899
  <tr><td class="name">header:contextmenu</td><td class="type">{ colId, column, element, x, y }</td><td class="desc">A column heading was right-clicked.</td></tr>
3862
3900
  <tr><td class="name">render:done</td><td class="type">{ first, last }</td><td class="desc">The cells are written and stable. Anything decorating them from outside must run after this, the cell layer rewrites each cell's <code>className</code> wholesale and would otherwise erase it.</td></tr>
@@ -4538,6 +4576,34 @@ charts.registerChartType('ridgeline', { draw: ridge.drawRidgeline });
4538
4576
  typeof parallel.drawParallel, typeof parallel.bindParallel,
4539
4577
  ].join(' | ');</code></pre>
4540
4578
 
4579
+ <p>When parallel coordinates is given a <code>colourBy</code> column it colours each line by its category — but a colour with no key is a code, so the chart now emits a <strong>legend</strong> of those categories (BACKLOG-0000999), one entry per category in the order the colours were assigned, exactly the shape every other coloured type returns. The base draws it and wires the click, so a click on a category toggles it off through the same hide-a-series gesture the rest of the module has, and the drawer skips a hidden category's lines. With no <code>colourBy</code> there is nothing to key and no legend is drawn. The categories the key is built from are the ones <code>bindParallel</code> returns as <code>groups</code>:</p>
4580
+ <pre data-run="js" data-expect="key red,green,blue" data-covers="export:bindParallel"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4581
+ <span class="kw">const</span> { bindParallel } = <span class="kw">await</span> import('../packages/modules/chart-parallel/index.js');
4582
+ <span class="kw">const</span> cats = ['red', 'green', 'blue'];
4583
+ <span class="kw">const</span> rows = Array.from({ length: 9 }, (unused, i) =&gt; ({ id: `R${i}`, a: i, b: i % 4, grp: cats[i % 3] }));
4584
+ <span class="kw">const</span> grid = createHeadlessGrid({
4585
+ columns: [{ field: 'a', type: 'number' }, { field: 'b', type: 'number' }, { field: 'grp', type: 'text' }],
4586
+ rows, rowKey: 'id',
4587
+ });
4588
+ <span class="cmt">// The colourBy categories, in colour order — each becomes a legend entry whose</span>
4589
+ <span class="cmt">// index picks the same colour its lines use.</span>
4590
+ <span class="kw">const</span> bound = bindParallel(grid, { columns: ['a', 'b'], colourBy: 'grp' });
4591
+ grid.destroy();
4592
+ <span class="kw">return</span> `key ${bound.groups.join(',')}`;</code></pre>
4593
+
4594
+ <p>A least-squares forecast on the trend overlay (<code>trend: { method: 'linear', forecast: n }</code>) no longer draws a bare dashed line: it shades the <strong>uncertainty band</strong> around the projection (BACKLOG-0000975). By default that is the Student-t <em>prediction</em> band (a future observation); <code>band: 'confidence'</code> shades the narrower mean-response band the fitted line's own doubt describes, and <code>band: false</code> leaves the bare line. The band widens as the line runs further past the data — the honest shape, since a projection is least certain where it reaches furthest — and <code>confidence</code> (default 0.95) sets its level. It is the exact interval the core <code>forecast</code> kernel reports for the linear method, computed locally in the charts bundle (never imported, for the bundle reason the trend maths already is) and asserted equal to the engine's to the last digit:</p>
4595
+ <pre data-run="js" data-expect="match true; conf 0.95" data-covers="export:forecast"><code><span class="kw">const</span> { forecast } = <span class="kw">await</span> import('../packages/core/src/index.js');
4596
+ <span class="kw">const</span> { linearTrend } = <span class="kw">await</span> import('../packages/modules/charts/trendline.js');
4597
+ <span class="kw">const</span> ys = [2, 5, 6, 9, 11, 12];
4598
+ <span class="kw">const</span> pairs = ys.map((y, i) =&gt; ({ x: i, y }));
4599
+ <span class="cmt">// The trend overlay's forecast band, three steps ahead at 95%…</span>
4600
+ <span class="kw">const</span> overlay = linearTrend(pairs, 3, { confidence: 0.95 });
4601
+ <span class="cmt">// …is the core forecast kernel's linear prediction band, to the last digit.</span>
4602
+ <span class="kw">const</span> engine = forecast(ys, { method: 'linear', horizon: 3, confidence: 0.95 });
4603
+ <span class="kw">const</span> b = overlay.band.points[3];
4604
+ <span class="kw">const</span> e = engine.points[2];
4605
+ <span class="kw">return</span> `match ${b.lower === e.lower &amp;&amp; b.upper === e.upper}; conf ${overlay.band.confidence}`;</code></pre>
4606
+
4541
4607
  <p>The hierarchy, flow and geographic remainder ships the same way — treemap, sunburst, funnel, radar, sankey, chord and network are already built in, so the new opt-in modules are: <strong>icicle</strong> (<code>drawIcicle</code>, <code>type: 'icicle'</code>, drawn from the grid's group tree), <strong>waffle</strong> (<code>drawWaffle</code>, <code>type: 'waffle'</code>), <strong>alluvial</strong> (<code>drawAlluvial</code>/<code>bindAlluvial</code>, <code>type: 'alluvial'</code>), <strong>arc diagram</strong> (<code>drawArc</code>/<code>bindArc</code>, <code>type: 'arc'</code>), <strong>bubble map</strong> (<code>drawBubbleMap</code>/<code>bindBubbleMap</code>, <code>type: 'bubblemap'</code>) and <strong>hexbin map</strong> (<code>drawHexMap</code>/<code>bindHexMap</code>, <code>type: 'hexmap'</code>). The two maps place <code>lon</code>/<code>lat</code> directly, so they need no outline data and fetch nothing.</p>
4542
4608
  <pre data-run="js" data-expect="true | function | function | function | function | function | function | function | function | function | function" data-covers="export:drawIcicle export:drawWaffle export:drawAlluvial export:bindAlluvial export:drawArc export:bindArc export:drawBubbleMap export:bindBubbleMap export:drawHexMap export:bindHexMap"><code><span class="kw">const</span> charts = <span class="kw">await</span> import('../packages/modules/charts/index.js');
4543
4609
  <span class="kw">const</span> icicle = <span class="kw">await</span> import('../packages/modules/chart-icicle/index.js');
@@ -4606,6 +4672,7 @@ router.load(snapshot); <span class="cmt">// every viewer
4606
4672
  <tr><td class="sig">configure(spec)</td><td class="desc"><strong>v5:</strong> the whole routing graph as one data spec &mdash; <code>routes</code> (grid/<code>default</code>/<code>subscribe</code>/<code>alert</code> entries), <code>links</code>, <code>relate</code>, <code>buffer</code> &mdash; desugared to the imperative API. Composes with imperative calls and round-trips to identical behaviour. Also accepted as <code>createDataRouter({ config })</code>.</td></tr>
4607
4673
  <tr><td class="sig">attach(grid, predicate, { writable, onWrite?, onConflict? })</td><td class="desc"><strong>v8 (BACKLOG-0000912):</strong> make a route <strong>writable</strong> &mdash; the router captures the grid's committed edits off its public edit surface (<code>grid.on('cell:changed')</code> &rarr; <code>grid.edit.setCells</code>) and routes them to <code>onWrite(change, { route, source })</code> (per-route here, or the router-global <code>onWrite</code>), reverting the cell on reject and re-entering an accepted write as a normal delta. <code>onConflict(change, { serverRow })</code> surfaces a last-write-wins conflict. A derived (<code>rollup</code>/<code>transform</code>) route cannot be writable &mdash; its edits are reverted and warned.</td></tr>
4608
4674
  <tr><td class="sig">attach(grid, predicate, { where })</td><td class="desc"><strong>v7 (BACKLOG-0000914):</strong> a route-level <code>where</code> &mdash; a filter-wire condition <code>{ col, op, value }</code> or an <code>and</code>/<code>or</code>/<code>not</code> group &mdash; used only by query-slice routing (<code>query()</code>): the router pushes it <em>down</em> to the engine where the adapter allows and finishes the residual client-side. Distinct from <code>filter</code> (a <code>fn(row)</code> that only ever runs in the browser).</td></tr>
4675
+ <tr><td class="sig">attach(grid, predicate, { label, backpressure })</td><td class="desc"><strong>v10/v13:</strong> a human <code>label</code> for the route (shown in <code>metrics()</code> and the devtools panel), and a per-route <strong>backpressure</strong> policy that throttles / coalesces / samples how that route's viewer is refreshed under load &mdash; <em>without</em> touching the keyed store or any other route. <code>backpressure: { maxHz, minInterval?, sample?, maxLag? }</code>: <code>maxLag</code> (a backlog depth) sets when it engages (below it, changes pass straight through); <code>maxHz</code>/<code>minInterval</code> cap the refresh rate; <code>sample</code> (an integer &gt; 1) thins intermediate refreshes. A trailing flush always lands the latest state (deletes included), so the viewer converges and is never left stale.</td></tr>
4609
4676
  <tr><td class="sig">query(adapter, request?)</td><td class="desc"><strong>v7 (BACKLOG-0000914):</strong> source the router from a DFQL/DuckDB (or any pushdown) adapter. Runs <code>adapter.execute</code>, partitions the result across the routes and drives the grids by the same keyed diff <code>load()</code> uses; a route's <code>where</code> is planned against the adapter's capabilities (pushed down where allowed, residual finished client-side). Composes with per-route <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> and links/graph. Async &mdash; resolves once every slice is fetched and applied.</td></tr>
4610
4677
  <tr><td class="sig">lastQueryPlan()</td><td class="desc"><strong>v7:</strong> the pushed/residual split of the last <code>query()</code>, per fetch &mdash; whether a filter reached the engine and what work was left client-side. <code>null</code> before any query. Provenance, so a slow slice is diagnosed rather than guessed.</td></tr>
4611
4678
  <tr><td class="sig">buffer({ window?, max? })</td><td class="desc"><strong>v4 (BACKLOG-0000911):</strong> turn on time-travel buffering &mdash; record the ordered, de-duplicated stream into a <strong>bounded</strong> ring (a time <code>window</code> in ms and/or a <code>max</code> delta count; eviction folds the oldest into a moving base, so memory never grows unbounded; a default cap applies if you name neither). Seeded from the current world, so it can be turned on at any time. Opt-in and off by default.</td></tr>
@@ -4618,9 +4685,12 @@ router.load(snapshot); <span class="cmt">// every viewer
4618
4685
  <tr><td class="sig">broadcasting</td><td class="desc"><strong>v6:</strong> whether the router is currently mirroring to a BroadcastChannel.</td></tr>
4619
4686
  <tr><td class="sig">addSource(feed, { map?, key? })</td><td class="desc"><strong>v9 (BACKLOG-0000931):</strong> register a source feed &mdash; fan-in. Returns a handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>, plus <code>id</code>/<code>size</code>) whose rows are normalized by <code>map</code> and namespaced by <code>key</code> (a prefix string, <code>true</code> to prefix with the source id, or a <code>keyFn(row)</code>) so ids from different feeds cannot collide, then merged through the router's ordinary path &mdash; partitioned, routed, linked, deduped, buffered and written back exactly as the single-source path. <code>feed</code> is an optional source id or an options object. A source may also carry a <code>join</code> spec (v11) to <strong>enrich</strong> its rows with fields looked up from another source.</td></tr>
4620
4687
  <tr><td class="sig">removeSource(ref) / sources()</td><td class="desc"><strong>v9:</strong> drop exactly the rows a feed contributed (by source id or handle) from every route and unregister it; and list the registered source ids.</td></tr>
4621
- <tr><td class="sig">metrics()</td><td class="desc"><strong>v10 (BACKLOG-0000932):</strong> a cheap point-in-time observability snapshot &mdash; per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global <code>unrouted</code> / <code>dropped</code> / <code>buffered</code> / <code>lag</code> figures. Throughput is sampled over the interval since the last <code>metrics()</code> call or emit.</td></tr>
4688
+ <tr><td class="sig">metrics()</td><td class="desc"><strong>v10 (BACKLOG-0000932):</strong> a cheap point-in-time observability snapshot &mdash; per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global <code>unrouted</code> / <code>dropped</code> / <code>buffered</code> / <code>lag</code> figures. Throughput is sampled over the interval since the last <code>metrics()</code> call or emit. Each entry in <code>routes[]</code> also carries its <code>label</code> and, when the route declares a backpressure policy, a <code>backpressure: { pending, coalesced }</code> object &mdash; <code>pending</code> is the held backlog since the last flush (the route's lag) and <code>coalesced</code> the cumulative change-events it has absorbed into deferred refreshes (<code>null</code> when the route has no policy).</td></tr>
4622
4689
  <tr><td class="sig">on('metrics', handler)</td><td class="desc"><strong>v10:</strong> subscribe to the periodic <code>metrics</code> emit (the <code>metricsInterval</code> ms, default 1000; <code>0</code> disables it). The timer runs only while at least one listener is registered and stops when the last is removed. Returns an unsubscribe function.</td></tr>
4623
4690
  <tr><td class="sig">mountDevtools(el, { interval? })</td><td class="desc"><strong>v10:</strong> mount an opt-in, DOM-touching live panel (in the module's own <code>devtools.js</code>, so the core stays DOM-free) that renders <code>metrics()</code> into <code>el</code> and refreshes on each emit. Returns a controller with <code>destroy()</code>. Off unless called.</td></tr>
4691
+ <tr><td class="sig">persist({ key?, debounce?, storage?, indexedDB?, dbName?, storeName? })</td><td class="desc"><strong>v12 (BACKLOG-0000961):</strong> turn on durable persistence &mdash; snapshot the keyed store and the time-travel ring to a durable async key/value store so an offline reload or a browser refresh resumes exactly where it left off. The default backend is <strong>IndexedDB</strong> (native, no dependency), opened lazily and guarded so private-mode or blocked storage degrades to in-memory with a one-time warning rather than throwing. Writes a coalesced snapshot after each <code>load</code>/<code>apply</code> (debounced by <code>debounce</code> ms, default 250; <code>0</code> is eager). Pass <code>storage</code> &mdash; any object with async <code>get(key)</code>/<code>set(key, value)</code> &mdash; to use another backend (a server, a test double). Opt-in and off by default.</td></tr>
4692
+ <tr><td class="sig">restore()</td><td class="desc"><strong>v12:</strong> resume from the durable snapshot. Read the last persisted state and apply it &mdash; <code>load</code> the live head through the ordinary keyed diff (so grids attached before this call repaint only what differs), restore the resume checkpoint and, when the snapshot carried a time-travel ring, restore buffering and the ring so <code>scrubTo</code>/<code>replay</code>/<code>live</code> work straight after a reload. Call it once, after attaching the grids. <code>async</code>; resolves <code>true</code> when a snapshot was found and applied, <code>false</code> when persistence is off/degraded or nothing was stored.</td></tr>
4693
+ <tr><td class="sig">flushPersist() / persisting</td><td class="desc"><strong>v12:</strong> flush any pending durable write now (<code>async</code>; cancels the debounce and resolves once the write settles &mdash; for a <code>beforeunload</code> handler, a deterministic checkpoint, or a test), and whether durable persistence is on and not degraded to in-memory.</td></tr>
4624
4694
  <tr><td class="sig">detach(grid)</td><td class="desc">Stop routing to a grid and forget its slice; drop any link/edge it is part of (restoring a filtered sibling). The host still owns and destroys the grid.</td></tr>
4625
4695
  <tr><td class="sig">destroy()</td><td class="desc">Detach every grid, drop every link, edge and subscription. <strong>Detaches only</strong> &mdash; the host owns and destroys its grids.</td></tr>
4626
4696
  </tbody>
@@ -4891,6 +4961,7 @@ g.destroy(); router.destroy();
4891
4961
  <span class="kw">return</span> [merged, ids, afterRemove].join(' | ');</code></pre>
4892
4962
 
4893
4963
  <p><strong>Fan-in JOIN / enrichment (v11, BACKLOG-0000957).</strong> Fan-in above <em>merges</em> feeds side by side; a <code>join</code> spec goes further and <em>enriches</em> one feed's rows with fields looked up from <em>another</em> registered source &mdash; e.g. an <code>orders</code> feed enriched with <code>name</code>/<code>tier</code> from a <code>customers</code> source keyed by <code>customerId</code>. Declared per source: <code>addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name', 'tier'], missing: 'hold' } })</code>. <code>localKey</code> is the field on the enriched (left) row holding the foreign key (a field name or <code>fn(row)</code>); <code>foreignKey</code> is the field matched on the lookup row (defaults to <code>localKey</code>'s name); <code>fields</code> is what to pull &mdash; an array, a <code>{ src: dest }</code> rename map, or <code>select(lookupRow, leftRow) =&gt; object</code>. No second store is built: the lookup source <em>is</em> an ordinary fan-in source, and the join probes its existing keyed store by an index of join-key&nbsp;&rarr;&nbsp;store-id. <code>missing</code> chooses what happens when the lookup is absent or late: <code>hold</code> withholds the row from viewers until its lookup arrives, <code>passthrough</code> (the default) lets it flow unenriched, and <code>null</code> flows it with the pulled fields set to <code>null</code>. Enriched rows reach viewers through the ordinary keyed-diff path. <strong>Late lookups re-enrich:</strong> when a lookup row arrives, changes, or is deleted, every already-seated left row that references it is re-enriched and re-emitted &mdash; a held row is released, a <code>null</code>/<code>passthrough</code> row gains its fields, and a row whose lookup vanished is nulled/stripped (or, under <code>hold</code>, withheld again). Enrichment always recomputes from the untouched base row, so it is idempotent.</p>
4964
+ <p><strong>Durable resume and backpressure (v12/v13, BACKLOG-0000961 / BACKLOG-0000962).</strong> <code>persist({ key })</code> turns on durability: the router snapshots its keyed store and time-travel ring to a durable async store (IndexedDB by default, or any <code>{ get, set }</code> you pass as <code>storage</code>) after each <code>load</code>/<code>apply</code>, and <code>await router.restore()</code> &mdash; called once after the grids are attached &mdash; rehydrates them through the ordinary keyed diff, so an offline reload or a browser refresh resumes exactly where it left off (blocked/private storage degrades to in-memory with a one-time warning, never a throw). Independently, a route can declare <strong>backpressure</strong> &mdash; <code>attach(grid, type, { label, backpressure: { maxHz } })</code> &mdash; to cap how often its viewer repaints under load without slowing the store or any sibling route: <code>maxHz</code>/<code>minInterval</code> rate-limit the refresh, <code>sample</code> thins intermediate ones, and <code>maxLag</code> sets the backlog depth at which throttling engages; a trailing flush always lands the latest state so the viewer converges. What it cost is observable: <code>router.metrics().routes[].backpressure</code> is <code>{ pending, coalesced }</code> &mdash; the held backlog and the cumulative change-events folded into deferred refreshes (<code>null</code> for a route with no policy).</p>
4894
4965
  <h3 id="datarouter-v11-example">A JOIN with a late lookup, executed</h3>
4895
4966
  <p class="section-note">An order arrives before its customer, so under <code>hold</code> it is withheld; when the customer
4896
4967
  feed loads, the order is released and enriched with the looked-up name. Run headless on every build.</p>
@@ -5130,9 +5201,10 @@ const board = createKanban(document.querySelector('#board'), {
5130
5201
  <tr><td class="sig">editCard(key, field) / applyEdit(key, field, value)</td><td class="desc">Inline-edit a card field opted in with <code>card: { title: { field, edit: true } }</code>: grid-bound it commits through the grid's own field editor path (<code>grid.edit.setCells</code>); standalone it uses a host editor factory or a default input, reverting when <code>onCardEdit</code> rejects. Double-click a card to edit; emits <code>card:edit</code>.</td></tr>
5131
5202
  <tr><td class="sig">addCard(columnId, seed?)</td><td class="desc">Add a card to a column (with <code>config.addCard</code>'s per-column affordance) and open it in inline edit; a host <code>onAddCard(columnId)</code> supplies the row, or one is generated (grid-bound via <code>grid.edit.addRow</code>). Emits <code>card:add</code>.</td></tr>
5132
5203
  <tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">Serialise and restore the board state — collapsed columns/lanes, column order, quick filter, sprint/epic selection and selection. Also accepted as <code>config.state</code> at construction.</td></tr>
5204
+ <tr><td class="sig">sla</td><td class="desc">The card-aging / SLA monitor, present only when a <code>sla</code> config is supplied. Read <code>sla.states()</code>, <code>sla.breaches()</code>/<code>sla.warnings()</code> and <code>sla.stateFor(cardOrKey)</code> for each card's age and level; <code>sla.evaluate()</code> re-checks and fires crossings. See the card-aging note below.</td></tr>
5133
5205
  <tr><td class="sig">setLoading(bool) / setError(message)</td><td class="desc">A loading state and a host-supplied error banner; empty columns already render their placeholder.</td></tr>
5134
5206
  <tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or recompute and re-render.</td></tr>
5135
- <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>, <code>card:move</code>, <code>card:reverted</code>, <code>selection:changed</code>, <code>drag:start</code>, <code>drag:end</code> (plus the vocabulary the later cycles emit).</td></tr>
5207
+ <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>, <code>card:move</code>, <code>card:reverted</code>, <code>card:confirmed</code>, <code>card:sla</code>, <code>selection:changed</code>, <code>drag:start</code>, <code>drag:end</code> (plus the vocabulary the later cycles emit). On a grid-bound board a move fires <code>card:move</code> optimistically; the grid's write-back then settles it with <code>card:confirmed</code> or, if the server rejects, <code>card:reverted</code> (the card re-reads and the flow transition log rolls the optimistic move back).</td></tr>
5136
5208
  <tr><td class="sig">readonly(scope)</td><td class="desc">Whether a scope is readonly &mdash; the whole board, a <code>{ column }</code> or a <code>{ card }</code>. A readonly card is not draggable; a move into a readonly column is refused.</td></tr>
5137
5209
  <tr><td class="sig">destroy()</td><td class="desc">Empty the element and drop the model. The host still owns any bound grid.</td></tr>
5138
5210
  </tbody>
@@ -5143,6 +5215,7 @@ const board = createKanban(document.querySelector('#board'), {
5143
5215
  <p><strong>Sprint, epic and card pop-out.</strong> <code>setSprint(id)</code> shows one sprint, <code>showBacklog()</code> the cards with no sprint, and <code>sprints()</code> feeds a switcher; <code>setEpic(id)</code> narrows to an epic and <code>epicRollup()</code> (or <code>rollup(property)</code>) returns per-epic count, points and progress toward the <code>done</code> columns. A card can <strong>pop out a nested grid of its children</strong> — an epic's stories, a story's tasks, recursively. The child relationship is a <code>childrenProperty</code> (parent-id within the dataset) and/or a <code>loadChildren(card)</code> (per-card dataset or async fetch), and the child is a full composed <code>createGrid</code> (sort/filter/edit/write-back) — supplied as <code>children.factory</code> — opened in a <code>drawer</code> (default), <code>modal</code> or <code>inline</code>. With <code>children.asBoard</code> the child is itself a board, so it can pop its own children. This reuses the grid by composition and adds no grid-core coupling. <code>expand(key)</code> and the per-card drill affordance emit <code>card:expand</code>; a deeper open emits <code>card:drill</code>.</p>
5144
5216
  <p><strong>Live updates.</strong> Because the board consumes data through the same keyed-diff contract a grid does, a <a href="#datarouter">Data Router</a> drives it directly — <code>router.attach(board, predicate)</code> — and one feed fans out to a grid, a kanban, a chart and a KPI tile at once. A live <code>rows.apply({ add, update, remove })</code> is applied as a keyed diff (an unchanged card keeps its model) and re-rendered <strong>preserving</strong> scroll, focus, selection, collapsed columns/lanes and any open pop-out, so a card can appear, move or update under the user without losing their place.</p>
5145
5217
  <p><strong>Scale, state and accessibility.</strong> <code>virtualize</code> renders only a scroll window of a tall column (with true-height spacers so the scrollbar stays honest), for boards of thousands of cards. <code>getState()</code>/<code>setState()</code> (and <code>config.state</code>) save and restore the collapsed columns and lanes, the column order, the quick filter and the sprint/epic selection, so a reopened board comes back as it was; <code>setLoading</code>/<code>setError</code> add loading and error states. Accessibility runs throughout: the board is a labelled group of labelled column lists, cards are a roving-tabindex focus ring (arrows to move focus, Enter to activate), the move is fully keyboard-driven (<kbd>Space</kbd> grab, arrows for column/position, <kbd>Alt</kbd>+<kbd>↑/↓</kbd> across swimlanes, <kbd>Space</kbd>/<kbd>Enter</kbd> drop, <kbd>Escape</kbd> cancel) with live-region announcements, and every affordance carries a name.</p>
5218
+ <p><strong>Card aging / SLA.</strong> A <code>sla</code> config ages each card and highlights the ones sitting too long. Thresholds are a raw millisecond count or a <code>{ days, hours, … }</code> spec, set globally as <code>{ warn, breach }</code>, per column (either <code>sla.columns[id]</code>, or a column def's own <code>sla</code>/<code>slaWarn</code>/<code>slaBreach</code>) and per swimlane (<code>sla.lanes[id]</code>); the most specific wins, lane&nbsp;&rarr;&nbsp;column&nbsp;&rarr;&nbsp;global. <code>basis</code> chooses whether the clock is time-in-current-column (default) or age-on-the-board, resolved from the flow transition log, an <code>enteredProperty</code>/<code>createdProperty</code> timestamp, or arrival. The view puts an age chip on aged cards (<code>showAge: 'always'</code> shows it on every card) and a highlight on breached ones. A rising crossing (ok&rarr;warn, warn&rarr;breach) fires the <code>card:sla</code> event <em>and</em> the <code>onWarn</code>/<code>onBreach(level, rows)</code> callbacks — the same <code>(signal, rows)</code> shape a <a href="#datarouter">Data Router</a> <code>alert</code> route uses, so one handler serves both. It is reached at runtime as <code>board.sla</code>; done-column cards are exempt by default (<code>ignoreDone: false</code> opts them in), and an optional <code>tick</code> re-checks so a card that breaches by simply sitting still still lights up. Example: <code>createKanban(el, { …, sla: { warn: { days: 2 }, breach: { days: 4 }, onBreach: notify } })</code>.</p>
5146
5219
  <p><strong>Inline edit and add-card.</strong> A field is opted into inline edit with the object mapping form — <code>card: { title: { field: 'title', edit: true } }</code>. Double-clicking a card (or <code>editCard(key, field)</code>) edits it in place: <strong>grid-bound</strong>, through the grid's own field editor for that column via its public edit path, so the column's parse, validate and optimistic/confirm/revert all run; <strong>standalone</strong>, through a host <code>editor</code> factory (or a default input), reverting when <code>onCardEdit</code> rejects. A per-column add-card affordance (<code>config.addCard</code>) creates a card carrying the column's group value — from a host <code>onAddCard(columnId)</code>, or generated, or appended through <code>grid.edit.addRow</code> when bound — and opens it straight in inline edit on its title, so the user just types. The module imports nothing from the grid's DOM package: grid-bound edits ride the grid's public edit API, standalone edits use the host's editor, so a board-only page never pulls the grid in.</p>
5147
5220
  <h3 id="kanban-example">A board, grouped and aggregated, executed</h3>
5148
5221
  <p class="section-note">A DemandFlow-shaped set &mdash; statuses as columns, points, an empty configured column,
@@ -5388,7 +5461,8 @@ const { text, flagged } = await ai.explain({ kind: 'column', colId: 'amount' });
5388
5461
  <thead><tr><th>Member</th><th>Description</th></tr></thead>
5389
5462
  <tbody>
5390
5463
  <tr><td class="sig">createAI(grid, config)</td><td class="desc">Create an AI narrative / insights controller over a live grid (headless or rendered). Config: <code>ask</code> (the host callback; falls back to the grid's <code>ai.ask</code>), <code>enable</code>, <code>maxRows</code>, <code>redact</code>, <code>tools</code>, <code>locale</code>, <code>reconcile</code> (<code>'strip'</code>/<code>'flag'</code>), <code>element</code>, <code>onNarrative</code>, <code>onError</code>.</td></tr>
5391
- <tr><td class="sig">explain(target?, opts?) / narrate(...)</td><td class="desc">Produce a grounded, reconciled narrative. <code>target</code> is <code>{ kind: 'view' }</code>, <code>{ kind: 'column', colId }</code>, <code>{ kind: 'forecast', colId, options }</code>, or <code>{ kind: 'kpi'|'chart', facts }</code>. Resolves to <code>{ text, facts, grounded, flagged, rounds, mode }</code>.</td></tr>
5464
+ <tr><td class="sig">explain(target?, opts?) / narrate(...)</td><td class="desc">Produce a grounded, reconciled narrative. <code>target</code> is <code>{ kind: 'view' }</code>, <code>{ kind: 'column', colId }</code>, <code>{ kind: 'forecast', colId, options }</code>, <code>{ kind: 'kpi'|'chart', facts }</code>, or <code>{ kind: 'risk', gantt, board }</code> for a board / Gantt risk summary. Resolves to <code>{ text, facts, grounded, flagged, rounds, mode }</code>.</td></tr>
5465
+ <tr><td class="sig">riskSummary(sources?, opts?)</td><td class="desc">A board / Gantt <strong>RISK SUMMARY</strong> &mdash; &ldquo;3 tasks at risk on the critical path, SPI 0.67, 2 SLA breaches&rdquo; &mdash; grounded on the separate Gantt / Kanban modules' outputs (<code>gantt</code>, <code>board</code>/<code>sla</code>, or their precomputed <code>earnedValue</code>/<code>schedule</code>/<code>breaches</code>). A convenience over <code>explain({ kind: 'risk' })</code>, through the same reconciliation guard.</td></tr>
5392
5466
  <tr><td class="sig">insights(el?, opts?)</td><td class="desc">Mount (or re-target) the insights panel into an element, its generate control wired to a view narrative. Keeps the grid usable on an <code>ask()</code> error.</td></tr>
5393
5467
  <tr><td class="sig">attachExplain(target, opts?)</td><td class="desc">Build an &ldquo;Explain&rdquo; button bound to a target (a KPI tile, a chart datum, a column). Clicking it narrates the target.</td></tr>
5394
5468
  <tr><td class="sig">facts(target?, opts?)</td><td class="desc">Build the facts packet for a target <em>without</em> calling <code>ask()</code> &mdash; the exact grounded set a narrative would use, and what would leave the browser.</td></tr>
@@ -5426,6 +5500,32 @@ ai.destroy();
5426
5500
  grid.destroy();
5427
5501
  <span class="kw">return</span> `${result.flagged.length} flagged | ${kept} kept | ${stripped} stripped`;</code></pre>
5428
5502
 
5503
+ <h3 id="ai-risk">Board / Gantt risk summary (<code>modules/ai</code>)</h3>
5504
+ <p>A project manager wants one line: <em>&ldquo;3 tasks at risk on the critical path, SPI 0.67, 2 SLA breaches.&rdquo;</em> <code>ai.riskSummary(&hellip;)</code> (and the <code>{ kind: 'risk' }</code> target of <code>explain</code>) produces exactly that, grounded on figures the <strong>separate</strong> optional modules have already computed: <code>gantt.earnedValue()</code> for SPI/CPI and the schedule/cost variances, <code>gantt.schedule</code> for the critical path and the tasks at risk on it, and <code>board.sla</code> for the SLA breach and warning counts. Every figure runs through the <strong>same number-reconciliation guard</strong> as the rest of the narrative &mdash; an ungrounded figure is stripped before the user sees it.</p>
5505
+ <p><strong>The AI bundle imports neither the Gantt nor the Kanban module.</strong> You pass the module instances (or their already-computed outputs) on the target, and the layer reads them duck-typed &mdash; so a page that loads the AI module without those bundles carries none of their weight. <code>buildRiskFacts(target, opts)</code> is exported to build (and preview) the exact grounded facts a risk summary would use, without calling <code>ask()</code>. <strong>Redaction:</strong> a risk summary carries aggregates only by default &mdash; counts and the EVM indices/variances; it withholds task names and card contents. <code>includeTaskNames</code> adds the at-risk task names (bounded by <code>maxTasks</code>) and <code>includeCost</code> adds the money figures (BAC/PV/EV/AC), each the host's explicit opt-in, reported back in <code>meta.exposed</code>.</p>
5506
+ <pre data-run="js" data-expect="2 at risk | SPI 0.8 | 2 breaches" data-covers="export:buildRiskFacts"><code><span class="kw">const</span> { buildRiskFacts } = <span class="kw">await</span> import('../packages/modules/ai/index.js');
5507
+
5508
+ <span class="cmt">// The public outputs a host already holds from the SEPARATE gantt / kanban</span>
5509
+ <span class="cmt">// modules. The AI bundle imports neither — it reads these duck-typed.</span>
5510
+ <span class="kw">const</span> earnedValue = { ok: <span class="kw">true</span>, project: { spi: 0.8, cpi: 0.9, sv: -1000, cv: -500 } };
5511
+ <span class="kw">const</span> schedule = {
5512
+ ok: <span class="kw">true</span>,
5513
+ order: ['a', 'b', 'c'],
5514
+ critical: ['a', 'b', 'c'], <span class="cmt">// all three on the critical path</span>
5515
+ tasks: <span class="kw">new</span> Map([
5516
+ ['a', { id: 'a', name: 'Design', percentComplete: 100, totalFloat: 0 }],
5517
+ ['b', { id: 'b', name: 'Build', percentComplete: 40, totalFloat: 0 }],
5518
+ ['c', { id: 'c', name: 'Ship', percentComplete: 0, totalFloat: -2 }],
5519
+ ]),
5520
+ };
5521
+ <span class="kw">const</span> breaches = [{ key: 'CARD-1' }, { key: 'CARD-2' }]; <span class="cmt">// from board.sla.breaches()</span>
5522
+
5523
+ <span class="kw">const</span> { facts } = buildRiskFacts({ kind: 'risk', earnedValue, schedule, breaches });
5524
+ <span class="kw">const</span> by = Object.fromEntries(facts.map((f) =&gt; [f.id, f]));
5525
+
5526
+ <span class="cmt">// Two incomplete tasks (Build, Ship) are on the critical path — at risk.</span>
5527
+ <span class="kw">return</span> `${by['risk.atRisk'].display} at risk | SPI ${by['risk.spi'].display} | ${by['risk.sla.breaches'].display} breaches`;</code></pre>
5528
+
5429
5529
  <h2 id="mocksocket">The mock socket</h2>
5430
5530
  <p><code>modules/mock-socket</code> is a serverless stand-in for a live <code>WebSocket</code> feed, for building and demonstrating a real-time UI with <strong>no backend</strong>. <code>MockWebSocket</code> presents the same surface as the browser's <code>WebSocket</code> &mdash; the same <code>readyState</code> and state constants, the same <code>onopen</code>, <code>onmessage</code>, <code>onclose</code> and <code>onerror</code>, <code>addEventListener</code>, <code>send</code> and <code>close</code> &mdash; so the code that reads it does not change when it is swapped for a real one. It fires an initial snapshot the moment it opens, then a stream of deltas on a timer, all from a generator you hand it. It is a dev and test utility: optional, imports nothing from the grid, and is never pulled into the core bundle. It pairs naturally with the data router (one mock stream, partitioned to many grids), but depends on it no more than a real socket does.</p>
5431
5531
  <pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
@@ -6180,7 +6280,7 @@ grid.destroy();
6180
6280
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
6181
6281
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
6182
6282
  has not fired yet — so the name is checked rather than left to be discovered.</p>
6183
- <pre data-run="js" data-expect="107" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6283
+ <pre data-run="js" data-expect="108" data-covers="event:find:changed event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6184
6284
 
6185
6285
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
6186
6286
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -6197,7 +6297,7 @@ grid.destroy();
6197
6297
  'diff:changed', 'diff:swapped', 'export:progress', 'facet:computed',
6198
6298
  'facet:expanded', 'facet:failed', 'facet:filtered', 'form:closed',
6199
6299
  'form:error', 'form:opened', 'form:saved', 'formatting:changed',
6200
- 'group:toggled', 'header:contextmenu', 'highlight:changed', 'history:applied',
6300
+ 'group:toggled', 'header:contextmenu', 'highlight:changed', 'find:changed', 'history:applied',
6201
6301
  'history:changed', 'licence:changed', 'page:changed', 'permissions:changed',
6202
6302
  'presence:failed', 'presence:joined', 'presence:left', 'presence:lockRefused',
6203
6303
  'presence:published', 'presence:updated', 'presentation:captured', 'presentation:changed',
@@ -6925,6 +7025,8 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
6925
7025
  <tr><td class="name">kind</td><td class="type">'ses' | 'holt'</td><td class="desc">For the exponential method, single smoothing (`ses`) or Holt's level+trend (`holt`). <small>(optional)</small></td></tr>
6926
7026
  <tr><td class="name">alpha</td><td class="type">number</td><td class="desc">For the exponential method, the level factor in `[0, 1]`; omit to fit it. <small>(optional)</small></td></tr>
6927
7027
  <tr><td class="name">beta</td><td class="type">number</td><td class="desc">For Holt's exponential smoothing, the trend factor in `[0, 1]`; omit to fit it. <small>(optional)</small></td></tr>
7028
+ <tr><td class="name">band</td><td class="type">boolean | 'prediction' | 'confidence'</td><td class="desc">The uncertainty band shaded around a linear `forecast` (BACKLOG-0000975). The Student-t `prediction` band (a future observation) by default; `confidence` shades the narrower mean-response band; `false` opts out and leaves the bare dashed line. Ignored where there is no linear forecast to put a band on. <small>(optional)</small></td></tr>
7029
+ <tr><td class="name">confidence</td><td class="type">number</td><td class="desc">The forecast band's confidence level in `(0, 1)`; 0.95 by default. <small>(optional)</small></td></tr>
6928
7030
  <tr><td class="name">label</td><td class="type">boolean</td><td class="desc">`false` suppresses the R² label on a linear trend. <small>(optional)</small></td></tr>
6929
7031
  </tbody>
6930
7032
  </table>
@@ -7222,7 +7324,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7222
7324
  <tr><td class="name">show</td><td class="type">(ids: string | string[]): void</td><td class="desc"></td></tr>
7223
7325
  <tr><td class="name">hide</td><td class="type">(ids: string | string[]): void</td><td class="desc"></td></tr>
7224
7326
  <tr><td class="name">move</td><td class="type">(id: string, to: number): void</td><td class="desc"></td></tr>
7225
- <tr><td class="name">groupColumns</td><td class="type">(ids: string | string[], opts?: { title?: string; at?: number; groupId?: string }): string | null</td><td class="desc">Wrap leaf columns in a banded header, or add them to an existing band (BACKLOG-0000739). Header banding, not row grouping (see {@link group}); the band is a {@link ColumnGroup} node so a drag-, keyboard- or config-built band is the same tree, and it round-trips through a saved view. Emits `columngroup:changed`.</td></tr>
7327
+ <tr><td class="name">groupColumns</td><td class="type">(ids: string | string[], opts?: { title?: string; at?: number; groupId?: string; id?: string }): string | null</td><td class="desc">Wrap leaf columns in a banded header, or add them to an existing band (BACKLOG-0000739). Header banding, not row grouping (see {@link group}); the band is a {@link ColumnGroup} node so a drag-, keyboard- or config-built band is the same tree, and it round-trips through a saved view. Emits `columngroup:changed`. Pass `groupId` to add to the band already carrying that id, or `id` (BACKLOG-0000985) to create a new band with a caller-chosen stable id you can reference later; `groupId` wins if both are given and an `id` already in use warns and no-ops.</td></tr>
7226
7328
  <tr><td class="name">ungroupColumn</td><td class="type">(id: string): void</td><td class="desc">Take a leaf out of its band; a band emptied by the move is dissolved.</td></tr>
7227
7329
  <tr><td class="name">renameGroup</td><td class="type">(groupId: string, title: string): void</td><td class="desc">Rename a banded header.</td></tr>
7228
7330
  <tr><td class="name">dissolveGroup</td><td class="type">(groupId: string): void</td><td class="desc">Dissolve a band, returning its columns to the enclosing level in place.</td></tr>
@@ -8021,6 +8123,92 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8021
8123
  </tbody>
8022
8124
  </table>
8023
8125
  </div>
8126
+ <h3 id="type-FindApi">FindApi</h3>
8127
+ <p class="section-note">In-grid find (BACKLOG-0001018): locate text and step through where it occurs without filtering anything away. Matches are a visual overlay — no row is reordered, removed or edited — and coexist with the quick filter.</p>
8128
+ <div class="table-wrap">
8129
+ <table>
8130
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8131
+ <tbody>
8132
+ <tr><td class="name">open</td><td class="type">(text?: string): void</td><td class="desc">Show the bar with focus in its input, optionally seeding the text.</td></tr>
8133
+ <tr><td class="name">close</td><td class="type">(): void</td><td class="desc">Hide the bar and clear every match.</td></tr>
8134
+ <tr><td class="name">clear</td><td class="type">(): void</td><td class="desc">Clear the query and the highlights, leaving the bar as it is.</td></tr>
8135
+ <tr><td class="name">next</td><td class="type">(): FindMatch | null</td><td class="desc">The next match, wrapping from the last to the first, scrolled into view and made the active cell unless an edit is open.</td></tr>
8136
+ <tr><td class="name">prev</td><td class="type">(): FindMatch | null</td><td class="desc">The previous match, wrapping from the first to the last.</td></tr>
8137
+ <tr><td class="name">goTo</td><td class="type">(index: number): FindMatch | null</td><td class="desc">Make the match at a position in `matches()` current.</td></tr>
8138
+ <tr><td class="name">matches</td><td class="type">(): FindMatch[]</td><td class="desc">Every match, in display order: pinned-top rows, then the body, then pinned-bottom rows.</td></tr>
8139
+ <tr><td class="name">count</td><td class="type">(): FindCount</td><td class="desc"></td></tr>
8140
+ <tr><td class="name">current</td><td class="type">(): FindMatch | null</td><td class="desc"></td></tr>
8141
+ <tr><td class="name">state</td><td class="type">(): FindState</td><td class="desc"></td></tr>
8142
+ <tr><td class="name">stateFor</td><td class="type">(key: string, colId: string): 'current' | 'match' | null</td><td class="desc">How a cell is painted: the current match, another match, or nothing.</td></tr>
8143
+ </tbody>
8144
+ </table>
8145
+ </div>
8146
+ <h3 id="type-FindConfig">FindConfig</h3>
8147
+ <p class="section-note">The in-grid find bar's settings (BACKLOG-0001018). `find: true` or an omitted key mounts the bar with these defaults; `find: false` removes the bar and its shortcut while `grid.find` keeps working programmatically.</p>
8148
+ <div class="table-wrap">
8149
+ <table>
8150
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8151
+ <tbody>
8152
+ <tr><td class="name">shortcut</td><td class="type">boolean</td><td class="desc">Bind Ctrl+F (Cmd+F on a Mac) while focus is in the grid. The browser's own find is untouched while focus is anywhere else on the page. Default true. <small>(optional)</small></td></tr>
8153
+ <tr><td class="name">debounce</td><td class="type">number</td><td class="desc">Milliseconds of typing quiet before the bar searches. Default 120. <small>(optional)</small></td></tr>
8154
+ </tbody>
8155
+ </table>
8156
+ </div>
8157
+ <h3 id="type-FindCount">FindCount</h3>
8158
+ <p class="section-note">How many matches there are and which is current. `windowed` is the honest scope flag: over a paged pushdown source only the loaded rows are searched, so `total` counts matches in `loaded` rows out of the `rows` the source reports for the whole matching set.</p>
8159
+ <div class="table-wrap">
8160
+ <table>
8161
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8162
+ <tbody>
8163
+ <tr><td class="name">current</td><td class="type">number</td><td class="desc">1-based position of the current match; 0 when there is none.</td></tr>
8164
+ <tr><td class="name">total</td><td class="type">number</td><td class="desc"></td></tr>
8165
+ <tr><td class="name">complete</td><td class="type">boolean</td><td class="desc">False while the bar's sliced scan is still running, so a partial count is never read as final.</td></tr>
8166
+ <tr><td class="name">windowed</td><td class="type">boolean</td><td class="desc"></td></tr>
8167
+ <tr><td class="name">loaded</td><td class="type">number</td><td class="desc">Rows the search actually read; a windowed source's not-yet-fetched placeholders are not counted.</td></tr>
8168
+ <tr><td class="name">rows</td><td class="type">number</td><td class="desc">The rows the source reports for the whole matching set, when it can say.</td></tr>
8169
+ </tbody>
8170
+ </table>
8171
+ </div>
8172
+ <h3 id="type-FindMatch">FindMatch</h3>
8173
+ <p class="section-note">One matching cell.</p>
8174
+ <div class="table-wrap">
8175
+ <table>
8176
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8177
+ <tbody>
8178
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc"></td></tr>
8179
+ <tr><td class="name">colId</td><td class="type">string</td><td class="desc"></td></tr>
8180
+ <tr><td class="name">index</td><td class="type">number</td><td class="desc">The display index, or -1 for a row pinned to an edge.</td></tr>
8181
+ <tr><td class="name">pinned</td><td class="type">'top' | 'bottom' | null</td><td class="desc">Which sticky strip a pinned row is in; null for a body row.</td></tr>
8182
+ </tbody>
8183
+ </table>
8184
+ </div>
8185
+ <h3 id="type-FindQuery">FindQuery</h3>
8186
+ <p class="section-note">How `grid.find(text, opts)` matches. Defaults: case-insensitive, substring, every visible column, starting from the first row. Find matches the **formatted display text** — what the cell shows, a column `format` included — never a raw value; there is no regular-expression mode.</p>
8187
+ <div class="table-wrap">
8188
+ <table>
8189
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8190
+ <tbody>
8191
+ <tr><td class="name">caseSensitive</td><td class="type">boolean</td><td class="desc">Match letter case exactly. Default false. <small>(optional)</small></td></tr>
8192
+ <tr><td class="name">wholeCell</td><td class="type">boolean</td><td class="desc">The whole cell text must equal the search text rather than contain it. Default false. <small>(optional)</small></td></tr>
8193
+ <tr><td class="name">columns</td><td class="type">string[] | string | null</td><td class="desc">Search only these column ids. Omitted searches every visible column. <small>(optional)</small></td></tr>
8194
+ <tr><td class="name">from</td><td class="type">number</td><td class="desc">The display index to start from: the first match at or after it becomes current. Default 0. <small>(optional)</small></td></tr>
8195
+ </tbody>
8196
+ </table>
8197
+ </div>
8198
+ <h3 id="type-FindState">FindState</h3>
8199
+ <p class="section-note">The current query and whether the bar is showing.</p>
8200
+ <div class="table-wrap">
8201
+ <table>
8202
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8203
+ <tbody>
8204
+ <tr><td class="name">text</td><td class="type">string</td><td class="desc"></td></tr>
8205
+ <tr><td class="name">caseSensitive</td><td class="type">boolean</td><td class="desc"></td></tr>
8206
+ <tr><td class="name">wholeCell</td><td class="type">boolean</td><td class="desc"></td></tr>
8207
+ <tr><td class="name">columns</td><td class="type">string[] | null</td><td class="desc"></td></tr>
8208
+ <tr><td class="name">open</td><td class="type">boolean</td><td class="desc"></td></tr>
8209
+ </tbody>
8210
+ </table>
8211
+ </div>
8024
8212
  <h3 id="type-ForecastPoint">ForecastPoint</h3>
8025
8213
  <p class="section-note">One forecast step: the point estimate and, where a band applies, its interval.</p>
8026
8214
  <div class="table-wrap">
@@ -8160,6 +8348,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8160
8348
  <tr><td class="name">licence</td><td class="type">LicenceApi</td><td class="desc">Licence state, and setting a key after construction. <small>(read-only)</small></td></tr>
8161
8349
  <tr><td class="name">pagination</td><td class="type">PaginationApi</td><td class="desc">Pages, where the grid is paged rather than scrolled. <small>(read-only)</small></td></tr>
8162
8350
  <tr><td class="name">highlight</td><td class="type">HighlightApi</td><td class="desc">Transient emphasis on a row, column or cell. <small>(read-only)</small></td></tr>
8351
+ <tr><td class="name">find</td><td class="type">FindApi</td><td class="desc">In-grid find: locate text without filtering, and step through the matches. <small>(read-only)</small></td></tr>
8163
8352
  <tr><td class="name">redaction</td><td class="type">RedactionApi</td><td class="desc">Values hidden from view and from export. <small>(read-only)</small></td></tr>
8164
8353
  <tr><td class="name">capture</td><td class="type">(opts?: CaptureOptions): Promise&lt;Blob&gt;</td><td class="desc">An image of the grid as drawn, where the module is installed. <small>(optional)</small></td></tr>
8165
8354
  <tr><td class="name">annotate</td><td class="type">AnnotationApi</td><td class="desc">Drawing over the grid, where the module is installed. <small>(optional)</small></td></tr>
@@ -8279,6 +8468,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8279
8468
  <tr><td class="name">columnMenu</td><td class="type">boolean | ((p: ColumnMenuParams, defaults: MenuItem[]) =&gt; MenuItem[] | void)</td><td class="desc">The header's 3-dot menu, and the right-click menu on a column heading. `false` suppresses both. A function supplies custom items, receiving the grid's own so it can add to them rather than reproduce them. Default true. <small>(optional)</small></td></tr>
8280
8469
  <tr><td class="name">rangeChart</td><td class="type"></td><td class="desc">Chart a selected cell range — the spreadsheet "chart this selection" gesture. Off by default, so a grid opts in. The DOM layer draws no charts itself — the charts module is optional and loaded by the host — so this is where the host wires the two together: a function, or an object carrying `onChart`, is called with the grid and the selected range when the reader chooses "Chart selection" from the cell menu. The handler typically calls `chartRange` from `lattice-grid/modules/charts`. `true` offers the item and emits nothing extra; supply a handler to have it actually draw. <small>(optional)</small></td></tr>
8281
8470
  <tr><td class="name">shortcuts</td><td class="type">boolean</td><td class="desc">The `?` keyboard shortcut overlay. `false` suppresses it, for a host that wants `?` for itself. Default true. <small>(optional)</small></td></tr>
8471
+ <tr><td class="name">find</td><td class="type">boolean | FindConfig</td><td class="desc">The in-grid find bar (BACKLOG-0001018): Ctrl+F / Cmd+F with focus in the grid opens it; typing highlights every matching cell in place without filtering a row away; Enter and Shift+Enter step through the matches. `false` removes the bar and its shortcut; the `grid.find` API still works. Default true. <small>(optional)</small></td></tr>
8282
8472
  <tr><td class="name">rowReorder</td><td class="type">boolean | { column?: string }</td><td class="desc">Let a user reorder rows by dragging a handle, or with Alt+Shift+Up/Down. `true` puts the handle in the first visible column; `{ column }` names a different one. The move reorders your data and emits `row:moved`; persisting it is yours, and `rows.data()` afterwards is the new order. Refused, with a reason announced, while a sort, filter or grouping is active, the position a row is dropped at has no single meaning in the underlying order then. <small>(optional)</small></td></tr>
8283
8473
  <tr><td class="name">rowTransfer</td><td class="type">boolean | {</td><td class="desc">Let rows be dragged out of this grid, into it, or both. Off by default: rows leaving a grid is a data change a host has to want, and a mis-drag that silently removed one has no gesture a user would think to undo. `send` and `receive` are both on when the option is present, so one-way is expressed by turning off the direction you do not want, a source grid is `{ receive: false }` and a target is `{ send: false }`. `mode: 'copy'` leaves the row where it was. `group` restricts exchange to grids sharing the same name, so two unrelated grids on a page do not accept each other's rows. The source needs `rowReorder` as well, since that is what draws the handle a drag starts from. <small>(optional)</small></td></tr>
8284
8474
  <tr><td class="name">alignedGrids</td><td class="type">unknown[]</td><td class="desc">Other grids to stay column-aligned with. Column widths, order, visibility and pinning are shared, and horizontal scrolling moves them together. Sort, filters, selection, grouping and the rows themselves stay independent: sharing those would make one grid with extra steps rather than two aligned ones. Declared on the grid created last, since it is the only one that can name the others; the link is peer-based once made. <small>(optional)</small></td></tr>
@@ -9481,7 +9671,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9481
9671
  <table>
9482
9672
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9483
9673
  <tbody>
9484
- <tr><td class="name">toRow</td><td class="type">(row: string | number, align?: 'start' | 'center' | 'end' | 'auto'): void</td><td class="desc">A row key, or a display index. A key survives a sort and is usually what a caller holds; resolving one scans the display order, so prefer an index when scrolling a very large grid repeatedly.</td></tr>
9674
+ <tr><td class="name">toRow</td><td class="type">(row: string | number, align?: 'start' | 'center' | 'end' | 'auto'): void</td><td class="desc">A row key, or a display index. A key survives a sort and is usually what a caller holds; resolving one scans the display order, so prefer an index when scrolling a very large grid repeatedly. The row lands fully visible in the part of the body the pinned strips (pinned rows, sticky group headings, a bottom grand total) do not cover: `end` puts it just above the bottom strip, `start` just below the top one.</td></tr>
9485
9675
  <tr><td class="name">toColumn</td><td class="type">(id: string): void</td><td class="desc"></td></tr>
9486
9676
  <tr><td class="name">toCell</td><td class="type">(row: string | number, colId: string, align?: 'start' | 'center' | 'end' | 'auto'): void</td><td class="desc">Scroll a cell into view, both axes in one call.</td></tr>
9487
9677
  <tr><td class="name">position</td><td class="type">(): { top: number; left: number }</td><td class="desc"></td></tr>