@toclocoinc/lattice-grid 1.50.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 +1 -1
  2. package/docs/API.html +296 -7
  3. package/docs/api-detail.html +233 -1
  4. package/lattice-grid.d.ts +149 -6
  5. package/lattice-grid.esm.min.js +380 -67
  6. package/lattice-grid.min.cjs +380 -67
  7. package/lattice-grid.min.js +380 -67
  8. package/modules/ai.esm.min.js +4 -4
  9. package/modules/ai.min.cjs +4 -4
  10. package/modules/ai.min.js +4 -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 +4 -4
  45. package/modules/gantt.min.cjs +4 -4
  46. package/modules/gantt.min.js +4 -4
  47. package/modules/htmx.esm.min.js +380 -67
  48. package/modules/htmx.min.cjs +380 -67
  49. package/modules/htmx.min.js +380 -67
  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 +20 -12
  66. package/modules/tabs.min.cjs +20 -12
  67. package/modules/tabs.min.js +20 -12
  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 +380 -67
  72. package/modules/webcomponent.min.cjs +380 -67
  73. package/modules/webcomponent.min.js +380 -67
  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.50.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
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.50.0, type declarations
2
+ * Lattice Grid 1.51.0, type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -863,6 +863,24 @@ export interface Column {
863
863
  layout?: ColumnLayoutSpec | number;
864
864
  /** The header cell: its text, tooltip, menu and any header chart. */
865
865
  header?: ColumnHeaderSpec | string;
866
+ /**
867
+ * The cell right-click menu for this column alone (BACKLOG-0001068), in the
868
+ * same shapes the grid-level `contextMenu` takes plus a bare array for the
869
+ * common "just these items here" case.
870
+ *
871
+ * Declared where the column is declared rather than as another branch inside
872
+ * one grid-level callback: the menu logic for a column belongs beside the
873
+ * column it belongs to. It does not replace the grid-level menu — the three
874
+ * levels compose as a chain, built-in defaults then grid-level then this one,
875
+ * each handed the previous result as its `defaults`, so a column adding one
876
+ * item does not have to restate Paste, Clear and Fill down.
877
+ *
878
+ * `false` suppresses the menu on this column and leaves every other column
879
+ * alone: what a sensitive or read-only column wants. The more specific level
880
+ * wins, so a column may also declare a menu on a grid whose `contextMenu` is
881
+ * `false`.
882
+ */
883
+ contextMenu?: boolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void);
866
884
  /**
867
885
  * When this column's header controls — its sort arrow, filter funnel and menu
868
886
  * button — are shown, overriding the grid-level `headerControls` default for
@@ -939,6 +957,12 @@ export interface ResolvedColumn {
939
957
  grandTotal: TotalName | TotalFn | null;
940
958
  layout: ColumnLayoutSpec;
941
959
  header: ColumnHeaderSpec;
960
+ /**
961
+ * This column's own cell-menu declaration (BACKLOG-0001068), or null when it
962
+ * makes none and the grid-level menu stands alone. Carried onto the resolved
963
+ * column so a column preset or `columnDefaults` can supply one.
964
+ */
965
+ contextMenu: boolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) => MenuItem[] | void) | null;
942
966
  export: ColumnExportSpec;
943
967
  lookup: LookupSpec | null;
944
968
  allowGroup: boolean;
@@ -1220,9 +1244,57 @@ export interface DerivedSourceConfig {
1220
1244
  * key when nothing is grouped. `config.rowKey` defaults to it, so it need not
1221
1245
  * be set; an explicit `rowKey` still wins.
1222
1246
  */
1223
- /** The grid to read. */
1224
- from: Grid;
1225
- /** Which of its rows to read. `filtered` by default. */
1247
+ /**
1248
+ * The grid to read, or several to combine into one row set before the rest
1249
+ * of the pipeline runs (BACKLOG-0001045). A bare `Grid` is shorthand for a
1250
+ * `UnionSourceOptions` with no `label`/`follow`/`map` override, so an
1251
+ * existing `from: <grid>` keeps meaning exactly what it always has.
1252
+ *
1253
+ * Given an array, every source is read (each narrowed by its own `follow`,
1254
+ * defaulting to `'filtered'` as a lone `from` does today), concatenated in
1255
+ * **declaration order** — deterministic, not interleaved — and only then
1256
+ * does `unnest`/`join`/`where`/`bucket`/`groupBy`/`select`/`sort`/`limit`/
1257
+ * `limitPer`/`cumulative` run, over the combined set, so "the worst
1258
+ * performers across both" is one derivation rather than a hand-merge.
1259
+ *
1260
+ * The output carries the **union of the sources' fields**: a field present
1261
+ * on only one source is `undefined` on rows from the others. Sources are
1262
+ * **not** type-reconciled — if two disagree on what a field means or holds,
1263
+ * that is not resolved for you; give each source a `map` to project it into
1264
+ * a common shape first. Every row also carries `__source` (the entry's
1265
+ * `label`, or its declaration index when unlabelled), which is required —
1266
+ * not optional — because without it a combined list cannot be read, filtered
1267
+ * or grouped by where it came from; it is an ordinary field to `where`,
1268
+ * `groupBy` and `select`. And because the derived key (`__key`) would
1269
+ * otherwise collide across sources sharing the same identifiers, it is
1270
+ * namespaced by the same source tag when nothing is grouped (a grouped
1271
+ * union's `__key` is the group value, exactly as today, and rows from
1272
+ * different sources correctly land in the *same* group when their group
1273
+ * values agree — that merging is the point of grouping a union, not a
1274
+ * collision to guard against).
1275
+ *
1276
+ * This is **not** a join: there is no dedup or merge-on-key, and it draws no
1277
+ * UNION/UNION ALL distinction — overlapping rows from two sources simply
1278
+ * both appear. Reach for `join` when two sides share a key and you want them
1279
+ * matched rather than stacked.
1280
+ *
1281
+ * An empty source contributes nothing and the rest still combine; a source
1282
+ * that fails to read is named in a `warnOnce` and skipped for that pass
1283
+ * rather than silently dropped, because a silently missing source would
1284
+ * make "worst across both" quietly wrong. A source list that includes the
1285
+ * grid being derived, directly or through a chain, is refused when the
1286
+ * source is built (naming the offender) rather than recursed into.
1287
+ *
1288
+ * `crossFilter` has no single target once there is more than one parent, so
1289
+ * it is not supported alongside a union `from` (ignored, with a `warnOnce`,
1290
+ * rather than guessing which parent to push onto).
1291
+ */
1292
+ from: Grid | UnionSourceOptions[];
1293
+ /**
1294
+ * Which of its rows to read. `filtered` by default. Ignored — with a
1295
+ * `warnOnce` — when `from` is a union array: each entry there carries its
1296
+ * own `follow` instead (BACKLOG-0001045).
1297
+ */
1226
1298
  follow?: 'filtered' | 'all' | 'selected' | 'grouped';
1227
1299
 
1228
1300
  /** An array property to expand, one row per element, before anything else. */
@@ -1265,6 +1337,38 @@ export interface DerivedSourceConfig {
1265
1337
  crossFilter?: boolean | string | { col?: string };
1266
1338
  }
1267
1339
 
1340
+ /**
1341
+ * One member of a union `from` (BACKLOG-0001045): a grid to combine with the
1342
+ * others, plus how to read it and reshape it before it joins the rest. A bare
1343
+ * `Grid` in the `from` array is shorthand for `{ grid }` with every other
1344
+ * field defaulted.
1345
+ */
1346
+ export interface UnionSourceOptions {
1347
+ /** The grid this source reads. */
1348
+ grid: Grid;
1349
+ /**
1350
+ * Identifies this source: it is what `__source` carries on every row this
1351
+ * source contributes, and what namespaces that row's `__key` so two sources
1352
+ * sharing the same identifiers do not collide. Defaults to the source's
1353
+ * position in the `from` array (`'0'`, `'1'`, …), as a string.
1354
+ */
1355
+ label?: string;
1356
+ /**
1357
+ * Which of this source's rows to read. `filtered` by default, exactly as a
1358
+ * lone `from` follows its grid today — set independently per source, so
1359
+ * filtering one narrows only its own contribution.
1360
+ */
1361
+ follow?: 'filtered' | 'all' | 'selected' | 'grouped';
1362
+ /**
1363
+ * Reshape this source's rows into the common shape before they join the
1364
+ * rest — typically a rename or a projection, for a field this source calls
1365
+ * something else. Not a type coercion: if a field means something different
1366
+ * on two sources, `map` is where you make them agree, because the union
1367
+ * itself does not guess.
1368
+ */
1369
+ map?: (row: unknown) => unknown;
1370
+ }
1371
+
1268
1372
  export interface DerivedJoin {
1269
1373
  /** The grid holding the other side. */
1270
1374
  with: Grid;
@@ -5952,6 +6056,18 @@ export function duckdbAdapter(options: {
5952
6056
  from: string;
5953
6057
  /** Columns to select. Everything by default. */
5954
6058
  fields?: string[];
6059
+ /**
6060
+ * Whether to count the matching set at all. `true` by default: the total is a
6061
+ * separate `count(*)` statement carrying the same `WHERE`, dispatched in the
6062
+ * same tick as the page query rather than serialised behind it
6063
+ * (BACKLOG-0001065). `false` issues no count statement, declares
6064
+ * `capabilities.total: false`, and leaves the result's `total` **absent** — so
6065
+ * the grid scrolls open-ended instead of being told the page length is the
6066
+ * whole set. Turn it off for a grid that never shows a count: an unfiltered
6067
+ * count is answered from Parquet metadata and a filtered one still has to
6068
+ * evaluate the predicate, so it is cheap rather than free.
6069
+ */
6070
+ count?: boolean;
5955
6071
  /**
5956
6072
  * The key column an update and a delete target in their `WHERE`, and that an
5957
6073
  * add-row is rekeyed by. Write-back is refused unless this names a real column,
@@ -5974,7 +6090,15 @@ export function duckdbAdapter(options: {
5974
6090
  * key to rekey the temp row.
5975
6091
  */
5976
6092
  returning?: 'row' | 'none';
5977
- }): PushdownAdapter & { sqlFor(query: RemoteRequest): { sql: string; params: unknown[] } };
6093
+ }): PushdownAdapter & {
6094
+ sqlFor(query: RemoteRequest): { sql: string; params: unknown[] };
6095
+ /**
6096
+ * The separate `count(*)` statement that reports the matching set's size, with
6097
+ * the same `WHERE` as {@link sqlFor} and no `ORDER BY` or `LIMIT`
6098
+ * (BACKLOG-0001065). `null` when the adapter was built with `count: false`.
6099
+ */
6100
+ countSqlFor(query: RemoteRequest): { sql: string; params: unknown[] } | null;
6101
+ };
5978
6102
 
5979
6103
  /**
5980
6104
  * An adapter for a DemandFlow entity, speaking `POST /v1/query`.
@@ -6054,6 +6178,15 @@ export function graphqlAdapter(options: {
6054
6178
  pagination?: 'offset' | 'cursor';
6055
6179
  /** The page size for the whole-result and forward-cursor walks. */
6056
6180
  pageSize?: number;
6181
+ /**
6182
+ * Whether the default query asks for `totalCount`. `true` by default.
6183
+ * `false` drops it from the selection set and declares
6184
+ * `capabilities.total: false`, so a grid that never shows a count does not
6185
+ * make the server compute one (BACKLOG-0001065). Unlike the DuckDB adapter the
6186
+ * count is not split into a second operation — that would cost an extra HTTP
6187
+ * round trip rather than saving one — so suppression is the only lever here.
6188
+ */
6189
+ count?: boolean;
6057
6190
  /** Rename the pagination variables the adapter drives per page. */
6058
6191
  vars?: Partial<Record<'offset' | 'limit' | 'first' | 'after', string>>;
6059
6192
  capabilities?: PushdownCapabilities; operators?: string[];
@@ -8356,7 +8489,17 @@ declare module 'lattice-grid/modules/kpi' {
8356
8489
  field?: string;
8357
8490
  value: unknown;
8358
8491
  formatted: string;
8359
- status: 'good' | 'warn' | 'critical' | null;
8492
+ /**
8493
+ * The tile's semantic band, or `unknown` when the panel holds no rows at
8494
+ * all. `unknown` is decided from data presence before any threshold is
8495
+ * consulted: an aggregation over nothing returns the identity of its
8496
+ * operation (`sum` and `count` return 0), and 0 is a number a threshold
8497
+ * grades, so without it an empty panel would report as a healthy one. A
8498
+ * tile whose `filter` matches none of the rows the panel *does* hold has
8499
+ * measured a real zero and is banded normally. `null` means the tile has no
8500
+ * thresholds or bands configured.
8501
+ */
8502
+ status: 'good' | 'warn' | 'critical' | 'unknown' | null;
8360
8503
  target?: number;
8361
8504
  baseline?: number;
8362
8505
  delta: number | null;