@toclocoinc/lattice-grid 1.59.0 → 1.61.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 (112) hide show
  1. package/README.md +3 -3
  2. package/docs/API.html +1623 -91
  3. package/docs/api-detail.html +270 -5
  4. package/lattice-grid.d.ts +100 -2880
  5. package/lattice-grid.esm.min.js +449 -66
  6. package/lattice-grid.min.cjs +449 -66
  7. package/lattice-grid.min.js +449 -66
  8. package/modules/ai.d.ts +401 -0
  9. package/modules/ai.esm.min.js +43 -6
  10. package/modules/ai.min.cjs +43 -6
  11. package/modules/ai.min.js +43 -6
  12. package/modules/angular.d.ts +31 -0
  13. package/modules/angular.esm.min.js +3 -3
  14. package/modules/angular.min.cjs +3 -3
  15. package/modules/angular.min.js +3 -3
  16. package/modules/chart-alluvial.d.ts +18 -0
  17. package/modules/chart-alluvial.esm.min.js +1 -1
  18. package/modules/chart-arc.d.ts +18 -0
  19. package/modules/chart-arc.esm.min.js +1 -1
  20. package/modules/chart-bubblemap.d.ts +18 -0
  21. package/modules/chart-bubblemap.esm.min.js +1 -1
  22. package/modules/chart-bump.d.ts +12 -0
  23. package/modules/chart-bump.esm.min.js +1 -1
  24. package/modules/chart-calendar.d.ts +12 -0
  25. package/modules/chart-calendar.esm.min.js +1 -1
  26. package/modules/chart-decomposition.d.ts +20 -0
  27. package/modules/chart-decomposition.esm.min.js +1 -1
  28. package/modules/chart-diverging.d.ts +12 -0
  29. package/modules/chart-diverging.esm.min.js +1 -1
  30. package/modules/chart-dumbbell.d.ts +18 -0
  31. package/modules/chart-dumbbell.esm.min.js +1 -1
  32. package/modules/chart-fan.d.ts +18 -0
  33. package/modules/chart-fan.esm.min.js +1 -1
  34. package/modules/chart-hexbin.d.ts +18 -0
  35. package/modules/chart-hexbin.esm.min.js +1 -1
  36. package/modules/chart-hexmap.d.ts +18 -0
  37. package/modules/chart-hexmap.esm.min.js +1 -1
  38. package/modules/chart-icicle.d.ts +12 -0
  39. package/modules/chart-icicle.esm.min.js +1 -1
  40. package/modules/chart-parallel.d.ts +19 -0
  41. package/modules/chart-parallel.esm.min.js +1 -1
  42. package/modules/chart-ridgeline.d.ts +14 -0
  43. package/modules/chart-ridgeline.esm.min.js +1 -1
  44. package/modules/chart-roc.d.ts +20 -0
  45. package/modules/chart-roc.esm.min.js +1 -1
  46. package/modules/chart-slope.d.ts +12 -0
  47. package/modules/chart-slope.esm.min.js +1 -1
  48. package/modules/chart-splom.d.ts +19 -0
  49. package/modules/chart-splom.esm.min.js +1 -1
  50. package/modules/chart-waffle.d.ts +12 -0
  51. package/modules/chart-waffle.esm.min.js +1 -1
  52. package/modules/charts.d.ts +122 -0
  53. package/modules/charts.esm.min.js +35 -8
  54. package/modules/charts.min.cjs +35 -8
  55. package/modules/charts.min.js +35 -8
  56. package/modules/data-router.d.ts +91 -0
  57. package/modules/data-router.esm.min.js +109 -17
  58. package/modules/data-router.min.cjs +109 -17
  59. package/modules/data-router.min.js +109 -17
  60. package/modules/devtools.d.ts +28 -0
  61. package/modules/devtools.esm.min.js +2 -2
  62. package/modules/devtools.min.cjs +2 -2
  63. package/modules/devtools.min.js +2 -2
  64. package/modules/dhtmlx-compat.d.ts +19 -0
  65. package/modules/dhtmlx-compat.esm.min.js +4 -4
  66. package/modules/dhtmlx-compat.min.cjs +4 -4
  67. package/modules/dhtmlx-compat.min.js +4 -4
  68. package/modules/gantt.d.ts +647 -0
  69. package/modules/gantt.esm.min.js +1070 -201
  70. package/modules/gantt.min.cjs +1070 -201
  71. package/modules/gantt.min.js +1070 -201
  72. package/modules/htmx.d.ts +176 -0
  73. package/modules/htmx.esm.min.js +449 -66
  74. package/modules/htmx.min.cjs +449 -66
  75. package/modules/htmx.min.js +449 -66
  76. package/modules/kanban.d.ts +492 -0
  77. package/modules/kanban.esm.min.js +4 -4
  78. package/modules/kanban.min.cjs +4 -4
  79. package/modules/kanban.min.js +4 -4
  80. package/modules/kpi.d.ts +255 -0
  81. package/modules/kpi.esm.min.js +40 -7
  82. package/modules/kpi.min.cjs +40 -7
  83. package/modules/kpi.min.js +40 -7
  84. package/modules/layout.d.ts +332 -0
  85. package/modules/layout.esm.min.js +4 -4
  86. package/modules/layout.min.cjs +4 -4
  87. package/modules/layout.min.js +4 -4
  88. package/modules/mock-socket.d.ts +114 -0
  89. package/modules/mock-socket.esm.min.js +2 -2
  90. package/modules/mock-socket.min.cjs +2 -2
  91. package/modules/mock-socket.min.js +2 -2
  92. package/modules/react.d.ts +25 -0
  93. package/modules/react.esm.min.js +3 -3
  94. package/modules/react.min.cjs +3 -3
  95. package/modules/react.min.js +3 -3
  96. package/modules/svelte.d.ts +26 -0
  97. package/modules/svelte.esm.min.js +3 -3
  98. package/modules/svelte.min.cjs +3 -3
  99. package/modules/svelte.min.js +3 -3
  100. package/modules/tabs.d.ts +133 -0
  101. package/modules/tabs.esm.min.js +11 -4
  102. package/modules/tabs.min.cjs +11 -4
  103. package/modules/tabs.min.js +11 -4
  104. package/modules/vue.d.ts +24 -0
  105. package/modules/vue.esm.min.js +3 -3
  106. package/modules/vue.min.cjs +3 -3
  107. package/modules/vue.min.js +3 -3
  108. package/modules/webcomponent.d.ts +47 -0
  109. package/modules/webcomponent.esm.min.js +449 -66
  110. package/modules/webcomponent.min.cjs +449 -66
  111. package/modules/webcomponent.min.js +449 -66
  112. package/package.json +2 -2
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.59.0</p>
440
+ <p class="rail__sub">Developer guide · v1.61.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -492,6 +492,7 @@
492
492
  <a href="#selection-guide">Selection and ranges</a>
493
493
  <a href="#fill-series">Filling a series</a>
494
494
  <a href="#updates-guide">Holding live updates</a>
495
+ <a href="#router-websocket-guide">Data Router: WebSocket feeds</a>
495
496
  <a href="#diagnostics-guide">Diagnostics and devtools</a>
496
497
  <a href="#presence-guide">Collaborative presence</a>
497
498
  <a href="#comments-guide">Cell comments</a>
@@ -553,7 +554,7 @@
553
554
  <a href="API.html">reference tables</a> are the shorter version for when you already know.
554
555
  </p>
555
556
  <p class="chips">
556
- <span class="chip">Version 1.59.0</span>
557
+ <span class="chip">Version 1.61.0</span>
557
558
  <span class="chip">Zero dependencies</span>
558
559
  <span class="chip">No build step</span>
559
560
  </p>
@@ -1278,7 +1279,7 @@ off(); <span class="cmt">// every subscrip
1278
1279
  </p>
1279
1280
  <div class="example">
1280
1281
  <p class="example__label">Which version am I running?</p>
1281
- <pre><code>grid.getVersion(); <span class="cmt">// '1.59.0'</span>
1282
+ <pre><code>grid.getVersion(); <span class="cmt">// '1.61.0'</span>
1282
1283
  LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid to hand</span></code></pre>
1283
1284
  </div>
1284
1285
  <p class="lead-in">
@@ -3609,6 +3610,45 @@ createGrid(el, {
3609
3610
  of which stand regardless.</p>
3610
3611
  </div>
3611
3612
 
3613
+ <h3 id="whererowlimit">Running a host predicate over a pushdown source</h3>
3614
+ <p class="lead-in">
3615
+ A <code>where</code> predicate is your code &mdash; whether this user may see the row, whether you
3616
+ hold a rate for its currency. No engine can evaluate it, so a pushdown source can only honour one
3617
+ by fetching every matching row and filtering here. <code>whereRowLimit</code> is the ceiling on
3618
+ doing that, because past a point it is no longer a filter, it is a full download.
3619
+ </p>
3620
+ <div class="example">
3621
+ <p class="example__label">The predicate runs under the limit; past it the source refuses and says so</p>
3622
+ <pre><code><span class="kw">const</span> source = createPushdownSource({
3623
+ adapter: duckdbAdapter({ connection, from: 'trades' }),
3624
+ whereRowLimit: 50_000, <span class="cmt">// the default: run the predicate up to this many matching rows</span>
3625
+ });
3626
+
3627
+ grid.filters.where('visibleToMe', (row) =&gt; row.owner === me);</code></pre>
3628
+ </div>
3629
+ <div class="why">
3630
+ <p><strong>Why a limit at all.</strong> A pushdown source exists so a host does not download ten
3631
+ million rows. Honouring a twinless predicate means holding the whole matching set, so wiring it in
3632
+ without a gate would let one filter function silently convert a windowed grid into a full download
3633
+ &mdash; trading one silent surprise for another. Under the limit the predicate really runs and the
3634
+ counts are whole-dataset counts; at or past it the predicate is <strong>not applied</strong>, the
3635
+ rows it would exclude stay on screen, and one warning names the adapter, the matching-set size, the
3636
+ limit and the way out.</p>
3637
+ <p><strong>No row total counts as over the limit.</strong> The only way to learn the size from an
3638
+ adapter that cannot count is to fetch the set &mdash; which is the download being guarded against
3639
+ &mdash; so the source refuses rather than guesses its way into it.</p>
3640
+ <p><strong>A refusal costs nothing extra.</strong> Where the predicate alone would force the whole
3641
+ result, the size is learned from a bounded probe that is exactly the window fetch the request would
3642
+ otherwise have made, and that result is used as that fetch. A host over the limit pays what it paid
3643
+ before the gate existed.</p>
3644
+ <p><strong>Prefer the twin.</strong> A predicate registered with a <code>{ condition }</code> twin
3645
+ is pushed to the engine, which narrows the fetch itself &mdash; no limit applies, nothing is held
3646
+ here, and it works at any size. Reach for <code>whereRowLimit</code> only when what you are testing
3647
+ genuinely cannot be written as a condition. On a <em>paged</em> or <em>remote</em> source the twin
3648
+ is the only route: those hold a block cache indexed by the server's own ranges and cannot run a host
3649
+ function at all, and the grid warns once when you register one without a twin.</p>
3650
+ </div>
3651
+
3612
3652
  <h3 id="aggregates">Pushing statistics down to the engine</h3>
3613
3653
  <p class="lead-in">
3614
3654
  A DuckDB-class engine computes a median or a standard deviation over the whole matching set far
@@ -5328,6 +5368,170 @@ createChart({
5328
5368
  taking the tab with it.</p>
5329
5369
  </div>
5330
5370
 
5371
+ <h2 id="router-websocket-guide">Data Router: a live WebSocket feed</h2>
5372
+ <p class="lead-in">
5373
+ <code>createDataRouter</code> is built for a continuous live feed &mdash; ordering,
5374
+ de-duplication, batching, resume after a drop, and moving a row between routes rather
5375
+ than duplicating it are all shipped. What it does not do is open the connection.
5376
+ <strong>The host owns the connection; the router owns everything after the message
5377
+ arrives.</strong> That is a boundary worth stating plainly, because nothing in the
5378
+ router's own name says so, and the two questions every integrator asks first &mdash;
5379
+ &ldquo;does it handle the socket?&rdquo; and &ldquo;how do I send it data?&rdquo;
5380
+ &mdash; both turn on it.
5381
+ </p>
5382
+ <div class="example">
5383
+ <p class="example__label">Wire a socket's messages to the router's two entry points</p>
5384
+ <pre><code>const router = createDataRouter({ rowKey: 'id', seq: 'v' });
5385
+ router.attach(grid, () => true);
5386
+
5387
+ const socket = new WebSocket('wss://example.com/feed');
5388
+ socket.onmessage = (event) => {
5389
+ const msg = JSON.parse(event.data);
5390
+ if (msg.kind === 'snapshot') router.load(msg.rows); <span class="cmt">// full keyed diff</span>
5391
+ else if (msg.kind === 'delta') router.apply(msg.changes); <span class="cmt">// in-place add/update/remove</span>
5392
+ };</code></pre>
5393
+ </div>
5394
+ <div class="why">
5395
+ <p><strong>Two message kinds, two entry points.</strong> A <strong>snapshot</strong>
5396
+ (the whole current world, sent once on connect or on resume) goes to
5397
+ <code>router.load(rows)</code> &mdash; a plain array of records; it re-partitions
5398
+ everything and applies a <em>keyed diff</em> per route, so rows that did not change do
5399
+ not repaint. A <strong>delta</strong> (an incremental change) goes to
5400
+ <code>router.apply(deltas)</code> &mdash; an array of <code>{ op: 'upsert'|'delete',
5401
+ row, seq? }</code> &mdash; which adds, updates or removes in place by <code>rowKey</code>,
5402
+ moving a row between routes if its partition changed rather than duplicating it. Getting
5403
+ this backwards is the mistake this guide exists to prevent: <code>load()</code> on every
5404
+ message re-partitions the whole world on every tick (correct only for a snapshot);
5405
+ <code>apply()</code> on the opening snapshot never seeds the store, so every route starts
5406
+ empty. There is no third method for &ldquo;a WebSocket message&rdquo; &mdash; the host
5407
+ reads <code>kind</code> (or whatever field its own wire format uses) and picks one of
5408
+ these two.</p>
5409
+ <p><strong>No transport lock-in, and that is a benefit, not a gap.</strong> The router
5410
+ takes rows, never a URL and never a socket object, so a real <code>WebSocket</code>, an
5411
+ <code>EventSource</code>, a change-data-capture feed, a long-poll loop or an existing
5412
+ message-bus client all wire up the same way &mdash; whatever arrives, hand the router the
5413
+ snapshot array or the delta array. Nothing in <code>packages/modules/data-router/</code>
5414
+ constructs a socket, so nothing there needs to change when the transport does.</p>
5415
+ </div>
5416
+
5417
+ <h3 id="router-websocket-hygiene-guide">Ordering, dedupe, and batching a fast feed</h3>
5418
+ <p class="lead-in">
5419
+ A socket is not a clean pipe: messages can arrive reordered, a reconnect can replay
5420
+ something already applied, and a fast feed can out-pace how often a grid should repaint.
5421
+ None of that is handled unless it is configured.
5422
+ </p>
5423
+ <div class="example">
5424
+ <p class="example__label">Without <code>seq</code>: a reordered packet wins silently — run and see it happen</p>
5425
+ <pre data-run="js" data-expect="100" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5426
+ <span class="kw">const</span> rows = <span class="kw">new</span> Map();
5427
+ <span class="kw">const</span> sink = { rows: { apply({ add = [], update = [] }) { <span class="kw">for</span> (<span class="kw">const</span> r <span class="kw">of</span> [...add, ...update]) rows.set(r.id, r); } } };
5428
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id' }); <span class="cmt">// no seq configured</span>
5429
+ router.attach(sink, () => <span class="kw">true</span>);
5430
+ router.load([]);
5431
+ router.apply([{ op: 'upsert', row: { id: 'x', price: 101 } }]); <span class="cmt">// the newer event</span>
5432
+ router.apply([{ op: 'upsert', row: { id: 'x', price: 100 } }]); <span class="cmt">// arrives late, but wins</span>
5433
+ <span class="kw">return</span> rows.get('x').price; <span class="cmt">// 100 — the stale packet clobbered the fresh one</span></code></pre>
5434
+ <p class="example__label">With <code>seq</code> (dedupe defaults on): the identical reorder, corrected</p>
5435
+ <pre data-run="js" data-expect="101 dropped=1" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5436
+ <span class="kw">const</span> rows = <span class="kw">new</span> Map();
5437
+ <span class="kw">const</span> sink = { rows: { apply({ add = [], update = [] }) { <span class="kw">for</span> (<span class="kw">const</span> r <span class="kw">of</span> [...add, ...update]) rows.set(r.id, r); } } };
5438
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v' });
5439
+ router.attach(sink, () => <span class="kw">true</span>);
5440
+ router.load([]);
5441
+ router.apply([{ op: 'upsert', row: { id: 'x', price: 101, v: 2 } }]);
5442
+ router.apply([{ op: 'upsert', row: { id: 'x', price: 100, v: 1 } }]); <span class="cmt">// v:1 &le; last seen v:2 — dropped</span>
5443
+ <span class="kw">return</span> `${rows.get('x').price} dropped=${router.dropped}`; <span class="cmt">// 101 dropped=1 — corrected, and counted</span></code></pre>
5444
+ </div>
5445
+ <div class="why">
5446
+ <p><strong>Without a <code>seq</code>, two rules to know.</strong> Within one
5447
+ <code>apply()</code> call, last-writer-wins per <code>rowKey</code> &mdash; the last
5448
+ element of the array for a given key is what lands, regardless of which one is
5449
+ &ldquo;newer&rdquo; in real time. Across separate calls (separate socket messages), the
5450
+ router has no way to tell a late, stale packet from a fresh one, so it applies whatever
5451
+ arrives, whenever it arrives &mdash; <strong>an out-of-order delta lands out of order</strong>.
5452
+ Configuring <code>seq</code> (a field name or <code>fn(row)</code>) fixes both: within a
5453
+ batch the router sorts by seq before applying, and across calls it keeps a running
5454
+ <code>seqSeen</code> per record and drops (into <code>router.dropped</code>) anything not
5455
+ newer than what it already applied &mdash; which is exactly the reconnect/resume gate
5456
+ below, working the same way for ordinary live reordering.</p>
5457
+ <p><strong>A fast feed: <code>push</code> plus <code>batch</code>/<code>coalesce</code>.</strong>
5458
+ Call <code>router.push(delta)</code> instead of <code>apply()</code> and, with
5459
+ <code>coalesce: true</code> or a <code>batch: { intervalMs }</code> configured, deltas
5460
+ buffer instead of applying immediately; rapid updates to the same key settle to a single
5461
+ apply on the timer (or on an explicit <code>flushStream()</code>, useful for a
5462
+ deterministic point such as an animation frame). With no batching mode configured,
5463
+ <code>push</code> applies at once, so it is always safe to feed a socket through
5464
+ <code>push</code> rather than choosing between it and <code>apply</code> up front.</p>
5465
+ </div>
5466
+
5467
+ <h3 id="router-websocket-resume-guide">Reconnect and resume</h3>
5468
+ <div class="example">
5469
+ <p class="example__label">Capture a cursor before the drop; replay after</p>
5470
+ <pre><code><span class="cmt">// While live:</span>
5471
+ socket.onclose = () => {
5472
+ const resumeFrom = router.lastSeq(); <span class="cmt">// ask the server to resume from here</span>
5473
+ const mark = router.checkpoint(); <span class="cmt">// or persist this per-record map instead</span>
5474
+ reconnect(resumeFrom);
5475
+ };
5476
+
5477
+ <span class="cmt">// On the new connection, the server sends a fresh snapshot plus a replay</span>
5478
+ <span class="cmt">// that may include deltas already applied — the same onmessage handles it:</span>
5479
+ socket = new WebSocket(`wss://example.com/feed?since=${resumeFrom}`);
5480
+ socket.onmessage = (event) => {
5481
+ const msg = JSON.parse(event.data);
5482
+ if (msg.kind === 'snapshot') router.load(msg.rows);
5483
+ else if (msg.kind === 'delta') router.apply(msg.changes); <span class="cmt">// replays are dropped, not reapplied</span>
5484
+ };</code></pre>
5485
+ </div>
5486
+ <div class="why">
5487
+ <p><strong>The pattern is snapshot-plus-replay, and the dedupe gate does the rest.</strong>
5488
+ On reconnect, load a fresh snapshot (a keyed diff, so unchanged rows do not repaint) and
5489
+ let the feed replay from around the last known point; any delta the router already
5490
+ applied &mdash; because its seq is not newer than what <code>checkpoint()</code> holds for
5491
+ that record &mdash; is dropped, so only genuinely new deltas advance the state.
5492
+ <code>seenThrough(mark)</code> primes that same checkpoint from a persisted map (e.g. after
5493
+ a page reload), so an early replay is dropped even before the router has applied anything
5494
+ itself in this session. None of this requires the socket to be gone &mdash; the router has
5495
+ no idea whether it is talking to the first connection or the fifth.</p>
5496
+ </div>
5497
+
5498
+ <h3 id="router-websocket-fanin-guide">Several independent feeds, one router</h3>
5499
+ <div class="example">
5500
+ <p class="example__label">Two sockets, two sources, one merged store</p>
5501
+ <pre><code>const orders = router.addSource('orders');
5502
+ const inventory = router.addSource('inventory');
5503
+
5504
+ const ordersSocket = new WebSocket('wss://example.com/orders');
5505
+ ordersSocket.onmessage = (event) => {
5506
+ const msg = JSON.parse(event.data);
5507
+ if (msg.kind === 'snapshot') orders.load(msg.rows);
5508
+ else if (msg.kind === 'delta') orders.apply(msg.changes);
5509
+ };
5510
+ <span class="cmt">// inventorySocket wired the same way, against the `inventory` handle</span></code></pre>
5511
+ </div>
5512
+ <div class="why">
5513
+ <p><strong><code>addSource(id)</code> returns a per-feed handle</strong> &mdash; its own
5514
+ <code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code> &mdash; so each
5515
+ socket is wired to its own handle exactly as a single feed is wired to the router
5516
+ directly; the router merges every source into one keyed store, namespaced by
5517
+ <code>key</code> when two feeds' ids could otherwise collide. There is no separate lookup
5518
+ method to re-fetch a handle later &mdash; keep the reference <code>addSource</code>
5519
+ returns, the same way the code above keeps <code>orders</code> and
5520
+ <code>inventory</code>.</p>
5521
+ </div>
5522
+
5523
+ <div class="example">
5524
+ <p class="example__label">No backend yet: <code>MockWebSocket</code> frames messages identically</p>
5525
+ <pre><code>import { MockWebSocket, opsFeed } from 'lattice-grid/modules/mock-socket';
5526
+ const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }) });
5527
+ <span class="cmt">// everything above this line is the only thing that changes going live:</span>
5528
+ <span class="cmt">// const socket = new WebSocket('wss://example.com/feed');</span></code></pre>
5529
+ <p>Every example on this page is executed, against the real module and
5530
+ <code>MockWebSocket</code>, in <code>demo/router-websocket.mjs</code>
5531
+ (<code>node demo/router-websocket.mjs</code>) &mdash; run it to see each property above as
5532
+ output rather than prose.</p>
5533
+ </div>
5534
+
5331
5535
  <h2 id="diagnostics-guide">Diagnostics and devtools</h2>
5332
5536
  <p class="lead-in">
5333
5537
  Data grids fail in ways that are hard to diagnose from outside. This is the grid saying
@@ -7571,7 +7775,7 @@ grid.import.apply(preview);</code></pre>
7571
7775
  <tr><td class="name">canChartRange</td><td class="desc">Whether <code>chartRange</code> would draw something for the grid’s current selection — the question a menu asks before offering the item.</td></tr>
7572
7776
  <tr><td class="name">deriveRangeSpec</td><td class="desc">Decide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for <code>createChart</code>.</td></tr>
7573
7777
  <tr><td class="name">regressionPlots</td><td class="desc">Turn a fitted regression model into diagnostic chart specs ready for <code>createChart</code>: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped.</td></tr>
7574
- <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. By default routing is first-match-wins (<code>overlap: false</code>); to fan one partition value to several viewers at once (a grid <em>and</em> a KPI panel <em>and</em> a chart off one feed) create the router with <code>overlap: true</code> — with the default, a second viewer on the same value receives nothing and the router emits a one-time dev warning naming the clash. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: <code>subscribe(value, handler)</code> routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; <code>alert(value, condition, handler)</code> evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and <code>configure(spec)</code> (or <code>createDataRouter({ config })</code>) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping — <code>transform</code>/<code>filter</code>/<code>sort</code> and <code>rollup</code> ({ groupBy, aggregate }) summaries — a relationship graph (<code>relate(edges)</code>: multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise <code>link</code>, and stream hygiene: a <code>seq</code>/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in <code>dropped</code>), <code>push</code> with a <code>batch</code>/<code>coalesce</code> buffers a high-frequency feed (<code>flushStream</code> for a deterministic point), and <code>lastSeq</code>/<code>checkpoint</code>/<code>seenThrough</code> resume precisely after a dropped socket. v4 adds time-travel: <code>buffer({ window, max })</code> records the ordered stream into a bounded ring over a moving base, so <code>scrubTo</code> reconstructs a past point, <code>replay</code> (with <code>pause</code>/<code>resume</code>) walks a range, and <code>live</code> returns to the head — every state pushed by the same keyed diff, <code>traveling</code>/<code>buffered</code> reporting the state. v6 adds cross-tab sync: <code>broadcast({ channel })</code> mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: <code>query(adapter, request)</code> sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level <code>where</code> is pushed down where the adapter's capabilities allow and the residual finished client-side, with <code>lastQueryPlan</code> reporting the split. v8 adds write-back: a <code>writable</code> route captures the grid's committed edits off its public edit surface and routes them to <code>onWrite(change, ctx)</code> (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins <code>onConflict</code>; a derived route cannot be writable. v9 adds fan-in: <code>addSource(feed, { map, key })</code> returns a per-feed handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, <code>removeSource</code> dropping exactly a feed's rows and <code>sources</code> listing them. v10 adds observability: <code>metrics()</code> is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, <code>on('metrics')</code> drives a periodic emit (off unless a listener is registered), and <code>mountDevtools(el)</code> renders a live panel from the module's own DOM file. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
7778
+ <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. The router never opens a connection itself — it takes rows, not a URL or a socket — so the host owns the connection (a <code>WebSocket</code>, SSE, CDC, long-poll, anything) and this module owns everything once a message has arrived; see <a href="#router-websocket-guide">a live WebSocket feed</a> for the worked, runnable example. By default routing is first-match-wins (<code>overlap: false</code>); to fan one partition value to several viewers at once (a grid <em>and</em> a KPI panel <em>and</em> a chart off one feed) create the router with <code>overlap: true</code> — with the default, a second viewer on the same value receives nothing and the router emits a one-time dev warning naming the clash. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: <code>subscribe(value, handler)</code> routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; <code>alert(value, condition, handler)</code> evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and <code>configure(spec)</code> (or <code>createDataRouter({ config })</code>) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping — <code>transform</code>/<code>filter</code>/<code>sort</code> and <code>rollup</code> ({ groupBy, aggregate }) summaries — a relationship graph (<code>relate(edges)</code>: multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise <code>link</code>, and stream hygiene: a <code>seq</code>/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in <code>dropped</code>), <code>push</code> with a <code>batch</code>/<code>coalesce</code> buffers a high-frequency feed (<code>flushStream</code> for a deterministic point), and <code>lastSeq</code>/<code>checkpoint</code>/<code>seenThrough</code> let the host resume precisely after its own connection drops and reconnects. v4 adds time-travel: <code>buffer({ window, max })</code> records the ordered stream into a bounded ring over a moving base, so <code>scrubTo</code> reconstructs a past point, <code>replay</code> (with <code>pause</code>/<code>resume</code>) walks a range, and <code>live</code> returns to the head — every state pushed by the same keyed diff, <code>traveling</code>/<code>buffered</code> reporting the state. v6 adds cross-tab sync: <code>broadcast({ channel })</code> mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: <code>query(adapter, request)</code> sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level <code>where</code> is pushed down where the adapter's capabilities allow and the residual finished client-side, with <code>lastQueryPlan</code> reporting the split. v8 adds write-back: a <code>writable</code> route captures the grid's committed edits off its public edit surface and routes them to <code>onWrite(change, ctx)</code> (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins <code>onConflict</code>; a derived route cannot be writable. v9 adds fan-in: <code>addSource(feed, { map, key })</code> returns a per-feed handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, <code>removeSource</code> dropping exactly a feed's rows and <code>sources</code> listing them. v10 adds observability: <code>metrics()</code> is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, <code>on('metrics')</code> drives a periodic emit (off unless a listener is registered), and <code>mountDevtools(el)</code> renders a live panel from the module's own DOM file. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
7575
7779
  <tr><td class="name">createKanban</td><td class="desc">A board (kanban) view of rows as cards, grouped into columns by a configurable property (a status, a stage, a state field), with per-column card count and an optional points sum, a WIP over-limit flag, configured columns shown even when empty, granular readonly (whole board / per column / per card), field-mapped card templates that reuse the grid’s own column formatters when a grid is bound, and the core pointer events (<code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>). It is a dataset viewer like any other: it consumes data through the same keyed-diff <code>rows.apply({ add, update, remove })</code> contract a grid exposes, so a Data Router can <code>attach(value, board)</code> and drive a kanban beside a grid and a chart off one feed. Accessibility is built in from the start — a labelled group of labelled column lists, cards in a roving-tabindex focus ring with arrow-key navigation, and a polite live region. Every structural property is named in config (<code>columnProperty</code>, <code>pointsProperty</code>, <code>orderProperty</code>, <code>swimlaneProperty</code>, <code>sprintProperty</code>, <code>epicProperty</code>) so it maps DemandFlow and any customer schema without code change. Pass <code>null</code> as the element for a headless board that computes the same model without a DOM. Cards drag between columns (writing the column property) and within a column into a position (writing the order property with fractional ranking), and the same move is keyboard-accessible — <kbd>Space</kbd> to grab, arrows to choose a target, <kbd>Space</kbd>/<kbd>Enter</kbd> to drop, <kbd>Escape</kbd> to cancel — announced on the live region; multi-select drags every selected card. A move calls <code>onBeforeMove(card, from, to, index)</code> first (return <code>false</code> to veto) and then persists through the grid’s shipped write-back path — grid-bound via <code>grid.edit.setCells</code> (the same public edit-commit path inline editing uses, so the grid’s pipeline owns optimistic apply / confirm / revert), standalone with a revert when <code>onCardMove</code> rejects — emitting <code>card:move</code>. A configurable per-card <code>contextMenu</code> replaces the <code>card:contextmenu</code> event when present. With <code>swimlanes: true</code> it renders a 2D lane&times;column grid grouped by <code>swimlaneProperty</code> (per-lane count/points, columns aligned across lanes, a cross-lane drag writing the swimlane property); columns and lanes collapse (state surviving a keyed-diff update), columns reorder by header drag, and <code>setQuickFilter</code>/<code>setFilter</code>/<code>facets</code> drive search and faceting. <code>setSprint</code>/<code>showBacklog</code>/<code>sprints</code> give a sprint view, switcher and backlog; <code>setEpic</code>/<code>epicRollup</code>/<code>rollup</code> give an epic view and rollups (count, points, progress toward the <code>done</code> columns). A card can pop out a nested grid of its children (an epic's stories, a story's tasks, recursively) via a <code>childrenProperty</code> and/or <code>loadChildren(card)</code>: the child is a full composed <code>createGrid</code> (or, with <code>asBoard</code>, a nested board) opened in a drawer/modal/inline container — reuse by composition, no grid-core coupling — emitting <code>card:expand</code>/<code>card:drill</code>. Live updates arrive through the same keyed-diff contract a grid uses, so a Data Router drives the board directly (<code>attach(board, predicate)</code>); on a standalone board (not <code>grid</code>-bound, which ignores <code>rows.apply</code> and routes to the bound grid instead) <code>rows.apply({ update })</code> merges each patch into the row already stored under its key, matching the grid's own <code>rows.apply({ update })</code> — a field the patch omits is preserved, and a patch that does change the grouping property still moves the card — while <code>add</code> sets the row outright; a live <code>rows.apply</code> re-renders preserving scroll, focus, selection, collapse and any open pop-out. <code>virtualize</code> renders only a scroll window of a tall column; <code>getState</code>/<code>setState</code> (and <code>config.state</code>) save and restore collapse, order, filter and sprint/epic selection; <code>setLoading</code>/<code>setError</code> give loading and error states. The move is fully keyboard-driven — Space to grab, arrows for column/position, Alt+Up/Down across swimlanes, Space/Enter to drop, Escape to cancel — announced on a live region. A field opted in with <code>card: { title: { field, edit: true } }</code> edits inline (double-click or <code>editCard</code>): grid-bound through the grid's own field editor via its public edit path, standalone through a host editor factory or a default input with an <code>onCardEdit</code> revert; a per-column add-card (<code>config.addCard</code>/<code>onAddCard</code>, or <code>grid.edit.addRow</code>) creates a card and opens it in edit. The module imports nothing from the grid's DOM package.</td></tr>
7576
7780
  <tr><td class="name">createKPI</td><td class="desc">A KPI / stat-tile view (module <code>kpi</code>) of a dataset as a panel of aggregate tiles — each tile a <code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>, <code>countDistinct</code> or a <code>custom</code> reducer over the routed rows, with an optional <code>filter</code> predicate, number <code>format</code> (<code>number</code>/<code>currency</code>/<code>percent</code>/<code>compact</code>), a <code>baseline</code> for a delta, semantic threshold bands (<code>thresholds</code> with two cut points and a direction, or an explicit <code>bands</code> list, giving a <code>good</code>/<code>warn</code>/<code>critical</code> status kept separate from any accent), and an optional <code>sparkline</code> series. It is a dataset viewer like any other: it consumes data through the same keyed-diff <code>rows.apply({ add, update, remove })</code> contract a grid exposes, so a Data Router can <code>attach(value, kpi)</code> and drive a KPI panel beside a grid, a kanban and a chart off one feed. Updates are incremental — a delta adjusts each tile's running accumulator by only the rows it carries (an add contributes, a remove reverses, an update reverses-then-contributes), the two bounded exceptions being a <code>min</code>/<code>max</code> whose current extreme is removed (a rescan of that tile's own value multiset) and a <code>custom</code> reducer (recomputed over the filtered store, an arbitrary function having no inverse). Each tile is a labelled <code>&lt;figure&gt;</code>, focusable and keyboard-activatable, its value announced, the sparkline respecting <code>prefers-reduced-motion</code>; a <code>tile:click</code> event (also from the keyboard) lets a host drill down or filter a routed grid. Pass <code>null</code> as the element for a headless panel that computes the same tile model without a DOM. Not a dashboard layout engine (that is the parked dashboard generator) and no charting beyond the minimal sparkline (that is the charts module).</td></tr>
7577
7781
  <tr><td class="name">createAI</td><td class="desc">Create an AI narrative / insights controller (module <code>ai</code>) over a live grid. It produces a short, plain-language narrative of the grid's <em>computed</em> figures — a per-KPI / per-chart / per-column <code>explain</code>, or an <code>insights</code> panel over the current filtered view. The grid makes no AI call of its own: <code>createAI</code> imports no provider SDK, holds no key, and makes no network request; it calls one host callback, <code>ask({ system, messages, prompt, tools?, schema?, signal })</code> — your model, your key, your privacy decision — the same philosophy as the data adapters. Two grounding paths feed one guard: where the provider offers tool-use the model is given a curated read-only tool set (<code>getSchema</code>, <code>getProfile</code>, <code>getStatistics</code>, <code>getForecast</code>, <code>runQuery</code>) and the grid's own engine computes what it asks for; otherwise the module builds a facts packet from <code>grid.statistics</code>/the profile/forecasts/view counts and passes it in the prompt. Every figure in the narrative is reconciled against the values the engine produced this render — an ungrounded number is stripped before display (the number-reconciliation guard), so a hallucinated figure never reaches the user. The prompt is constrained to narrate-only; the layer is read-only and never mutates data. <code>redact</code> (a column id, a list, or a predicate) and <code>maxRows</code> bound what the module hands <code>ask()</code>, and the module sends nothing itself; an <code>ask()</code> error surfaces a friendly message and the grid stays fully usable, AI being additive rather than load-bearing. Complementary to <code>grid.ai</code> (the intent/plan skill layer): pass no <code>ask</code> and it adopts the grid's configured <code>ai.ask</code>, running the facts-packet path over it. Pass a headless grid for a DOM-free narrative; <code>facts(target)</code> returns the exact grounded packet without calling <code>ask()</code>. UMD global <code>LatticeGridAI</code>.</td></tr>
@@ -7603,6 +7807,65 @@ grid.import.apply(preview);</code></pre>
7603
7807
  </tbody>
7604
7808
  </table>
7605
7809
  </div>
7810
+ <h3>Module configuration</h3>
7811
+ <p class="lead-in">
7812
+ The optional module bundles take their own configuration object rather than extending
7813
+ <code>GridConfig</code>. Each is documented in full in the
7814
+ <a href="API.html">reference</a>; these are the keys most easily missed, listed here so that
7815
+ reading the guide end to end leaves nothing you have never heard of. A callback named
7816
+ <code>on&hellip;</code> is a second route to an event the module already raises: passing the
7817
+ callback in config and binding the event both work, and both fire.
7818
+ </p>
7819
+ <div class="table-wrap">
7820
+ <table>
7821
+ <thead><tr><th>Name</th><th>What it does</th></tr></thead>
7822
+ <tbody>
7823
+ <tr><td class="name">onBeforeTabChange</td><td class="desc">Tabs (<code>TabsConfig</code>). Cancellable gate before the active tab changes: return <code>false</code>, or call <code>preventDefault()</code> on the event, to veto the switch &mdash; an unsaved edit, say. A veto fires <code>onTabChangeCancelled</code> instead of the change. May return a promise, in which case the switch waits on it.</td></tr>
7824
+ <tr><td class="name">onTabChange</td><td class="desc">Tabs (<code>TabsConfig</code>). Fires after the active tab has changed. The config route to the <code>tab:changed</code> event.</td></tr>
7825
+ <tr><td class="name">onTabChangeCancelled</td><td class="desc">Tabs (<code>TabsConfig</code>). Fires when <code>onBeforeTabChange</code> vetoed a switch, carrying the resolved <code>reason</code>. The config route to the <code>tabChange:cancelled</code> event.</td></tr>
7826
+ <tr><td class="name">onBeforeWindowClose</td><td class="desc">Layout (<code>LayoutConfig</code>). Cancellable gate before a window closes: return <code>false</code> to veto, which fires <code>onWindowCloseCancelled</code> instead. May return a promise.</td></tr>
7827
+ <tr><td class="name">onBeforeWindowMove</td><td class="desc">Layout (<code>LayoutConfig</code>). Cancellable gate before a window moves: return <code>false</code> to veto, which fires <code>onWindowMoveCancelled</code> instead. May return a promise.</td></tr>
7828
+ <tr><td class="name">onBeforeWindowResize</td><td class="desc">Layout (<code>LayoutConfig</code>). Cancellable gate before a window resizes: return <code>false</code> to veto, which fires <code>onWindowResizeCancelled</code> instead. May return a promise.</td></tr>
7829
+ <tr><td class="name">onLayoutChanged</td><td class="desc">Layout (<code>LayoutConfig</code>). Fires after a layout change with the full layout snapshot &mdash; the shape you persist and restore. The config route to the <code>layout:changed</code> event.</td></tr>
7830
+ <tr><td class="name">onWindowCloseCancelled</td><td class="desc">Layout (<code>LayoutConfig</code>). Fires when <code>onBeforeWindowClose</code> vetoed a close. The config route to the <code>windowClose:cancelled</code> event.</td></tr>
7831
+ <tr><td class="name">onWindowClosed</td><td class="desc">Layout (<code>LayoutConfig</code>). Fires after a window has closed. The config route to the <code>window:closed</code> event.</td></tr>
7832
+ <tr><td class="name">onWindowMoveCancelled</td><td class="desc">Layout (<code>LayoutConfig</code>). Fires when <code>onBeforeWindowMove</code> vetoed a move. The config route to the <code>windowMove:cancelled</code> event.</td></tr>
7833
+ <tr><td class="name">onWindowMoved</td><td class="desc">Layout (<code>LayoutConfig</code>). Fires after a window has moved. The config route to the <code>window:moved</code> event.</td></tr>
7834
+ <tr><td class="name">onWindowResizeCancelled</td><td class="desc">Layout (<code>LayoutConfig</code>). Fires when <code>onBeforeWindowResize</code> vetoed a resize. The config route to the <code>windowResize:cancelled</code> event.</td></tr>
7835
+ <tr><td class="name">onWindowResized</td><td class="desc">Layout (<code>LayoutConfig</code>). Fires after a window has resized. The config route to the <code>window:resized</code> event.</td></tr>
7836
+ <tr><td class="name">nullText</td><td class="desc">KPI (<code>KPIConfig</code>). The placeholder rendered in place of a null tile value. Defaults to an em dash.</td></tr>
7837
+ <tr><td class="name">onChange</td><td class="desc">KPI (<code>KPIConfig</code>). Fires after every update with the resolved model: its tiles, and its nodes when the strip is a tree. The config route to the <code>change</code> event.</td></tr>
7838
+ <tr><td class="name">onNodeToggle</td><td class="desc">KPI (<code>KPIConfig</code>). Fires when a branch of a KPI tree opens or closes, with the node key and its new expanded state. The config route to the <code>node:toggle</code> event.</td></tr>
7839
+ <tr><td class="name">onTileClick</td><td class="desc">KPI (<code>KPIConfig</code>). Tile click handler, for drilling into what a tile summarises. The config route to the <code>tile:click</code> event.</td></tr>
7840
+ <tr><td class="name">onTileContextMenu</td><td class="desc">KPI (<code>KPIConfig</code>). Tile right-click handler. The config route to the <code>tile:contextmenu</code> event.</td></tr>
7841
+ <tr><td class="name">onTileDblClick</td><td class="desc">KPI (<code>KPIConfig</code>). Tile double-click handler. The config route to the <code>tile:dblclick</code> event.</td></tr>
7842
+ <tr><td class="name">ariaLabel</td><td class="desc">Kanban (<code>KanbanConfig</code>). The board's accessible name. Defaults to <code>Board</code>.</td></tr>
7843
+ <tr><td class="name">columnOrder</td><td class="desc">Kanban (<code>KanbanConfig</code>). An explicit column order, by column id.</td></tr>
7844
+ <tr><td class="name">doneColumns</td><td class="desc">Kanban (<code>KanbanConfig</code>). Column ids that count as done when a rollup computes progress. A column definition's <code>done: true</code> says the same thing.</td></tr>
7845
+ <tr><td class="name">emptyText</td><td class="desc">Kanban (<code>KanbanConfig</code>). Host-localised placeholder shown in a column holding no cards. Empty by default, which shows no placeholder at all.</td></tr>
7846
+ <tr><td class="name">enforceWip</td><td class="desc">Kanban (<code>KanbanConfig</code>). Enforce <code>wipLimit</code> as a hard gate: a move that would take a column over its limit is refused rather than merely flagged. Default <code>false</code>.</td></tr>
7847
+ <tr><td class="name">laneOrder</td><td class="desc">Kanban (<code>KanbanConfig</code>). An explicit swimlane order, by lane id. Also written by a lane-header drag.</td></tr>
7848
+ <tr><td class="name">onCardClick</td><td class="desc">Kanban (<code>KanbanConfig</code>). Card click handler. The config route to the <code>card:click</code> event.</td></tr>
7849
+ <tr><td class="name">onCardContextMenu</td><td class="desc">Kanban (<code>KanbanConfig</code>). Card right-click handler. The config route to the <code>card:contextmenu</code> event.</td></tr>
7850
+ <tr><td class="name">onCardDblClick</td><td class="desc">Kanban (<code>KanbanConfig</code>). Card double-click handler. The config route to the <code>card:dblclick</code> event.</td></tr>
7851
+ <tr><td class="name">quickFilter</td><td class="desc">Kanban (<code>KanbanConfig</code>). Quick-filter text, matched case-insensitively across a card's fields.</td></tr>
7852
+ <tr><td class="name">showPoints</td><td class="desc">Kanban (<code>KanbanConfig</code>). Show the points sum in each column header. Needs <code>pointsProperty</code> to say which row property carries the points.</td></tr>
7853
+ <tr><td class="name">createdProperty</td><td class="desc">Kanban SLA (<code>KanbanSlaConfig</code>). The row property holding the wall-clock time the card was created.</td></tr>
7854
+ <tr><td class="name">enteredProperty</td><td class="desc">Kanban SLA (<code>KanbanSlaConfig</code>). The row property holding the wall-clock time the card entered its current column.</td></tr>
7855
+ <tr><td class="name">ignoreDone</td><td class="desc">Kanban SLA (<code>KanbanSlaConfig</code>). Whether cards in a done column are exempt from ageing. Default <code>true</code>.</td></tr>
7856
+ <tr><td class="name">onBreach</td><td class="desc">Kanban SLA (<code>KanbanSlaConfig</code>). Called on a rising crossing into breach level, as <code>(level, rows)</code> &mdash; the same shape the router's alert handler takes.</td></tr>
7857
+ <tr><td class="name">onWarn</td><td class="desc">Kanban SLA (<code>KanbanSlaConfig</code>). Called on a rising crossing into warn level, as <code>(level, rows)</code> &mdash; the same shape the router's alert handler takes.</td></tr>
7858
+ <tr><td class="name">showAge</td><td class="desc">Kanban SLA (<code>KanbanSlaConfig</code>). Show the age chip on every aged card (<code>'always'</code>), or only once a card reaches warn or breach (<code>'threshold'</code>, the default).</td></tr>
7859
+ <tr><td class="name">useTransitionLog</td><td class="desc">Kanban SLA (<code>KanbanSlaConfig</code>). Whether the flow transition log drives the ageing basis when one is present. Default <code>true</code>.</td></tr>
7860
+ <tr><td class="name">autoApply</td><td class="desc">AI (<code>AIConfig</code>). Ask-your-data: apply a safe, read-only query result without a confirm step. Off by default &mdash; the resolved query is shown and waits for Apply.</td></tr>
7861
+ <tr><td class="name">onError</td><td class="desc">AI (<code>AIConfig</code>). Called when <code>ask()</code> errors, with the error and the target it was asked of. The grid stays usable.</td></tr>
7862
+ <tr><td class="name">onNarrative</td><td class="desc">AI (<code>AIConfig</code>). Called when a narrative has been produced.</td></tr>
7863
+ <tr><td class="name">onProposal</td><td class="desc">AI (<code>AIConfig</code>). Called with each governed-actor proposal, before any approval.</td></tr>
7864
+ <tr><td class="name">onQuery</td><td class="desc">AI (<code>AIConfig</code>). Called with each ask-your-data result.</td></tr>
7865
+ <tr><td class="name">schemaOptions</td><td class="desc">AI (<code>AIConfig</code>). Budgets passed to the schema builder that describes your data to the model for ask-your-data.</td></tr>
7866
+ </tbody>
7867
+ </table>
7868
+ </div>
7606
7869
 
7607
7870
  <h2 id="webcomponent-guide">Web component</h2>
7608
7871
  <p class="lead-in">
@@ -7758,6 +8021,8 @@ el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
7758
8021
  <tr><td class="name">cell:dblclicked</td><td class="desc">A cell was double-clicked. Carries the row, column, value and text.</td></tr>
7759
8022
  <tr><td class="name">cell:mouseover</td><td class="desc">The pointer entered a cell. Fires once per cell, carries what cell:clicked carries plus the cell element as target, and is delegated on the viewport so it stays correct over pooled rows.</td></tr>
7760
8023
  <tr><td class="name">cell:mouseout</td><td class="desc">The pointer left a cell. Fires once per cell, including when the pointer left the grid; moving to the next cell fires this first, then cell:mouseover.</td></tr>
8024
+ <tr><td class="name">cell:mousedown</td><td class="desc">A pointer button went down on a cell. Carries what cell:clicked carries plus the cell element as target, and is delegated on the viewport so it stays correct over pooled rows.</td></tr>
8025
+ <tr><td class="name">cell:mouseup</td><td class="desc">A pointer button was released over a cell. Same shape as cell:mousedown.</td></tr>
7761
8026
  <tr><td class="name">cell:edit:end</td><td class="desc">It closed: committed or cancelled.</td></tr>
7762
8027
  <tr><td class="name">cell:edit:start</td><td class="desc">An edit session opened.</td></tr>
7763
8028
  <tr><td class="name">cell:pending</td><td class="desc">Applied optimistically, not yet durable. Only with edit.commit.</td></tr>
@@ -8077,7 +8342,7 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'<
8077
8342
 
8078
8343
  <footer>
8079
8344
  <p>
8080
- Lattice Grid 1.59.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
8345
+ Lattice Grid 1.61.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
8081
8346
  Written against the shipped source. Where this guide and the code disagree, the code wins,
8082
8347
  please <a href="https://www.latticegrid.dev">tell us</a>.
8083
8348
  </p>