@toclocoinc/lattice-grid 1.55.0 → 1.57.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 (78) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +389 -42
  3. package/docs/api-detail.html +373 -8
  4. package/lattice-grid.d.ts +525 -26
  5. package/lattice-grid.esm.min.js +1943 -920
  6. package/lattice-grid.min.cjs +1942 -920
  7. package/lattice-grid.min.css +1 -1
  8. package/lattice-grid.min.js +1942 -920
  9. package/modules/ai.esm.min.js +6 -4
  10. package/modules/ai.min.cjs +6 -4
  11. package/modules/ai.min.js +6 -4
  12. package/modules/angular.esm.min.js +7 -4
  13. package/modules/angular.min.cjs +7 -4
  14. package/modules/angular.min.js +7 -4
  15. package/modules/chart-alluvial.esm.min.js +1 -1
  16. package/modules/chart-arc.esm.min.js +1 -1
  17. package/modules/chart-bubblemap.esm.min.js +1 -1
  18. package/modules/chart-bump.esm.min.js +1 -1
  19. package/modules/chart-calendar.esm.min.js +1 -1
  20. package/modules/chart-decomposition.esm.min.js +1 -1
  21. package/modules/chart-diverging.esm.min.js +1 -1
  22. package/modules/chart-dumbbell.esm.min.js +1 -1
  23. package/modules/chart-fan.esm.min.js +1 -1
  24. package/modules/chart-hexbin.esm.min.js +1 -1
  25. package/modules/chart-hexmap.esm.min.js +1 -1
  26. package/modules/chart-icicle.esm.min.js +1 -1
  27. package/modules/chart-parallel.esm.min.js +1 -1
  28. package/modules/chart-ridgeline.esm.min.js +1 -1
  29. package/modules/chart-roc.esm.min.js +1 -1
  30. package/modules/chart-slope.esm.min.js +1 -1
  31. package/modules/chart-splom.esm.min.js +1 -1
  32. package/modules/chart-waffle.esm.min.js +1 -1
  33. package/modules/charts.esm.min.js +15 -10
  34. package/modules/charts.min.cjs +15 -10
  35. package/modules/charts.min.js +15 -10
  36. package/modules/data-router.esm.min.js +37 -4
  37. package/modules/data-router.min.cjs +37 -4
  38. package/modules/data-router.min.js +37 -4
  39. package/modules/devtools.esm.min.js +2 -2
  40. package/modules/devtools.min.cjs +2 -2
  41. package/modules/devtools.min.js +2 -2
  42. package/modules/dhtmlx-compat.esm.min.js +4 -4
  43. package/modules/dhtmlx-compat.min.cjs +4 -4
  44. package/modules/dhtmlx-compat.min.js +4 -4
  45. package/modules/gantt.esm.min.js +4 -4
  46. package/modules/gantt.min.cjs +4 -4
  47. package/modules/gantt.min.js +4 -4
  48. package/modules/htmx.esm.min.js +1939 -920
  49. package/modules/htmx.min.cjs +1939 -920
  50. package/modules/htmx.min.js +1939 -920
  51. package/modules/kanban.esm.min.js +106 -25
  52. package/modules/kanban.min.cjs +106 -25
  53. package/modules/kanban.min.js +106 -25
  54. package/modules/kpi.esm.min.js +44 -7
  55. package/modules/kpi.min.cjs +44 -7
  56. package/modules/kpi.min.js +44 -7
  57. package/modules/layout.esm.min.js +4 -4
  58. package/modules/layout.min.cjs +4 -4
  59. package/modules/layout.min.js +4 -4
  60. package/modules/mock-socket.esm.min.js +2 -2
  61. package/modules/mock-socket.min.cjs +2 -2
  62. package/modules/mock-socket.min.js +2 -2
  63. package/modules/react.esm.min.js +7 -4
  64. package/modules/react.min.cjs +7 -4
  65. package/modules/react.min.js +7 -4
  66. package/modules/svelte.esm.min.js +7 -4
  67. package/modules/svelte.min.cjs +7 -4
  68. package/modules/svelte.min.js +7 -4
  69. package/modules/tabs.esm.min.js +4 -4
  70. package/modules/tabs.min.cjs +4 -4
  71. package/modules/tabs.min.js +4 -4
  72. package/modules/vue.esm.min.js +7 -4
  73. package/modules/vue.min.cjs +7 -4
  74. package/modules/vue.min.js +7 -4
  75. package/modules/webcomponent.esm.min.js +1942 -920
  76. package/modules/webcomponent.min.cjs +1942 -920
  77. package/modules/webcomponent.min.js +1942 -920
  78. package/package.json +3 -2
package/docs/API.html CHANGED
@@ -360,7 +360,7 @@
360
360
  <div class="shell">
361
361
  <aside class="rail">
362
362
  <p class="rail__brand">Lattice Grid</p>
363
- <p class="rail__sub">API reference · v1.55.0</p>
363
+ <p class="rail__sub">API reference · v1.57.0</p>
364
364
  <nav>
365
365
  <div class="rail__group">
366
366
  <span class="rail__label">Start</span>
@@ -443,7 +443,7 @@
443
443
  </header>
444
444
 
445
445
  <p class="chips">
446
- <span class="chip">Version 1.55.0</span>
446
+ <span class="chip">Version 1.57.0</span>
447
447
  <span class="chip">Zero dependencies</span>
448
448
  <span class="chip"><a href="api-detail.html">Developer guide &rarr;</a></span>
449
449
  </p>
@@ -571,17 +571,107 @@
571
571
  <tr>
572
572
  <td class="sig">createGrid(element, config?)</td>
573
573
  <td class="type">Grid</td>
574
- <td class="desc">Resolves the document from <code>element.ownerDocument</code>, so a grid inside an iframe uses that frame's document. Throws with a clear message if there is no DOM.</td>
574
+ <td class="desc">Resolves the document from <code>element.ownerDocument</code>, so a grid inside an iframe uses that frame's document. Throws with a clear message if there is no DOM. The exported name itself cannot be reassigned to wrap it — see the note below.</td>
575
575
  </tr>
576
576
  <tr>
577
577
  <td class="sig">createHeadlessGrid(config?)</td>
578
578
  <td class="type">Grid</td>
579
- <td class="desc">Core only. Everything below except <code>grid.element</code> and the DOM-only config keys works unchanged.</td>
579
+ <td class="desc">Core only. Everything below except <code>grid.element</code> and the DOM-only config keys works unchanged. What that does and doesn't reach is spelled out below.</td>
580
+ </tr>
581
+ <tr>
582
+ <td class="sig">defaults(config?)</td>
583
+ <td class="type">object</td>
584
+ <td class="desc">House-wide defaults, merged <em>beneath</em> the config of every grid built afterwards, through either factory. The per-grid value always wins. <code>defaults()</code> reads the current set; <code>defaults(null)</code> clears it. See the note below.</td>
580
585
  </tr>
581
586
  </tbody>
582
587
  </table>
583
588
  </div>
584
589
 
590
+ <div class="note" id="wrapping-creategrid">
591
+ <p><strong><code>createGrid</code> cannot be monkey-patched.</strong> Wherever it is exported —
592
+ <code>window.LatticeGrid.createGrid</code> from the UMD build, or the named import from the ESM
593
+ build — it is defined with <code>Object.defineProperty(..., { get, enumerable: true })</code>
594
+ and no setter, and <code>configurable</code> defaults to <code>false</code> because the
595
+ descriptor never sets it. Assigning to it in an ordinary (non-strict) script is not an error:
596
+ the assignment is simply discarded and <code>LatticeGrid.createGrid</code> still returns the
597
+ original function. In a module or any script under <code>'use strict'</code> — which every ES
598
+ module is — the same assignment throws <code>TypeError: Cannot set property createGrid of
599
+ [object Object] which has only a getter</code>. Either way, a house-wide patch applied this way
600
+ has no effect, and in the sloppy-mode case nothing tells you it didn't. There is no supported
601
+ way to replace the function in place. The supported pattern is a factory your own code owns:</p>
602
+ <pre><code><span class="cmt">// your-lattice.js — the one place that knows your house defaults</span>
603
+ <span class="kw">import</span> { createGrid <span class="kw">as</span> baseCreateGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
604
+
605
+ <span class="kw">export function</span> createGrid(element, config) {
606
+ <span class="kw">return</span> baseCreateGrid(element, { theme: 'house', density: 'compact', ...config });
607
+ }
608
+
609
+ <span class="cmt">// everywhere else</span>
610
+ <span class="kw">import</span> { createGrid } <span class="kw">from</span> './your-lattice.js';</code></pre>
611
+ <p>The wrapping function above is still a good seam when the wrapper does more than supply
612
+ options. When all it does is supply options, use <code>defaults()</code> instead: it applies to
613
+ every grid built afterwards through <em>either</em> factory, including the ones built for you
614
+ inside a framework adapter or a module, which a wrapper in your own code never reaches.</p>
615
+ <ul>
616
+ <li><strong>The per-grid config always wins.</strong> Defaults sit beneath what the factory
617
+ is passed: a key the grid names keeps the grid's value, a key it omits takes the house one.
618
+ A key passed as <code>undefined</code> means "say nothing" — as it does everywhere else in
619
+ the config surface — and so takes the house value rather than blanking it.</li>
620
+ <li><strong>Plain objects deep-merge; arrays and everything else replace.</strong> A house
621
+ <code>views: { storage }</code> and a grid's <code>views: { local: true }</code> both
622
+ survive. A grid's <code>columns</code> array replaces the house one rather than extending
623
+ it. Which keys behave as option bags follows from the value at the key, not from a fixed
624
+ list.</li>
625
+ <li><strong>Calling it again replaces the set, it does not accumulate</strong>, so the
626
+ result never depends on the order your modules load. Extend explicitly with
627
+ <code>defaults({ ...defaults(), density: 'compact' })</code>.</li>
628
+ <li><strong>Never retroactive.</strong> The merge happens as a grid is built, so a grid that
629
+ already exists is never revisited. Nothing reached from the defaults is shared between two
630
+ grids: nested objects and arrays are copied per grid.</li>
631
+ </ul>
632
+ <pre data-run="js" data-expect="Ada; own-key true; cleared {}" data-covers="export:defaults"><code><span class="kw">const</span> { createHeadlessGrid, defaults } = <span class="kw">await</span> import('../packages/core/src/index.js');
633
+
634
+ <span class="cmt">// One place says what every grid in this application starts from.</span>
635
+ defaults({ rowKey: 'id', selection: 'multiple' });
636
+
637
+ <span class="cmt">// This grid says nothing about rowKey, so the house value applies.</span>
638
+ <span class="kw">const</span> a = createHeadlessGrid({
639
+ columns: [{ field: 'id' }, { field: 'name' }],
640
+ rows: [{ id: 'a1', name: 'Ada' }],
641
+ });
642
+ <span class="kw">const</span> house = a.rows.byKey('a1').data.name;
643
+
644
+ <span class="cmt">// This one names its own rowKey. The grid's own config always wins.</span>
645
+ <span class="kw">const</span> b = createHeadlessGrid({
646
+ rowKey: 'sku',
647
+ columns: [{ field: 'sku' }],
648
+ rows: [{ sku: 's9', id: 'ignored' }],
649
+ });
650
+ <span class="kw">const</span> own = b.rows.byKey('s9') !== <span class="kw">undefined</span> &amp;&amp; b.rows.byKey('ignored') === <span class="kw">undefined</span>;
651
+
652
+ a.destroy();
653
+ b.destroy();
654
+ defaults(<span class="kw">null</span>); <span class="cmt">// clear: grids built after this are unaffected</span>
655
+ <span class="kw">return</span> `${house}; own-key ${own}; cleared ${JSON.stringify(defaults())}`;</code></pre>
656
+ </div>
657
+
658
+ <div class="note" id="headless-coverage">
659
+ <p><strong>What <code>createHeadlessGrid</code> covers, and what it cannot.</strong> It builds
660
+ the same core the DOM build attaches a renderer to, so everything that is not the renderer
661
+ itself is exercised exactly as it runs in a browser:</p>
662
+ <ul>
663
+ <li>Covered: data (<code>rows</code>, <code>columns</code>), state (<code>grid.state</code>,
664
+ saved views), sort, filter, group, total and pivot, formulas and computed columns, editing
665
+ and optimistic write-back, export, and every event the grid emits.</li>
666
+ <li>Not covered: the DOM renderer, layout and measurement (column widths, row heights,
667
+ scrolling), focus, and anything whose behaviour depends on a real box being painted on
668
+ screen — <code>grid.element</code> is <code>null</code> and there is nothing to measure.</li>
669
+ </ul>
670
+ <p>See <a href="api-detail.html#concepts">How it works</a> in the guide for the two specifics
671
+ that have cost real debugging time: a grid mounted where it has no rendered box, and what the
672
+ in-repo test DOM stub does and does not stand in for.</p>
673
+ </div>
674
+
585
675
  <h2 id="webcomponent">&lt;lattice-grid&gt; web component</h2>
586
676
  <p class="section-note">A self-contained module bundle that registers a custom element on import. One script, one tag, no build step, for Rails, Django, Laravel or any page without a bundler. Load this <em>or</em> <code>lattice-grid.esm.js</code>, not both: the module carries the grid with it.</p>
587
677
 
@@ -756,7 +846,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
756
846
  <tr><td class="name">columns</td><td class="type">(Column | ColumnGroup)[]</td><td class="dflt">, </td><td class="desc">Column definitions. Groups may nest.</td></tr>
757
847
  <tr><td class="name">columnGroups</td><td class="type">ColumnGroup[]</td><td class="dflt">, </td><td class="desc">Header grouping declared separately from the columns.</td></tr>
758
848
  <tr><td class="name">rows</td><td class="type">unknown[]</td><td class="dflt">, </td><td class="desc">Row objects. Held by reference; not copied.</td></tr>
759
- <tr><td class="name">rowKey</td><td class="type">string | (row) =&gt; string</td><td class="dflt">, </td><td class="desc">Stable row identity. Without it the grid assigns a key per row object and warns: enough for sorting, filtering, selection and copying within a session, but change tracking, streaming dedupe, selection persistence and remote reload all switch off, because new objects are new rows.</td></tr>
849
+ <tr><td class="name">rowKey</td><td class="type">string | (row) =&gt; string</td><td class="dflt">, </td><td class="desc">Stable row identity. Without it the grid assigns a key per row object and warns: enough for sorting, filtering, selection and copying within a session, but change tracking, streaming dedupe, selection persistence and remote reload all switch off, because new objects are new rows. If it is configured but resolves to nothing for some rows — a field absent, or present on some rows only — those rows collapse onto one key and the grid warns once, naming the field(s) and how many rows were affected, on <code>rows.load()</code> as well as at construction.</td></tr>
760
850
  <tr><td class="name">source</td><td class="type">SourceConfig</td><td class="dflt">memory</td><td class="desc">Where rows come from: <code>memory</code>, <code>paged</code>, <code>remote</code> or <code>stream</code>. See <a href="#sources">Sources</a>.</td></tr>
761
851
  <tr><td class="name">ingest</td><td class="type">IngestConfig</td><td class="dflt">, </td><td class="desc"><code>{ retainSource, dropSourceRows }</code>. How rows enter the column store. <code>retainSource</code> defaults to <code>true</code>: the caller's row objects are held by reference so <code>rows.data()</code> returns them unchanged and <code>row === sourceObject</code> holds. Set it <code>false</code> to stop the store retaining them and reconstruct a row on demand — but the source layer and grid config still hold the array, so the resident footprint does not actually fall. <code>dropSourceRows: true</code> closes that gap: it releases the objects from the source layer too, so the packed columns become the only copy and the footprint drops by roughly an order of magnitude at scale. Either way <code>rows.data()</code> then returns freshly reconstructed objects, so identity checks and <code>row.sourceObject</code> no longer hold and equality becomes value-based. Cell values are unchanged.</td></tr>
762
852
  <tr><td class="name">tree</td><td class="type">TreeConfig</td><td class="dflt">, </td><td class="desc"><code>{ path }</code> or <code>{ parentKey }</code>, plus <code>label</code>, <code>orphans</code>. Rows form a hierarchy. See <a href="api-detail.html#tree-data">Tree data</a>.</td></tr>
@@ -789,7 +879,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
789
879
  <table>
790
880
  <thead><tr><th>Property</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
791
881
  <tbody>
792
- <tr><td class="name">selection</td><td class="type">SelectionConfig | 'single' | 'multiple' | 'none'</td><td class="dflt">, </td><td class="desc">Object form adds <code>checkbox</code> (a pinned column of row checkboxes), <code>headerCheckbox</code> (tri-state select-all in its heading), <code>groupSelectsChildren</code>, <code>ranges</code>, <code>fillHandle</code>, <code>fill</code>. See <a href="api-detail.html#selection-checkbox">Selection and ranges</a>.</td></tr>
882
+ <tr><td class="name">selection</td><td class="type">SelectionConfig | 'single' | 'multiple' | 'none'</td><td class="dflt">, </td><td class="desc">Object form adds <code>checkbox</code> (a pinned column of row checkboxes), <code>headerCheckbox</code> (tri-state select-all in its heading), <code>checkboxOnly</code> (only that column may change selection — for a row with its own click action), <code>groupSelectsChildren</code>, <code>ranges</code>, <code>fillHandle</code>, <code>fill</code>. See <a href="api-detail.html#selection-checkbox">Selection and ranges</a>.</td></tr>
793
883
  <tr><td class="name">edit</td><td class="type">EditConfig | boolean</td><td class="dflt">, </td><td class="desc"><code>{ enabled, mode: 'cell' | 'row', start: 'single' | 'double' | 'key', enterMovesDown, undoDepth, commit, confirm, pendingTimeout, pastePreview }</code>. <code>commit</code>/<code>confirm</code>/<code>pendingTimeout</code> turn on optimistic writes; <code>pastePreview</code> (default off) shows a confirm/cancel diff before a bulk paste commits.</td></tr>
794
884
  <tr><td class="name">pagination</td><td class="type">PaginationConfig | boolean</td><td class="dflt">, </td><td class="desc">Local or remote paging.</td></tr>
795
885
  <tr><td class="name">quickFilterText</td><td class="type">string</td><td class="dflt">, </td><td class="desc">Initial quick-filter term. Equivalent to <code>grid.filters.quick(text)</code>.</td></tr>
@@ -905,7 +995,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
905
995
 
906
996
  <h2 id="column">Column definition</h2>
907
997
  <p class="section-note">Everything is optional. A column with only <code>field</code> infers its type from sampled data and takes every default from there.</p>
908
- <p><strong>Inferring a <code>Date</code>.</strong> Inference walks
998
+ <p><strong>Inferring a <code>Date</code>, or an ISO string.</strong> Inference walks
909
999
  <code>boolean</code>, <code>number</code>, <code>date</code>, <code>dateString</code>,
910
1000
  <code>datetime</code>, <code>text</code>, <code>object</code> and takes the first type that
911
1001
  matches every sampled value. A <code>Date</code> whose <em>local</em> wall clock reads exactly
@@ -913,17 +1003,27 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
913
1003
  a <code>Date</code> carrying any time of day infers as <code>datetime</code> and stores
914
1004
  <code>YYYY-MM-DDTHH:mm</code> (with seconds when they are non-zero), because a
915
1005
  <code>date</code> column would discard the clock on ingest and nothing downstream could
916
- recover it. <strong>This is a heuristic with one stated blind spot:</strong> a genuine
1006
+ recover it. <strong>An ISO string follows the same rule:</strong> a date-only string
1007
+ (<code>YYYY-MM-DD</code>) infers as <code>date</code>; a string with a time part
1008
+ (<code>T</code> plus a time, with or without a zone offset — <code>'2026-09-12T14:30:00Z'</code>)
1009
+ infers as <code>datetime</code> and keeps the time, and a column mixing both forms infers
1010
+ <code>datetime</code> rather than falling back to <code>text</code>. A timestamp such as
1011
+ <code>'2026-09-12T14:30:00Z'</code> therefore keeps its 14:30 on ingest, rather than being
1012
+ read as the calendar day <code>'2026-09-12'</code> with nothing to say that a time had been
1013
+ dropped. <strong>This is a heuristic with one stated blind spot:</strong> a genuine
917
1014
  timestamp that lands on exactly local midnight — a nightly batch stamped
918
- <code>00:00:00.000</code> — is indistinguishable from a date-only value and is still inferred
1015
+ <code>00:00:00.000</code>, or a bare <code>'2026-09-12'</code> string that really meant an
1016
+ instant — is indistinguishable from a date-only value and is still inferred
919
1017
  as <code>date</code>, so its time of day is still discarded. Sub-second resolution is never
920
1018
  retained by <code>datetime</code>. <strong>If you group by such a column, declare it:</strong> an
921
1019
  undeclared <code>Date</code> column groups by the instant, which is one group per row, where a
922
1020
  <code>type: 'date'</code> column groups into day buckets. Date filters are unaffected either way
923
1021
  — they compare on the day and return the same rows. Declare <code>type</code> and none of this applies:
924
1022
  <code>'date'</code> truncates on purpose, <code>'datetime'</code> keeps a wall clock, and
925
- <code>'timestamp'</code> keeps the instant to the millisecond. Strings are unaffected — an ISO
926
- string with or without a time component still infers as <code>date</code>.</p>
1023
+ <code>'timestamp'</code> keeps the instant to the millisecond. A column of ISO timestamps that
1024
+ must stay a calendar day opts out with an explicit <code>type: 'date'</code> — no warning is
1025
+ logged for that column, because the value is preserved (declared, not narrowed) and there is
1026
+ nothing to disclose.</p>
927
1027
 
928
1028
  <div class="table-wrap">
929
1029
  <table>
@@ -963,9 +1063,9 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
963
1063
  <table>
964
1064
  <thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
965
1065
  <tbody>
966
- <tr><td class="name">compute</td><td class="type">(deps, ctx) =&gt; unknown</td><td class="desc">Derived value. Receives only its declared dependencies.</td></tr>
967
- <tr><td class="name">deps</td><td class="type">string[] | '*'</td><td class="desc">Declared dependencies. Cycles are caught at compile time, not at render.</td></tr>
968
- <tr><td class="name">pure</td><td class="type">boolean</td><td class="desc">Allows caching. A DEV-mode proxy flags impure computes that read outside their deps.</td></tr>
1066
+ <tr><td class="name">compute</td><td class="type">(deps, ctx) =&gt; unknown</td><td class="desc">Derived value. Receives only its declared dependencies. A pure result is computed at ingest and cached; it is re-run when its row is replaced by <code>rows.apply({ update })</code> or <code>rows.load()</code>, when the grid a derived grid follows changes, and when the host asks with <code>rows.refresh({ rows, columns, force: true })</code>. An in-place cell edit to one of its <code>deps</code> does not currently re-run it. For an answer that arrives later (an id-to-name lookup, a rate table), return a placeholder, then call <code>rows.refresh({ rows, columns: [id], force: true })</code> once it resolves &mdash; or declare <code>pure: false</code>.</td></tr>
1067
+ <tr><td class="name">deps</td><td class="type">string[] | '*'</td><td class="desc">Declared dependencies. Cycles are caught at compile time, not at render. An edit to a column outside <code>deps</code> does not re-run a pure compute.</td></tr>
1068
+ <tr><td class="name">pure</td><td class="type">boolean</td><td class="desc">Default <code>true</code>: the result is cached and served until a dependency changes or a refresh forces it. <code>false</code> guarantees the compute is re-evaluated on every read and every paint &mdash; never served from a cache &mdash; and is the right declaration for a value that depends on something the grid cannot see. A DEV-mode proxy flags pure computes that read outside their deps.</td></tr>
969
1069
  <tr><td class="name">format</td><td class="type">(p) =&gt; string</td><td class="desc">Overrides the type's formatter.</td></tr>
970
1070
  <tr><td class="name">parse</td><td class="type">(p) =&gt; unknown</td><td class="desc">Editor output to value. Always called, whatever the editor emitted.</td></tr>
971
1071
  <tr><td class="name">apply</td><td class="type">(p) =&gt; boolean</td><td class="desc">Writes the value back into the row object.</td></tr>
@@ -1030,7 +1130,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
1030
1130
  <table>
1031
1131
  <thead><tr><th>Member</th><th>Returns</th><th>Description</th></tr></thead>
1032
1132
  <tbody>
1033
- <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.55.0'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
1133
+ <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.57.0'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
1034
1134
  <tr><td class="sig">get(key)</td><td class="type">unknown</td><td class="desc">Read any configuration key.</td></tr>
1035
1135
  <tr><td class="sig">set(key, value)</td><td class="type">void</td><td class="desc">Write one key. Every key is live; nothing needs a rebuild.</td></tr>
1036
1136
  <tr><td class="sig">setAll(values)</td><td class="type">void</td><td class="desc">Write several in one pass. Emits one <code>config:changed</code> for the batch, not one per key.</td></tr>
@@ -1080,7 +1180,7 @@ grid.overlay.hide();</code></pre>
1080
1180
  <tr><td class="sig">forEachExcept(colId, fn)</td><td class="type">void</td><td class="desc">Walks the rows surviving every filter <em>except</em> that column's own, the faceting question, asked of the rows. It is what lets a header histogram keep every bar after one is clicked, and what lets a <a href="#cross-filter">cross-filtering</a> panel avoid narrowing itself out of existence. Needs a memory source; anything else falls back to the filtered rows and warns.</td></tr>
1081
1181
  <tr><td class="sig">apply(change)</td><td class="type">object</td><td class="desc">Transactional add / update / remove. Needs <code>rowKey</code>.</td></tr>
1082
1182
  <tr><td class="sig">queue(change)</td><td class="type">void</td><td class="desc">Batches a change into the next frame, the high-frequency path.</td></tr>
1083
- <tr><td class="sig">refresh(opts)</td><td class="type">void</td><td class="desc">Force re-evaluation of computed values and cells.</td></tr>
1183
+ <tr><td class="sig">refresh(opts)</td><td class="type">void</td><td class="desc">Re-run computed values and repaint, without re-running sort, filter or grouping. <code>{ rows, columns }</code> narrows it to those cells; nothing named means every cell. Either way, the cached results for the named cells are discarded from every cache the grid keeps &mdash; the one behind <code>rows.text()</code> and the painted cell, and the one sort and filter read &mdash; so a non-stored computation is re-run on the next read, sort or filter. <code>force: true</code> also recomputes a <em>pure</em> (stored) computation for those cells and rewrites it, and repaints cells whose text did not change &mdash; the call to make when the answer changed for a reason the grid cannot see, such as an async lookup resolving. A column declared <code>pure: false</code> is never cached, so a plain <code>refresh()</code> is enough to show its new value. Without <code>force</code>, whether a <em>stored</em> (pure) computation is re-run for the named cells depends on the store the grid chose for the row count, and it flips at <code>columnarBelow</code>: below that many rows the named cell is re-run on its next read, at or above it the stored value stands until a <code>force: true</code>. Pass <code>force: true</code> when you want the same answer whatever the row count.</td></tr>
1084
1184
  <tr><td class="sig">move(key, to)</td><td class="type">{ moved, from, to, reason? }</td><td class="desc">Move a row to another position in the data. Refuses, naming the reason, while a sort, filter or grouping is active. Emits <code>row:moved</code>; persisting the new order is yours.</td></tr>
1085
1185
  <tr><td class="sig">groupHeadings(index)</td><td class="type">Row[]</td><td class="desc">The group rows enclosing a display index, outermost first. Empty when the grid is not grouped. Useful for a breadcrumb of your own.</td></tr>
1086
1186
  <tr><td class="sig">expand(key, deep?)</td><td class="type">void</td><td class="desc"></td></tr>
@@ -1124,7 +1224,7 @@ grid.overlay.hide();</code></pre>
1124
1224
  <tr><td class="sig">keys()</td><td class="type">string[]</td><td class="desc">Selected row keys.</td></tr>
1125
1225
  <tr><td class="sig">rows()</td><td class="type">Row[]</td><td class="desc"></td></tr>
1126
1226
  <tr><td class="sig">all()</td><td class="type">Row[]</td><td class="desc">Including rows selected but currently filtered out.</td></tr>
1127
- <tr><td class="sig">set(keys)</td><td class="type">void</td><td class="desc">Replace the selection.</td></tr>
1227
+ <tr><td class="sig">set(keys)</td><td class="type">void</td><td class="desc">Replace the selection. In <code>mode: 'single'</code>, only the <em>first</em> key of the array is kept; the rest are dropped, they are not an error.</td></tr>
1128
1228
  <tr><td class="sig">clear()</td><td class="type">void</td><td class="desc"></td></tr>
1129
1229
  <tr><td class="sig">ranges()</td><td class="type">Range[]</td><td class="desc">Cell ranges, for spreadsheet-style selection.</td></tr>
1130
1230
  <tr><td class="sig">setRange(range)</td><td class="type">void</td><td class="desc">Replace every range with one.</td></tr>
@@ -2843,10 +2943,12 @@ grid.destroy();
2843
2943
  <tr><td class="name">profile</td><td class="type">string | string[]</td><td class="desc">Replaces the pipeline with a transpose: one row per column, with count, present, missing, distinct, min, max, mean, median, quartiles, deviation and outlier count as its columns.</td></tr>
2844
2944
  <tr><td class="name">orient</td><td class="type">'columns' | 'metrics'</td><td class="desc">With <code>profile</code>, emit one row per statistic instead of one per column, the shape a dashboard tile wants.</td></tr>
2845
2945
  <tr><td class="name">crossFilter</td><td class="type">boolean | string</td><td class="desc">Let this grid filter the grid it derives from. <code>true</code> cross-filters through whatever it groups by; a string names a different source column.</td></tr>
2846
- <tr><td class="name">refresh</td><td class="type">'live' | 'idle' | 'manual' | number</td><td class="desc">When to re-derive. <code>idle</code> by default, coalescing to a frame, because a hundred cell updates in one frame are one derivation. A number debounces by that many milliseconds.</td></tr>
2946
+ <tr><td class="name">refresh</td><td class="type">'live' | 'idle' | 'manual' | number</td><td class="desc">When to re-derive. <code>idle</code> by default, coalescing to a frame, because a hundred cell updates in one frame are one derivation. A number debounces by that many milliseconds. <code>live</code> re-derives on every change. <code>manual</code> never re-derives on its own: the host triggers it by calling <code>rows.load()</code> on the derived grid, with no argument, which re-reads <code>from</code> there and then &mdash; <a href="api-detail.html#derived-manual-refresh">a frozen panel refreshed on a button press</a>, executed.</td></tr>
2847
2947
  </tbody>
2848
2948
  </table>
2849
2949
  </div>
2950
+ <pre><code><span class="cmt">// refresh: 'manual' — the summary re-derives only when the host asks.</span>
2951
+ refreshButton.addEventListener('click', () =&gt; summary.rows.load());</code></pre>
2850
2952
  <h3 id="derived-statistics">The relational statistics, as rows</h3>
2851
2953
  <p class="section-note">
2852
2954
  A single-column statistic already has a route: <code>select</code> reduces a group with any
@@ -4184,7 +4286,7 @@ off(); <span class="cmt">// on() returns i
4184
4286
  <tr><td class="name">scroll</td><td class="type">{ top, left }</td><td class="desc">Throttled to the frame.</td></tr>
4185
4287
  <tr><td class="name">scroll:end</td><td class="type">{}</td><td class="desc">Scrolling settled, the moment to trigger deferred work.</td></tr>
4186
4288
  <tr><td class="name">size:changed</td><td class="type">{}</td><td class="desc">The viewport resized.</td></tr>
4187
- <tr><td class="name">state:changed</td><td class="type">{ state, report }</td><td class="desc"><code>report</code> lists anything a restore could not apply.</td></tr>
4289
+ <tr><td class="name">state:changed</td><td class="type">{ cause, sections, state, report }</td><td class="desc">Every state change, gesture or API call, announced exactly once — with one known gap: a predicate registered through <code>filters.where()</code> changes the <code>where</code> section and the rows on screen without raising it (BACKLOG-0001235). <code>cause</code> is <code>'user'</code> (a sort, filter, column move, resize, pin or hide, a grouping, a pivot, a page), <code>'apply'</code> (<code>state.apply()</code>, including an undo and a <code>config.state</code> seed) or <code>'reset'</code> (<code>state.reset()</code>) — build view persistence on this one event and skip <code>'reset'</code>, or the default is written back over the view the user just left. <code>sections</code> names the <code>GridState</code> keys that moved. <code>state</code> and <code>report</code> are carried by an apply or a reset and are <code>null</code> for <code>'user'</code>: call <code>grid.state.get()</code>, which is permission-sanitised, when you write. Selection, scroll, expansion, facets and annotations do not raise it.</td></tr>
4188
4290
  <tr><td class="name">stream:chunk</td><td class="type">{ loaded, estimated, count, renders }</td><td class="desc">A streamed chunk landed.</td></tr>
4189
4291
  <tr><td class="name">stream:end</td><td class="type">{ loaded, promoted, threshold }</td><td class="desc">Streaming finished; <code>promoted</code> means it switched to in-memory.</td></tr>
4190
4292
  <tr><td class="name">source:error</td><td class="type">{ error, block?, range? }</td><td class="desc">A source or block load failed.</td></tr>
@@ -4234,6 +4336,10 @@ off(); <span class="cmt">// on() returns i
4234
4336
  <tr><td class="name">row:sent</td><td class="desc">A row was written back to a source.</td></tr>
4235
4337
  <tr><td class="name">row:copied</td><td class="desc">A row was duplicated.</td></tr>
4236
4338
  <tr><td class="name">row:moved</td><td class="desc">A row was dragged to a new position.</td></tr>
4339
+ <tr><td class="name">rowDrag:started</td><td class="type">{ key, data, over, at, overKey }</td><td class="desc">A row drag passed the drag threshold and began (BACKLOG-0001224). Fires on the grid the drag started in, as do the other three, for a same-grid reorder and a cross-grid transfer alike. See <a href="#type-RowDragEvent">RowDragEvent</a>.</td></tr>
4340
+ <tr><td class="name">rowDrag:moved</td><td class="type">{ key, data, over, at, overKey }</td><td class="desc">The drag is over a candidate position. <strong>Coalesced to one event per animation frame</strong>, carrying that frame's latest pointer position, so a handler runs at the display's rate rather than the pointer's several hundred a second. <code>over</code> is the grid under the pointer (null over none), <code>at</code> and <code>overKey</code> where the row would land in it.</td></tr>
4341
+ <tr><td class="name">rowDrag:left</td><td class="type">{ key, data, over, at, overKey }</td><td class="desc">The pointer left a grid: <code>over</code> names the grid it left, <code>at</code> and <code>overKey</code> are null. Emitted on the transition rather than on a frame, so un-highlighting is never a frame behind the pointer.</td></tr>
4342
+ <tr><td class="name">rowDrag:ended</td><td class="type">{ key, data, over, at, overKey, dropped }</td><td class="desc">The gesture ended, whether or not a drop followed — including a release outside every grid, where <code>over</code> is null. <code>dropped</code> is whether the release is being acted on; the outcome is reported by <code>row:moved</code>, <code>row:sent</code>, <code>row:received</code> and <code>rowReceive:cancelled</code>. These four are notifications: none is cancellable, because the drop is already vetoable by <code>beforeRowMove</code> and <code>beforeRowReceive</code>.</td></tr>
4237
4343
  <tr><td class="name">stream:evicted</td><td class="desc">A streaming source dropped rows to stay within its cap.</td></tr>
4238
4344
  <tr><td class="name">header:contextmenu</td><td class="desc">A heading was right-clicked.</td></tr>
4239
4345
  <tr><td class="name">timeline:attached</td><td class="desc">A time brush was connected to the grid.</td></tr>
@@ -4281,6 +4387,7 @@ off(); <span class="cmt">// on() returns i
4281
4387
  <tr><td class="name">beforeDelete</td><td class="type">{ key, rows, origin }</td><td class="desc">An optimistic row delete is about to apply — the canonical confirm-before-delete hook.</td></tr>
4282
4388
  <tr><td class="name">beforeRowMove</td><td class="type">{ key, from, to, origin }</td><td class="desc">A row reorder is about to apply.</td></tr>
4283
4389
  <tr><td class="name">beforeGroup</td><td class="type">{ key, expanded, origin }</td><td class="desc">A group/tree expand or collapse is about to apply.</td></tr>
4390
+ <tr><td class="name">beforeRowReceive</td><td class="type">{ data, at, overKey, source, origin }</td><td class="desc">A row dragged from another grid is about to be inserted into this one; fires on the <strong>receiving</strong> grid (BACKLOG-0001225). <code>overKey</code> is the key of the row under the pointer — null past the last row, on empty space, on the header or on a pinned row — <code>at</code> the display index it would take, <code>source</code> the grid it came from. A veto leaves the source untouched: the row stays and neither <code>row:sent</code> nor <code>row:copied</code> fires. See <a href="#type-BeforeRowReceiveEvent">BeforeRowReceiveEvent</a>.</td></tr>
4284
4391
  <tr><td class="name">edit:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeEdit</code> was vetoed. <code>reason</code> is <code>'stale'</code> when a live delta moved the cell during an async gate.</td></tr>
4285
4392
  <tr><td class="name">sort:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeSort</code> was vetoed.</td></tr>
4286
4393
  <tr><td class="name">filter:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeFilter</code> was vetoed.</td></tr>
@@ -4292,6 +4399,7 @@ off(); <span class="cmt">// on() returns i
4292
4399
  <tr><td class="name">delete:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeDelete</code> was vetoed. <code>reason</code> is <code>'stale'</code> when the row was already gone.</td></tr>
4293
4400
  <tr><td class="name">rowMove:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeRowMove</code> was vetoed. <code>reason</code> is <code>'stale'</code> when the row had moved.</td></tr>
4294
4401
  <tr><td class="name">group:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeGroup</code> was vetoed.</td></tr>
4402
+ <tr><td class="name">rowReceive:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeRowReceive</code> was vetoed; nothing was inserted and the source still holds the row. <code>reason</code> is <code>'stale'</code> when the row under the pointer had moved or was gone, or the source row was gone, by the time an async handler settled. See <a href="#type-RowReceiveCancelledEvent">RowReceiveCancelledEvent</a>.</td></tr>
4295
4403
  </tbody>
4296
4404
  </table>
4297
4405
  </div>
@@ -4332,7 +4440,7 @@ grid.destroy();
4332
4440
  settles, which is what makes a confirm dialog or a server check a genuine gate. On a veto the paired
4333
4441
  <code>&lt;action&gt;:cancelled</code> fires with the reason. Host/API writes and remote deltas do not
4334
4442
  fire them. Run headless on every build.</p>
4335
- <pre data-run="js" data-expect="0|Ann|sort,edit locked" data-covers="event:beforeEdit event:beforeSort event:beforeFilter event:beforeColumnMove event:beforeColumnResize event:beforeColumnHide event:beforeSelect event:beforeRowAdd event:beforeDelete event:beforeRowMove event:beforeGroup event:edit:cancelled event:sort:cancelled event:filter:cancelled event:columnMove:cancelled event:columnResize:cancelled event:columnHide:cancelled event:selection:cancelled event:rowAdd:cancelled event:delete:cancelled event:rowMove:cancelled event:group:cancelled event:print:before event:print:after event:export:request event:export:done event:shortcuts:opened event:shortcuts:closed"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4443
+ <pre data-run="js" data-expect="0|Ann|sort,edit locked" data-covers="event:beforeEdit event:beforeSort event:beforeFilter event:beforeColumnMove event:beforeColumnResize event:beforeColumnHide event:beforeSelect event:beforeRowAdd event:beforeDelete event:beforeRowMove event:beforeGroup event:edit:cancelled event:sort:cancelled event:filter:cancelled event:columnMove:cancelled event:columnResize:cancelled event:columnHide:cancelled event:selection:cancelled event:rowAdd:cancelled event:delete:cancelled event:rowMove:cancelled event:group:cancelled event:beforeRowReceive event:rowReceive:cancelled event:print:before event:print:after event:export:request event:export:done event:shortcuts:opened event:shortcuts:closed"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4336
4444
  <span class="kw">const</span> grid = createHeadlessGrid({
4337
4445
  rowKey: 'id',
4338
4446
  columns: [{ field: 'id' }, { field: 'name', edit: { enabled: <span class="kw">true</span> } }, { field: 'v', type: 'number', edit: { enabled: <span class="kw">true</span> } }],
@@ -4363,6 +4471,10 @@ grid.on('beforeRowMove', (e) =&gt; e.preventDefault('ordered'));
4363
4471
  grid.on('rowMove:cancelled', () =&gt; log.push('rowmove'));
4364
4472
  grid.on('beforeRowAdd', (e) =&gt; e.preventDefault('quota'));
4365
4473
  grid.on('rowAdd:cancelled', () =&gt; log.push('rowadd'));
4474
+ <span class="cmt">// A row dropped in from another grid: fires on the receiving grid, naming the</span>
4475
+ <span class="cmt">// row under the pointer, so "assign this to that" can veto the insert.</span>
4476
+ grid.on('beforeRowReceive', (e) =&gt; { <span class="kw">if</span> (e.overKey !== <span class="kw">null</span>) e.preventDefault('assigned'); });
4477
+ grid.on('rowReceive:cancelled', () =&gt; log.push('receive'));
4366
4478
  grid.on('beforeSelect', () =&gt; {});
4367
4479
  grid.on('selection:cancelled', () =&gt; log.push('sel'));
4368
4480
 
@@ -4616,7 +4728,7 @@ const chart = createChart({
4616
4728
  </table>
4617
4729
  </div>
4618
4730
  <p>A chart given data it cannot draw (a candlestick with three measures rather than four) says so on the chart rather than drawing nothing, because a chart that silently draws nothing is indistinguishable from one that is broken.</p>
4619
- <p><strong>Hierarchical data.</strong> The part-to-whole types read nested input from the grid's own grouping, not from the spec: with <code>grid.columns.group(['region', 'product'])</code> in place, the tree is that grouping, one level per grouped column in that order, and <code>x</code> is not consulted; <code>depth</code> caps how many levels are read. On a flat grid, <code>x</code> is the single level. What each type draws of that tree: a <code>pie</code> or <code>donut</code> draws the top level; a <code>sunburst</code> draws every level as a ring, each segment its share of the segment inside it, and names a segment on any ring where the name fits, leaving it unnamed where it does not; a <code>treemap</code> nests: children inside their parent's tile, each branch with a header band naming it and padding round its children, to the depth the tree has. A child whose tile would be smaller than a line of text is not drawn and its parent's tile stands for it, so the levels drawn are the levels that can be read; a small group at the top level is always drawn, as a labelled tile with no children inside it. <code>drill</code> descends the tree a level per click on either.</p>
4731
+ <p><strong>Hierarchical data.</strong> The part-to-whole types read nested input from the grid's own grouping, not from the spec: with <code>grid.columns.group(['region', 'product'])</code> in place, the tree is that grouping, one level per grouped column in that order, and <code>x</code> is not consulted; <code>depth</code> caps how many levels are read. On a flat grid, <code>x</code> is the single level. What each type draws of that tree: a <code>pie</code> or <code>donut</code> draws the top level; a <code>sunburst</code> draws every level as a ring, each segment its share of the segment inside it, and names a segment on any ring where the name fits, leaving it unnamed where it does not; a <code>treemap</code> nests: children inside their parent's tile, each branch with a header band naming it and padding round its children, to the depth the tree has. A child whose tile would be smaller than a line of text is not drawn and its parent's tile stands for it, so the levels drawn are the levels that can be read; a small group at the top level is always drawn, as a labelled tile with no children inside it. <code>drill</code> descends the tree on click, on either: clicking any tile or arc, at any depth, makes that node the root — a tile nested two levels down, or a segment on the outer ring, not only the top level — and the <code>drill</code> event carries the full <code>path</code> of labels from the root to it. <code>ascend()</code> comes back out a level at a time along the same path.</p>
4620
4732
 
4621
4733
  <h3>The spec</h3>
4622
4734
  <div class="table-wrap">
@@ -4661,6 +4773,48 @@ const chart = createChart({
4661
4773
  </tbody>
4662
4774
  </table>
4663
4775
  </div>
4776
+ <p class="section-note">
4777
+ <strong>A reduction over no readings is a gap, not a zero (BACKLOG-0001088).</strong>
4778
+ <code>sum</code>, <code>avg</code>/<code>mean</code>, <code>min</code>, <code>max</code>,
4779
+ <code>first</code> and <code>last</code> all answer <code>null</code> for a category whose
4780
+ rows carry no value to reduce, so the line breaks and the bar is absent rather than dropping
4781
+ to zero &mdash; a zero is a real reading, and drawing one where the data reported nothing
4782
+ would show a plunge that never happened. <code>count</code> and <code>countValues</code> are
4783
+ the deliberate exception: <code>count</code> tallies rows and <code>countValues</code> tallies
4784
+ the values actually present, so both are honestly zero when that is the true answer.
4785
+ <code>countValues</code> is the one to ask for when &ldquo;none arrived&rdquo; is the reading
4786
+ you want drawn as zero rather than as a break in the line.
4787
+ </p>
4788
+ <pre data-run="js" data-expect="10,null | 1,0" data-covers="export:bindSeries config:fn config:countValues"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4789
+ <span class="kw">const</span> { bindSeries } = <span class="kw">await</span> import('../packages/modules/charts/bind.js');
4790
+
4791
+ <span class="cmt">// 'a' has a reading; 'b' has a row, but the reading itself is absent.</span>
4792
+ <span class="kw">const</span> grid = createHeadlessGrid({
4793
+ rowKey: 'id',
4794
+ columns: [{ field: 'day', type: 'text' }, { field: 'sales', type: 'number' }],
4795
+ rows: [
4796
+ { id: 1, day: 'a', sales: 10 },
4797
+ { id: 2, day: 'b', sales: null },
4798
+ ],
4799
+ });
4800
+
4801
+ <span class="cmt">// avg over no readings is null: 'b' is a gap in the line, not a zero.</span>
4802
+ <span class="kw">const</span> gap = bindSeries(grid, { x: 'day', y: { col: 'sales', fn: 'avg' } });
4803
+ <span class="cmt">// countValues asks "how many arrived": honestly 0 for 'b', not null.</span>
4804
+ <span class="kw">const</span> none = bindSeries(grid, { x: 'day', y: { col: 'sales', fn: 'countValues' } });
4805
+ grid.destroy();
4806
+
4807
+ <span class="kw">return</span> [gap, none].map((bound) =&gt; bound.series[0].points.map((p) =&gt; String(p.y)).join(',')).join(' | ');</code></pre>
4808
+ <p class="section-note">
4809
+ <strong>A rolling <code>axis.x.window</code> that has aged past its data shows the empty
4810
+ state, not a picture drawn off-plot.</strong> The window's domain ends at the wall clock
4811
+ (see <code>window</code> under the <a href="#type-ChartAxis">axis</a> options below), so a
4812
+ feed that has gone quiet for longer than the window's span would otherwise have every mark
4813
+ fall outside the plot, with the axes and legend still drawn as if the chart were healthy.
4814
+ The chart shows its empty state instead and warns once <strong>per chart instance</strong>,
4815
+ naming the span and how old the newest reading is, so a dead feed reads as no data rather
4816
+ than as a chart that quietly stopped moving.
4817
+ </p>
4664
4818
 
4665
4819
  <h3>The chart</h3>
4666
4820
  <div class="table-wrap">
@@ -5511,7 +5665,9 @@ const board = createKanban(document.querySelector('#board'), {
5511
5665
  <tr><td class="sig">select / selection / isSelected / clearSelection</td><td class="desc">Card selection: <code>select(keys, 'set'|'add'|'toggle'|'remove')</code>, the selected keys, a membership test, and a clear. Click selects; Ctrl/Cmd toggles; Shift extends within a column.</td></tr>
5512
5666
  <tr><td class="sig">collapseColumn(id) / collapseLane(id)</td><td class="desc">Collapse, expand or toggle a column or swimlane; emits <code>column:collapse</code> / <code>swimlane:collapse</code>. State survives a keyed-diff update.</td></tr>
5513
5667
  <tr><td class="sig">reorderColumns(order) / moveColumn(id, before)</td><td class="desc">Reorder the columns (also done by dragging a column header); emits <code>column:reorder</code>.</td></tr>
5668
+ <tr><td class="sig">setColumns(defs)</td><td class="desc">Replace the whole column set after construction &mdash; a tenant renaming, adding or removing a column, in one call, without rebuilding the board. Id-keyed like the grid's own <code>columns.apply(state)</code>: a column that keeps its id keeps its cards, its collapsed state and its place in a pinned <code>reorderColumns</code> order; the quick filter and the selection are untouched. A column dropped from <code>defs</code> is not specially handled &mdash; a card whose value has nowhere configured to go re-derives a plain, humanised ad hoc column rather than being dropped, the same rule an always-unconfigured value already gets.</td></tr>
5514
5669
  <tr><td class="sig">setQuickFilter(text) / setFilter(fn) / facets(property)</td><td class="desc">Quick text search across card fields, a predicate filter, and distinct-value counts for a facet control.</td></tr>
5670
+ <tr><td class="sig">filters.where(name, fn) / filters.where(name, null) / filters.where() / filters.reapply(name?)</td><td class="desc">Named predicates, composed with AND (BACKLOG-0001229) &mdash; the grid's own <code>filters.where</code> convention. Register or replace one under <code>name</code>; <code>where(name, null)</code> removes only that one, leaving the others in force; <code>where()</code> lists the registered names; <code>reapply(name?)</code> re-runs and re-renders. The quick filter is untouched by any of this. <code>setFilter(fn)</code> is unchanged sugar for <code>where(filters.DEFAULT, fn)</code>, so it composes with any other named predicate instead of replacing it.</td></tr>
5515
5671
  <tr><td class="sig">setSprint(id) / showBacklog() / sprints()</td><td class="desc">Sprint view and switcher: show one sprint, the backlog (<code>board.BACKLOG</code> — cards with no sprint), or all; and the distinct sprint values. Emits <code>sprint:changed</code>.</td></tr>
5516
5672
  <tr><td class="sig">setEpic(id) / epics() / epicRollup() / rollup(property)</td><td class="desc">Epic view and rollup: filter to an epic, list epics, and roll rows up by epic (or any property) into count, points, and progress toward the <code>done</code> columns.</td></tr>
5517
5673
  <tr><td class="sig">expand(key) / closeDetail() / canExpand(card)</td><td class="desc">Pop a card's children out as a nested child grid (or board) in a drawer/modal/inline container; emits <code>card:expand</code> and <code>card:drill</code>.</td></tr>
@@ -5529,7 +5685,7 @@ const board = createKanban(document.querySelector('#board'), {
5529
5685
  </table>
5530
5686
  </div>
5531
5687
  <p><strong>Drag-and-drop and keyboard move (write-back).</strong> Cards drag between columns (writing <code>columnProperty</code>) and within a column into a position (writing <code>orderProperty</code> with fractional ranking, so only the moved cards' order is written). The same move is available from the keyboard: focus a card, press <kbd>Space</kbd> to grab it, use the arrows to choose a target column and position (announced on a live region), <kbd>Space</kbd>/<kbd>Enter</kbd> to drop, <kbd>Escape</kbd> to cancel. Multi-select drags every selected card. A move calls <code>onBeforeMove(card, from, to, index)</code> first — return <code>false</code> (or a promise of it) to veto — then persists: <strong>grid-bound</strong>, through <code>grid.edit.setCells</code> (the same public edit-commit path inline editing and the write-back adapter use, so the grid's own pipeline owns the optimistic apply, the confirm and the revert); <strong>standalone</strong>, optimistically with a revert when <code>onCardMove</code> returns <code>false</code>/rejects. A configurable per-card <code>contextMenu</code> (an array or <code>fn(card, selected)</code>) replaces the <code>card:contextmenu</code> event when present.</p>
5532
- <p><strong>Swimlanes, collapse, reorder and search.</strong> Set <code>swimlanes: true</code> to render a 2D lane&times;column grid grouped by <code>swimlaneProperty</code>: one band per lane with its own count/points, columns aligned across every lane, the board scrolling vertically through lanes and horizontally through columns inside its own box. A drag across lanes writes the swimlane property too. Columns and lanes collapse (their state survives a keyed-diff update); columns reorder by dragging their header (<code>reorderColumns</code>/<code>moveColumn</code>). <code>setQuickFilter(text)</code> searches across card fields, <code>setFilter(fn)</code> applies a predicate, and <code>facets(property)</code> returns distinct-value counts to build a facet control. Naming the <code>swimlaneProperty</code> is separate from turning on the lane view, so a board can carry it for a cross-lane move without switching layout.</p>
5688
+ <p><strong>Swimlanes, collapse, reorder and search.</strong> Set <code>swimlanes: true</code> to render a 2D lane&times;column grid grouped by <code>swimlaneProperty</code>: one band per lane with its own count/points, columns aligned across every lane, the board scrolling vertically through lanes and horizontally through columns inside its own box. A drag across lanes writes the swimlane property too. Columns and lanes collapse (their state survives a keyed-diff update); columns reorder by dragging their header (<code>reorderColumns</code>/<code>moveColumn</code>). <code>setQuickFilter(text)</code> searches across card fields, <code>setFilter(fn)</code> applies a predicate (sugar for <code>filters.where(filters.DEFAULT, fn)</code> &mdash; <code>filters.where(name, fn)</code> registers any number of independent named predicates, ANDed together), and <code>facets(property)</code> returns distinct-value counts to build a facet control. Naming the <code>swimlaneProperty</code> is separate from turning on the lane view, so a board can carry it for a cross-lane move without switching layout.</p>
5533
5689
  <p><strong>Sprint, epic and card pop-out.</strong> <code>setSprint(id)</code> shows one sprint, <code>showBacklog()</code> the cards with no sprint, and <code>sprints()</code> feeds a switcher; <code>setEpic(id)</code> narrows to an epic and <code>epicRollup()</code> (or <code>rollup(property)</code>) returns per-epic count, points and progress toward the <code>done</code> columns. A card can <strong>pop out a nested grid of its children</strong> — an epic's stories, a story's tasks, recursively. The child relationship is a <code>childrenProperty</code> (parent-id within the dataset) and/or a <code>loadChildren(card)</code> (per-card dataset or async fetch), and the child is a full composed <code>createGrid</code> (sort/filter/edit/write-back) — supplied as <code>children.factory</code> — opened in a <code>drawer</code> (default), <code>modal</code> or <code>inline</code>. With <code>children.asBoard</code> the child is itself a board, so it can pop its own children. This reuses the grid by composition and adds no grid-core coupling. <code>expand(key)</code> and the per-card drill affordance emit <code>card:expand</code>; a deeper open emits <code>card:drill</code>.</p>
5534
5690
  <p><strong>Live updates.</strong> Because the board consumes data through the same keyed-diff contract a grid does, a <a href="#datarouter">Data Router</a> drives it directly — <code>router.attach(board, predicate)</code> — and one feed fans out to a grid, a kanban, a chart and a KPI tile at once. A live <code>rows.apply({ add, update, remove })</code> is applied as a keyed diff (an unchanged card keeps its model) and re-rendered <strong>preserving</strong> scroll, focus, selection, collapsed columns/lanes and any open pop-out, so a card can appear, move or update under the user without losing their place.</p>
5535
5691
  <p><strong>Scale, state and accessibility.</strong> <code>virtualize</code> renders only a scroll window of a tall column (with true-height spacers so the scrollbar stays honest), for boards of thousands of cards. <code>getState()</code>/<code>setState()</code> (and <code>config.state</code>) save and restore the collapsed columns and lanes, the column order, the quick filter and the sprint/epic selection, so a reopened board comes back as it was; <code>setLoading</code>/<code>setError</code> add loading and error states. Accessibility runs throughout: the board is a labelled group of labelled column lists, cards are a roving-tabindex focus ring (arrows to move focus, Enter to activate), the move is fully keyboard-driven (<kbd>Space</kbd> grab, arrows for column/position, <kbd>Alt</kbd>+<kbd>↑/↓</kbd> across swimlanes, <kbd>Space</kbd>/<kbd>Enter</kbd> drop, <kbd>Escape</kbd> cancel) with live-region announcements, and every affordance carries a name.</p>
@@ -6308,6 +6464,9 @@ createGrid(el, {
6308
6464
  }],
6309
6465
  },
6310
6466
  });</code></pre>
6467
+ <p>An <code>icon</code> naming a sprite the registry does not have draws the blank glyph
6468
+ and logs a <code>[lattice]</code> warning once, naming the icon and how to register it
6469
+ (BACKLOG-0001211) — it does not fail silently as an empty, still-clickable button.</p>
6311
6470
 
6312
6471
  <h2 id="styling">Styling and your page's CSS</h2>
6313
6472
  <p><strong>Forced colours.</strong> In Windows High Contrast Mode the grid translates state that
@@ -6413,7 +6572,7 @@ createGrid(el, {
6413
6572
  <span class="chip">clock</span><span class="chip">lock</span><span class="chip">link</span><span class="chip">external</span><span class="chip">filter</span>
6414
6573
  <span class="chip">sortAsc</span><span class="chip">sortDesc</span><span class="chip">menu</span><span class="chip">drag</span>
6415
6574
  <span class="chip">star</span><span class="chip">heart</span><span class="chip">circleFilled</span><span class="chip">square</span><span class="chip">bolt</span><span class="chip">flag</span><span class="chip">thumbUp</span>
6416
- <span class="chip">eye</span><span class="chip">eyeOff</span><span class="chip">copy</span><span class="chip">blank</span>
6575
+ <span class="chip">eye</span><span class="chip">eyeOff</span><span class="chip">copy</span><span class="chip">present</span><span class="chip">blank</span>
6417
6576
  </div>
6418
6577
 
6419
6578
  <h2 id="operators">Filter grammar</h2>
@@ -6449,10 +6608,93 @@ createGrid(el, {
6449
6608
  </table>
6450
6609
  </div>
6451
6610
 
6611
+ <div class="note">
6612
+ <p><strong>An operator outside this list is refused, not applied (BACKLOG-0001180).</strong> <code>filters.set()</code> and <code>state.apply()</code> drop a condition whose <code>op</code> is not one of the operators above rather than installing it &mdash; it never reaches <code>filters.get()</code> and the grid is left exactly as filtered as it was before. A <code>[lattice]</code> warning names the operator received and the operators valid for that column's type. In a compound filter only the offending leaf is dropped; every other condition still applies.</p>
6613
+ </div>
6614
+
6452
6615
  <div class="note">
6453
6616
  <p><strong>One filter, not two.</strong> A condition set from a header popup, from the tool panel, or through <code>grid.filters.set()</code> all merge into the same tree. Reading <code>grid.filters.get()</code> always gives the whole truth.</p>
6454
6617
  </div>
6455
6618
 
6619
+ <h3 id="where-predicates">Host predicates: <code>where</code></h3>
6620
+ <p class="section-note">
6621
+ Some filters cannot be written as a condition, because what they test is not in any column:
6622
+ whether this user may see the row, whether you hold a rate for its currency, whether it is in
6623
+ the set your last API call returned. Register those as named predicates.
6624
+ </p>
6625
+
6626
+ <pre><code>grid.filters.where('visibleToMe', row =&gt; row.owner === me);
6627
+ grid.filters.where('rateKnown', row =&gt; rates.has(row.ccy), { deps: ['ccy'], pinned: <span class="kw">true</span> });
6628
+ grid.filters.where('visibleToMe', <span class="kw">null</span>); <span class="cmt">// remove</span>
6629
+ grid.filters.where(); <span class="cmt">// the registered names</span>
6630
+ grid.filters.reapply('rateKnown'); <span class="cmt">// re-run one</span>
6631
+ grid.filters.reapply(); <span class="cmt">// re-run all</span></code></pre>
6632
+
6633
+ <div class="note">
6634
+ <p><strong>Registering is activating.</strong> There is no "a filter is present" flag to keep in step, because that flag is the thing that goes wrong: it is a second piece of state describing the first, and when the two disagree the grid either filters while reporting that it is not, or reports a filter while every row passes. A predicate is in force from the moment it is registered until it is removed.</p>
6635
+ </div>
6636
+
6637
+ <p>Several are in force at once under their own names, ANDed with each other and with the
6638
+ condition tree; removing one leaves the rest alone. The predicate is handed the <strong>data
6639
+ row</strong>, the same shape <code>DerivedSourceConfig.where</code> receives.</p>
6640
+
6641
+ <div class="table-wrap">
6642
+ <table>
6643
+ <thead><tr><th>Option</th><th>Type</th><th>What it does</th></tr></thead>
6644
+ <tbody>
6645
+ <tr><td class="name">deps</td><td class="type">string[]</td><td class="desc">The columns the predicate reads, in the same spirit as <code>value.deps</code> on a computed column. Declared, the verdict is cached per row and re-run only when one of these columns changes on that row. Omitted, the predicate is treated as reading the whole row and runs on every pass &mdash; never stale, and never skipped either.</td></tr>
6646
+ <tr><td class="name">pinned</td><td class="type">boolean</td><td class="desc">Survive <code>filters.clear()</code>. For row-level permissions and tenant scoping, where a "clear filters" button must never widen what the user can see.</td></tr>
6647
+ <tr><td class="name">condition</td><td class="type">FilterSet</td><td class="desc">A declarative twin, pushed to the source while the function stays as the residual. It must be implied by the predicate: the grid ANDs both, so a twin wider than the function costs only time, while one narrower than it hides rows the function would have kept.</td></tr>
6648
+ </tbody>
6649
+ </table>
6650
+ </div>
6651
+
6652
+ <div class="note">
6653
+ <p><strong>Coming from AG Grid's external filter?</strong> The three pieces map onto two. <code>isExternalFilterPresent()</code> disappears &mdash; registration <em>is</em> presence. <code>doesExternalFilterPass(node)</code> becomes the named predicate you pass to <code>where</code>. <code>onFilterChanged()</code> becomes either <code>deps</code>, when what changed is a column the grid can watch, or <code>reapply(name?)</code>, when it is something the grid cannot see at all &mdash; a rate table arriving late, a permission refresh.</p>
6654
+ </div>
6655
+
6656
+ <div class="note">
6657
+ <p><strong>On a pushdown source the counts are page-relative, and the grid says so.</strong> A predicate is a function: no engine can evaluate it, so it always runs client-side, over the rows that came back. The grid warns once that match counts and totals are therefore relative to the fetched set, and names the fix &mdash; give the predicate a <code>condition</code> twin so the engine narrows the fetch itself. The paged and remote sources receive the twin but do <strong>not</strong> apply the predicate to their window: their rows are held in a block cache indexed by the server's own ranges and totals, so filtering a block client-side would leave <code>count()</code> disagreeing with what is painted.</p>
6658
+ </div>
6659
+
6660
+ <div class="note">
6661
+ <p><strong>Only names are state.</strong> <code>filters.get()</code> still returns exactly what the user set. <code>state.get()</code> carries <code>where: string[]</code> &mdash; the names in force &mdash; because a predicate is your code and cannot be serialised into a saved view or restored from one. <code>state.apply()</code> naming a predicate you have not registered <em>reports the skip</em> rather than installing anything, and never removes a predicate a saved view did not name.</p>
6662
+ </div>
6663
+
6664
+ <p class="section-note">A pinned permission filter and a "my items" toggle on one grid, executed on every build:</p>
6665
+ <pre data-run="js" data-expect="1|2|2|teamVisible|teamVisible" data-covers="config:deps config:pinned config:condition"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6666
+
6667
+ <span class="kw">const</span> me = 'ana';
6668
+ <span class="kw">const</span> grid = createHeadlessGrid({
6669
+ rowKey: 'id',
6670
+ columns: [{ field: 'team' }, { field: 'owner' }, { field: 'ccy' }],
6671
+ rows: [
6672
+ { id: '1', team: 'eu', owner: 'ana', ccy: 'USD' },
6673
+ { id: '2', team: 'us', owner: 'ana', ccy: 'USD' },
6674
+ { id: '3', team: 'eu', owner: 'bo', ccy: 'ZWL' },
6675
+ ],
6676
+ });
6677
+
6678
+ <span class="cmt">// Row-level permission. Pinned, so "clear filters" cannot widen it, and it</span>
6679
+ <span class="cmt">// carries a declarative twin a server can push.</span>
6680
+ grid.filters.where('teamVisible', (row) =&gt; row.team === 'eu', {
6681
+ pinned: <span class="kw">true</span>,
6682
+ condition: { col: 'team', op: 'eq', value: 'eu' },
6683
+ });
6684
+ <span class="cmt">// An ordinary "my items" toggle, re-run only when `owner` changes on a row.</span>
6685
+ grid.filters.where('myItems', (row) =&gt; row.owner === me, { deps: ['owner'] });
6686
+
6687
+ <span class="kw">const</span> both = grid.rows.count(); <span class="cmt">// permission AND my items</span>
6688
+ grid.filters.where('myItems', <span class="kw">null</span>); <span class="cmt">// toggle off</span>
6689
+ <span class="kw">const</span> afterToggleOff = grid.rows.count();
6690
+ grid.filters.clear(); <span class="cmt">// the pinned one survives</span>
6691
+ <span class="kw">const</span> afterClear = grid.rows.count();
6692
+ <span class="kw">const</span> stillOn = grid.filters.where().join(',');
6693
+ <span class="kw">const</span> inState = grid.state.get().where.join(',');
6694
+ grid.destroy();
6695
+
6696
+ <span class="kw">return</span> `${both}|${afterToggleOff}|${afterClear}|${stillOn}|${inState}`;</code></pre>
6697
+
6456
6698
  <h3 id="config-example">A configuration, executed</h3>
6457
6699
  <p class="section-note">This block runs on every build. If a key here stopped being honoured, or was
6458
6700
  renamed, the build would fail rather than the documentation quietly going stale.</p>
@@ -6593,6 +6835,38 @@ grid.state.reset(); <span class="cmt">// state:re
6593
6835
  grid.destroy();
6594
6836
  <span class="kw">return</span> raised;</code></pre>
6595
6837
 
6838
+ <h3 id="persistence-example">View persistence, executed</h3>
6839
+ <p class="section-note">One event carries every state change and says what caused it, so a save
6840
+ layer subscribes once and skips the restore-to-default — which would otherwise write the default
6841
+ straight back over the view the user had just abandoned.</p>
6842
+ <pre data-run="js" data-expect="user,apply,reset:2" data-covers="event:state:changed method:state method:on method:destroy"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6843
+
6844
+ <span class="kw">const</span> grid = createHeadlessGrid({
6845
+ rowKey: 'id',
6846
+ columns: [{ id: 'n', field: 'n' }, { id: 's', field: 's', type: 'number' }],
6847
+ rows: [{ id: '1', n: 'a', s: 3 }, { id: '2', n: 'b', s: 1 }],
6848
+ });
6849
+
6850
+ <span class="kw">const</span> causes = [];
6851
+ <span class="kw">let</span> writes = 0;
6852
+ grid.on('state:changed', (event) =&gt; {
6853
+ causes.push(event.cause);
6854
+ <span class="cmt">// The one cause a save must ignore: persisting a reset writes the default</span>
6855
+ <span class="cmt">// back over the view the user has just abandoned.</span>
6856
+ <span class="kw">if</span> (event.cause === 'reset') <span class="kw">return</span>;
6857
+ <span class="cmt">// A real host debounces, then writes grid.state.get() — which is</span>
6858
+ <span class="cmt">// permission-sanitised, unlike the raw capture.</span>
6859
+ writes++;
6860
+ });
6861
+
6862
+ grid.sort.set([{ col: 's', dir: 'asc' }]); <span class="cmt">// cause: 'user', sections: ['sort']</span>
6863
+ grid.state.apply({ version: 2, sort: [] }); <span class="cmt">// cause: 'apply', with a report</span>
6864
+ grid.state.reset(); <span class="cmt">// cause: 'reset' — deliberately not saved</span>
6865
+
6866
+ <span class="kw">const</span> result = `${causes.join(',')}:${writes}`;
6867
+ grid.destroy();
6868
+ <span class="kw">return</span> result;</code></pre>
6869
+
6596
6870
  <h3 id="methods-example">Every grid namespace and method, executed</h3>
6597
6871
  <p class="section-note">Reading a namespace builds it, so this proves each is reachable rather than
6598
6872
  declared and absent. The plain methods are called, not merely named.</p>
@@ -6788,7 +7062,7 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
6788
7062
  <h3 id="nested-config-example">Nested configuration, executed</h3>
6789
7063
  <p class="section-note">Thirteen option blocks, each key written where it belongs. Parsed and evaluated on
6790
7064
  every build, so a key that was renamed or moved shows up here.</p>
6791
- <pre data-run="js" data-expect="14" data-covers="config:aboveLimit config:adapter config:ageBy config:binary config:bucket config:bucketFn config:buckets config:cacheLimit config:cardinalityLimit config:checkbox config:coalesceMs config:commit config:confirm config:cumulative config:debounce config:decimals config:delimiter config:display config:download config:enabled config:enterMovesDown config:fetch config:fileName config:fill config:fillHandle config:follow config:format config:from config:granularity config:groupBy config:hasChildren config:headerCheckbox config:headers config:hint config:idleMs config:indexLimit config:isMaster config:join config:label config:limit config:limitPer config:lineEnding config:loadChildren config:lock config:lockMs config:markdown config:maxCachedPages config:maxAge config:maxDecimals config:maxRows config:me config:minDecimals config:mode config:onCreate config:open config:orient config:orphans config:pageSize config:palette config:parentKey config:path config:pendingTimeout config:placement config:processCell config:profile config:promoteToMemoryBelow config:provider config:quote config:ranges config:removeMs config:retainSource config:roster config:scale config:select config:space config:start config:strategy config:system config:target config:throttleMs config:undoDepth config:unit config:unnest config:where"><code><span class="cmt">// Placeholders for the things a real page supplies. The point of this block is</span>
7065
+ <pre data-run="js" data-expect="14" data-covers="config:aboveLimit config:adapter config:ageBy config:binary config:bucket config:bucketFn config:buckets config:cacheLimit config:cardinalityLimit config:checkbox config:checkboxOnly config:coalesceMs config:commit config:confirm config:cumulative config:debounce config:decimals config:delimiter config:display config:download config:enabled config:enterMovesDown config:fetch config:fileName config:fill config:fillHandle config:follow config:format config:from config:granularity config:groupBy config:hasChildren config:headerCheckbox config:headers config:hint config:idleMs config:indexLimit config:isMaster config:join config:label config:limit config:limitPer config:lineEnding config:loadChildren config:lock config:lockMs config:markdown config:maxCachedPages config:maxAge config:maxDecimals config:maxRows config:me config:minDecimals config:mode config:onCreate config:open config:orient config:orphans config:pageSize config:palette config:parentKey config:path config:pendingTimeout config:placement config:processCell config:profile config:promoteToMemoryBelow config:provider config:quote config:ranges config:removeMs config:retainSource config:roster config:scale config:select config:space config:start config:strategy config:system config:target config:throttleMs config:undoDepth config:unit config:unnest config:where"><code><span class="cmt">// Placeholders for the things a real page supplies. The point of this block is</span>
6792
7066
  <span class="cmt">// the option names: each one below is a documented key, written where it</span>
6793
7067
  <span class="cmt">// belongs, so a key that was renamed or moved stops matching its interface.</span>
6794
7068
  <span class="kw">const</span> source = {}, other = {}, provider = {}, compute = {}, adapter = {};
@@ -6808,7 +7082,7 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
6808
7082
  enterMovesDown: true, undoDepth: 50, pendingTimeout: 2000 };
6809
7083
 
6810
7084
  <span class="cmt">// Selection — SelectionConfig</span>
6811
- <span class="kw">const</span> selectionConfig = { checkbox: true, headerCheckbox: true, ranges: true, fill: true, fillHandle: true };
7085
+ <span class="kw">const</span> selectionConfig = { checkbox: true, headerCheckbox: true, checkboxOnly: true, ranges: true, fill: true, fillHandle: true };
6812
7086
 
6813
7087
  <span class="cmt">// Tree data — TreeConfig</span>
6814
7088
  <span class="kw">const</span> treeConfig = { parentKey: 'parentId', path: 'path', orphans: 'root',
@@ -6903,7 +7177,7 @@ grid.destroy();
6903
7177
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
6904
7178
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
6905
7179
  has not fired yet — so the name is checked rather than left to be discovered.</p>
6906
- <pre data-run="js" data-expect="108" data-covers="event:find:changed event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
7180
+ <pre data-run="js" data-expect="112" data-covers="event:rowDrag:started event:rowDrag:moved event:rowDrag:left event:rowDrag:ended event:find:changed event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6907
7181
 
6908
7182
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
6909
7183
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -6937,6 +7211,7 @@ grid.destroy();
6937
7211
  'tree:loadFailed', 'tree:loaded', 'tree:loading', 'view:applied',
6938
7212
  'view:default', 'view:removed', 'view:renamed', 'view:saved',
6939
7213
  'views:changed',
7214
+ 'rowDrag:started', 'rowDrag:moved', 'rowDrag:left', 'rowDrag:ended',
6940
7215
  ];
6941
7216
 
6942
7217
  <span class="kw">const</span> grid = createHeadlessGrid({
@@ -7384,6 +7659,19 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7384
7659
  </tbody>
7385
7660
  </table>
7386
7661
  </div>
7662
+ <h3 id="type-BeforeRowReceiveEvent">BeforeRowReceiveEvent</h3>
7663
+ <p class="section-note">The `beforeRowReceive` event (BACKLOG-0001225): a row dragged from another grid is about to be inserted into this one. Fires on the **receiving** grid, before the insert, with the row under the pointer named — so a drop that means "assign this to that" can be recorded by the host and the insert stopped with `preventDefault(reason)`. A veto leaves the source grid untouched: the row stays where it was, and neither `row:sent` nor `row:copied` fires there. The source removes its row only after the target has admitted it, and a veto is a refusal to admit. The paired `rowReceive:cancelled` carries the same context plus the reason. Like every {@link BeforeEvent}, the handler may be `async`; the insert is held until it settles, and is cancelled as `'stale'` (BACKLOG-0001242) if the source row is gone by then, or if the row under the pointer is gone or has moved to a different index — `at` names a slot as "before `overKey`", and once that is no longer where `overKey`'s row sits, `at` is a stale index into a list that changed while the handler was thinking, not the slot the drop meant. `overKey: null` (the drop landed on no row) has no row to drift against and is never stale on that account.</p>
7664
+ <div class="table-wrap">
7665
+ <table>
7666
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7667
+ <tbody>
7668
+ <tr><td class="name">data</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc">The row about to be inserted: a shallow copy of the source row's data, and the very object that is inserted if no handler vetoes, so a change made to it here lands with the row.</td></tr>
7669
+ <tr><td class="name">at</td><td class="type">number</td><td class="desc">The display index the row would be inserted at: the index of the row under the pointer, or `rows.count()` when the drop landed on no row. When `overKey` names a row, this is guaranteed to still be that row's index at the moment the insert actually runs — an async handler that leaves the named row at a different index causes the drop to be cancelled as `'stale'` (BACKLOG-0001242) rather than inserted at this index regardless.</td></tr>
7670
+ <tr><td class="name">overKey</td><td class="type">string | null</td><td class="desc">The key of the row under the pointer when the drop happened — the row the user meant. Null when the drop landed past the last row, on empty space, on the header, or on a pinned row: there is no row to name, and a nearest guess would be wrong in a way that looks right.</td></tr>
7671
+ <tr><td class="name">source</td><td class="type">Grid</td><td class="desc">The grid the row is being dragged from.</td></tr>
7672
+ </tbody>
7673
+ </table>
7674
+ </div>
7387
7675
  <h3 id="type-BooleanFormat">BooleanFormat</h3>
7388
7676
  <div class="table-wrap">
7389
7677
  <table>
@@ -7870,7 +8158,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7870
8158
  <tr><td class="name">template</td><td class="type">string</td><td class="desc">Not read by the header renderer; use `render` to draw a custom heading. <small>(optional)</small></td></tr>
7871
8159
  <tr><td class="name">render</td><td class="type">string | RendererCtor</td><td class="desc">A custom heading renderer: a function, or a component (a class with a `render` method). A string names a registered renderer. Either form draws the same two ways and they are interchangeable — it may append to the passed label element itself and return nothing, or return an `Element` (attached for you) or a `string` (used as the heading text). <small>(optional)</small></td></tr>
7872
8160
  <tr><td class="name">props</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc">Props passed to `render` as `params.props`. <small>(optional)</small></td></tr>
7873
- <tr><td class="name">class</td><td class="type">string | string[]</td><td class="desc">A class, or classes, added to the heading cell. <small>(optional)</small></td></tr>
8161
+ <tr><td class="name">class</td><td class="type">string | string[]</td><td class="desc">A class, or classes, added to the heading cell. A string may hold several space-separated tokens (`'a b'`), each applied individually. <small>(optional)</small></td></tr>
7874
8162
  <tr><td class="name">tooltip</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
7875
8163
  <tr><td class="name">align</td><td class="type">Align</td><td class="desc"><small>(optional)</small></td></tr>
7876
8164
  </tbody>
@@ -8311,7 +8599,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8311
8599
  <tr><td class="name">outline</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
8312
8600
  <tr><td class="name">edge</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
8313
8601
  <tr><td class="name">position</td><td class="type">'start' | 'end'</td><td class="desc"><small>(optional)</small></td></tr>
8314
- <tr><td class="name">name</td><td class="type">string | Record&lt;string, string&gt;</td><td class="desc"><small>(optional)</small></td></tr>
8602
+ <tr><td class="name">name</td><td class="type">IconName | Record&lt;string, IconName&gt;</td><td class="desc">`icon` decoration only: either a single glyph name (see {@link IconName}) used for every value, or a value -&gt; glyph name map for exact-value icons. Omit both `name` and `bands` to use `iconSet`/its default instead. <small>(optional)</small></td></tr>
8315
8603
  <tr><td class="name">iconSet</td><td class="type">IconSetName</td><td class="desc">icon only: a built-in threshold icon set, expanded to `bands`. <small>(optional)</small></td></tr>
8316
8604
  <tr><td class="name">bands</td><td class="type">IconBand[]</td><td class="desc">icon only: value bands mapped to glyphs, first match by descending `min`. <small>(optional)</small></td></tr>
8317
8605
  <tr><td class="name">min</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
@@ -8407,8 +8695,8 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8407
8695
  <tr><td class="name">cumulative</td><td class="type">{ of: string; upTo: number }</td><td class="desc">Keep rows until their running share of the total reaches `upTo`, 0 to 1. <small>(optional)</small></td></tr>
8408
8696
  <tr><td class="name">profile</td><td class="type">string | string[]</td><td class="desc">One row per column, with the statistics as columns. Replaces the pipeline. <small>(optional)</small></td></tr>
8409
8697
  <tr><td class="name">orient</td><td class="type">'columns' | 'metrics'</td><td class="desc">With `profile`, emit one row per statistic instead of one per column. <small>(optional)</small></td></tr>
8410
- <tr><td class="name">statistics</td><td class="type">DerivedStatistics</td><td class="desc">Project a **relational** statistic into rows (BACKLOG-0001046): the figures that need two or more columns, or a second grid, and so cannot be reached through `select`. Every *single-column* statistic already has a route and this is not it — the derived `select` reduces a group by any kernel the totals row uses, and that table is a superset of the statistics one, so `select: { p95: { of: 'amount', fn: 'p95' } }` (or `gini`, `stddev`, `median`, `trimmedMean`, …) works today. Reach for `statistics` only when the answer is a correlation, a series summary or a comparison against another dataset. **A terminal producer, like `profile`, not a pipeline stage.** A correlation is one row per column *pair*, a series summary one row per *metric*, a comparison one row per compared *column* — none of which is one row per group, so there is no position in `unnest → where → bucket → groupBy → select → sort → limit` for it to occupy. It replaces the pipeline, and those keys are ignored with a warning naming them (BACKLOG-0001092) rather than silently discarded. Sort, filter or limit the derived grid itself instead, or chain a second derived grid whose `from` is this one. **`profile` and `statistics` are mutually exclusive** and declaring both is refused, by name, when the source is built. **Not supported alongside a union `from`** — a relational statistic reduces one grid's own columns and a union has no single set of them; also refused by name. **Cost.** Like every terminal producer this never patches incrementally: a change on the parent re-derives the whole thing. `correlation` additionally scans the rows once *per pair*, so N columns cost N·(N−1)/2 passes. Use `refresh` (`'idle'` is the default; `'manual'` or a debounce in ms for an expensive analysis over a live feed) — see `docs/api-detail.html` for the measured figures. Every row carries `n`, the rows the figure covered, because a derived statistic travels into an export or a chart without its grid and "r = 0.98 over eleven rows" is a different claim from the same number over eleven thousand. It does NOT carry a windowed/approximate flag: whether a source held fewer rows than matched its filters is decided from the source's own counters, which a derived source cannot reach, so that signal stays where it already works - the `stat.windowed:*` console warning the parent grid emits. <small>(optional)</small></td></tr>
8411
- <tr><td class="name">refresh</td><td class="type">'live' | 'idle' | 'manual' | number</td><td class="desc">When to re-derive. `idle` by default: coalesced to a frame. <small>(optional)</small></td></tr>
8698
+ <tr><td class="name">statistics</td><td class="type">DerivedStatistics</td><td class="desc">Project a **relational** statistic into rows (BACKLOG-0001046): the figures that need two or more columns, or a second grid, and so cannot be reached through `select`. Every *single-column* statistic already has a route and this is not it — the derived `select` reduces a group by any kernel the totals row uses, and that table is a superset of the statistics one, so `select: { p95: { of: 'amount', fn: 'p95' } }` (or `gini`, `stddev`, `median`, `trimmedMean`, …) works today. Reach for `statistics` only when the answer is a correlation, a series summary or a comparison against another dataset. **A terminal producer, like `profile`, not a pipeline stage.** A correlation is one row per column *pair*, a series summary one row per *metric*, a comparison one row per compared *column* — none of which is one row per group, so there is no position in `unnest → where → bucket → groupBy → select → sort → limit` for it to occupy. It replaces the pipeline, and those keys are ignored with a warning naming them (BACKLOG-0001092) rather than silently discarded. Sort, filter or limit the derived grid itself instead, or chain a second derived grid whose `from` is this one. **`profile` and `statistics` are mutually exclusive** and declaring both is refused, by name, when the source is built. **Not supported alongside a union `from`** — a relational statistic reduces one grid's own columns and a union has no single set of them; also refused by name. **Cost.** Like every terminal producer this never patches incrementally: a change on the parent re-derives the whole thing. `correlation` additionally scans the rows once *per pair*, so N columns cost N·(N−1)/2 passes. Use `refresh` (`'idle'` is the default; `'manual'` or a debounce in ms for an expensive analysis over a live feed; under `'manual'` the host re-derives by calling `rows.load()` on the derived grid) — see `docs/api-detail.html` for the measured figures. Every row carries `n`, the rows the figure covered, because a derived statistic travels into an export or a chart without its grid and "r = 0.98 over eleven rows" is a different claim from the same number over eleven thousand. It does NOT carry a windowed/approximate flag: whether a source held fewer rows than matched its filters is decided from the source's own counters, which a derived source cannot reach, so that signal stays where it already works - the `stat.windowed:*` console warning the parent grid emits. <small>(optional)</small></td></tr>
8699
+ <tr><td class="name">refresh</td><td class="type">'live' | 'idle' | 'manual' | number</td><td class="desc">When to re-derive. `idle` by default: coalesced to a frame. A number debounces by that many milliseconds; `live` re-derives on every change. `manual` never re-derives on its own: the host triggers it by calling `rows.load()`, with no argument, on the derived grid - from a Refresh button, say. Each call re-reads `from` there and then and replaces the rows; a derived grid takes its rows from `from`, so anything passed to `load` is not used. Executed example: `docs/api-detail.html#derived-manual-refresh`. <small>(optional)</small></td></tr>
8412
8700
  <tr><td class="name">crossFilter</td><td class="type">boolean | string | { col?: string }</td><td class="desc">Let this grid filter the grid it derives from. `true` cross-filters through whatever it groups by; a string names a different source column. <small>(optional)</small></td></tr>
8413
8701
  </tbody>
8414
8702
  </table>
@@ -8781,8 +9069,11 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8781
9069
  <tr><td class="name">quickState</td><td class="type">(): { text: string; mode: string }</td><td class="desc">The quick filter's text and match mode, for restoring a control.</td></tr>
8782
9070
  <tr><td class="name">get</td><td class="type">(): FilterSet</td><td class="desc"></td></tr>
8783
9071
  <tr><td class="name">set</td><td class="type">(filters: FilterSet): void</td><td class="desc"></td></tr>
8784
- <tr><td class="name">clear</td><td class="type">(): void</td><td class="desc"></td></tr>
9072
+ <tr><td class="name">clear</td><td class="type">(): void</td><td class="desc">Drop the condition tree, the quick filter, and every `where` predicate that was not registered `{ pinned: true }`.</td></tr>
8785
9073
  <tr><td class="name">quick</td><td class="type">(text: string): void</td><td class="desc"></td></tr>
9074
+ <tr><td class="name">where</td><td class="type">(): string[]</td><td class="desc">The names of the `where` predicates in force, in registration order.</td></tr>
9075
+ <tr><td class="name">where</td><td class="type">(name: string, predicate: ((row: any) =&gt; boolean) | null, opts?: WhereOptions): void</td><td class="desc">Register, replace or remove a named row predicate composed with the filter set (BACKLOG-0001202). Registering *is* activating: there is no companion "a predicate is present" flag to keep in sync, which is the failure mode this replaces. Several may be in force at once under their own names, ANDed with each other and with the declarative set, and removing one leaves the rest alone. The predicate is handed the **data row**. grid.filters.where('visibleToMe', row =&gt; row.owner === me); grid.filters.where('rateKnown', row =&gt; rates.has(row.ccy), { deps: ['ccy'], pinned: true }); grid.filters.where('visibleToMe', null); // remove Only the names reach `filters.get()` and `state.get()`; the functions never do.</td></tr>
9076
+ <tr><td class="name">reapply</td><td class="type">(name?: string): boolean</td><td class="desc">Re-run `where` predicates whose inputs changed where the grid could not see it — a rate table that arrived late, a permission set that refreshed. The out-of-band half of re-evaluation; `deps` is the half the grid observes for itself. Together they replace the manual "filter again" call.</td></tr>
8786
9077
  </tbody>
8787
9078
  </table>
8788
9079
  </div>
@@ -9054,7 +9345,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9054
9345
  <tr><td class="name">columns</td><td class="type">(Column | ColumnGroup)[]</td><td class="desc">The columns, in order. A group nests columns under one heading. <small>(optional)</small></td></tr>
9055
9346
  <tr><td class="name">columnGroups</td><td class="type">ColumnGroup[]</td><td class="desc">Header groups declared separately from the columns they contain. <small>(optional)</small></td></tr>
9056
9347
  <tr><td class="name">rows</td><td class="type">unknown[]</td><td class="desc">The data, for a memory grid. Use `source` for anything fetched. <small>(optional)</small></td></tr>
9057
- <tr><td class="name">rowKey</td><td class="type">string | ((row: unknown) =&gt; string)</td><td class="desc">What identifies a row. Everything that survives a refresh (selection, expansion, and edits in flight) is keyed on it, so it must be stable and unique. A derived grid defaults to its own derived key. <small>(optional)</small></td></tr>
9348
+ <tr><td class="name">rowKey</td><td class="type">string | string[] | ((row: unknown) =&gt; string | string[])</td><td class="desc">What identifies a row. Everything that survives a refresh (selection, expansion, and edits in flight) is keyed on it, so it must be stable and unique. A derived grid defaults to its own derived key. Three shapes: a field name (`'id'`, dot paths allowed); an array of field names, joined into one composite key (`['tenantId', 'circuitId']`); or a function of the row (`row =&gt; \`${row.tenantId}#${row.circuitId}\``), itself allowed to return an array to the same effect. <small>(optional)</small></td></tr>
9058
9349
  <tr><td class="name">source</td><td class="type">SourceConfig</td><td class="desc">Where rows come from: memory, paged, remote, stream or derived. <small>(optional)</small></td></tr>
9059
9350
  <tr><td class="name">ingest</td><td class="type">IngestConfig</td><td class="desc">How rows are ingested into the column store. <small>(optional)</small></td></tr>
9060
9351
  <tr><td class="name">columnDefaults</td><td class="type">Column</td><td class="desc">Applied to every column before its own settings. <small>(optional)</small></td></tr>
@@ -9068,7 +9359,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9068
9359
  <tr><td class="name">variants</td><td class="type">Record&lt;string, VariantDefinition&gt;</td><td class="desc">Named appearance variants a row or cell can be switched into by a rule. <small>(optional)</small></td></tr>
9069
9360
  <tr><td class="name">tree</td><td class="type">TreeConfig</td><td class="desc">Hierarchical rows: where the parent link or the path lives. <small>(optional)</small></td></tr>
9070
9361
  <tr><td class="name">detail</td><td class="type">DetailConfig</td><td class="desc">The expandable panel beneath a row. <small>(optional)</small></td></tr>
9071
- <tr><td class="name">selection</td><td class="type">SelectionConfig | 'single' | 'multiple' | 'none'</td><td class="desc">What the user may select, and how selection behaves across groups. <small>(optional)</small></td></tr>
9362
+ <tr><td class="name">selection</td><td class="type">SelectionConfig | 'single' | 'multiple' | 'none'</td><td class="desc">What the user may select, and how selection behaves across groups. The `'none'` shorthand is `{ mode: 'none' }` and behaves identically: no row selection, and no cell ranges or fill handle either. <small>(optional)</small></td></tr>
9072
9363
  <tr><td class="name">edit</td><td class="type">EditConfig | boolean</td><td class="desc">Editing, and how a change is committed and validated. <small>(optional)</small></td></tr>
9073
9364
  <tr><td class="name">pagination</td><td class="type">PaginationConfig | boolean</td><td class="desc">Page the rows rather than scrolling them. <small>(optional)</small></td></tr>
9074
9365
  <tr><td class="name">locale</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
@@ -9099,7 +9390,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9099
9390
  <tr><td class="name">headerHeight</td><td class="type">number</td><td class="desc">Header height in pixels. Omitted, the header takes its height from the density-scaled `--lattice-header-height` token, so `density` sizes the header as it sizes the rows. A number names one explicitly and outranks the token. <small>(optional)</small></td></tr>
9100
9391
  <tr><td class="name">overscan</td><td class="type">number</td><td class="desc">How many rows to render beyond the viewport. More costs memory and smooths fast scrolling; fewer is lighter and can show a gap. <small>(optional)</small></td></tr>
9101
9392
  <tr><td class="name">autoHeight</td><td class="type">boolean | 'visible'</td><td class="desc">Size rows to their content rather than to the density token. Only rows that are actually rendered are ever measured, in both settings: the grid does not lay out rows you cannot see. The difference is what happens on a large grid: `true` gives up above ten thousand rows and falls back to fixed heights, because a cumulative offset array being patched as you scroll a million rows is not worth the result. `'visible'` keeps measuring at any size, accepting that the scrollbar shifts as rows are measured on the way past. The name is historical and reads as though it were about which rows are measured; it is about whether the ceiling applies. <small>(optional)</small></td></tr>
9102
- <tr><td class="name">state</td><td class="type">GridState</td><td class="desc">Sort, filters, grouping, widths and the rest, restored at construction. <small>(optional)</small></td></tr>
9393
+ <tr><td class="name">state</td><td class="type">GridState</td><td class="desc">Sort, filters, grouping, widths and the rest, restored at construction. Takes precedence over a saved view flagged `isDefault`: when both are present, this wins outright and the default view is never applied — the active view id stays `null`. <small>(optional)</small></td></tr>
9103
9394
  <tr><td class="name">licence</td><td class="type">string</td><td class="desc">Your licence key. Without one the grid renders in full and watermarks off localhost. <small>(optional)</small></td></tr>
9104
9395
  <tr><td class="name">maximise</td><td class="type">boolean</td><td class="desc">Offer a full-screen control. <small>(optional)</small></td></tr>
9105
9396
  <tr><td class="name">formulaFunctions</td><td class="type">Record&lt;string, (args: unknown[]) =&gt; unknown&gt;</td><td class="desc">Extra functions a formula may call, on top of the built-in library. <small>(optional)</small></td></tr>
@@ -9187,6 +9478,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9187
9478
  <tr><td class="name">columnOrder</td><td class="type">string[]</td><td class="desc"><small>(optional)</small></td></tr>
9188
9479
  <tr><td class="name">columnGroups</td><td class="type">ColumnGroupState[]</td><td class="desc">The banded-header tree, when the grid has one (BACKLOG-0000739). <small>(optional)</small></td></tr>
9189
9480
  <tr><td class="name">filters</td><td class="type">FilterSet</td><td class="desc"><small>(optional)</small></td></tr>
9481
+ <tr><td class="name">where</td><td class="type">string[]</td><td class="desc">The `where` predicates that were in force, as names only (BACKLOG-0001202). A predicate is host code: it cannot be serialised into a view or restored from one. `apply` reconciles these against what the host has registered and reports every name it cannot honour rather than restoring a view that silently shows more rows than the one that was saved. Absent when none is registered. <small>(optional)</small></td></tr>
9190
9482
  <tr><td class="name">quick</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
9191
9483
  <tr><td class="name">sort</td><td class="type">SortEntry[]</td><td class="desc"><small>(optional)</small></td></tr>
9192
9484
  <tr><td class="name">group</td><td class="type">string[]</td><td class="desc"><small>(optional)</small></td></tr>
@@ -9323,7 +9615,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9323
9615
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9324
9616
  <tbody>
9325
9617
  <tr><td class="name">min</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
9326
- <tr><td class="name">icon</td><td class="type">string</td><td class="desc"></td></tr>
9618
+ <tr><td class="name">icon</td><td class="type">IconName</td><td class="desc">A glyph name from the icon registry (see {@link IconName}).</td></tr>
9327
9619
  <tr><td class="name">label</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
9328
9620
  <tr><td class="name">variant</td><td class="type">VariantName</td><td class="desc"><small>(optional)</small></td></tr>
9329
9621
  </tbody>
@@ -9657,7 +9949,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9657
9949
  <tr><td class="name">label</td><td class="type">string</td><td class="desc"></td></tr>
9658
9950
  <tr><td class="name">disabled</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
9659
9951
  <tr><td class="name">variant</td><td class="type">VariantName</td><td class="desc"><small>(optional)</small></td></tr>
9660
- <tr><td class="name">icon</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
9952
+ <tr><td class="name">icon</td><td class="type">IconName</td><td class="desc">A glyph name from the icon registry (see {@link IconName}), shown before the label. <small>(optional)</small></td></tr>
9661
9953
  <tr><td class="name">group</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
9662
9954
  </tbody>
9663
9955
  </table>
@@ -9975,7 +10267,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9975
10267
  <tbody>
9976
10268
  <tr><td class="name">name</td><td class="type">string</td><td class="desc"></td></tr>
9977
10269
  <tr><td class="name">title</td><td class="type">string | (() =&gt; string)</td><td class="desc"></td></tr>
9978
- <tr><td class="name">icon</td><td class="type">string | (() =&gt; string)</td><td class="desc"><small>(optional)</small></td></tr>
10270
+ <tr><td class="name">icon</td><td class="type">IconName | (() =&gt; IconName)</td><td class="desc">A glyph name from the icon registry (see {@link IconName}) — a built-in name, or one registered with `registerIcon`/`registerIcons`, `config.icons` or `grid.icons`. A function form is re-read on every repaint, the same as `title`, so a toggle can swap its glyph with its state. When omitted, the rail tries `name` as the icon name instead (so an action named after a built-in, e.g. `'undo'`, needs no separate `icon`); an unrecognised name — from either `icon` or the `name` fallback — draws a blank glyph, and only an explicitly-given unrecognised `icon` warns once in the console. <small>(optional)</small></td></tr>
9979
10271
  <tr><td class="name">run</td><td class="type">(params: RailActionParams): void</td><td class="desc"></td></tr>
9980
10272
  <tr><td class="name">enabled</td><td class="type">(): boolean</td><td class="desc"><small>(optional)</small></td></tr>
9981
10273
  <tr><td class="name">active</td><td class="type">(): boolean</td><td class="desc">Marks the action as a toggle and reports whether it is currently on. When present the rail renders `aria-pressed` and a pressed style, re-read on every repaint; a one-shot action omits it and is unchanged. This is the hook the native annotation tools use, and it is available to a host button that is itself a toggle. <small>(optional)</small></td></tr>
@@ -10257,6 +10549,21 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10257
10549
  </tbody>
10258
10550
  </table>
10259
10551
  </div>
10552
+ <h3 id="type-RowDragEvent">RowDragEvent</h3>
10553
+ <p class="section-note">The row-drag lifecycle events (BACKLOG-0001224): `rowDrag:started`, `rowDrag:moved`, `rowDrag:left` and `rowDrag:ended`, which report a row drag *as it happens* rather than once it has settled. Before them a host got the handle the grid draws and then one settled event, with nothing in between to highlight a candidate target, drive a custom drop indicator, or react when the pointer left the grid. **All four fire on the grid the drag started in**, whether the row is being reordered within that grid or dragged into another one. A drag is one gesture with one owner, and the source grid is the only grid present for the whole of it — the pointer may cross several others, or none. `over` names whichever grid the event is about, so a single subscription can drive decoration on any of them. **Notifications, not gates.** None of these is cancellable and none carries `preventDefault`. The drop is already vetoable twice over — `beforeRowMove` for a reorder, `beforeRowReceive` for a drop into another grid — and a third veto on the same gesture would be a third place to look when a drop does not happen. **What is safe to do in a handler.** Read, measure and draw: highlight a candidate row, move an indicator, update a side panel. Do not mutate rows, columns, sort, filters or grouping from one of these. The drag resolves where it would land against the display order, so changing that order mid-gesture moves the ground under the drop; and `data` is the source row's own object rather than a copy, so writing to it edits the row that is still in the grid without announcing it. Work that changes the grid belongs in `beforeRowReceive`, which is asked before the insert, or in the settled events afterwards. **`rowDrag:moved` is coalesced to one event per animation frame**, carrying the latest pointer position of that frame, so a handler runs at the display's rate rather than the pointer's several hundred events a second. The other three fire on the transition itself. The sequence for any gesture is `rowDrag:started`, then `rowDrag:moved` and `rowDrag:left` as the pointer travels, then exactly one `rowDrag:ended` — including when the pointer is released outside every grid. No `rowDrag:moved` is delivered after `rowDrag:ended`. A press that never passes the drag threshold is a click and raises none of them; a grid destroyed mid-drag raises no `rowDrag:ended`.</p>
10554
+ <div class="table-wrap">
10555
+ <table>
10556
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
10557
+ <tbody>
10558
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc">The key of the row being dragged.</td></tr>
10559
+ <tr><td class="name">data</td><td class="type">Record&lt;string, unknown&gt; | null</td><td class="desc">The dragged row's data as it stands in the source grid — that row's own object, not a copy. Null if the row has left the source during the drag.</td></tr>
10560
+ <tr><td class="name">over</td><td class="type">Grid | null</td><td class="desc">The grid the event is about: the grid under the pointer for `rowDrag:started`, `rowDrag:moved` and `rowDrag:ended`, and the grid just left for `rowDrag:left`. Null when the pointer is over no grid at all.</td></tr>
10561
+ <tr><td class="name">at</td><td class="type">number | null</td><td class="desc">Where the row would land in `over`: the display index it would take. Null when there is no candidate to report — the pointer is over no grid, over a grid that will refuse the row, or over a header; and on `rowDrag:left`, which is about a grid the pointer has already gone from.</td></tr>
10562
+ <tr><td class="name">overKey</td><td class="type">string | null</td><td class="desc">The key of the row under the pointer in `over`, or null where there is no row to name: past the last row, on empty space, on a header, on a pinned row, on a grid that will refuse the drop, or on `rowDrag:left`.</td></tr>
10563
+ <tr><td class="name">dropped</td><td class="type">boolean</td><td class="desc">`rowDrag:ended` only: whether the release is being acted on — a transfer the target accepts, or a same-grid reorder that is a real move and is not refused by a sort, filter or grouping. False when the row was released over no grid, over a grid that refuses it, or back where it started. What became of an acted-on drop is reported by `row:moved`, `row:sent`, `row:received` and `rowReceive:cancelled`. <small>(optional)</small></td></tr>
10564
+ </tbody>
10565
+ </table>
10566
+ </div>
10260
10567
  <h3 id="type-RowFormApi">RowFormApi</h3>
10261
10568
  <div class="table-wrap">
10262
10569
  <table>
@@ -10269,6 +10576,20 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10269
10576
  </tbody>
10270
10577
  </table>
10271
10578
  </div>
10579
+ <h3 id="type-RowReceiveCancelledEvent">RowReceiveCancelledEvent</h3>
10580
+ <p class="section-note">The `rowReceive:cancelled` event (BACKLOG-0001225): a `beforeRowReceive` was vetoed, or went stale during an async handler. Nothing was inserted and the source grid is untouched.</p>
10581
+ <div class="table-wrap">
10582
+ <table>
10583
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
10584
+ <tbody>
10585
+ <tr><td class="name">data</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc">The row that was not inserted, as the handler saw it.</td></tr>
10586
+ <tr><td class="name">at</td><td class="type">number</td><td class="desc">The display index it would have taken.</td></tr>
10587
+ <tr><td class="name">overKey</td><td class="type">string | null</td><td class="desc">The key of the row under the pointer, or null.</td></tr>
10588
+ <tr><td class="name">source</td><td class="type">Grid</td><td class="desc">The grid the row would have come from; it still holds the row.</td></tr>
10589
+ <tr><td class="name">reason</td><td class="type">string</td><td class="desc">The reason given to `preventDefault`, `'prevented'` when none was given, or `'stale'` when the row under the pointer or the source row was gone by the time an async handler settled.</td></tr>
10590
+ </tbody>
10591
+ </table>
10592
+ </div>
10272
10593
  <h3 id="type-RowsApi">RowsApi</h3>
10273
10594
  <div class="table-wrap">
10274
10595
  <table>
@@ -10322,7 +10643,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10322
10643
  <tr><td class="name">name</td><td class="type">string</td><td class="desc"></td></tr>
10323
10644
  <tr><td class="name">description</td><td class="type">string</td><td class="desc"></td></tr>
10324
10645
  <tr><td class="name">shared</td><td class="type">boolean</td><td class="desc"></td></tr>
10325
- <tr><td class="name">isDefault</td><td class="type">boolean</td><td class="desc"></td></tr>
10646
+ <tr><td class="name">isDefault</td><td class="type">boolean</td><td class="desc">Applied on load when no `config.state` is given. `config.state` wins outright over this flag: with both present, the default view is never applied and the active view id stays `null`.</td></tr>
10326
10647
  <tr><td class="name">builtin</td><td class="type">boolean</td><td class="desc">Supplied in `config.views.saved`: listed apart, and not renamable or deletable.</td></tr>
10327
10648
  <tr><td class="name">createdAt</td><td class="type">number</td><td class="desc"></td></tr>
10328
10649
  <tr><td class="name">updatedAt</td><td class="type">number</td><td class="desc"></td></tr>
@@ -10372,9 +10693,10 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10372
10693
  <table>
10373
10694
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
10374
10695
  <tbody>
10375
- <tr><td class="name">mode</td><td class="type">'none' | 'single' | 'multiple'</td><td class="desc"><small>(optional)</small></td></tr>
10696
+ <tr><td class="name">mode</td><td class="type">'none' | 'single' | 'multiple'</td><td class="desc">`'none'` also turns off `ranges` and `fillHandle` unless either is set explicitly alongside it. <small>(optional)</small></td></tr>
10376
10697
  <tr><td class="name">checkbox</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
10377
10698
  <tr><td class="name">headerCheckbox</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
10699
+ <tr><td class="name">checkboxOnly</td><td class="type">boolean</td><td class="desc">Only the `checkbox` column may change row selection — a click anywhere else in the row, and Space with focus anywhere but the checkbox, leave selection untouched. Range and cell selection are unaffected either way. For a host whose row click is bound to its own action (opening a record): without this, that click also selects the row, so a later bulk action can reach rows nobody chose. Off by default. `mode: 'none'` already refuses every selection path regardless of this flag. <small>(optional)</small></td></tr>
10378
10700
  <tr><td class="name">groupSelectsChildren</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
10379
10701
  <tr><td class="name">groupSelectsFiltered</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
10380
10702
  <tr><td class="name">ranges</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
@@ -10411,7 +10733,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10411
10733
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
10412
10734
  <tbody>
10413
10735
  <tr><td class="name">get</td><td class="type">(): SortEntry[]</td><td class="desc"></td></tr>
10414
- <tr><td class="name">set</td><td class="type">(entries: SortEntry[]): void</td><td class="desc"></td></tr>
10736
+ <tr><td class="name">set</td><td class="type">(entries: SortEntry[]): void</td><td class="desc">Replace the sort model; an entry naming no known column is dropped with a warning.</td></tr>
10415
10737
  <tr><td class="name">clear</td><td class="type">(): void</td><td class="desc"></td></tr>
10416
10738
  </tbody>
10417
10739
  </table>
@@ -10489,7 +10811,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10489
10811
  <tbody>
10490
10812
  <tr><td class="name">get</td><td class="type">(): GridState</td><td class="desc"></td></tr>
10491
10813
  <tr><td class="name">apply</td><td class="type">(state: GridState, opts?: { skip?: (keyof GridState)[] }): StateApplyReport</td><td class="desc"></td></tr>
10492
- <tr><td class="name">baseline</td><td class="type">(): GridState | null</td><td class="desc">The state the grid started in, captured once after `config.state`.</td></tr>
10814
+ <tr><td class="name">baseline</td><td class="type">(): GridState | null</td><td class="desc">The grid as configured, without `config.state` — captured once, before that seed is applied, so a view opened through `config.state` is never itself mistaken for the default `reset()` returns to.</td></tr>
10493
10815
  <tr><td class="name">reset</td><td class="type">(): StateApplyReport | null</td><td class="desc">Put the grid back the way it started, as one undoable step.</td></tr>
10494
10816
  <tr><td class="name">modified</td><td class="type">(): boolean</td><td class="desc">Whether anything has changed since construction.</td></tr>
10495
10817
  </tbody>
@@ -10505,6 +10827,19 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10505
10827
  </tbody>
10506
10828
  </table>
10507
10829
  </div>
10830
+ <h3 id="type-StateChangedEvent">StateChangedEvent</h3>
10831
+ <p class="section-note">The `state:changed` event (BACKLOG-0001182). Fires once per logical state change, whether it began as a user gesture or as a programmatic call, so view persistence is built on this one event rather than on the ten individual ones — `reset()` raises those too, which made a debounced save write the reset arrangement back. **Exactly one event per change.** A change that internally routes through `state.apply()` — applying a saved view, an undo, a reset — announces itself once, carrying the outermost cause rather than the inner mechanism's. A host predicate registered, replaced or removed through `filters.where(name, fn)`, and a `filters.reapply()` that re-runs one, go through the same tracked door as `sort` and `filters`: each fires this event once, `cause: 'user'`, with `'where'` in `sections` (BACKLOG-0001235).</p>
10832
+ <div class="table-wrap">
10833
+ <table>
10834
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
10835
+ <tbody>
10836
+ <tr><td class="name">cause</td><td class="type">StateChangeCause</td><td class="desc">Why the state changed. `'reset'` is the one a save should ignore.</td></tr>
10837
+ <tr><td class="name">sections</td><td class="type">StateSection[]</td><td class="desc">Which sections moved, sorted and de-duplicated. For `'apply'` and `'reset'` these are the sections the report applied; for `'user'`, the sections the change touches.</td></tr>
10838
+ <tr><td class="name">state</td><td class="type">GridState | null</td><td class="desc">The state that was applied — present for `'apply'` and `'reset'`, null for `'user'`. A full capture on every gesture would put an unsanitised copy of the state, hidden column ids and widths included, on the bus for every listener; a host calls `grid.state.get()` when it decides to write, which is permission-sanitised.</td></tr>
10839
+ <tr><td class="name">report</td><td class="type">StateApplyReport | null</td><td class="desc">What an apply could not restore; null for `'user'`.</td></tr>
10840
+ </tbody>
10841
+ </table>
10842
+ </div>
10508
10843
  <h3 id="type-StatisticsApi">StatisticsApi</h3>
10509
10844
  <div class="table-wrap">
10510
10845
  <table>
@@ -10817,6 +11152,18 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10817
11152
  </tbody>
10818
11153
  </table>
10819
11154
  </div>
11155
+ <h3 id="type-WhereOptions">WhereOptions</h3>
11156
+ <p class="section-note">How a `where` predicate is re-evaluated, whether `filters.clear()` may remove it, and what the source may be told about it (BACKLOG-0001202).</p>
11157
+ <div class="table-wrap">
11158
+ <table>
11159
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11160
+ <tbody>
11161
+ <tr><td class="name">deps</td><td class="type">string[]</td><td class="desc">The columns the predicate reads, in the same spirit as `value.deps` on a computed column (§8.4.2). Declared, the verdict is cached per row and re-run only when one of these columns changes on that row. Omitted, the predicate is treated as reading the whole row and is called on every pass — never stale, and never skipped either. <small>(optional)</small></td></tr>
11162
+ <tr><td class="name">pinned</td><td class="type">boolean</td><td class="desc">Survive `filters.clear()`. For a predicate that is not the user's filter — row-level permissions, tenant scoping — where a "clear filters" button must never widen what the user can see. <small>(optional)</small></td></tr>
11163
+ <tr><td class="name">condition</td><td class="type">FilterSet</td><td class="desc">A declarative twin of the predicate, pushed to the source while the function stays as the residual. On a pushdown engine this narrows the fetch instead of filtering a page client-side. It must be implied by the predicate: the grid ANDs both, so a twin wider than the function costs only time, while one narrower than it hides rows the function would have kept. <small>(optional)</small></td></tr>
11164
+ </tbody>
11165
+ </table>
11166
+ </div>
10820
11167
  <h3 id="type-WindowedResult">WindowedResult</h3>
10821
11168
  <p class="section-note">One windowed figure and the window it covers.</p>
10822
11169
  <div class="table-wrap">
@@ -10843,7 +11190,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10843
11190
  <!-- END GENERATED TYPE REFERENCE -->
10844
11191
 
10845
11192
  <footer>
10846
- Lattice Grid 1.55.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
11193
+ Lattice Grid 1.57.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
10847
11194
  This document describes the behaviour of the shipped library. Where this guide and the code
10848
11195
  disagree, the code wins: please <a href="https://www.latticegrid.dev">tell us</a>.
10849
11196
  </footer>