@toclocoinc/lattice-grid 1.49.0 → 1.51.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 (74) hide show
  1. package/README.md +2 -1
  2. package/docs/API.html +363 -8
  3. package/docs/api-detail.html +235 -2
  4. package/lattice-grid.d.ts +270 -6
  5. package/lattice-grid.esm.min.js +549 -75
  6. package/lattice-grid.min.cjs +549 -75
  7. package/lattice-grid.min.js +549 -75
  8. package/modules/ai.esm.min.js +6 -4
  9. package/modules/ai.min.cjs +6 -4
  10. package/modules/ai.min.js +6 -4
  11. package/modules/angular.esm.min.js +2 -2
  12. package/modules/angular.min.cjs +2 -2
  13. package/modules/angular.min.js +2 -2
  14. package/modules/chart-alluvial.esm.min.js +1 -1
  15. package/modules/chart-arc.esm.min.js +1 -1
  16. package/modules/chart-bubblemap.esm.min.js +1 -1
  17. package/modules/chart-bump.esm.min.js +1 -1
  18. package/modules/chart-calendar.esm.min.js +1 -1
  19. package/modules/chart-decomposition.esm.min.js +1 -1
  20. package/modules/chart-diverging.esm.min.js +1 -1
  21. package/modules/chart-dumbbell.esm.min.js +1 -1
  22. package/modules/chart-fan.esm.min.js +1 -1
  23. package/modules/chart-hexbin.esm.min.js +1 -1
  24. package/modules/chart-hexmap.esm.min.js +1 -1
  25. package/modules/chart-icicle.esm.min.js +1 -1
  26. package/modules/chart-parallel.esm.min.js +1 -1
  27. package/modules/chart-ridgeline.esm.min.js +1 -1
  28. package/modules/chart-roc.esm.min.js +1 -1
  29. package/modules/chart-slope.esm.min.js +1 -1
  30. package/modules/chart-splom.esm.min.js +1 -1
  31. package/modules/chart-waffle.esm.min.js +1 -1
  32. package/modules/charts.esm.min.js +4 -4
  33. package/modules/charts.min.cjs +4 -4
  34. package/modules/charts.min.js +4 -4
  35. package/modules/data-router.esm.min.js +7 -5
  36. package/modules/data-router.min.cjs +7 -5
  37. package/modules/data-router.min.js +7 -5
  38. package/modules/devtools.esm.min.js +2 -2
  39. package/modules/devtools.min.cjs +2 -2
  40. package/modules/devtools.min.js +2 -2
  41. package/modules/dhtmlx-compat.esm.min.js +4 -4
  42. package/modules/dhtmlx-compat.min.cjs +4 -4
  43. package/modules/dhtmlx-compat.min.js +4 -4
  44. package/modules/gantt.esm.min.js +78 -4
  45. package/modules/gantt.min.cjs +77 -4
  46. package/modules/gantt.min.js +77 -4
  47. package/modules/htmx.esm.min.js +549 -75
  48. package/modules/htmx.min.cjs +549 -75
  49. package/modules/htmx.min.js +549 -75
  50. package/modules/kanban.esm.min.js +4 -4
  51. package/modules/kanban.min.cjs +4 -4
  52. package/modules/kanban.min.js +4 -4
  53. package/modules/kpi.esm.min.js +30 -7
  54. package/modules/kpi.min.cjs +30 -7
  55. package/modules/kpi.min.js +30 -7
  56. package/modules/mock-socket.esm.min.js +2 -2
  57. package/modules/mock-socket.min.cjs +2 -2
  58. package/modules/mock-socket.min.js +2 -2
  59. package/modules/react.esm.min.js +2 -2
  60. package/modules/react.min.cjs +2 -2
  61. package/modules/react.min.js +2 -2
  62. package/modules/svelte.esm.min.js +2 -2
  63. package/modules/svelte.min.cjs +2 -2
  64. package/modules/svelte.min.js +2 -2
  65. package/modules/tabs.esm.min.js +878 -0
  66. package/modules/tabs.min.cjs +881 -0
  67. package/modules/tabs.min.js +881 -0
  68. package/modules/vue.esm.min.js +2 -2
  69. package/modules/vue.min.cjs +2 -2
  70. package/modules/vue.min.js +2 -2
  71. package/modules/webcomponent.esm.min.js +549 -75
  72. package/modules/webcomponent.min.cjs +549 -75
  73. package/modules/webcomponent.min.js +549 -75
  74. package/package.json +1 -1
@@ -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.49.0</p>
440
+ <p class="rail__sub">Developer guide · v1.51.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -473,6 +473,7 @@
473
473
  <span class="rail__label">Connected grids</span>
474
474
  <a href="#pushdown">Querying an engine</a>
475
475
  <a href="#derived">Derived grids</a>
476
+ <a href="#derived-union">Combining several grids</a>
476
477
  <a href="#cross-filter">Cross-filtering</a>
477
478
  <a href="#joins">Joining two grids</a>
478
479
  <a href="#aggregate-safety">Aggregate safety</a>
@@ -3325,6 +3326,81 @@ createGrid(right, {
3325
3326
  </table>
3326
3327
  </div>
3327
3328
 
3329
+ <h2 id="derived-union">Combining several grids into one</h2>
3330
+ <p class="lead-in">
3331
+ A <code>join</code> matches two grids on a shared key. Some questions have no key to share at
3332
+ all &mdash; "the worst performers across two regional datasets" when the two regions use
3333
+ unrelated ids &mdash; and for those, <code>from</code> takes an array of sources instead of one
3334
+ grid: every source is read, concatenated in the order you declared them, and only then does the
3335
+ rest of the pipeline run, once, over the combined set.
3336
+ </p>
3337
+ <div class="example">
3338
+ <p class="example__label">Worst performers, two unrelated datasets</p>
3339
+ <pre><code>createGrid(right, {
3340
+ columns: [{ id: 'title' }, { id: 'severity', type: 'number' }, { id: '__source' }],
3341
+ source: {
3342
+ mode: 'derived',
3343
+ from: [
3344
+ { grid: eastIncidents, label: 'east' },
3345
+ { grid: westIncidents, label: 'west', map: (row) =&gt; ({ title: row.name, severity: row.rating }) },
3346
+ ],
3347
+ sort: [{ col: 'severity', dir: 'desc' }],
3348
+ limit: 10,
3349
+ },
3350
+ });</code></pre>
3351
+ </div>
3352
+ <p>
3353
+ <code>label</code> defaults to the source's position in the array (<code>'0'</code>,
3354
+ <code>'1'</code>, &hellip;), and <code>follow</code> is independent per source &mdash; filtering
3355
+ one narrows only its own contribution, exactly as a lone <code>from</code> follows its grid
3356
+ today. A source with differently-named fields uses <code>map</code> to project them into a
3357
+ common shape before it joins the rest.
3358
+ </p>
3359
+ <div class="why">
3360
+ <p><strong><code>__source</code> is not optional.</strong> Every combined row carries it &mdash;
3361
+ the entry's <code>label</code>, or its index when unlabelled &mdash; and it is an ordinary field
3362
+ to <code>where</code>, <code>groupBy</code> and <code>select</code>. Leaving it out would mean a
3363
+ combined list that cannot say where any row came from, which defeats most of the reason to
3364
+ combine several sources in the first place.</p>
3365
+ <p><strong>The union of fields, never a merge.</strong> A field only one source has is
3366
+ <code>undefined</code> on the others' rows, not fabricated and not type-coerced &mdash; two
3367
+ sources disagreeing about what a field means is yours to resolve with <code>map</code>, not
3368
+ something the union guesses at. And there is no dedup: two sources reporting the same fact both
3369
+ appear as separate rows. There is no UNION-vs-UNION-ALL distinction to draw; reach for
3370
+ <code>join</code> when rows should be matched on a key rather than stacked.</p>
3371
+ <p><strong>The key is namespaced, only where it needs to be.</strong> Two sources can easily
3372
+ share row identifiers, so the derived <code>__key</code> is qualified by the source tag when
3373
+ nothing is grouped. Grouped, <code>__key</code> is the group value exactly as it always has
3374
+ been, and rows from different sources landing in the same group is what grouping a union is
3375
+ <em>for</em>, not a collision.</p>
3376
+ <p><strong>An empty source is fine; a broken one is loud.</strong> A source with no rows
3377
+ contributes nothing. A source that throws while being read is named in a console warning and
3378
+ skipped for that pass &mdash; a silently missing source would make "worst across both" quietly
3379
+ wrong, so it is reported rather than swallowed.</p>
3380
+ <p><strong>A cycle is refused when the source is built.</strong> If a union's sources include
3381
+ the grid being derived, directly or through a chain of other derived grids, it is refused up
3382
+ front, naming the offending source, rather than being recursed into.</p>
3383
+ <p><strong>A union never patches &mdash; know the cost before you reach for one at scale.</strong>
3384
+ A lone <code>from</code> maintains its grouping incrementally: an edit that names the rows it
3385
+ touched re-reduces only the groups those rows belong to. A union does not do this for any of its
3386
+ sources &mdash; every change on <em>any</em> parent re-reads and re-derives the <em>whole</em>
3387
+ combined set from scratch. On a synthetic 200,000-row union across four sources, one row changed
3388
+ on one parent cost on the order of <strong>800&nbsp;ms&ndash;3&nbsp;s</strong> (machine-dependent;
3389
+ run <code>node bench/union-parents.mjs</code> against your own shape), against a few
3390
+ milliseconds for the equivalent patched change on a lone <code>from</code> over the same row
3391
+ count. And because a union is watched by as many independent change streams as it has sources,
3392
+ that full-rescan cost is paid once <em>per source that moves</em>, not once per union &mdash;
3393
+ four active parents each firing their own updates pay it four times over. Below a few tens of
3394
+ thousands of combined rows this is unlikely to matter; above that, or with several sources each
3395
+ updating on their own live feed, budget for it, keep <code>refresh</code> away from every tick
3396
+ (<code>idle</code>, or a debounce), and prefer fewer, larger sources over many small ones where
3397
+ the shape of your data allows it.</p>
3398
+ <p><strong>Not supported alongside a union.</strong> <code>crossFilter</code> has no single
3399
+ target once there is more than one parent; <code>profile</code> reduces one grid's own columns.
3400
+ Both are refused with a warning rather than guessed at, and the top-level <code>follow</code> is
3401
+ ignored in favour of each source's own.</p>
3402
+ </div>
3403
+
3328
3404
  <h2 id="cross-filter">Cross-filtering</h2>
3329
3405
  <p class="lead-in">
3330
3406
  A derived panel can filter the grid it summarises. Click a region in the summary and the
@@ -5982,6 +6058,162 @@ r.ok ? r.value : r.error; <span class="cmt">// 42</span></code></pre>
5982
6058
  a missing <code>return</code> is a typo and deleting the whole menu is a harsh reading of
5983
6059
  one.</p>
5984
6060
  </div>
6061
+ <h3 id="per-column-menu">Declaring a menu on the column itself</h3>
6062
+ <p class="lead-in">
6063
+ A cell menu can also be declared <strong>on the column</strong>, with
6064
+ <code>contextMenu</code> on the column definition. It takes the same shapes the grid-level
6065
+ option takes, plus a bare array for the common &ldquo;just these items here&rdquo; case:
6066
+ <code>boolean | MenuItem[] | (params, defaults) =&gt; items</code>.
6067
+ </p>
6068
+ <div class="example">
6069
+ <p class="example__label">Each column's menu logic beside the column it is about</p>
6070
+ <pre><code>createGrid(el, {
6071
+ columns: [
6072
+ { field: 'account' },
6073
+ <span class="cmt">// Just these items, here.</span>
6074
+ { field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },
6075
+ <span class="cmt">// Or the built-ins plus one, the same form the grid-level option takes.</span>
6076
+ {
6077
+ field: 'amount',
6078
+ contextMenu: (params, defaults) =&gt; [
6079
+ ...defaults,
6080
+ { separator: <span class="kw">true</span> },
6081
+ { name: `Reprice ${params.value}`, action: (ctx) =&gt; reprice(ctx.data) },
6082
+ ],
6083
+ },
6084
+ <span class="cmt">// And nothing at all on a column nobody should act on from here.</span>
6085
+ { field: 'nationalId', contextMenu: <span class="kw">false</span> },
6086
+ ],
6087
+ });</code></pre>
6088
+ </div>
6089
+ <p class="lead-in">
6090
+ This adds no power the grid-level option did not have &mdash; <code>params.colId</code> and
6091
+ <code>params.column</code> always let one callback branch by column. What it adds is
6092
+ <strong>locality</strong>: the menu for a column is declared where the column is, instead of
6093
+ collecting into one growing <code>switch</code> a long way from the thing it is about.
6094
+ </p>
6095
+
6096
+ <h4 id="per-column-menu-chain">The three levels compose as a chain</h4>
6097
+ <p class="lead-in">
6098
+ The built-in items go in first, then the grid-level <code>contextMenu</code>, then the
6099
+ column's own &mdash; <strong>each handed the previous level's result as its
6100
+ <code>defaults</code></strong>. A column that wants one extra item writes one extra item; it
6101
+ never has to restate Paste, Clear and Fill&nbsp;down, nor whatever the grid-level builder just
6102
+ added.
6103
+ </p>
6104
+ <div class="example">
6105
+ <p class="example__label">Built-ins &rarr; grid &rarr; column, executed</p>
6106
+ <pre data-run="js" data-expect="grid|column" data-covers="config:contextMenu"><code><span class="kw">const</span> { createTestDom, flushFrames } = <span class="kw">await</span> import('../packages/dom/src/renderer/testdom.js');
6107
+ <span class="kw">const</span> { createGrid } = <span class="kw">await</span> import('../packages/dom/src/index.js');
6108
+ <span class="kw">const</span> { root } = createTestDom({ width: 600, height: 300 });
6109
+
6110
+ <span class="kw">let</span> handedToTheColumn = [];
6111
+ <span class="kw">const</span> grid = createGrid(root, {
6112
+ rowKey: 'id',
6113
+ rows: [{ id: 1, account: 'Acme', amount: 120 }],
6114
+ columns: [
6115
+ { field: 'account' },
6116
+ {
6117
+ field: 'amount',
6118
+ contextMenu: (params, defaults) =&gt; {
6119
+ handedToTheColumn = defaults.map((d) =&gt; d.name);
6120
+ <span class="kw">return</span> [...defaults, { name: 'from the column', action() {} }];
6121
+ },
6122
+ },
6123
+ ],
6124
+ contextMenu: (params, defaults) =&gt; [...defaults, { name: 'from the grid', action() {} }],
6125
+ });
6126
+ flushFrames();
6127
+
6128
+ <span class="kw">const</span> row = grid.rows.get(0);
6129
+ grid.emit('cell:contextmenu', {
6130
+ row, key: row.key, index: 0, colId: 'amount',
6131
+ column: grid.columns.get('amount'), value: 120,
6132
+ event: { clientX: 10, clientY: 10, preventDefault() {} },
6133
+ });
6134
+ flushFrames();
6135
+
6136
+ <span class="cmt">// The column builder was handed the grid builder's output, not the raw</span>
6137
+ <span class="cmt">// built-ins: 'from the grid' is already in its `defaults`.</span>
6138
+ <span class="kw">const</span> chained = handedToTheColumn.includes('from the grid');
6139
+ <span class="kw">const</span> shown = [...root.querySelectorAll('.lat-menu__item')]
6140
+ .map((i) =&gt; String(i.textContent).trim());
6141
+ grid.destroy();
6142
+ <span class="kw">return</span> chained &amp;&amp; shown.includes('from the column') ? 'grid|column' : 'broken';</code></pre>
6143
+ </div>
6144
+ <p class="lead-in">
6145
+ <strong>Suppression follows the same order, and the more specific level wins.</strong>
6146
+ <code>contextMenu: false</code> on a column is a statement about <em>that column</em> and no
6147
+ other. Equally, a column may declare a menu on a grid whose <code>contextMenu</code> is
6148
+ <code>false</code> &mdash; which is how you say &ldquo;no menu anywhere except here&rdquo;.
6149
+ <code>contextMenu: true</code> on a column means &ldquo;whatever came before&rdquo;, so it
6150
+ restores the built-in menu on a grid that turned it off.
6151
+ </p>
6152
+ <table class="ref">
6153
+ <thead><tr><th>Grid level</th><th>Column level</th><th>What opens on that column</th></tr></thead>
6154
+ <tbody>
6155
+ <tr><td><em>not set</em></td><td><em>not set</em></td><td>the built-in menu</td></tr>
6156
+ <tr><td>a builder</td><td><em>not set</em></td><td>the builder's result</td></tr>
6157
+ <tr><td>a builder</td><td>a builder</td><td>the column's builder, handed the grid builder's result</td></tr>
6158
+ <tr><td>a builder</td><td>an array</td><td>the array &mdash; the grid level still ran, and was replaced</td></tr>
6159
+ <tr><td>a builder, or not set</td><td><code>false</code></td><td><strong>nothing</strong> &mdash; the column suppresses, and no other column is affected</td></tr>
6160
+ <tr><td><code>false</code></td><td><em>not set</em></td><td><strong>nothing</strong> &mdash; the grid-level off stands, as it always has</td></tr>
6161
+ <tr><td><code>false</code></td><td>an array</td><td><strong>the column's array.</strong> The column opts back in: grid-level <code>false</code> is a default, not a lock</td></tr>
6162
+ <tr><td><code>false</code></td><td>a builder</td><td><strong>the builder's result</strong>, handed the built-in items as its <code>defaults</code>. The column opts back in</td></tr>
6163
+ <tr><td><code>false</code></td><td><code>true</code></td><td><strong>the built-in menu.</strong> The column opts back in and asks for the defaults</td></tr>
6164
+ </tbody>
6165
+ </table>
6166
+ <div class="note">
6167
+ <p><strong><code>contextMenu: false</code> on the grid is a default, not a lock.</strong> If
6168
+ you set it as a safety property &mdash; a read-only grid, a screen where nobody should be
6169
+ able to copy or clear from a right-click &mdash; be aware that a column declaring its own
6170
+ <code>contextMenu</code> will still open one, because the more specific level wins in
6171
+ <em>both</em> directions. That is deliberate: a read-only grid with one actionable column is a
6172
+ real shape, and it is the only way to say &ldquo;no menu anywhere except here&rdquo;. But it
6173
+ does mean grid-level <code>false</code> does not guarantee that no cell menu can open
6174
+ anywhere &mdash; only that none opens unless a column asks for one. If you need the absolute
6175
+ guarantee, do not declare <code>contextMenu</code> on any column.</p>
6176
+ </div>
6177
+ <div class="why">
6178
+ <p><strong>A chain, not a replacement.</strong> If the column level replaced the grid level,
6179
+ every column that wanted one extra item would have to restate everything the grid-level
6180
+ builder does &mdash; and would then stop tracking it the first time it changed. This is the
6181
+ same rule <code>columnMenu</code> already follows for the header: you are handed what came
6182
+ before so you can add to it rather than reproduce it.</p>
6183
+ </div>
6184
+
6185
+ <h4 id="per-column-menu-edges">A range, and rows that belong to no column</h4>
6186
+ <p class="lead-in">
6187
+ <strong>On a multi-column selection, the column you right-clicked decides.</strong> Not the
6188
+ intersection of the selected columns' menus, which silently drops items; not their union,
6189
+ which offers actions that are wrong for most of the selection. The clicked column is the one
6190
+ the user pointed at, and it is the one that answers &mdash; the built-in range actions
6191
+ (Copy, Clear, Fill&nbsp;down) still act on the whole range as they always did.
6192
+ </p>
6193
+ <p class="lead-in">
6194
+ <strong>A row with no owning column falls back to the grid-level menu.</strong> Group rows,
6195
+ pivot group rows and full-width rows do not belong to one column, so there is no column-level
6196
+ declaration to consult; the chain simply has one fewer link and the grid-level menu stands.
6197
+ Nothing errors and nothing silently shows an empty menu.
6198
+ </p>
6199
+ <p class="lead-in">
6200
+ Every route honours the column: a right-click, and the keyboard's
6201
+ <kbd>Shift</kbd>+<kbd>F10</kbd> or <kbd>Context&nbsp;Menu</kbd> key on the focused cell.
6202
+ The menu is a <code>role="menu"</code> of <code>role="menuitem"</code>s that takes focus and
6203
+ closes on <kbd>Escape</kbd> wherever it was opened from.
6204
+ </p>
6205
+ <div class="why">
6206
+ <p><strong>Trust is unchanged.</strong> A <code>MenuItem</code> is the same object it always
6207
+ was, including <code>icon</code> markup being trusted at the same level as
6208
+ <code>action</code>. Declaring one on a column changes <em>where</em> it is written, not who
6209
+ is trusted to write it: a column definition is your code, exactly as a grid config is.</p>
6210
+ </div>
6211
+ <p class="lead-in">
6212
+ A column preset or <code>columnDefaults</code> may supply <code>contextMenu</code> too, and
6213
+ the column's own declaration outranks both &mdash; so a house rule like &ldquo;no cell menu on
6214
+ anything tagged sensitive&rdquo; is written once.
6215
+ </p>
6216
+
5985
6217
  <p class="lead-in">
5986
6218
  <code>columnMenu</code> takes the same function form, for both routes into a column's menu:
5987
6219
  the header's 3-dot button and a right-click on the heading. Its <code>params</code> is
@@ -6643,7 +6875,8 @@ grid.import.apply(preview);</code></pre>
6643
6875
  <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>); 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>
6644
6876
  <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>
6645
6877
  <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>
6646
- <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
6878
+ <tr><td class="name">createTabs</td><td class="desc">Create a tabbed grid (module <code>tabs</code>): a <code>role="tablist"</code> strip above a stack of <code>role="tabpanel"</code> regions, each hosting its own, independently-configured <code>createGrid</code> instance — "configure each tab as per a normal grid" rather than one grid whose state is swapped (<code>ColumnModel#applyState</code> only repositions/hides/resizes existing columns by id; it carries no field, type or row data, so a state-swap only works when every tab shares one schema). <code>createGrid</code> is injected (<code>createTabs(el, { createGrid, tabs })</code>), the same pattern the React/Vue/Svelte adapters use, so the module imports no engine code and adds nothing to a page that does not load it. A tab that names <code>from: '&lt;tabId&gt;'</code> gets a <code>source: { mode: 'derived', from: &lt;the parent tab’s live grid&gt;, where, group, join, … }</code> wired for it automatically — reusing the shipped derived-source mechanism rather than a new config-inheritance one — and activating a derived tab materialises its whole ancestor chain first; a cyclic <code>from</code> graph is refused (naming the exact cycle) when <code>createTabs</code> is called, not at first click. A tab’s grid mounts on first activation and then stays alive, hidden, so its scroll/selection/filters/sort/grouping/expansion — and an open cell/row editor, left exactly as it was, uncommitted and undiscarded — survive a switch natively; <code>destroy()</code> tears every mounted tab down. The strip is a real tablist with <code>aria-selected</code>, a roving <code>tabindex</code>, and manual-activation keyboard handling (arrows/Home/End move focus, Enter/Space or a click activates). Events: <code>tab:changed</code>, a cancellable <code>beforeTabChange</code> paired with <code>tabChange:cancelled</code>. UMD global <code>LatticeGridTabs</code>.</td></tr>
6879
+ <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
6647
6880
  <tr><td class="name">createGantt</td><td class="desc">Create a project-planning Gantt controller (module <code>gantt</code>) over a task list and a dependency list. A CPM engine (<code>computeSchedule</code>) computes each task's early/late start and finish, its slack and the zero-float critical path, honouring the four link types (<code>LINK_TYPES</code>: FS/SS/FF/SF) with lag, and recomputes on every edit — emitting <code>schedule</code> or, on a dependency cycle or bad input, <code>error</code> (a code from <code>SCHEDULE_ERROR</code>). Milestones are zero-duration points; summary (WBS) tasks are derived from their children (earliest start, latest finish, weighted progress) rather than scheduled; <code>findViolations</code> flags a task placed earlier than its predecessors allow, and <code>toISODate</code> maps an engine day-number back to a calendar date.</td></tr>
6648
6881
  <tr><td class="name">createLatticeGridElement</td><td class="desc">Build the element class without registering it, for a custom registry.</td></tr>
6649
6882
  <tr><td class="name">createMessages</td><td class="desc">Build a message catalogue. A partial set lays over the built-in British English one.</td></tr>