@toclocoinc/lattice-grid 1.37.0 → 1.39.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 (69) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +411 -13
  3. package/docs/api-detail.html +72 -1
  4. package/lattice-grid.d.ts +587 -17
  5. package/lattice-grid.esm.min.js +1203 -58
  6. package/lattice-grid.min.cjs +1200 -58
  7. package/lattice-grid.min.css +1 -1
  8. package/lattice-grid.min.js +1200 -58
  9. package/modules/angular.esm.min.js +4 -2
  10. package/modules/angular.min.cjs +4 -2
  11. package/modules/angular.min.js +4 -2
  12. package/modules/chart-alluvial.esm.min.js +1 -1
  13. package/modules/chart-arc.esm.min.js +1 -1
  14. package/modules/chart-bubblemap.esm.min.js +1 -1
  15. package/modules/chart-bump.esm.min.js +1 -1
  16. package/modules/chart-calendar.esm.min.js +1 -1
  17. package/modules/chart-decomposition.esm.min.js +1 -1
  18. package/modules/chart-diverging.esm.min.js +1 -1
  19. package/modules/chart-dumbbell.esm.min.js +1 -1
  20. package/modules/chart-fan.esm.min.js +1 -1
  21. package/modules/chart-hexbin.esm.min.js +1 -1
  22. package/modules/chart-hexmap.esm.min.js +1 -1
  23. package/modules/chart-icicle.esm.min.js +1 -1
  24. package/modules/chart-parallel.esm.min.js +1 -1
  25. package/modules/chart-ridgeline.esm.min.js +1 -1
  26. package/modules/chart-roc.esm.min.js +1 -1
  27. package/modules/chart-slope.esm.min.js +1 -1
  28. package/modules/chart-splom.esm.min.js +1 -1
  29. package/modules/chart-waffle.esm.min.js +1 -1
  30. package/modules/charts.esm.min.js +467 -10
  31. package/modules/charts.min.cjs +467 -10
  32. package/modules/charts.min.js +467 -10
  33. package/modules/data-router.esm.min.js +184 -7
  34. package/modules/data-router.min.cjs +184 -7
  35. package/modules/data-router.min.js +184 -7
  36. package/modules/devtools.esm.min.js +2 -2
  37. package/modules/devtools.min.cjs +2 -2
  38. package/modules/devtools.min.js +2 -2
  39. package/modules/dhtmlx-compat.esm.min.js +4 -4
  40. package/modules/dhtmlx-compat.min.cjs +4 -4
  41. package/modules/dhtmlx-compat.min.js +4 -4
  42. package/modules/gantt.esm.min.js +845 -16
  43. package/modules/gantt.min.cjs +842 -16
  44. package/modules/gantt.min.js +842 -16
  45. package/modules/htmx.esm.min.js +1197 -58
  46. package/modules/htmx.min.cjs +1197 -58
  47. package/modules/htmx.min.js +1197 -58
  48. package/modules/kanban.esm.min.js +379 -5
  49. package/modules/kanban.min.cjs +379 -5
  50. package/modules/kanban.min.js +379 -5
  51. package/modules/kpi.esm.min.js +4 -4
  52. package/modules/kpi.min.cjs +4 -4
  53. package/modules/kpi.min.js +4 -4
  54. package/modules/mock-socket.esm.min.js +2 -2
  55. package/modules/mock-socket.min.cjs +2 -2
  56. package/modules/mock-socket.min.js +2 -2
  57. package/modules/react.esm.min.js +4 -2
  58. package/modules/react.min.cjs +4 -2
  59. package/modules/react.min.js +4 -2
  60. package/modules/svelte.esm.min.js +4 -2
  61. package/modules/svelte.min.cjs +4 -2
  62. package/modules/svelte.min.js +4 -2
  63. package/modules/vue.esm.min.js +4 -2
  64. package/modules/vue.min.cjs +4 -2
  65. package/modules/vue.min.js +4 -2
  66. package/modules/webcomponent.esm.min.js +1200 -58
  67. package/modules/webcomponent.min.cjs +1200 -58
  68. package/modules/webcomponent.min.js +1200 -58
  69. package/package.json +1 -1
package/docs/API.html CHANGED
@@ -386,6 +386,7 @@
386
386
  <a href="#pagination">grid.pagination</a>
387
387
  <a href="#scroll">grid.scroll</a>
388
388
  <a href="#export">grid.export</a>
389
+ <a href="#import">grid.import</a>
389
390
  <a href="#state">grid.state</a>
390
391
  <a href="#history">grid.history</a>
391
392
  <a href="#diagnostics">grid.diagnostics</a>
@@ -1155,6 +1156,7 @@ grid.overlay.hide();</code></pre>
1155
1156
  <tr><td class="sig">status(key, colId)</td><td class="type">'pending' | null</td><td class="desc">Whether a cell has a write in flight.</td></tr>
1156
1157
  <tr><td class="sig">addRow(row)</td><td class="type">string | null</td><td class="desc">Append a row optimistically and persist it (over a source declaring <code>mutate.append</code>). Returns the client temp key; on the server key it fires <code>row:confirmed</code> after rekeying selection, expansion, focus and in-flight cell edits. <code>null</code> when append is unavailable.</td></tr>
1157
1158
  <tr><td class="sig">deleteRow(key)</td><td class="type">string | null</td><td class="desc">Delete a row optimistically and persist it (over a source declaring <code>mutate.delete</code>). Tombstones then confirms, or restores on refusal. <code>null</code> when delete is unavailable.</td></tr>
1159
+ <tr><td class="sig">deleteRows(keys?, opts?)</td><td class="type">string[] | Promise</td><td class="desc">The user-gesture delete (what the Delete key and the "Delete row" menu item call), through the cancellable <code>beforeDelete</code> event — on a <strong>memory</strong> grid as well as a remote one. Off until <code>config.rowDelete</code>. Keys default to the selection; returns the keys removed (empty on a veto or when disabled), or a Promise when a handler deferred.</td></tr>
1158
1160
  <tr><td class="sig">settleRow(id, ok, reason?, reconcile?)</td><td class="type">boolean</td><td class="desc">Report the outcome of a structural op. Only needed with <code>edit.confirm: 'manual'</code>; the id arrives on <code>row:pending</code>.</td></tr>
1159
1161
  <tr><td class="sig">rowStatus(key)</td><td class="type">'pending' | null</td><td class="desc">Whether a row has an append/delete in flight.</td></tr>
1160
1162
  <tr><td class="sig">pendingRows()</td><td class="type">OpenRowOp[]</td><td class="desc">Structural ops still awaiting an outcome. Empty unless the source can append or delete.</td></tr>
@@ -1217,6 +1219,35 @@ grid.destroy();
1217
1219
  <span class="cmt">// fired: pending:append, confirmed:append, pending:delete, confirmed:delete</span>
1218
1220
  <span class="kw">return</span> `${movedTo} selected; ${gone ? 'deleted' : 'still-there'}`;</code></pre>
1219
1221
 
1222
+ <p class="section-note">
1223
+ <strong>The built-in delete gesture.</strong> <code>deleteRow</code> above only fires
1224
+ <code>beforeDelete</code> on a remote source. For the common case &mdash; letting a user delete
1225
+ rows of a <em>memory</em> grid with the Delete key or a "Delete row" menu item, confirmed
1226
+ through the same <code>beforeDelete</code> hook &mdash; set <code>config.rowDelete: true</code>.
1227
+ It is off by default because deleting data on a keystroke is destructive; every deletion still
1228
+ flows through <code>beforeDelete</code>, so a handler can confirm or veto it with
1229
+ <code>preventDefault(reason)</code>. <code>grid.edit.deleteRows</code> is the programmatic entry.
1230
+ </p>
1231
+ <pre data-run="js" data-expect="2 rows" data-covers="config:rowDelete"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
1232
+
1233
+ <span class="kw">const</span> grid = createHeadlessGrid({
1234
+ columns: [{ field: 'id' }],
1235
+ rows: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
1236
+ rowKey: 'id',
1237
+ selection: 'multiple',
1238
+ rowDelete: <span class="kw">true</span>, <span class="cmt">// opt in to the Delete-key / menu gesture and the API</span>
1239
+ });
1240
+
1241
+ <span class="cmt">// A confirm hook that vetoes deleting row 'b' but allows the rest.</span>
1242
+ grid.on('beforeDelete', (e) =&gt; { <span class="kw">if</span> (e.rows.includes('b')) e.preventDefault('kept'); });
1243
+
1244
+ grid.edit.deleteRows(['a']); <span class="cmt">// allowed — 'a' is removed</span>
1245
+ grid.edit.deleteRows(['b']); <span class="cmt">// vetoed — 'b' stays, delete:cancelled fires</span>
1246
+
1247
+ <span class="kw">const</span> count = grid.rows.count();
1248
+ grid.destroy();
1249
+ <span class="kw">return</span> `${count} rows`; <span class="cmt">// 2 rows: 'b' and 'c'</span></code></pre>
1250
+
1220
1251
  <p class="section-note">
1221
1252
  <strong>Previewing a bulk paste.</strong> A paste can rewrite dozens of cells at once, and one
1222
1253
  that lands somewhere unexpected looks exactly like one that worked. Set
@@ -1317,6 +1348,37 @@ grid.edit.setCells(preview.changes.map((c) =&gt; ({ key: c.key, colId: c.colId,
1317
1348
  <p>IP columns are stored as packed integers so they sort as addresses, and declare <code>excelKind: 'string'</code> so the dotted form is what reaches the file. Radix columns export decimal: OOXML cannot express base 16, and staying numeric was judged worth more than display fidelity.</p>
1318
1349
  </div>
1319
1350
 
1351
+ <h2 id="import">grid.import</h2>
1352
+ <p>The mirror of <a href="#export">grid.export</a>: rows coming <em>in</em> from delimited text — a CSV or TSV file, or the tab-separated block a spreadsheet puts on the clipboard. The pipeline is parse, infer each column's type, map the columns onto the grid's own fields, then preview and confirm. The API is always present; set <code>config.import</code> to add the DOM affordances (a "Import rows from CSV…" cell-menu item, a file drop target, and paste).</p>
1353
+ <div class="table-wrap">
1354
+ <table>
1355
+ <thead><tr><th>Method</th><th>Returns</th><th>Description</th></tr></thead>
1356
+ <tbody>
1357
+ <tr><td class="sig">preview(text, opts?)</td><td class="type">ImportPreview</td><td class="desc">Parse, infer and map without changing the grid — the columns, the coerced records, a sample and any warnings a confirm dialog needs.</td></tr>
1358
+ <tr><td class="sig">csv(text, opts?)</td><td class="type">object[]</td><td class="desc">Parse delimited text into coerced record objects, the inverse of <code>export.csv</code>.</td></tr>
1359
+ <tr><td class="sig">apply(input, opts?)</td><td class="type">ChangeResult | null</td><td class="desc">Add (<code>mode: 'append'</code>, the default) or replace (<code>mode: 'replace'</code>) the grid's rows from text, a preview or a record array. A client-side operation, so it applies to a memory-source grid; on any other source it declines rather than show rows that cannot persist.</td></tr>
1360
+ </tbody>
1361
+ </table>
1362
+ </div>
1363
+ <div class="note"><p><strong>Excel.</strong> <code>.xlsx</code> is not read: it needs an inflate and XML reader the zero-dependency envelope does not carry. Save the sheet as CSV — every spreadsheet does — and import that.</p></div>
1364
+ <pre data-run="js" data-expect="2 previewed, 3 rows" data-covers="method:import config:import"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
1365
+
1366
+ <span class="kw">const</span> grid = createHeadlessGrid({
1367
+ columns: [{ field: 'id' }, { field: 'age', type: 'integer' }],
1368
+ rows: [{ id: 'seed', age: 1 }],
1369
+ rowKey: 'id',
1370
+ <span class="cmt">// Opt in to the DOM affordances; the grid.import API is present either way.</span>
1371
+ import: <span class="kw">true</span>,
1372
+ });
1373
+
1374
+ <span class="cmt">// Preview changes nothing — it is what a confirm dialog shows.</span>
1375
+ <span class="kw">const</span> preview = grid.import.preview('id,age\nx,20\ny,30');
1376
+
1377
+ <span class="cmt">// Confirming appends the previewed rows; `age` arrives coerced to a number.</span>
1378
+ grid.import.apply(preview);
1379
+
1380
+ <span class="kw">return</span> `${preview.rowCount} previewed, ${grid.rows.count()} rows`; <span class="cmt">// 2 previewed, 3 rows</span></code></pre>
1381
+
1320
1382
  <h2 id="state">grid.state</h2>
1321
1383
  <div class="table-wrap">
1322
1384
  <table>
@@ -1783,6 +1845,41 @@ grid.formatting.restat(); // re-derive every threshold from the
1783
1845
  <p>A colour scale can take its bounds the same way, with <code>from</code> in place of <code>min</code> and <code>max</code>: <code>'minmax'</code> spans the data, <code>'quantile'</code> spans <code>low</code> to <code>high</code> (5th to 95th percentile by default), <code>'stddev'</code> spans <code>deviations</code> either side of the mean. The quantile form is the better default on real data, one mistyped order of magnitude otherwise compresses every real value into the first swatch.</p>
1784
1846
  <div class="note"><p>Thresholds are <strong>pinned</strong> when the rules compile and do not move on their own. That is deliberate: a boundary that re-derived itself as rows were filtered would repaint cells whose values had not changed, and nobody comparing two screenshots could tell which of the two things had moved. <code>grid.formatting.restat()</code> is how you move it, and a "recalculate" control is the natural place to put it.</p></div>
1785
1847
 
1848
+ <h3>Data bars and icon sets as rules (BACKLOG-0000955)</h3>
1849
+ <p>A rule can carry a <strong>data bar</strong> or an <strong>icon set</strong> instead of a <code>style</code> or a <code>scale</code>, so the same declarative, view-persisted, headless rule list that already paints colour scales also paints proportional bars and per-band glyphs. Both compile to a plain style object — a data bar is a CSS gradient on the background, an icon set a <code>background-image</code> — so they need no extra element, compose with the cell's text, and resolve the same way for a server-side export as for a browser paint. This is the rule-engine sibling of the cell <em>decoration</em> below; reach for a rule when you want the visual to travel in a saved view and to derive its bounds from the column's distribution.</p>
1850
+ <pre><code><span class="cmt">// A data bar spanning the data, and a three-arrow icon set split at the tertiles.</span>
1851
+ grid.formatting.add('revenue', { dataBar: { from: 'minmax', colour: '#5b9bd5' } });
1852
+ grid.formatting.add('score', { iconSet: { set: 'trafficLights' } });
1853
+
1854
+ <span class="cmt">// Bounds and bands can be pinned instead of derived; bars that straddle zero</span>
1855
+ <span class="cmt">// grow both ways from a shared axis, in their own colours.</span>
1856
+ grid.formatting.add('delta', { dataBar: { min: -100, max: 100, colour: '#2e7d32', negativeColour: '#c0392b' } });
1857
+ grid.formatting.add('rank', { iconSet: { set: 'arrows', thresholds: [10, 20], reverse: true } });</code></pre>
1858
+ <p>A data bar takes <code>min</code>/<code>max</code> to pin its scale, or <code>from: 'minmax' | 'quantile' | 'stddev'</code> to derive it from the column; <code>colour</code> and <code>negativeColour</code> fill the two sides of a zero axis, and <code>direction: 'rtl'</code> reverses it. An icon set names a built-in — the keys of <code>ICON_SETS</code> (<code>arrows</code>, <code>trafficLights</code>, <code>ratings</code>) — or supplies its own <code>icons</code>; <code>thresholds</code> place the band edges, or, given none, the column is cut into equal-count bands; <code>reverse</code> flips the order so a high value can read as red.</p>
1859
+ <pre data-run="js" data-expect="bar|icon|3" data-covers="export:ICON_SETS method:formatting"><code><span class="kw">const</span> { createHeadlessGrid, ICON_SETS } = <span class="kw">await</span> import('../packages/core/src/index.js');
1860
+
1861
+ <span class="kw">const</span> grid = createHeadlessGrid({
1862
+ columns: [{ field: 'id' }, { field: 'revenue', type: 'number' }, { field: 'score', type: 'number' }],
1863
+ rowKey: 'id',
1864
+ rows: [1, 2, 3, 4, 5].map((n) => ({ id: 'r' + n, revenue: n * 20, score: n })),
1865
+ formatting: {
1866
+ revenue: [{ dataBar: { from: 'minmax', colour: '#5b9bd5' } }],
1867
+ score: [{ iconSet: { set: 'trafficLights' } }],
1868
+ },
1869
+ });
1870
+
1871
+ <span class="cmt">// A data bar compiles to a gradient the cell layer paints as a background.</span>
1872
+ <span class="kw">const</span> bar = grid.formatting.styleFor('revenue', 100).backgroundImage.includes('linear-gradient') ? 'bar' : 'none';
1873
+
1874
+ <span class="cmt">// An icon set resolves different bands to different glyphs.</span>
1875
+ <span class="kw">const</span> low = grid.formatting.styleFor('score', 1).backgroundImage;
1876
+ <span class="kw">const</span> high = grid.formatting.styleFor('score', 5).backgroundImage;
1877
+ <span class="kw">const</span> icon = low !== high ? 'icon' : 'flat';
1878
+
1879
+ <span class="kw">const</span> sets = Object.keys(ICON_SETS).length; <span class="cmt">// the three built-in sets</span>
1880
+ grid.destroy();
1881
+ <span class="kw">return</span> [bar, icon, sets].join('|');</code></pre>
1882
+
1786
1883
  <h3>Runtime decorations: data bars and icon sets on demand</h3>
1787
1884
  <p>Where a colour rule paints the cell's background, a <em>decoration</em> changes the shape the cell renders as, a data bar sized by value, or a threshold icon set. <code>grid.columns.decorate(id, spec)</code> turns one on, changes it, or clears it with <code>null</code>, after the grid is built. It is presentation config rather than query state: unlike a formatting rule it is not on the undo timeline and does not travel in a saved view. Icon sets carry an <code>aria-label</code> per band and keep the value beside the glyph, so the meaning is announced, never only shown.</p>
1788
1885
  <pre data-run="js" data-expect="bar|arrows|increasing|cleared|painted" data-covers="method:columns method:formatting"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
@@ -1815,12 +1912,57 @@ grid.formatting.add('score', { when: { op: 'lt', value: 50 }, style: { backgroun
1815
1912
 
1816
1913
  <span class="kw">return</span> [bar, set, band, cleared, painted].join('|');</code></pre>
1817
1914
 
1915
+ <h2 id="validation">grid.validation</h2>
1916
+ <p>Declarative column validation (BACKLOG-0000956). Where <code>edit.validate</code> is an imperative function you write, this is the same job said as <em>data</em>: <code>required</code>, <code>min</code>/<code>max</code>, <code>minLength</code>/<code>maxLength</code>, <code>pattern</code>, <code>oneOf</code>, and a <code>crossField</code> predicate, declared per column in <code>validation</code>. Each rule is checked against a new value <strong>before it is written</strong>, riding the cancellable <code>beforeEdit</code> before-event: a failing value cancels the commit so no cell is written, marks the cell with the grid's ordinary invalid state (an accessible error, not only a red border), and fires <code>validation:failed</code>. A corrected value clears the mark and, where you are watching, fires <code>validation:cleared</code>. The cancellation carries <code>reason: 'validation:&lt;code&gt;'</code>, so a host logging cancellations can tell a validation veto from any other.</p>
1917
+ <div class="note"><p>Only a <em>user</em>-initiated edit is gated, the same contract <code>beforeEdit</code> itself keeps: a host API write (<code>grid.edit.setCells</code>) and a remote/router-applied delta are the authority and do not self-veto. A grid whose columns declare no rules wires no gate and keeps the byte-for-byte synchronous edit path.</p></div>
1918
+ <pre><code><span class="cmt">// Declared per column, config-time.</span>
1919
+ createGrid(el, {
1920
+ columns: [
1921
+ { field: 'name', edit: true, validation: { required: true, minLength: 2 } },
1922
+ { field: 'age', type: 'number', edit: true, validation: { min: 0, max: 120 } },
1923
+ { field: 'code', edit: true, validation: { pattern: '^[A-Z]{3}$', messages: { pattern: 'Three capitals.' } } },
1924
+ ],
1925
+ });
1926
+
1927
+ grid.validation.check('age', 200); <span class="cmt">// { code: 'max', message: 'Must be at most 120.' } — records nothing</span>
1928
+ grid.validation.errorFor('r1', 'age'); <span class="cmt">// the recorded error for a cell, or null</span>
1929
+ grid.validation.errors(); <span class="cmt">// every cell that currently holds an error</span>
1930
+ grid.validation.define('age', { min: 18 }); <span class="cmt">// set or replace a column's rules at runtime</span>
1931
+ grid.validation.clear('r1', 'age'); <span class="cmt">// drop a mark by hand</span></code></pre>
1932
+ <pre data-run="js" data-expect="30|max|40|1" data-covers="method:validation event:validation:failed event:validation:cleared"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
1933
+
1934
+ <span class="kw">const</span> grid = createHeadlessGrid({
1935
+ columns: [{ field: 'id' }, { field: 'age', type: 'number', edit: true, validation: { min: 0, max: 120 } }],
1936
+ rowKey: 'id',
1937
+ rows: [{ id: 'r1', age: 30 }],
1938
+ });
1939
+
1940
+ <span class="kw">let</span> failed = 0;
1941
+ grid.on('validation:failed', () =&gt; { failed++; });
1942
+ grid.on('validation:cleared', () =&gt; {});
1943
+
1944
+ <span class="cmt">// A user edit that breaks the rule is refused: the cell is not written.</span>
1945
+ grid.edit.start('r1', 'age'); grid.edit.stop(<span class="kw">false</span>, { value: 999 });
1946
+ <span class="kw">const</span> blocked = grid.rows.byKey('r1').data.age; <span class="cmt">// still 30</span>
1947
+ <span class="kw">const</span> why = grid.validation.errorFor('r1', 'age').code; <span class="cmt">// 'max'</span>
1948
+
1949
+ <span class="cmt">// A valid value writes through and clears the mark.</span>
1950
+ grid.edit.start('r1', 'age'); grid.edit.stop(<span class="kw">false</span>, { value: 40 });
1951
+ <span class="kw">const</span> now = grid.rows.byKey('r1').data.age; <span class="cmt">// 40</span>
1952
+
1953
+ grid.destroy();
1954
+ <span class="kw">return</span> [blocked, why, now, failed].join('|');</code></pre>
1955
+
1818
1956
  <h2 id="statistics">grid.statistics</h2>
1819
1957
  <p>What the grid knows about its own numbers, and about how they have changed since the page loaded. Every figure is computed over the <em>filtered</em> rows, through the same column handles the totals row uses, so a median here and a median in the footer are the same number, by the same definition (R type 7).</p>
1820
1958
  <pre><code>grid.statistics.profile('margin');
1821
1959
  // { column, rows, present, missing, distinct, min, max, mean, median,
1822
1960
  // q1, q3, iqr, stddev, outliers, histogram: [{ from, to, count }, …] }
1823
1961
 
1962
+ grid.statistics.profile('region'); // a categorical column (BACKLOG-0000959)
1963
+ // { column, rows, present, missing, distinct, …numeric figures null…,
1964
+ // histogram: [], topValues: [{ value, count, share }, …] }
1965
+
1824
1966
  grid.statistics.reduce('margin', 'p95'); // any registered kernel
1825
1967
  grid.statistics.correlation('spend', 'revenue'); // Pearson's r, clamped to [-1, 1]
1826
1968
  grid.statistics.weightedAverage('price', 'qty');
@@ -1842,7 +1984,8 @@ grid.statistics.capability('mm', { baseline: 20 });
1842
1984
  grid.statistics.shadow('price', 'delta', 'R42'); // one row's shadow value
1843
1985
  grid.statistics.rebase('price'); // "mark all": today's values become the baseline
1844
1986
  grid.statistics.tracking(); // { columns, rows, forgotten }</code></pre>
1845
- <p>The <code>statistics</code> tool panel is the end-user half of <code>profile()</code>: a column picker, the twelve figures and a histogram of the column's shape, all following the filters. Add it with <code>toolPanel: { panels: ['columns', 'statistics'] }</code>.</p>
1987
+ <p>The <code>statistics</code> tool panel is the end-user half of <code>profile()</code>: a column picker, the twelve figures and a histogram of the column's shape, all following the filters. Add it with <code>toolPanel: { panels: ['columns', 'statistics'] }</code>. It profiles categorical columns too (BACKLOG-0000959): a text column shows its count, distinct count and <strong>Top values</strong> (each value with its count and share) instead of the numeric figures and the histogram it has none of.</p>
1988
+ <p>The column header menu carries a <strong>Column statistics</strong> item that opens this panel seeded on the column it belongs to. It emits <code>column:profile:open</code> with <code>{ colId }</code> rather than reaching into the dock, exactly as the header filter affordance emits <code>column:filter:open</code>; a mounted tool panel turns that into the open, seeded <code>statistics</code> panel.</p>
1846
1989
  <p>The <code>regression</code> tool panel is its multi-column sibling (BACKLOG-0000812): it fits the model you name and shows the coefficient table — each term's estimate ± standard error with its t and p — alongside R² and adjusted R², the variance-inflation factor per predictor, and the Breusch–Pagan heteroscedasticity flag, all over the filtered rows and computed by the one core engine (<code>grid.statistics.regressionModel</code>). Name the model on the panel: <code>toolPanel: { panels: ['columns', { name: 'regression', props: { predictors: ['x1', 'x2'], response: 'y' } }] }</code>. p-values are reported as numbers with a documented method, never a significance verdict.</p>
1847
1990
  <p>The same fitted model can live in the data as shadow columns (BACKLOG-0000812): <code>shadow: { kind: 'fitPredicted', model: { predictors: ['x'], response: 'y' } }</code>, and likewise <code>fitResidual</code> and <code>fitInfluence</code> — plus <code>fitStdResidual</code>, <code>fitLeverage</code> and <code>fitCooksD</code> (BACKLOG-0000872), which surface the internally studentised residual, the hat-matrix leverage and Cook's distance the engine already computes. They are ordinary numeric/boolean cells — sortable, filterable, groupable, exportable — that read the fit by row key and follow the grid's filters (the model refits over the filtered rows); a row outside the fit reads <code>null</code>. <code>fitInfluence</code> flags Cook's D &gt; 4/n by default (overridable with <code>threshold</code>), keeping "not influential" (<code>false</code>) and "cannot tell" (<code>null</code>) distinct.</p>
1848
1991
  <p>It shows the twelve one-pass figures, then <strong>Shape</strong> (skewness, kurtosis, Jarque–Bera), <strong>Robust</strong> (trimmed and winsorized means, MAD, robust outliers), <strong>Concentration</strong> (Gini, HHI, entropy, evenness, top-3 share) and <strong>Capability</strong> where the column declares a <code>spec</code>. A section whose reductions all return null is left out rather than shown as a column of dashes.</p>
@@ -3597,6 +3740,7 @@ off(); <span class="cmt">// on() returns i
3597
3740
  <tr><td class="name">column:pivoted</td><td class="type">{ columns } | { pivotFields, remote }</td><td class="desc"></td></tr>
3598
3741
  <tr><td class="name">column:menu:open</td><td class="type">{ colId }</td><td class="desc">Header menu opened.</td></tr>
3599
3742
  <tr><td class="name">column:filter:open</td><td class="type">{ colId }</td><td class="desc">Header filter popup opened.</td></tr>
3743
+ <tr><td class="name">column:profile:open</td><td class="type">{ colId }</td><td class="desc">The column statistics ("describe") panel was asked to open on a column, from the column menu's "Column statistics" item. A mounted tool panel opens its <code>statistics</code> panel seeded on <code>colId</code>.</td></tr>
3600
3744
  <tr><td class="name">selection:changed</td><td class="type">{ keys, rows }</td><td class="desc"></td></tr>
3601
3745
  <tr><td class="name">range:changed</td><td class="type">{ ranges }</td><td class="desc">Cell range selection changed.</td></tr>
3602
3746
  <tr><td class="name">page:changed</td><td class="type">{ page, pageSize, total, pageCount }</td><td class="desc">Fired after the rows have moved, whether the page changed by API or by the pager control.</td></tr>
@@ -4060,6 +4204,7 @@ const chart = createChart({
4060
4204
  <tr><td class="name">footnote</td><td class="type">string</td><td class="desc">A note under the plot, a source, a caveat, a unit.</td></tr>
4061
4205
  <tr><td class="name">emptyText</td><td class="type">string</td><td class="desc">What to show when the binding produces nothing. Said rather than left blank, because an empty plot and a broken one look identical.</td></tr>
4062
4206
  <tr><td class="name">fit</td><td class="type">boolean | 'line'</td><td class="desc">A least-squares line through a scatter or bubble chart, one per series. <code>true</code> draws it with its R&sup2;; <code>'line'</code> draws the line alone. Only where the x axis is numeric: on a band scale a slope would be a slope through the order the categories happened to be listed in.</td></tr>
4207
+ <tr><td class="name">trend</td><td class="type">boolean | string | ChartTrend | array</td><td class="desc">Trend and forecast overlays (BACKLOG-0000952): a <code>linear</code> least-squares line, a <code>movingAverage</code>, or <code>exponential</code> smoothing, one per series. <code>true</code> draws a single linear trend; a method name or a <code>{ method, forecast, window, kind, alpha, beta }</code> object configures one; an array draws several. The maths matches the core stats engine to the last digit (a parity test asserts it) but is computed locally to keep the charts bundle lean. For the linear method, <code>forecast: n</code> projects the line <code>n</code> steps past the data as a dashed forecast; a moving average and a smoothed level have no slope to project, so <code>forecast</code> is ignored for them and the fact is said in the accessible description.</td></tr>
4063
4208
  <tr><td class="name">error</td><td class="type">boolean | object</td><td class="desc">Whiskers showing the uncertainty in each mark, computed from the readings the chart can see behind it. <code>{ of }</code> takes a symmetric margin from a column instead; <code>{ confidence }</code> sets the level. A mark the chart sees only one value for gets none, and the chart says so.</td></tr>
4064
4209
  <tr><td class="name">stack</td><td class="type">boolean</td><td class="desc">Stack the series rather than drawing them side by side.</td></tr>
4065
4210
  <tr><td class="name">curve</td><td class="type">boolean</td><td class="desc">Overlay a kernel density curve on a histogram, which shows which features are in the data and which are in the binning.</td></tr>
@@ -4373,7 +4518,7 @@ router.load(snapshot); <span class="cmt">// every viewer
4373
4518
  <tr><td class="sig">traveling / buffered</td><td class="desc"><strong>v4:</strong> whether the grids currently show a reconstructed past, and how many deltas are held in the bounded buffer.</td></tr>
4374
4519
  <tr><td class="sig">broadcast({ channel })</td><td class="desc"><strong>v6 (BACKLOG-0000913):</strong> mirror the router's ordered, de-duplicated deltas to other browser tabs/windows over a <strong>BroadcastChannel</strong>, so a grid popped into its own tab joins the same feed with no second socket. Each tab runs its own router on the same <code>channel</code> name; an inbound mirror is applied without re-broadcasting (no echo loop), and <code>broadcast</code> announces the tab so a peer holding the feed resyncs it mid-stream (snapshot + replay). Off by default; needs a <code>seq</code>/<code>dedupe</code> router to drop replayed deltas cleanly.</td></tr>
4375
4520
  <tr><td class="sig">broadcasting</td><td class="desc"><strong>v6:</strong> whether the router is currently mirroring to a BroadcastChannel.</td></tr>
4376
- <tr><td class="sig">addSource(feed, { map?, key? })</td><td class="desc"><strong>v9 (BACKLOG-0000931):</strong> register a source feed &mdash; fan-in. Returns a handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>, plus <code>id</code>/<code>size</code>) whose rows are normalized by <code>map</code> and namespaced by <code>key</code> (a prefix string, <code>true</code> to prefix with the source id, or a <code>keyFn(row)</code>) so ids from different feeds cannot collide, then merged through the router's ordinary path &mdash; partitioned, routed, linked, deduped, buffered and written back exactly as the single-source path. <code>feed</code> is an optional source id or an options object.</td></tr>
4521
+ <tr><td class="sig">addSource(feed, { map?, key? })</td><td class="desc"><strong>v9 (BACKLOG-0000931):</strong> register a source feed &mdash; fan-in. Returns a handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>, plus <code>id</code>/<code>size</code>) whose rows are normalized by <code>map</code> and namespaced by <code>key</code> (a prefix string, <code>true</code> to prefix with the source id, or a <code>keyFn(row)</code>) so ids from different feeds cannot collide, then merged through the router's ordinary path &mdash; partitioned, routed, linked, deduped, buffered and written back exactly as the single-source path. <code>feed</code> is an optional source id or an options object. A source may also carry a <code>join</code> spec (v11) to <strong>enrich</strong> its rows with fields looked up from another source.</td></tr>
4377
4522
  <tr><td class="sig">removeSource(ref) / sources()</td><td class="desc"><strong>v9:</strong> drop exactly the rows a feed contributed (by source id or handle) from every route and unregister it; and list the registered source ids.</td></tr>
4378
4523
  <tr><td class="sig">metrics()</td><td class="desc"><strong>v10 (BACKLOG-0000932):</strong> a cheap point-in-time observability snapshot &mdash; per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global <code>unrouted</code> / <code>dropped</code> / <code>buffered</code> / <code>lag</code> figures. Throughput is sampled over the interval since the last <code>metrics()</code> call or emit.</td></tr>
4379
4524
  <tr><td class="sig">on('metrics', handler)</td><td class="desc"><strong>v10:</strong> subscribe to the periodic <code>metrics</code> emit (the <code>metricsInterval</code> ms, default 1000; <code>0</code> disables it). The timer runs only while at least one listener is registered and stops when the last is removed. Returns an unsubscribe function.</td></tr>
@@ -4647,6 +4792,33 @@ router.removeSource('erp'); <span class="cmt">// drops exactl
4647
4792
  g.destroy(); router.destroy();
4648
4793
  <span class="kw">return</span> [merged, ids, afterRemove].join(' | ');</code></pre>
4649
4794
 
4795
+ <p><strong>Fan-in JOIN / enrichment (v11, BACKLOG-0000957).</strong> Fan-in above <em>merges</em> feeds side by side; a <code>join</code> spec goes further and <em>enriches</em> one feed's rows with fields looked up from <em>another</em> registered source &mdash; e.g. an <code>orders</code> feed enriched with <code>name</code>/<code>tier</code> from a <code>customers</code> source keyed by <code>customerId</code>. Declared per source: <code>addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name', 'tier'], missing: 'hold' } })</code>. <code>localKey</code> is the field on the enriched (left) row holding the foreign key (a field name or <code>fn(row)</code>); <code>foreignKey</code> is the field matched on the lookup row (defaults to <code>localKey</code>'s name); <code>fields</code> is what to pull &mdash; an array, a <code>{ src: dest }</code> rename map, or <code>select(lookupRow, leftRow) =&gt; object</code>. No second store is built: the lookup source <em>is</em> an ordinary fan-in source, and the join probes its existing keyed store by an index of join-key&nbsp;&rarr;&nbsp;store-id. <code>missing</code> chooses what happens when the lookup is absent or late: <code>hold</code> withholds the row from viewers until its lookup arrives, <code>passthrough</code> (the default) lets it flow unenriched, and <code>null</code> flows it with the pulled fields set to <code>null</code>. Enriched rows reach viewers through the ordinary keyed-diff path. <strong>Late lookups re-enrich:</strong> when a lookup row arrives, changes, or is deleted, every already-seated left row that references it is re-enriched and re-emitted &mdash; a held row is released, a <code>null</code>/<code>passthrough</code> row gains its fields, and a row whose lookup vanished is nulled/stripped (or, under <code>hold</code>, withheld again). Enrichment always recomputes from the untouched base row, so it is idempotent.</p>
4796
+ <h3 id="datarouter-v11-example">A JOIN with a late lookup, executed</h3>
4797
+ <p class="section-note">An order arrives before its customer, so under <code>hold</code> it is withheld; when the customer
4798
+ feed loads, the order is released and enriched with the looked-up name. Run headless on every build.</p>
4799
+ <pre data-run="js" data-expect="0 | 1 | Acme" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4800
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4801
+
4802
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'kind', field: 'kind' }, { id: 'customerId', field: 'customerId' }, { id: 'name', field: 'name' }];
4803
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
4804
+ <span class="kw">const</span> router = createDataRouter({ key: 'kind', rowKey: 'id' });
4805
+ router.attach(g, 'order');
4806
+
4807
+ <span class="cmt">// Enrich orders with a customer name looked up by customerId; hold until known.</span>
4808
+ <span class="kw">const</span> orders = router.addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name'], missing: 'hold' } });
4809
+ <span class="kw">const</span> customers = router.addSource('customers');
4810
+
4811
+ orders.load([{ id: 'O1', kind: 'order', customerId: 'C1', amount: 100 }]);
4812
+ <span class="kw">const</span> held = g.rows.count(); <span class="cmt">// 0 — withheld until the lookup arrives</span>
4813
+
4814
+ customers.load([{ id: 'C1', kind: 'customer', customerId: 'C1', name: 'Acme' }]);
4815
+ <span class="kw">const</span> released = g.rows.count(); <span class="cmt">// 1 — released and enriched</span>
4816
+ <span class="kw">let</span> name;
4817
+ <span class="kw">for</span> (<span class="kw">let</span> i = 0; i &lt; g.rows.count(); i++) { <span class="kw">const</span> r = g.rows.get(i); <span class="kw">if</span> (r.key === 'O1') name = r.data.name; }
4818
+
4819
+ g.destroy(); router.destroy();
4820
+ <span class="kw">return</span> [held, released, name].join(' | ');</code></pre>
4821
+
4650
4822
  <p><strong>Observability (v10, BACKLOG-0000932).</strong> <code>metrics()</code> is a cheap point-in-time snapshot of the router's runtime &mdash; per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global <code>unrouted</code> / <code>dropped</code> (duplicate) / <code>buffered</code> (buffer depth) / <code>lag</code> figures. Throughput is sampled, so it is measured over the interval since the last read or emit. <code>on('metrics', handler)</code> drives it on a periodic timer (the <code>metricsInterval</code> ms, default 1000; <code>0</code> disables it) and returns an unsubscribe &mdash; the timer runs only while a listener is registered, so collection is off-by-default. <code>mountDevtools(el, { interval? })</code> mounts an opt-in DOM panel (in the module's own <code>devtools.js</code>, so the core stays DOM-free) that renders <code>metrics()</code> and refreshes on each emit.</p>
4651
4823
  <h3 id="datarouter-v10-example">A metrics snapshot, executed</h3>
4652
4824
  <p class="section-note">A snapshot fanned to a route and a sink; the metrics read reports the route's row count and the
@@ -4700,6 +4872,9 @@ plan.applyEdit({ id: 'design', duration: 7 }); // recomputes; the critical path
4700
4872
  <tr><td class="sig">toISODate(day)</td><td class="desc">Format an engine day-number as an ISO calendar date (<code>YYYY-MM-DD</code>, UTC).</td></tr>
4701
4873
  <tr><td class="sig">LINK_TYPES</td><td class="desc">The four dependency link types, in order: <code>['FS','SS','FF','SF']</code>.</td></tr>
4702
4874
  <tr><td class="sig">SCHEDULE_ERROR</td><td class="desc">The error codes the engine reports rather than throwing (<code>cycle</code>, <code>duplicate-id</code>, <code>unknown-task</code>, <code>bad-duration</code>, <code>bad-link-type</code>, <code>unknown-parent</code>, <code>parent-cycle</code>, &hellip;).</td></tr>
4875
+ <tr><td class="sig">importMSPDI(xml, { hoursPerDay? })</td><td class="desc">Import a Microsoft Project (MSPDI) <code>.xml</code> document into a <code>{ tasks, dependencies, resources, projectStart, calendar }</code> model ready for <code>createGantt</code>: the task tree, typed dependencies with lag, constraints, baseline, %complete, resources with capacity and the resource assignments.</td></tr>
4876
+ <tr><td class="sig">exportMSPDI(model, { hoursPerDay?, projectName? })</td><td class="desc">Serialise a Gantt model (optionally a scheduled one) back to Microsoft Project (MSPDI) XML &mdash; the same fields, round-tripping with <code>importMSPDI</code>. The controller offers <code>gantt.toMSPDI()</code> as a shortcut over the current plan.</td></tr>
4877
+ <tr><td class="sig">computeEarnedValue(tasks, schedule, { statusDate?, costField?, actualCostField? })</td><td class="desc">Earned-value management (EVM) from the baseline and %complete at a status date: Planned Value (PV/BCWS), Earned Value (EV/BCWP), Actual Cost (AC/ACWP, from a per-task <code>actualCost</code>), plus Schedule Variance (EV&minus;PV), Cost Variance (EV&minus;AC), SPI (EV/PV) and CPI (EV/AC) &mdash; per task, rolled up to summaries and the project. Budget (BAC) is the task's <code>cost</code>, or its duration when no cost is given. The controller exposes <code>gantt.earnedValue({ statusDate })</code> as the shortcut; the split view surfaces the metrics through <code>kind: 'evm'</code> columns.</td></tr>
4703
4878
  </tbody>
4704
4879
  </table>
4705
4880
  </div>
@@ -4730,6 +4905,35 @@ const tasks = [
4730
4905
  const s = computeSchedule(tasks, [{ from: 'a', to: 'b' }, { from: 'phase', to: 'ship' }]);
4731
4906
  const phase = s.tasks.get('phase');
4732
4907
  return [phase.es, phase.ef, phase.percentComplete, phase.isSummary, s.tasks.get('ship').es].join(' | ');</code></pre>
4908
+ <p><strong>Resource management.</strong> Assign people (or machines) to tasks with <code>assignee</code>/<code>assignees</code> or explicit <code>assignments: [{ resource, units }]</code>, and give each resource a capacity through the <code>resources</code> option. The controller then reports <code>overAllocations</code> &mdash; where one resource is booked beyond its capacity across concurrent tasks &mdash; and the full <code>resourceLoad</code>; the split view rings the over-booked avatars and outlines the clashing bars. <code>gantt.level()</code> auto-shifts the lower-priority tasks later to clear the over-allocation, honouring the critical path and the working-time calendar (pass <code>{ dryRun: true }</code> to preview the moves).</p>
4909
+ <p><strong>Microsoft Project.</strong> <code>importMSPDI</code> reads a Project <code>.xml</code> (MSPDI) document into a model for <code>createGantt</code>, and <code>exportMSPDI</code> (or <code>gantt.toMSPDI()</code>) writes one back &mdash; tasks, typed dependencies with lag, constraints, baseline, %complete, resources and assignments all round-trip.</p>
4910
+ <pre data-run="js" data-expect="1 | 0 | true | 2 | true" data-covers="export:importMSPDI export:exportMSPDI"><code>const { createGantt, exportMSPDI, importMSPDI } = await import('../packages/modules/gantt/index.js');
4911
+ const plan = createGantt({
4912
+ tasks: [
4913
+ { id: '1', name: 'Design', duration: 5, assignee: 'Ada' },
4914
+ { id: '2', name: 'Build', duration: 3, assignee: 'Ada' },
4915
+ ],
4916
+ });
4917
+ const overBefore = plan.overAllocations.length; // Ada is double-booked
4918
+ plan.level(); // shift one task later
4919
+ const overAfter = plan.overAllocations.length; // now clear
4920
+ const xml = exportMSPDI({ tasks: plan.tasks, dependencies: plan.dependencies, schedule: plan.schedule });
4921
+ const back = importMSPDI(xml);
4922
+ return [overBefore, overAfter, back.ok, back.tasks.length, xml.startsWith('&lt;?xml')].join(' | ');</code></pre>
4923
+ <p><strong>Earned-value analytics.</strong> With a captured baseline (<code>captureBaseline</code>) and per-task <code>percentComplete</code>, <code>gantt.earnedValue({ statusDate })</code> (or the standalone <code>computeEarnedValue(tasks, schedule, { statusDate })</code>) reports EVM at a status date &mdash; Planned Value (PV/BCWS) from the baseline, Earned Value (EV/BCWP) from %complete, Actual Cost (AC/ACWP) from a per-task <code>actualCost</code> &mdash; and the derived Schedule Variance (EV&minus;PV), Cost Variance (EV&minus;AC), SPI (EV/PV) and CPI (EV/AC), per task and rolled up to summaries and the project. Give each task a <code>cost</code> for money-based EVM, or omit it for schedule-only EVM off the durations. The split view surfaces any metric through <code>kind: 'evm'</code> columns. This worked example reproduces the classic four-side fence: at the end of day 3, three sides are planned (PV 3000) but two are done (EV 2000) at a cost of 2500 (AC).</p>
4924
+ <pre data-run="js" data-expect="3000 | 2000 | 2500 | -1000 | -500 | 0.67 | 0.80 | 2000" data-covers="export:computeEarnedValue"><code>const { createGantt, computeEarnedValue } = await import('../packages/modules/gantt/index.js');
4925
+ const tasks = [
4926
+ { id: 's1', name: 'Side 1', duration: 1, cost: 1000, percentComplete: 100, actualCost: 1250, baselineStart: 0, baselineEnd: 1 },
4927
+ { id: 's2', name: 'Side 2', duration: 1, cost: 1000, percentComplete: 100, actualCost: 1250, baselineStart: 1, baselineEnd: 2 },
4928
+ { id: 's3', name: 'Side 3', duration: 1, cost: 1000, percentComplete: 0, baselineStart: 2, baselineEnd: 3 },
4929
+ { id: 's4', name: 'Side 4', duration: 1, cost: 1000, percentComplete: 0, baselineStart: 3, baselineEnd: 4 },
4930
+ ];
4931
+ const deps = [{ from: 's1', to: 's2' }, { from: 's2', to: 's3' }, { from: 's3', to: 's4' }];
4932
+ const plan = createGantt({ tasks, dependencies: deps, projectStart: 0 });
4933
+ const evm = plan.earnedValue({ statusDate: 3 }); // via the controller
4934
+ const direct = computeEarnedValue(tasks, plan.schedule, { statusDate: 3 }); // or standalone
4935
+ const p = evm.project;
4936
+ return [p.pv, p.ev, p.ac, p.sv, p.cv, p.spi.toFixed(2), p.cpi.toFixed(2), direct.project.ev].join(' | ');</code></pre>
4733
4937
  <p>Render the plan as an SVG timeline with <code>mount(container, options)</code> &mdash; bars on a time scale, dependency arrows with a per-link-type anchor and a lag/lead label, the critical path highlighted, a today line, optional non-working-day shading, milestones as diamonds, a progress bar-fill and configurable labels. The view redraws itself whenever the schedule recomputes; <code>unmount()</code> detaches it. All geometry is computed from the schedule, so it draws identically headless or in a browser.</p>
4734
4938
  <pre><code>import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';
4735
4939
 
@@ -5818,7 +6022,7 @@ grid.destroy();
5818
6022
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
5819
6023
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
5820
6024
  has not fired yet — so the name is checked rather than left to be discovered.</p>
5821
- <pre data-run="js" data-expect="106" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column: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');
6025
+ <pre data-run="js" data-expect="107" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5822
6026
 
5823
6027
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
5824
6028
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -5827,7 +6031,7 @@ grid.destroy();
5827
6031
  <span class="kw">const</span> documented = [
5828
6032
  'cell:changed', 'cell:clicked', 'cell:confirmed', 'cell:conflict',
5829
6033
  'cell:contextmenu', 'cell:dblclicked', 'cell:edit:end', 'cell:edit:start',
5830
- 'cell:pending', 'cell:reverted', 'clipboard:copy', 'column:filter:open', 'column:grouped',
6034
+ 'cell:pending', 'cell:reverted', 'clipboard:copy', 'column:filter:open', 'column:profile:open', 'column:grouped',
5831
6035
  'column:menu:open', 'column:pivoted', 'column:resized', 'columns:changed',
5832
6036
  'columns:tagged', 'comment:added', 'comment:deleted', 'comment:edited',
5833
6037
  'comment:failed', 'comment:indexLoaded', 'comment:resolved', 'comment:threadClosed',
@@ -6047,6 +6251,9 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6047
6251
  <tr><td class="name">modifiedZScores</td><td class="type">(values, opts?) =&gt; { median, mad, threshold, scores, flags, flagged }</td><td class="desc">Per-row robust outlier score, <code>0.6745·(x − median)/MAD</code>, flagged past <code>threshold</code> (default 3.5). Built on the median and MAD, so one wild reading cannot inflate the spread and hide — the masking effect that fools an ordinary z-score. A non-finite reading and a zero-MAD column yield a null score, not an invented one.</td></tr>
6048
6252
  <tr><td class="name">iqrFences</td><td class="type">(values, opts?) =&gt; { q1, q3, iqr, lower, upper, k } | null</td><td class="desc">Tukey's fences, <code>[Q1 − k·IQR, Q3 + k·IQR]</code> (default <code>k = 1.5</code>), the same fence the box plot draws, on R type 7 quartiles.</td></tr>
6049
6253
  <tr><td class="name">mahalanobis</td><td class="type">(matrix, opts?) =&gt; { center, df, cutoff, singular, used, distances, squared, flags, flagged } | null</td><td class="desc">Distance of every row from the joint centre in the metric of the data's own covariance, cut at a χ² quantile (default the 0.975 point). Catches a row impossible only in combination — heavy <em>and</em> short — that a per-column scan misses. A row with any missing coordinate is left unplaced; a singular covariance is ridge-regularised and reported as <code>singular</code> rather than throwing.</td></tr>
6254
+ <tr><td class="name">ROLLING_ANOMALY_METHODS</td><td class="type">readonly ('rollingModifiedZScore' | 'rollingIqr')[]</td><td class="desc">The rolling (windowed) methods, named alongside <code>ANOMALY_METHODS</code> so a caller can enumerate every detector. Also accepted by <code>grid.statistics.anomalies({ method, windowLen })</code>.</td></tr>
6255
+ <tr><td class="name">rollingAnomalies</td><td class="type">(values, opts?) =&gt; { method, windowLen, minPeriods, threshold, k, scores, flags, flagged }</td><td class="desc">Rolling (windowed) detection (BACKLOG-0000954): judge every reading against a causal trailing window of <code>windowLen</code> ending at it, so a spike is caught against its recent neighbours and a drift never poisons a global baseline. <code>rollingModifiedZScore</code> (median + MAD) or <code>rollingIqr</code> (Tukey fences). With a window as long as the series the last point's score equals the static <code>modifiedZScores</code> one.</td></tr>
6256
+ <tr><td class="name">anomalyCondition</td><td class="type">(opts) =&gt; (rows) =&gt; false | { method, field, flagged }</td><td class="desc">Build a Data Router alert condition from a detector: a <code>(rows) =&gt; signal</code> the router's existing <code>router.alert(value, condition, handler)</code> drives, so live anomaly monitoring reuses the router's partitioning, debounce and rising-edge re-arm rather than duplicating an alert engine. Reads one numeric <code>field</code> per row; <code>latest: true</code> signals only when the newest reading is the anomaly.</td></tr>
6050
6257
  </tbody>
6051
6258
  </table>
6052
6259
  </div>
@@ -6065,6 +6272,21 @@ hw.push([182, 45]);
6065
6272
  const m = mahalanobis(hw); // the χ² cut flags the off-line row
6066
6273
  return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.upper}; joint ${m.flags[12]}`;</code></pre>
6067
6274
 
6275
+ <p class="section-note">Rolling (windowed) detection and the Data Router bridge (BACKLOG-0000954): judge each reading against its trailing window for live monitoring, and turn any detector into a <code>router.alert(...)</code> condition without a second alert engine.</p>
6276
+ <pre data-run="js" data-expect="methods 2; spike true; alert 1" data-covers="export:ROLLING_ANOMALY_METHODS export:rollingAnomalies export:anomalyCondition"><code>const { rollingAnomalies, anomalyCondition, ROLLING_ANOMALY_METHODS } = await import('../packages/core/src/index.js');
6277
+ // A steady stream that suddenly spikes. The trailing window catches the spike
6278
+ // against its recent neighbours, where scoring against the whole run could let a
6279
+ // long earlier drift hide it.
6280
+ const stream = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10, 40];
6281
+ const roll = rollingAnomalies(stream, { windowLen: 6, minPeriods: 4 });
6282
+ // The SAME detector, wired as a Data Router alert condition: (rows) =&gt; signal,
6283
+ // exactly the predicate router.alert(value, condition, handler) already drives —
6284
+ // no second alert engine. `latest` fires only when the newest tick is the one out.
6285
+ const condition = anomalyCondition({ field: 'temp', method: 'rollingModifiedZScore', orderBy: 't', windowLen: 6, minPeriods: 4, latest: true });
6286
+ const rows = stream.map((temp, t) =&gt; ({ t, temp }));
6287
+ const signal = condition(rows);
6288
+ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; alert ${signal ? signal.flagged.length : 0}`;</code></pre>
6289
+
6068
6290
 
6069
6291
  <!-- BEGIN GENERATED TYPE REFERENCE -->
6070
6292
  <h2 id="type-reference">Type reference</h2>
@@ -6365,21 +6587,21 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6365
6587
  </table>
6366
6588
  </div>
6367
6589
  <h3 id="type-ChartAnnotation">ChartAnnotation</h3>
6368
- <p class="section-note">One declarative annotation (BACKLOG-0000744). A reference or target line, a shaded band, or a callout. Its value is a constant `value` (or `from`/`to` for a band), or a `compute` reduction of the data it annotates — `mean`, `median`, `min`, `max`, or `p95` for a percentile — so it follows the data as the grid is filtered. Every annotation names the axis it reads, which on a dual-axis chart is what stops it being placed against the wrong scale, and is written into the accessible table as a sentence.</p>
6590
+ <p class="section-note">One declarative annotation (BACKLOG-0000744, extended by BACKLOG-0000953). A reference or target line, a shaded band, a callout, or an `event` marker. Its value is a constant `value` (or `from`/`to` for a band), or a `compute` reduction of the data it annotates — `mean`, `median`, `min`, `max`, or `p95` for a percentile — so it follows the data as the grid is filtered. A band with `orient: 'vertical'` shades an x-range instead — an event window, a maintenance period — and an `event` marker is a labelled vertical rule with a flag at a position on the x axis. Every annotation names the axis it reads, which on a dual-axis chart is what stops it being placed against the wrong scale, and is written into the accessible table as a sentence — a vertical marker and an event stating the position they sit at, because a screen-reader user needs where and when, not only that a marker exists.</p>
6369
6591
  <div class="table-wrap">
6370
6592
  <table>
6371
6593
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6372
6594
  <tbody>
6373
- <tr><td class="name">kind</td><td class="type">'line' | 'target' | 'band' | 'callout'</td><td class="desc">The default is a reference line. <small>(optional)</small></td></tr>
6595
+ <tr><td class="name">kind</td><td class="type">'line' | 'target' | 'band' | 'callout' | 'event'</td><td class="desc">The default is a reference line. `event` is a labelled vertical marker with a flag at a position on the x axis, described into the accessible table with that position stated (BACKLOG-0000953). <small>(optional)</small></td></tr>
6374
6596
  <tr><td class="name">value</td><td class="type">number</td><td class="desc">A constant value, for a line, target or callout's measure position. <small>(optional)</small></td></tr>
6375
6597
  <tr><td class="name">compute</td><td class="type">'mean' | 'avg' | 'median' | 'min' | 'max' | string</td><td class="desc">A reduction of the annotated data instead of a constant. <small>(optional)</small></td></tr>
6376
- <tr><td class="name">from</td><td class="type">number</td><td class="desc">A band's two edges, each a constant or (with `fromCompute`/`toCompute`) computed. <small>(optional)</small></td></tr>
6377
- <tr><td class="name">to</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
6598
+ <tr><td class="name">from</td><td class="type">number | string</td><td class="desc">A band's two edges. On a horizontal band each is a measure value, a constant or (with `fromCompute`/`toCompute`) computed. On a vertical band (`orient: 'vertical'`, BACKLOG-0000953) each is an x position — a category or a number — and the band shades the x-range between them: an event window, a maintenance period, a recession. <small>(optional)</small></td></tr>
6599
+ <tr><td class="name">to</td><td class="type">number | string</td><td class="desc"><small>(optional)</small></td></tr>
6378
6600
  <tr><td class="name">fromCompute</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
6379
6601
  <tr><td class="name">toCompute</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
6380
- <tr><td class="name">x</td><td class="type">unknown</td><td class="desc">A vertical line's or callout's x position: a category or a number. <small>(optional)</small></td></tr>
6602
+ <tr><td class="name">x</td><td class="type">unknown</td><td class="desc">A vertical line's, event marker's or callout's x position: a category or a number. <small>(optional)</small></td></tr>
6381
6603
  <tr><td class="name">at</td><td class="type">unknown</td><td class="desc"><small>(optional)</small></td></tr>
6382
- <tr><td class="name">orient</td><td class="type">'horizontal' | 'vertical'</td><td class="desc">Force a line vertical rather than horizontal. <small>(optional)</small></td></tr>
6604
+ <tr><td class="name">orient</td><td class="type">'horizontal' | 'vertical'</td><td class="desc">Force a line vertical rather than horizontal, or shade a `band` across an x-range rather than a measure range (BACKLOG-0000953). <small>(optional)</small></td></tr>
6383
6605
  <tr><td class="name">axis</td><td class="type">'left' | 'right' | 'y2'</td><td class="desc">Which measure axis the annotation reads. <small>(optional)</small></td></tr>
6384
6606
  <tr><td class="name">series</td><td class="type">string</td><td class="desc">Restrict a `compute` to one series, by its key. <small>(optional)</small></td></tr>
6385
6607
  <tr><td class="name">label</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
@@ -6461,6 +6683,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6461
6683
  <tr><td class="name">font</td><td class="type">object</td><td class="desc"><small>(optional)</small></td></tr>
6462
6684
  <tr><td class="name">margin</td><td class="type">number | { top?: number; right?: number; bottom?: number; left?: number }</td><td class="desc"><small>(optional)</small></td></tr>
6463
6685
  <tr><td class="name">fit</td><td class="type">boolean | 'line'</td><td class="desc">A least-squares line through a scatter or bubble chart, one per series. `true` draws the line and its R²; `'line'` draws the line alone. Only where the x axis is numeric: on a band scale the positions are categories in an arbitrary order, and a slope through them would be a slope through the order they happened to be listed in. <small>(optional)</small></td></tr>
6686
+ <tr><td class="name">trend</td><td class="type">boolean | ChartTrendMethod | ChartTrend | Array&lt;ChartTrendMethod | ChartTrend&gt;</td><td class="desc">Trend and forecast overlays (BACKLOG-0000952): a least-squares line, a trailing moving average, or exponential smoothing, drawn over a line, area or scatter chart. `true` draws a single linear trend; a method name or a {@link ChartTrend} object configures one; an array draws several. The maths matches the core stats engine to the last digit — the same least-squares fit, rolling window and exponential recursions — but is computed locally in the charts module rather than imported, because the in-tree bundler does not tree-shake and the import would inline the whole statistics closure; a test asserts the parity. A `forecast` count projects the linear line that many steps past the data, drawn dashed so it never reads as a reading; a moving average and a smoothed level have no slope to project, so `forecast` is ignored for them and the fact is stated in the accessible description rather than faked. <small>(optional)</small></td></tr>
6464
6687
  <tr><td class="name">band</td><td class="type">(RegressionBand &amp; { line?: boolean }) | null</td><td class="desc">A pointwise confidence band, drawn as a varying-width ribbon beneath the fit line (BACKLOG-0000812). Fed by a fitted model's own interval — the `band` from {@link StatisticsApi.regressionModel}, or as produced by {@link regressionPlots} — so the ribbon and the diagnostics report the one computation rather than a slope redrawn here. `line: false` suppresses the band's own centre line, for a chart that already draws the fit with `fit`. Only where the x axis is numeric, for the same reason `fit` is. <small>(optional)</small></td></tr>
6465
6688
  <tr><td class="name">points</td><td class="type">{</td><td class="desc">An explicit point set, bypassing the by-column binder (BACKLOG-0000872): a cartesian chart whose values are not a grid column — a scale-location plot's √|standardised residual|, a coefficient forest's per-coefficient estimate — hands its points in directly. Each is `{x, y}` with an optional `label`, `size` (a bubble's third channel) and `lower`/`upper` (interval bounds the error-bar primitive reads). Numeric `x` throughout gives a continuous axis. <small>(optional)</small></td></tr>
6466
6689
  <tr><td class="name">error</td><td class="type">boolean | { of?: string; confidence?: number }</td><td class="desc">Whiskers showing the uncertainty in each mark. `true` computes a confidence interval from the readings behind the mark; `of` takes a symmetric margin from another column instead. <small>(optional)</small></td></tr>
@@ -6499,6 +6722,23 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6499
6722
  </tbody>
6500
6723
  </table>
6501
6724
  </div>
6725
+ <h3 id="type-ChartTrend">ChartTrend</h3>
6726
+ <p class="section-note">One trend or forecast overlay (BACKLOG-0000952).</p>
6727
+ <div class="table-wrap">
6728
+ <table>
6729
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6730
+ <tbody>
6731
+ <tr><td class="name">method</td><td class="type">ChartTrendMethod</td><td class="desc">The overlay method; `linear` by default. <small>(optional)</small></td></tr>
6732
+ <tr><td class="name">forecast</td><td class="type">number</td><td class="desc">For the linear method, how many steps to project the line past the data as a dashed forecast. Ignored by the moving-average and exponential methods, which have no slope to extrapolate. <small>(optional)</small></td></tr>
6733
+ <tr><td class="name">window</td><td class="type">number</td><td class="desc">For the moving-average method, the trailing window in points; 3 by default. <small>(optional)</small></td></tr>
6734
+ <tr><td class="name">period</td><td class="type">number</td><td class="desc">An alias for `window`. <small>(optional)</small></td></tr>
6735
+ <tr><td class="name">kind</td><td class="type">'ses' | 'holt'</td><td class="desc">For the exponential method, single smoothing (`ses`) or Holt's level+trend (`holt`). <small>(optional)</small></td></tr>
6736
+ <tr><td class="name">alpha</td><td class="type">number</td><td class="desc">For the exponential method, the level factor in `[0, 1]`; omit to fit it. <small>(optional)</small></td></tr>
6737
+ <tr><td class="name">beta</td><td class="type">number</td><td class="desc">For Holt's exponential smoothing, the trend factor in `[0, 1]`; omit to fit it. <small>(optional)</small></td></tr>
6738
+ <tr><td class="name">label</td><td class="type">boolean</td><td class="desc">`false` suppresses the R² label on a linear trend. <small>(optional)</small></td></tr>
6739
+ </tbody>
6740
+ </table>
6741
+ </div>
6502
6742
  <h3 id="type-Chunk">Chunk</h3>
6503
6743
  <div class="table-wrap">
6504
6744
  <table>
@@ -6537,6 +6777,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6537
6777
  <tr><td class="name">value</td><td class="type">ColumnValueSpec</td><td class="desc">A computed value, with the columns it depends on, in place of a stored one. <small>(optional)</small></td></tr>
6538
6778
  <tr><td class="name">cell</td><td class="type">ColumnCellSpec | string</td><td class="desc">The renderer, and what it is given. A string names a registered renderer. <small>(optional)</small></td></tr>
6539
6779
  <tr><td class="name">edit</td><td class="type">ColumnEditSpec | boolean | string</td><td class="desc">Whether and how the cell can be edited. A string names an editor. <small>(optional)</small></td></tr>
6780
+ <tr><td class="name">validation</td><td class="type">ColumnValidation</td><td class="desc">Declarative edit-validation rules (BACKLOG-0000956). Each is checked against a value before it is written, through the `beforeEdit` before-event: a failing value cancels the commit and marks the cell. Distinct from and complementary to `edit.validate`, which is an imperative function. <small>(optional)</small></td></tr>
6540
6781
  <tr><td class="name">sort</td><td class="type">ColumnSortSpec | boolean</td><td class="desc">Whether the column sorts, and by what comparison. `false` refuses it. <small>(optional)</small></td></tr>
6541
6782
  <tr><td class="name">filter</td><td class="type">ColumnFilterSpec | boolean | FilterName</td><td class="desc">Whether the column filters, and with which filter. A string names one. <small>(optional)</small></td></tr>
6542
6783
  <tr><td class="name">group</td><td class="type">{</td><td class="desc">Row grouping by this column. `index` fixes its place among several; `explode` gives a multi-value cell one group per value rather than one group for the combination. `granularity` and `weekStart` apply to a `timestamp` column: it buckets by civil `day` (the default), `week` or `month` in the display zone, or `instant` for one group per exact moment. `weekStart` is the first weekday, 1=Monday (default) to 7=Sunday. <small>(optional)</small></td></tr>
@@ -6765,6 +7006,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6765
7006
  <tr><td class="name">stddev</td><td class="type">number | null</td><td class="desc"></td></tr>
6766
7007
  <tr><td class="name">outliers</td><td class="type">number</td><td class="desc"></td></tr>
6767
7008
  <tr><td class="name">histogram</td><td class="type">HistogramBin[]</td><td class="desc"></td></tr>
7009
+ <tr><td class="name">topValues</td><td class="type">TopValue[]</td><td class="desc">For a categorical (non-numeric) column, the commonest values, largest first (BACKLOG-0000959). Absent for a numeric column, whose shape the numeric figures and the histogram already carry. <small>(optional)</small></td></tr>
6768
7010
  </tbody>
6769
7011
  </table>
6770
7012
  </div>
@@ -6837,6 +7079,26 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
6837
7079
  </tbody>
6838
7080
  </table>
6839
7081
  </div>
7082
+ <h3 id="type-ColumnValidation">ColumnValidation</h3>
7083
+ <p class="section-note">Declarative edit-validation rules for a column (BACKLOG-0000956). Rules are checked in a fixed order — `required` first, then the value-shape rules, then the functions — and the first failure wins. A blank but optional value passes everything after `required`: an empty cell is empty, not "below the minimum". A failure vetoes the commit through `beforeEdit` and marks the cell; the cancellation carries `reason: 'validation:&lt;code&gt;'`.</p>
7084
+ <div class="table-wrap">
7085
+ <table>
7086
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7087
+ <tbody>
7088
+ <tr><td class="name">required</td><td class="type">boolean | string</td><td class="desc">The value may not be blank. A string is used as the message. <small>(optional)</small></td></tr>
7089
+ <tr><td class="name">min</td><td class="type">number</td><td class="desc">Minimum, for a number or a date. <small>(optional)</small></td></tr>
7090
+ <tr><td class="name">max</td><td class="type">number</td><td class="desc">Maximum, for a number or a date. <small>(optional)</small></td></tr>
7091
+ <tr><td class="name">minLength</td><td class="type">number</td><td class="desc">Minimum text length. <small>(optional)</small></td></tr>
7092
+ <tr><td class="name">maxLength</td><td class="type">number</td><td class="desc">Maximum text length. <small>(optional)</small></td></tr>
7093
+ <tr><td class="name">pattern</td><td class="type">string | RegExp</td><td class="desc">A pattern the whole value must match. A string is a RegExp source. <small>(optional)</small></td></tr>
7094
+ <tr><td class="name">oneOf</td><td class="type">unknown[]</td><td class="desc">The value must be one of these. <small>(optional)</small></td></tr>
7095
+ <tr><td class="name">crossField</td><td class="type">(value: unknown, row: unknown, ctx: { key: string; colId: string; changes: unknown[] }) =&gt; true | string | void</td><td class="desc">A cross-field rule: return `true` to pass, or a message string to fail. The row is passed so a rule can compare against its siblings. <small>(optional)</small></td></tr>
7096
+ <tr><td class="name">validate</td><td class="type">(value: unknown, row: unknown, ctx: { key: string; colId: string; changes: unknown[] }) =&gt; true | string | void</td><td class="desc">A free-form check, the same contract as `crossField`. <small>(optional)</small></td></tr>
7097
+ <tr><td class="name">message</td><td class="type">string</td><td class="desc">A default message for any rule without its own. <small>(optional)</small></td></tr>
7098
+ <tr><td class="name">messages</td><td class="type">Record&lt;string, string&gt;</td><td class="desc">Per-rule messages, keyed by rule name (`required`, `min`, `pattern`, …). <small>(optional)</small></td></tr>
7099
+ </tbody>
7100
+ </table>
7101
+ </div>
6840
7102
  <h3 id="type-ColumnValueSpec">ColumnValueSpec</h3>
6841
7103
  <div class="table-wrap">
6842
7104
  <table>
@@ -7032,6 +7294,26 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7032
7294
  </tbody>
7033
7295
  </table>
7034
7296
  </div>
7297
+ <h3 id="type-DataBarSpec">DataBarSpec</h3>
7298
+ <p class="section-note">An in-cell proportional bar (BACKLOG-0000955). Drawn as a CSS gradient on the cell background — no extra element, and it composes with the cell's text. The bar's length is the value's position between `min` and `max`. Give both to pin the scale (0 to 100 for a percentage); otherwise `from` derives them from the column — `'minmax'` (the default) spans the data, `'quantile'` the 5th–95th percentile, `'stddev'` a number of deviations either side of the mean. When the range straddles zero, bars grow from a shared axis: positive right, negative left, each in its own colour.</p>
7299
+ <div class="table-wrap">
7300
+ <table>
7301
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7302
+ <tbody>
7303
+ <tr><td class="name">min</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7304
+ <tr><td class="name">max</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7305
+ <tr><td class="name">from</td><td class="type">'minmax' | 'quantile' | 'stddev'</td><td class="desc"><small>(optional)</small></td></tr>
7306
+ <tr><td class="name">low</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7307
+ <tr><td class="name">high</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7308
+ <tr><td class="name">deviations</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7309
+ <tr><td class="name">colour</td><td class="type">string</td><td class="desc">The fill for non-negative values. <small>(optional)</small></td></tr>
7310
+ <tr><td class="name">color</td><td class="type">string</td><td class="desc">American spelling of `colour`. <small>(optional)</small></td></tr>
7311
+ <tr><td class="name">negativeColour</td><td class="type">string</td><td class="desc">The fill for negative values. <small>(optional)</small></td></tr>
7312
+ <tr><td class="name">negativeColor</td><td class="type">string</td><td class="desc">American spelling of `negativeColour`. <small>(optional)</small></td></tr>
7313
+ <tr><td class="name">direction</td><td class="type">'ltr' | 'rtl'</td><td class="desc">Which way the bar grows. `'ltr'` (the default) or `'rtl'`. <small>(optional)</small></td></tr>
7314
+ </tbody>
7315
+ </table>
7316
+ </div>
7035
7317
  <h3 id="type-DatasetColumnDifference">DatasetColumnDifference</h3>
7036
7318
  <div class="table-wrap">
7037
7319
  <table>
@@ -7295,6 +7577,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7295
7577
  <tr><td class="name">status</td><td class="type">(key: string, colId: string): 'pending' | null</td><td class="desc"></td></tr>
7296
7578
  <tr><td class="name">addRow</td><td class="type">(row: object): string | null</td><td class="desc">Append a row to a remote source optimistically and persist it (§5.3), the structural analog of the cell edit path. The row shows immediately under a client temp key, and `adapter.mutate({ kind: 'append', rows: [row] })` is asked to persist it; when the server returns the real key the row is rekeyed everywhere the grid tracks it and `row:confirmed` fires, while a refused append is removed and fires `row:reverted`. Only wired when the source declares `mutate.append`; otherwise it warns once and returns null.</td></tr>
7297
7579
  <tr><td class="name">deleteRow</td><td class="type">(key: string): string | null</td><td class="desc">Delete a row from a remote source optimistically and persist it (§5.3). The row is tombstoned immediately and `adapter.mutate({ kind: 'delete', keys: [key] })` is asked to remove it; on confirmation the row is purged and `row:confirmed` fires, on refusal it is restored and `row:reverted` fires. Only wired when the source declares `mutate.delete`; otherwise it warns once and returns null.</td></tr>
7580
+ <tr><td class="name">deleteRows</td><td class="type">(keys?: string | string[], opts?: { origin?: string }): string[] | Promise&lt;string[]&gt;</td><td class="desc">Delete rows on a user gesture, through the cancellable `beforeDelete` event (§18.4, BACKLOG-0000968) — what the built-in Delete-key and "Delete row" gestures call. Unlike {@link deleteRow}, `beforeDelete` fires on a memory-source grid too, so the row can be confirmed or vetoed there. Off until `config.rowDelete` opts in; the keys default to the row selection. Returns the keys removed (empty on a veto or when disabled), or a Promise of them when a `beforeDelete` handler deferred.</td></tr>
7298
7581
  <tr><td class="name">settleRow</td><td class="type">(id: string, ok: boolean, reason?: string, reconcile?: { key?: string; row?: unknown; conflict?: { serverRow?: unknown } }): boolean</td><td class="desc">Report the outcome of an optimistic structural write (§5.3), the counterpart to {@link settle} for `edit.confirm: 'manual'` over a backend that acknowledges an append/delete on a separate channel. The id arrives on `row:pending`.</td></tr>
7299
7582
  <tr><td class="name">rowStatus</td><td class="type">(key: string): 'pending' | null</td><td class="desc">Whether a row has a structural op in flight (§5.3).</td></tr>
7300
7583
  <tr><td class="name">pendingRows</td><td class="type">(): OpenRowOp[]</td><td class="desc">Every structural op still awaiting an outcome (§5.3), oldest first; always empty when the source cannot append or delete.</td></tr>
@@ -7578,7 +7861,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7578
7861
  </table>
7579
7862
  </div>
7580
7863
  <h3 id="type-FormattingRule">FormattingRule</h3>
7581
- <p class="section-note">One rule. Either a condition and the styling it produces, or a colour scale. A rule held as runtime state must be JSON, so `style` may not be a function there: config-time `cell.style` still accepts one.</p>
7864
+ <p class="section-note">One rule. A condition and the styling it produces, a colour scale, a data bar or an icon set. A rule held as runtime state must be JSON, so `style` may not be a function there (config-time `cell.style` still accepts one) and a data bar / icon set / scale is the JSON way to say the same visual intent.</p>
7582
7865
  <div class="table-wrap">
7583
7866
  <table>
7584
7867
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
@@ -7587,10 +7870,10 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7587
7870
  <tr><td class="name">when</td><td class="type">FormattingCondition</td><td class="desc"><small>(optional)</small></td></tr>
7588
7871
  <tr><td class="name">style</td><td class="type">CellStyle | ((p: CellParams) =&gt; CellStyle | null)</td><td class="desc"><small>(optional)</small></td></tr>
7589
7872
  <tr><td class="name">scale</td><td class="type">FormattingScale</td><td class="desc"><small>(optional)</small></td></tr>
7873
+ <tr><td class="name">dataBar</td><td class="type">DataBarSpec</td><td class="desc">An in-cell proportional bar (BACKLOG-0000955). <small>(optional)</small></td></tr>
7874
+ <tr><td class="name">iconSet</td><td class="type">IconSetSpec</td><td class="desc">A per-band glyph beside the value (BACKLOG-0000955). <small>(optional)</small></td></tr>
7590
7875
  <tr><td class="name">stopIfTrue</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
7591
7876
  <tr><td class="name">enabled</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
7592
- <tr><td class="name">icon</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
7593
- <tr><td class="name">bar</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
7594
7877
  <tr><td class="name">label</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
7595
7878
  </tbody>
7596
7879
  </table>
@@ -7638,6 +7921,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7638
7921
  <tr><td class="name">edit</td><td class="type">EditApi</td><td class="desc">Editing sessions: starting, committing and cancelling them. <small>(read-only)</small></td></tr>
7639
7922
  <tr><td class="name">scroll</td><td class="type">ScrollApi</td><td class="desc">Where the viewport is, and moving it. <small>(read-only)</small></td></tr>
7640
7923
  <tr><td class="name">export</td><td class="type">ExportApi</td><td class="desc">CSV, Excel and clipboard. <small>(read-only)</small></td></tr>
7924
+ <tr><td class="name">import</td><td class="type">ImportApi</td><td class="desc">Bringing rows in from CSV/TSV text, a file, the clipboard or a drop. <small>(read-only)</small></td></tr>
7641
7925
  <tr><td class="name">state</td><td class="type">StateApi</td><td class="desc">Everything the user arranged, as a serialisable object. <small>(read-only)</small></td></tr>
7642
7926
  <tr><td class="name">overlay</td><td class="type">OverlayApi</td><td class="desc">The loading, empty and error surfaces drawn over the grid. <small>(read-only)</small></td></tr>
7643
7927
  <tr><td class="name">history</td><td class="type">HistoryApi</td><td class="desc">Undo and redo over edits and structural changes. <small>(read-only)</small></td></tr>
@@ -7664,6 +7948,7 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7664
7948
  <tr><td class="name">diagnostics</td><td class="type">DiagnosticsApi</td><td class="desc">What the grid is doing, for when it is doing it slowly. <small>(read-only)</small></td></tr>
7665
7949
  <tr><td class="name">statistics</td><td class="type">StatisticsApi</td><td class="desc">Reductions, profiles, correlations, capability and intervals. <small>(read-only)</small></td></tr>
7666
7950
  <tr><td class="name">formatting</td><td class="type">FormattingApi</td><td class="desc">Formatting a value as the grid would, outside a cell. <small>(read-only)</small></td></tr>
7951
+ <tr><td class="name">validation</td><td class="type">ValidationApi</td><td class="desc">Declarative column validation: why a write was refused, and clearing marks. <small>(read-only)</small></td></tr>
7667
7952
  <tr><td class="name">maximise</td><td class="type">MaximiseApi</td><td class="desc">Full-screen control, where it is enabled. <small>(read-only, optional)</small></td></tr>
7668
7953
  <tr><td class="name">element</td><td class="type">HTMLElement | null</td><td class="desc">The element you passed to `createGrid`, not the grid's own root. The grid builds its `.lattice` root *inside* that element, so `el.closest('.lattice')` never matches this, and a theme attribute set on it has no effect, the theme is read from the root within. Use `element.querySelector('.lattice')` for the grid's own root. <small>(read-only)</small></td></tr>
7669
7954
  <tr><td class="name">destroyed</td><td class="type">boolean</td><td class="desc">Whether `destroy` has run. Every other member is inert afterwards. <small>(read-only)</small></td></tr>
@@ -7759,6 +8044,8 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7759
8044
  <tr><td class="name">columnVirtualisationAbove</td><td class="type">number</td><td class="desc">Render only the visible columns once there are more than this many. <small>(optional)</small></td></tr>
7760
8045
  <tr><td class="name">statusBar</td><td class="type">boolean | { panels?: string[] }</td><td class="desc">The bar beneath the grid, and which panels it carries. <small>(optional)</small></td></tr>
7761
8046
  <tr><td class="name">contextMenu</td><td class="type">boolean | ((p: CellMenuParams, defaults: MenuItem[]) =&gt; MenuItem[] | void)</td><td class="desc">The cell right-click menu. A function supplies custom items; `false` suppresses it entirely, which is what a read-only grid wants, the default menu offers Paste, Clear and Fill down. <small>(optional)</small></td></tr>
8047
+ <tr><td class="name">import</td><td class="type">boolean | ImportSettings</td><td class="desc">Bringing rows in from a file, the clipboard or a drop (§14, the mirror of export). `true` adds a "Import rows from CSV…" item to the cell menu, makes the grid a drop target for `.csv`/`.tsv` files, and reads a pasted spreadsheet block, each opening a preview the user confirms. An object tunes the affordances. Off by default; the `grid.import` API is always present. Import is a client-side data operation, so it applies to a memory grid. <small>(optional)</small></td></tr>
8048
+ <tr><td class="name">rowDelete</td><td class="type">boolean</td><td class="desc">Enable the built-in row-delete gesture (§18.4, BACKLOG-0000968) — the Delete/Backspace key on selected rows and a "Delete row" cell-menu item — and the `grid.edit.deleteRows` API. Off by default, because deleting data on a keystroke is destructive and opt-in. Every deletion flows through the cancellable `beforeDelete` event, so a handler can confirm or veto it, on a memory-source grid as well as a remote one. <small>(optional)</small></td></tr>
7762
8049
  <tr><td class="name">columnMenu</td><td class="type">boolean | ((p: ColumnMenuParams, defaults: MenuItem[]) =&gt; MenuItem[] | void)</td><td class="desc">The header's 3-dot menu, and the right-click menu on a column heading. `false` suppresses both. A function supplies custom items, receiving the grid's own so it can add to them rather than reproduce them. Default true. <small>(optional)</small></td></tr>
7763
8050
  <tr><td class="name">rangeChart</td><td class="type"></td><td class="desc">Chart a selected cell range — the spreadsheet "chart this selection" gesture. Off by default, so a grid opts in. The DOM layer draws no charts itself — the charts module is optional and loaded by the host — so this is where the host wires the two together: a function, or an object carrying `onChart`, is called with the grid and the selected range when the reader chooses "Chart selection" from the cell menu. The handler typically calls `chartRange` from `lattice-grid/modules/charts`. `true` offers the item and emits nothing extra; supply a handler to have it actually draw. <small>(optional)</small></td></tr>
7764
8051
  <tr><td class="name">shortcuts</td><td class="type">boolean</td><td class="desc">The `?` keyboard shortcut overlay. `false` suppresses it, for a host that wants `?` for itself. Default true. <small>(optional)</small></td></tr>
@@ -7958,6 +8245,77 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
7958
8245
  </tbody>
7959
8246
  </table>
7960
8247
  </div>
8248
+ <h3 id="type-IconSetSpec">IconSetSpec</h3>
8249
+ <p class="section-note">An icon set (BACKLOG-0000955): a glyph placed beside the value by the band it falls in. Drawn as a `background-image` with padding, so it too needs no extra element and stays a plain style value. `set` names a built-in — `'arrows'`, `'trafficLights'` or `'ratings'` (see {@link ICON_SETS}) — or supply your own ordered `icons` (SVG documents, data URIs or `url(...)` values). Bands are split at `thresholds` (ascending, one fewer than the icons); without them the column's distribution is cut into equal-count bands. `reverse` flips the order so a high value can read as red.</p>
8250
+ <div class="table-wrap">
8251
+ <table>
8252
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8253
+ <tbody>
8254
+ <tr><td class="name">set</td><td class="type">'arrows' | 'trafficLights' | 'ratings' | string</td><td class="desc"><small>(optional)</small></td></tr>
8255
+ <tr><td class="name">icons</td><td class="type">string[]</td><td class="desc">Your own glyphs, low value first: SVG documents, data URIs or `url(...)`. <small>(optional)</small></td></tr>
8256
+ <tr><td class="name">count</td><td class="type">number</td><td class="desc">How many bands, where the set's size is not fixed (e.g. `'ratings'`). <small>(optional)</small></td></tr>
8257
+ <tr><td class="name">thresholds</td><td class="type">number[]</td><td class="desc">Band edges, ascending; one fewer than the number of icons. <small>(optional)</small></td></tr>
8258
+ <tr><td class="name">reverse</td><td class="type">boolean</td><td class="desc">Reverse the glyph order, so the highest band takes the first icon. <small>(optional)</small></td></tr>
8259
+ <tr><td class="name">size</td><td class="type">number</td><td class="desc">Glyph height in pixels. Default 16. <small>(optional)</small></td></tr>
8260
+ </tbody>
8261
+ </table>
8262
+ </div>
8263
+ <h3 id="type-ImportApi">ImportApi</h3>
8264
+ <p class="section-note">Bringing rows in — the mirror of {@link ExportApi} (§14, BACKLOG-0000949).</p>
8265
+ <div class="table-wrap">
8266
+ <table>
8267
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8268
+ <tbody>
8269
+ <tr><td class="name">preview</td><td class="type">(text: string, opts?: object): ImportPreview</td><td class="desc">Parse delimited text into a preview, changing nothing.</td></tr>
8270
+ <tr><td class="name">csv</td><td class="type">(text: string, opts?: object): Record&lt;string, unknown&gt;[]</td><td class="desc">Parse delimited text into coerced records — the inverse of `export.csv`.</td></tr>
8271
+ <tr><td class="name">apply</td><td class="type">(</td><td class="desc">Add or replace the grid's rows from text, a preview or records.</td></tr>
8272
+ </tbody>
8273
+ </table>
8274
+ </div>
8275
+ <h3 id="type-ImportColumn">ImportColumn</h3>
8276
+ <p class="section-note">One source column as understood by the importer, after type inference (§14).</p>
8277
+ <div class="table-wrap">
8278
+ <table>
8279
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8280
+ <tbody>
8281
+ <tr><td class="name">source</td><td class="type">string</td><td class="desc">The heading as written in the file.</td></tr>
8282
+ <tr><td class="name">index</td><td class="type">number</td><td class="desc">The column's position in each row.</td></tr>
8283
+ <tr><td class="name">field</td><td class="type">string</td><td class="desc">The grid field this column maps onto; empty to exclude it from the import.</td></tr>
8284
+ <tr><td class="name">type</td><td class="type">string</td><td class="desc">The inferred (or grid-dictated) type used to coerce the column's values.</td></tr>
8285
+ <tr><td class="name">samples</td><td class="type">string[]</td><td class="desc">A few non-blank sample values, for the preview.</td></tr>
8286
+ <tr><td class="name">matched</td><td class="type">boolean</td><td class="desc">Whether the heading matched one of the grid's own columns.</td></tr>
8287
+ </tbody>
8288
+ </table>
8289
+ </div>
8290
+ <h3 id="type-ImportPreview">ImportPreview</h3>
8291
+ <p class="section-note">What a preview carries — everything a confirm dialog needs (§14).</p>
8292
+ <div class="table-wrap">
8293
+ <table>
8294
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8295
+ <tbody>
8296
+ <tr><td class="name">delimiter</td><td class="type">string</td><td class="desc">The delimiter that was used, detected or supplied.</td></tr>
8297
+ <tr><td class="name">header</td><td class="type">string[]</td><td class="desc">The source column headings.</td></tr>
8298
+ <tr><td class="name">columns</td><td class="type">ImportColumn[]</td><td class="desc">The per-column mapping and inference the user may edit before confirming.</td></tr>
8299
+ <tr><td class="name">records</td><td class="type">Record&lt;string, unknown&gt;[]</td><td class="desc">Every mapped, coerced record the import would add.</td></tr>
8300
+ <tr><td class="name">sample</td><td class="type">Record&lt;string, unknown&gt;[]</td><td class="desc">The leading records, for a preview table.</td></tr>
8301
+ <tr><td class="name">rowCount</td><td class="type">number</td><td class="desc">How many data rows the file holds.</td></tr>
8302
+ <tr><td class="name">warnings</td><td class="type">string[]</td><td class="desc">Anything worth flagging before confirming — a ragged file, a bad quote.</td></tr>
8303
+ </tbody>
8304
+ </table>
8305
+ </div>
8306
+ <h3 id="type-ImportSettings">ImportSettings</h3>
8307
+ <p class="section-note">How `config.import` tunes the DOM import affordances (§14).</p>
8308
+ <div class="table-wrap">
8309
+ <table>
8310
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8311
+ <tbody>
8312
+ <tr><td class="name">file</td><td class="type">boolean</td><td class="desc">Add the cell-menu item and open a file picker for CSV/TSV. Default true. <small>(optional)</small></td></tr>
8313
+ <tr><td class="name">drop</td><td class="type">boolean</td><td class="desc">Make the grid a drop target for `.csv`/`.tsv` files. Default true. <small>(optional)</small></td></tr>
8314
+ <tr><td class="name">paste</td><td class="type">boolean</td><td class="desc">Read a pasted spreadsheet block into a preview. Default true. <small>(optional)</small></td></tr>
8315
+ <tr><td class="name">mode</td><td class="type">'append' | 'replace'</td><td class="desc">How a confirmed import lands: append (default) or replace the dataset. <small>(optional)</small></td></tr>
8316
+ </tbody>
8317
+ </table>
8318
+ </div>
7961
8319
  <h3 id="type-IngestConfig">IngestConfig</h3>
7962
8320
  <p class="section-note">How rows are ingested into the column store.</p>
7963
8321
  <div class="table-wrap">
@@ -9154,6 +9512,18 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
9154
9512
  </tbody>
9155
9513
  </table>
9156
9514
  </div>
9515
+ <h3 id="type-TopValue">TopValue</h3>
9516
+ <p class="section-note">One row of a categorical column's top-values table (BACKLOG-0000959).</p>
9517
+ <div class="table-wrap">
9518
+ <table>
9519
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9520
+ <tbody>
9521
+ <tr><td class="name">value</td><td class="type">unknown</td><td class="desc">The value itself, as it is stored.</td></tr>
9522
+ <tr><td class="name">count</td><td class="type">number</td><td class="desc">How many present rows carry it.</td></tr>
9523
+ <tr><td class="name">share</td><td class="type">number</td><td class="desc">Its share of the present values, 0 to 1.</td></tr>
9524
+ </tbody>
9525
+ </table>
9526
+ </div>
9157
9527
  <h3 id="type-TreeConfig">TreeConfig</h3>
9158
9528
  <div class="table-wrap">
9159
9529
  <table>
@@ -9233,6 +9603,34 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
9233
9603
  </tbody>
9234
9604
  </table>
9235
9605
  </div>
9606
+ <h3 id="type-ValidationApi">ValidationApi</h3>
9607
+ <p class="section-note">The runtime face of declarative column validation (BACKLOG-0000956).</p>
9608
+ <div class="table-wrap">
9609
+ <table>
9610
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9611
+ <tbody>
9612
+ <tr><td class="name">check</td><td class="type">(colId: string, value: unknown, row?: unknown): { code: string; message: string } | null</td><td class="desc">Run a column's rules against a value, returning the first failure or null.</td></tr>
9613
+ <tr><td class="name">errorFor</td><td class="type">(key: string, colId: string): ValidationError | null</td><td class="desc">The recorded error for one cell, or null when it is valid.</td></tr>
9614
+ <tr><td class="name">errors</td><td class="type">(): ValidationError[]</td><td class="desc">Every cell that currently holds a validation error.</td></tr>
9615
+ <tr><td class="name">clear</td><td class="type">(key?: string, colId?: string): boolean</td><td class="desc">Clear errors: one cell, a whole row, or all of them.</td></tr>
9616
+ <tr><td class="name">define</td><td class="type">(colId: string, spec: ColumnValidation | null): void</td><td class="desc">Set or replace a column's rules at runtime; null removes them.</td></tr>
9617
+ <tr><td class="name">active</td><td class="type">boolean</td><td class="desc">Whether at least one column declares a rule. <small>(read-only)</small></td></tr>
9618
+ </tbody>
9619
+ </table>
9620
+ </div>
9621
+ <h3 id="type-ValidationError">ValidationError</h3>
9622
+ <p class="section-note">One recorded validation error (BACKLOG-0000956).</p>
9623
+ <div class="table-wrap">
9624
+ <table>
9625
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9626
+ <tbody>
9627
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc"></td></tr>
9628
+ <tr><td class="name">colId</td><td class="type">string</td><td class="desc"></td></tr>
9629
+ <tr><td class="name">code</td><td class="type">string</td><td class="desc"></td></tr>
9630
+ <tr><td class="name">message</td><td class="type">string</td><td class="desc"></td></tr>
9631
+ </tbody>
9632
+ </table>
9633
+ </div>
9236
9634
  <h3 id="type-ValueParams">ValueParams</h3>
9237
9635
  <div class="table-wrap">
9238
9636
  <table>