@toclocoinc/lattice-grid 1.38.0 → 1.40.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 (68) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +314 -7
  3. package/docs/api-detail.html +29 -1
  4. package/lattice-grid.d.ts +353 -8
  5. package/lattice-grid.esm.min.js +1367 -162
  6. package/lattice-grid.min.cjs +1365 -162
  7. package/lattice-grid.min.js +1365 -162
  8. package/modules/angular.esm.min.js +4 -2
  9. package/modules/angular.min.cjs +4 -2
  10. package/modules/angular.min.js +4 -2
  11. package/modules/chart-alluvial.esm.min.js +1 -1
  12. package/modules/chart-arc.esm.min.js +1 -1
  13. package/modules/chart-bubblemap.esm.min.js +1 -1
  14. package/modules/chart-bump.esm.min.js +1 -1
  15. package/modules/chart-calendar.esm.min.js +1 -1
  16. package/modules/chart-decomposition.esm.min.js +1 -1
  17. package/modules/chart-diverging.esm.min.js +1 -1
  18. package/modules/chart-dumbbell.esm.min.js +1 -1
  19. package/modules/chart-fan.esm.min.js +1 -1
  20. package/modules/chart-hexbin.esm.min.js +1 -1
  21. package/modules/chart-hexmap.esm.min.js +1 -1
  22. package/modules/chart-icicle.esm.min.js +1 -1
  23. package/modules/chart-parallel.esm.min.js +1 -1
  24. package/modules/chart-ridgeline.esm.min.js +1 -1
  25. package/modules/chart-roc.esm.min.js +1 -1
  26. package/modules/chart-slope.esm.min.js +1 -1
  27. package/modules/chart-splom.esm.min.js +1 -1
  28. package/modules/chart-waffle.esm.min.js +1 -1
  29. package/modules/charts.esm.min.js +21 -7
  30. package/modules/charts.min.cjs +21 -7
  31. package/modules/charts.min.js +21 -7
  32. package/modules/data-router.esm.min.js +392 -9
  33. package/modules/data-router.min.cjs +392 -9
  34. package/modules/data-router.min.js +392 -9
  35. package/modules/devtools.esm.min.js +2 -2
  36. package/modules/devtools.min.cjs +2 -2
  37. package/modules/devtools.min.js +2 -2
  38. package/modules/dhtmlx-compat.esm.min.js +4 -4
  39. package/modules/dhtmlx-compat.min.cjs +4 -4
  40. package/modules/dhtmlx-compat.min.js +4 -4
  41. package/modules/gantt.esm.min.js +149 -18
  42. package/modules/gantt.min.cjs +148 -18
  43. package/modules/gantt.min.js +148 -18
  44. package/modules/htmx.esm.min.js +1363 -162
  45. package/modules/htmx.min.cjs +1363 -162
  46. package/modules/htmx.min.js +1363 -162
  47. package/modules/kanban.esm.min.js +350 -7
  48. package/modules/kanban.min.cjs +350 -7
  49. package/modules/kanban.min.js +350 -7
  50. package/modules/kpi.esm.min.js +4 -4
  51. package/modules/kpi.min.cjs +4 -4
  52. package/modules/kpi.min.js +4 -4
  53. package/modules/mock-socket.esm.min.js +2 -2
  54. package/modules/mock-socket.min.cjs +2 -2
  55. package/modules/mock-socket.min.js +2 -2
  56. package/modules/react.esm.min.js +4 -2
  57. package/modules/react.min.cjs +4 -2
  58. package/modules/react.min.js +4 -2
  59. package/modules/svelte.esm.min.js +4 -2
  60. package/modules/svelte.min.cjs +4 -2
  61. package/modules/svelte.min.js +4 -2
  62. package/modules/vue.esm.min.js +4 -2
  63. package/modules/vue.min.cjs +4 -2
  64. package/modules/vue.min.js +4 -2
  65. package/modules/webcomponent.esm.min.js +1365 -162
  66. package/modules/webcomponent.min.cjs +1365 -162
  67. package/modules/webcomponent.min.js +1365 -162
  68. package/package.json +1 -1
package/docs/API.html CHANGED
@@ -1845,6 +1845,41 @@ grid.formatting.restat(); // re-derive every threshold from the
1845
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>
1846
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>
1847
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
+
1848
1883
  <h3>Runtime decorations: data bars and icon sets on demand</h3>
1849
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>
1850
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');
@@ -1877,12 +1912,57 @@ grid.formatting.add('score', { when: { op: 'lt', value: 50 }, style: { backgroun
1877
1912
 
1878
1913
  <span class="kw">return</span> [bar, set, band, cleared, painted].join('|');</code></pre>
1879
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
+
1880
1956
  <h2 id="statistics">grid.statistics</h2>
1881
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>
1882
1958
  <pre><code>grid.statistics.profile('margin');
1883
1959
  // { column, rows, present, missing, distinct, min, max, mean, median,
1884
1960
  // q1, q3, iqr, stddev, outliers, histogram: [{ from, to, count }, …] }
1885
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
+
1886
1966
  grid.statistics.reduce('margin', 'p95'); // any registered kernel
1887
1967
  grid.statistics.correlation('spend', 'revenue'); // Pearson's r, clamped to [-1, 1]
1888
1968
  grid.statistics.weightedAverage('price', 'qty');
@@ -1904,7 +1984,8 @@ grid.statistics.capability('mm', { baseline: 20 });
1904
1984
  grid.statistics.shadow('price', 'delta', 'R42'); // one row's shadow value
1905
1985
  grid.statistics.rebase('price'); // "mark all": today's values become the baseline
1906
1986
  grid.statistics.tracking(); // { columns, rows, forgotten }</code></pre>
1907
- <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>
1908
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>
1909
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>
1910
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>
@@ -3659,6 +3740,7 @@ off(); <span class="cmt">// on() returns i
3659
3740
  <tr><td class="name">column:pivoted</td><td class="type">{ columns } | { pivotFields, remote }</td><td class="desc"></td></tr>
3660
3741
  <tr><td class="name">column:menu:open</td><td class="type">{ colId }</td><td class="desc">Header menu opened.</td></tr>
3661
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>
3662
3744
  <tr><td class="name">selection:changed</td><td class="type">{ keys, rows }</td><td class="desc"></td></tr>
3663
3745
  <tr><td class="name">range:changed</td><td class="type">{ ranges }</td><td class="desc">Cell range selection changed.</td></tr>
3664
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>
@@ -4436,7 +4518,7 @@ router.load(snapshot); <span class="cmt">// every viewer
4436
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>
4437
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>
4438
4520
  <tr><td class="sig">broadcasting</td><td class="desc"><strong>v6:</strong> whether the router is currently mirroring to a BroadcastChannel.</td></tr>
4439
- <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>
4440
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>
4441
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>
4442
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>
@@ -4710,6 +4792,33 @@ router.removeSource('erp'); <span class="cmt">// drops exactl
4710
4792
  g.destroy(); router.destroy();
4711
4793
  <span class="kw">return</span> [merged, ids, afterRemove].join(' | ');</code></pre>
4712
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
+
4713
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>
4714
4823
  <h3 id="datarouter-v10-example">A metrics snapshot, executed</h3>
4715
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
@@ -4765,6 +4874,7 @@ plan.applyEdit({ id: 'design', duration: 7 }); // recomputes; the critical path
4765
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>
4766
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>
4767
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>
4768
4878
  </tbody>
4769
4879
  </table>
4770
4880
  </div>
@@ -4810,6 +4920,20 @@ const overAfter = plan.overAllocations.length; // now clear
4810
4920
  const xml = exportMSPDI({ tasks: plan.tasks, dependencies: plan.dependencies, schedule: plan.schedule });
4811
4921
  const back = importMSPDI(xml);
4812
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>
4813
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>
4814
4938
  <pre><code>import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';
4815
4939
 
@@ -5898,7 +6022,7 @@ grid.destroy();
5898
6022
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
5899
6023
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
5900
6024
  has not fired yet — so the name is checked rather than left to be discovered.</p>
5901
- <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');
5902
6026
 
5903
6027
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
5904
6028
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -5907,7 +6031,7 @@ grid.destroy();
5907
6031
  <span class="kw">const</span> documented = [
5908
6032
  'cell:changed', 'cell:clicked', 'cell:confirmed', 'cell:conflict',
5909
6033
  'cell:contextmenu', 'cell:dblclicked', 'cell:edit:end', 'cell:edit:start',
5910
- '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',
5911
6035
  'column:menu:open', 'column:pivoted', 'column:resized', 'columns:changed',
5912
6036
  'columns:tagged', 'comment:added', 'comment:deleted', 'comment:edited',
5913
6037
  'comment:failed', 'comment:indexLoaded', 'comment:resolved', 'comment:threadClosed',
@@ -6163,6 +6287,38 @@ const rows = stream.map((temp, t) =&gt; ({ t, temp }));
6163
6287
  const signal = condition(rows);
6164
6288
  return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; alert ${signal ? signal.flagged.length : 0}`;</code></pre>
6165
6289
 
6290
+ <h3 id="forecasting">Forecasting</h3>
6291
+ <p class="section-note">Project an ordered series forward, and carry a prediction band where a defensible closed form exists (BACKLOG-0000963). Five methods behind one entry point: <code>movingAverage</code> and <code>ses</code> are flat forecasts (the trailing-window mean, the final smoothed level); <code>holt</code> adds a projected trend, <code>holtWinters</code> a projected trend and an additive seasonal; <code>linear</code> extrapolates an ordinary least-squares fit of the time axis. The exponential-smoothing bands are the innovations state-space forecast variances (Hyndman &amp; Athanasopoulos) at the normal quantile; the linear and moving-average bands are the exact Student-t intervals, and <code>linear</code> also reports the narrower mean-response (confidence) band a trendline draws. These are the pure kernels the chart trendline overlay and the time-series grid forecast from; a host can forecast a plain array the same way.</p>
6292
+ <div class="table-wrap">
6293
+ <table>
6294
+ <thead><tr><th>Name</th><th>Signature</th><th>Description</th></tr></thead>
6295
+ <tbody>
6296
+ <tr><td class="name">FORECAST_METHODS</td><td class="type">readonly ('movingAverage' | 'ses' | 'holt' | 'holtWinters' | 'linear')[]</td><td class="desc">The five methods a caller may ask for, named so a result can say which produced it.</td></tr>
6297
+ <tr><td class="name">forecast</td><td class="type">(seq, opts?) =&gt; ForecastResult | null</td><td class="desc">Forecast an ordered series <code>opts.horizon</code> steps ahead by <code>opts.method</code> (default <code>linear</code>), at <code>opts.confidence</code> (default 0.95). Accepts a plain array of numbers (index is the time axis) or <code>{at, value}</code> rows. A smoothing factor absent from <code>opts</code> (<code>alpha</code>/<code>beta</code>/<code>gamma</code>) is fit by minimising the in-sample one-step SSE; <code>holtWinters</code> needs <code>opts.period</code> (≥ 2) and two whole periods of data. Each point carries <code>mean</code> and, where a band applies, <code>lower</code>/<code>upper</code>; <code>linear</code> adds <code>lowerMean</code>/<code>upperMean</code>. Null when the series is too short for the method.</td></tr>
6298
+ </tbody>
6299
+ </table>
6300
+ </div>
6301
+ <pre data-run="js" data-expect="methods 5; next 5.8; r2 0.6; band 1.68..9.92; season 63.125" data-covers="export:FORECAST_METHODS export:forecast"><code>const { forecast, FORECAST_METHODS } = await import('../packages/core/src/index.js');
6302
+ // Linear: fit the time axis, project one step, and carry the prediction band.
6303
+ const lin = forecast([{ at: 1, value: 2 }, { at: 2, value: 4 }, { at: 3, value: 5 }, { at: 4, value: 4 }, { at: 5, value: 5 }], { method: 'linear', horizon: 1 });
6304
+ const p = lin.points[0];
6305
+ // Holt-Winters additive: a level, a trend and a two-step season, projected two
6306
+ // steps ahead — the seasonal swing is carried into the forecast, not smoothed away.
6307
+ const hw = forecast([10, 20, 30, 40], { method: 'holtWinters', period: 2, alpha: 0.5, beta: 0.5, gamma: 0.5, horizon: 2 });
6308
+ return `methods ${FORECAST_METHODS.length}; next ${p.mean.toFixed(1)}; r2 ${lin.r2.toFixed(1)}; band ${p.lower.toFixed(2)}..${p.upper.toFixed(2)}; season ${hw.points[1].mean}`;</code></pre>
6309
+
6310
+ <p class="section-note">The same forecast off the standard stats surface: <code>grid.statistics.forecast(colId, opts)</code> reads the column over the filtered rows — ordered by <code>opts.by</code> when the time axis matters, exactly as <code>grid.statistics.series(...)</code> orders — and returns the same <code>ForecastResult</code>, so a host reaches a forecast the way it reaches <code>grid.statistics.anomalies(...)</code> rather than assembling the series itself.</p>
6311
+ <pre data-run="js" data-expect="next 5.8; r2 0.6; band true" data-covers="method:statistics"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
6312
+ const grid = createHeadlessGrid({
6313
+ columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
6314
+ rows: [[1, 2], [2, 4], [3, 5], [4, 4], [5, 5]].map(([t, v], i) =&gt; ({ id: String(i), t, v })),
6315
+ rowKey: 'id',
6316
+ });
6317
+ // Forecast the 'v' column one step ahead, ordered by 't', with the prediction band.
6318
+ const f = grid.statistics.forecast('v', { by: 't', method: 'linear', horizon: 1 });
6319
+ const p = f.points[0];
6320
+ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.lower &gt; 0}`;</code></pre>
6321
+
6166
6322
 
6167
6323
  <!-- BEGIN GENERATED TYPE REFERENCE -->
6168
6324
  <h2 id="type-reference">Type reference</h2>
@@ -6653,6 +6809,7 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
6653
6809
  <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>
6654
6810
  <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>
6655
6811
  <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>
6812
+ <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>
6656
6813
  <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>
6657
6814
  <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>
6658
6815
  <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>
@@ -6881,6 +7038,7 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
6881
7038
  <tr><td class="name">stddev</td><td class="type">number | null</td><td class="desc"></td></tr>
6882
7039
  <tr><td class="name">outliers</td><td class="type">number</td><td class="desc"></td></tr>
6883
7040
  <tr><td class="name">histogram</td><td class="type">HistogramBin[]</td><td class="desc"></td></tr>
7041
+ <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>
6884
7042
  </tbody>
6885
7043
  </table>
6886
7044
  </div>
@@ -6953,6 +7111,26 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
6953
7111
  </tbody>
6954
7112
  </table>
6955
7113
  </div>
7114
+ <h3 id="type-ColumnValidation">ColumnValidation</h3>
7115
+ <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>
7116
+ <div class="table-wrap">
7117
+ <table>
7118
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7119
+ <tbody>
7120
+ <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>
7121
+ <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>
7122
+ <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>
7123
+ <tr><td class="name">minLength</td><td class="type">number</td><td class="desc">Minimum text length. <small>(optional)</small></td></tr>
7124
+ <tr><td class="name">maxLength</td><td class="type">number</td><td class="desc">Maximum text length. <small>(optional)</small></td></tr>
7125
+ <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>
7126
+ <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>
7127
+ <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>
7128
+ <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>
7129
+ <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>
7130
+ <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>
7131
+ </tbody>
7132
+ </table>
7133
+ </div>
6956
7134
  <h3 id="type-ColumnValueSpec">ColumnValueSpec</h3>
6957
7135
  <div class="table-wrap">
6958
7136
  <table>
@@ -7148,6 +7326,26 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
7148
7326
  </tbody>
7149
7327
  </table>
7150
7328
  </div>
7329
+ <h3 id="type-DataBarSpec">DataBarSpec</h3>
7330
+ <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>
7331
+ <div class="table-wrap">
7332
+ <table>
7333
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7334
+ <tbody>
7335
+ <tr><td class="name">min</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7336
+ <tr><td class="name">max</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7337
+ <tr><td class="name">from</td><td class="type">'minmax' | 'quantile' | 'stddev'</td><td class="desc"><small>(optional)</small></td></tr>
7338
+ <tr><td class="name">low</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7339
+ <tr><td class="name">high</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7340
+ <tr><td class="name">deviations</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
7341
+ <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>
7342
+ <tr><td class="name">color</td><td class="type">string</td><td class="desc">American spelling of `colour`. <small>(optional)</small></td></tr>
7343
+ <tr><td class="name">negativeColour</td><td class="type">string</td><td class="desc">The fill for negative values. <small>(optional)</small></td></tr>
7344
+ <tr><td class="name">negativeColor</td><td class="type">string</td><td class="desc">American spelling of `negativeColour`. <small>(optional)</small></td></tr>
7345
+ <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>
7346
+ </tbody>
7347
+ </table>
7348
+ </div>
7151
7349
  <h3 id="type-DatasetColumnDifference">DatasetColumnDifference</h3>
7152
7350
  <div class="table-wrap">
7153
7351
  <table>
@@ -7662,6 +7860,40 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
7662
7860
  </tbody>
7663
7861
  </table>
7664
7862
  </div>
7863
+ <h3 id="type-ForecastPoint">ForecastPoint</h3>
7864
+ <p class="section-note">One forecast step: the point estimate and, where a band applies, its interval.</p>
7865
+ <div class="table-wrap">
7866
+ <table>
7867
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7868
+ <tbody>
7869
+ <tr><td class="name">step</td><td class="type">number</td><td class="desc">The step ahead, `1 … horizon`.</td></tr>
7870
+ <tr><td class="name">at</td><td class="type">number</td><td class="desc">The time-axis position the step is stamped at, extrapolated at the mean spacing.</td></tr>
7871
+ <tr><td class="name">mean</td><td class="type">number</td><td class="desc">The point forecast.</td></tr>
7872
+ <tr><td class="name">lower</td><td class="type">number | null</td><td class="desc">The prediction-interval lower bound (a future observation), or null when none applies.</td></tr>
7873
+ <tr><td class="name">upper</td><td class="type">number | null</td><td class="desc">The prediction-interval upper bound, or null when none applies.</td></tr>
7874
+ <tr><td class="name">lowerMean</td><td class="type">number | null</td><td class="desc">The mean-response (confidence) lower bound — `linear` only, the band a trendline draws. <small>(optional)</small></td></tr>
7875
+ <tr><td class="name">upperMean</td><td class="type">number | null</td><td class="desc">The mean-response (confidence) upper bound — `linear` only. <small>(optional)</small></td></tr>
7876
+ <tr><td class="name">se</td><td class="type">number | null</td><td class="desc">The prediction standard error the band was built from, or null when none applies.</td></tr>
7877
+ </tbody>
7878
+ </table>
7879
+ </div>
7880
+ <h3 id="type-ForecastResult">ForecastResult</h3>
7881
+ <p class="section-note">A forecast: the chosen model, its parameters, and the projected points.</p>
7882
+ <div class="table-wrap">
7883
+ <table>
7884
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7885
+ <tbody>
7886
+ <tr><td class="name">method</td><td class="type">'movingAverage' | 'ses' | 'holt' | 'holtWinters' | 'linear'</td><td class="desc">Which method produced it.</td></tr>
7887
+ <tr><td class="name">horizon</td><td class="type">number</td><td class="desc">How many steps ahead were projected.</td></tr>
7888
+ <tr><td class="name">confidence</td><td class="type">number</td><td class="desc">The band level, e.g. 0.95.</td></tr>
7889
+ <tr><td class="name">n</td><td class="type">number</td><td class="desc">How many finite readings the fit used.</td></tr>
7890
+ <tr><td class="name">sigma</td><td class="type">number | null</td><td class="desc">The residual standard deviation the bands were built from, or null when there was none.</td></tr>
7891
+ <tr><td class="name">r2</td><td class="type">number</td><td class="desc">The fit's coefficient of determination — `linear` only. <small>(optional)</small></td></tr>
7892
+ <tr><td class="name">params</td><td class="type">{</td><td class="desc">The model parameters: `slope`/`intercept` (linear), `alpha`/`beta`/`gamma`/`period`, or `windowLen`.</td></tr>
7893
+ <tr><td class="name">points</td><td class="type">ForecastPoint[]</td><td class="desc">The forecast, one entry per step.</td></tr>
7894
+ </tbody>
7895
+ </table>
7896
+ </div>
7665
7897
  <h3 id="type-FormattingApi">FormattingApi</h3>
7666
7898
  <div class="table-wrap">
7667
7899
  <table>
@@ -7695,7 +7927,7 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
7695
7927
  </table>
7696
7928
  </div>
7697
7929
  <h3 id="type-FormattingRule">FormattingRule</h3>
7698
- <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>
7930
+ <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>
7699
7931
  <div class="table-wrap">
7700
7932
  <table>
7701
7933
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
@@ -7704,10 +7936,10 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
7704
7936
  <tr><td class="name">when</td><td class="type">FormattingCondition</td><td class="desc"><small>(optional)</small></td></tr>
7705
7937
  <tr><td class="name">style</td><td class="type">CellStyle | ((p: CellParams) =&gt; CellStyle | null)</td><td class="desc"><small>(optional)</small></td></tr>
7706
7938
  <tr><td class="name">scale</td><td class="type">FormattingScale</td><td class="desc"><small>(optional)</small></td></tr>
7939
+ <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>
7940
+ <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>
7707
7941
  <tr><td class="name">stopIfTrue</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
7708
7942
  <tr><td class="name">enabled</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
7709
- <tr><td class="name">icon</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
7710
- <tr><td class="name">bar</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
7711
7943
  <tr><td class="name">label</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
7712
7944
  </tbody>
7713
7945
  </table>
@@ -7782,6 +8014,7 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
7782
8014
  <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>
7783
8015
  <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>
7784
8016
  <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>
8017
+ <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>
7785
8018
  <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>
7786
8019
  <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>
7787
8020
  <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>
@@ -8078,6 +8311,21 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
8078
8311
  </tbody>
8079
8312
  </table>
8080
8313
  </div>
8314
+ <h3 id="type-IconSetSpec">IconSetSpec</h3>
8315
+ <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>
8316
+ <div class="table-wrap">
8317
+ <table>
8318
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8319
+ <tbody>
8320
+ <tr><td class="name">set</td><td class="type">'arrows' | 'trafficLights' | 'ratings' | string</td><td class="desc"><small>(optional)</small></td></tr>
8321
+ <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>
8322
+ <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>
8323
+ <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>
8324
+ <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>
8325
+ <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>
8326
+ </tbody>
8327
+ </table>
8328
+ </div>
8081
8329
  <h3 id="type-ImportApi">ImportApi</h3>
8082
8330
  <p class="section-note">Bringing rows in — the mirror of {@link ExportApi} (§14, BACKLOG-0000949).</p>
8083
8331
  <div class="table-wrap">
@@ -8086,6 +8334,8 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
8086
8334
  <tbody>
8087
8335
  <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>
8088
8336
  <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>
8337
+ <tr><td class="name">previewXlsx</td><td class="type">(bytes: Uint8Array | ArrayBuffer, opts?: object): Promise&lt;ImportXlsxPreview&gt;</td><td class="desc">Parse an `.xlsx` file's bytes into a preview, changing nothing (§14, BACKLOG-0000970). Async: the archive is inflated with `DecompressionStream`.</td></tr>
8338
+ <tr><td class="name">xlsx</td><td class="type">(bytes: Uint8Array | ArrayBuffer, opts?: object): Promise&lt;Record&lt;string, unknown&gt;[]&gt;</td><td class="desc">Parse an `.xlsx` file's bytes into coerced records — the inverse of `export.excel`.</td></tr>
8089
8339
  <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>
8090
8340
  </tbody>
8091
8341
  </table>
@@ -8134,6 +8384,22 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
8134
8384
  </tbody>
8135
8385
  </table>
8136
8386
  </div>
8387
+ <h3 id="type-ImportXlsxPreview">ImportXlsxPreview</h3>
8388
+ <p class="section-note">What an `.xlsx` preview carries — an {@link ImportPreview} plus the sheet read (§14).</p>
8389
+ <div class="table-wrap">
8390
+ <table>
8391
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8392
+ <tbody>
8393
+ <tr><td class="name">sheet</td><td class="type">string | null</td><td class="desc">The archive path of the worksheet that was read, e.g. `xl/worksheets/sheet1.xml`.</td></tr>
8394
+ <tr><td class="name">header</td><td class="type">string[]</td><td class="desc">The source column headings.</td></tr>
8395
+ <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>
8396
+ <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>
8397
+ <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>
8398
+ <tr><td class="name">rowCount</td><td class="type">number</td><td class="desc">How many data rows the sheet holds.</td></tr>
8399
+ <tr><td class="name">warnings</td><td class="type">string[]</td><td class="desc">Anything worth flagging before confirming.</td></tr>
8400
+ </tbody>
8401
+ </table>
8402
+ </div>
8137
8403
  <h3 id="type-IngestConfig">IngestConfig</h3>
8138
8404
  <p class="section-note">How rows are ingested into the column store.</p>
8139
8405
  <div class="table-wrap">
@@ -9249,6 +9515,7 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
9249
9515
  <tr><td class="name">capability</td><td class="type">(colId: string, opts?: {</td><td class="desc">Process capability against the column's `spec`, with control limits and the Western Electric rule breaks. `baseline` fixes the limits over the first N readings, which is how a shift is found rather than hidden by the limits it widened.</td></tr>
9250
9516
  <tr><td class="name">interval</td><td class="type">(colId: string, opts?: {</td><td class="desc">A confidence interval for what a column measures, the range the estimate pins the figure down to, not a verdict about it. Reads the rows the filters left, so an interval narrows as the grid does: it describes the filtered population, not the whole table.</td></tr>
9251
9517
  <tr><td class="name">series</td><td class="type">(colId: string, opts: { by: string; periodsPerYear?: number }): SeriesStats | null</td><td class="desc">How a column varies along an ordering. `by` is required and never guessed: kernels see rows in the order they arrived, which is not the grid's sort.</td></tr>
9518
+ <tr><td class="name">forecast</td><td class="type">(colId: string, opts?: {</td><td class="desc">Forecast one column forward (BACKLOG-0000963): the stats-surface face of the {@link forecast} kernel. The column is read over the filtered rows in arrival order, or ordered by `opts.by` (a date or numeric column, as {@link series} orders) when the time axis matters, then projected `opts.horizon` steps ahead by `opts.method` (default `linear`) with a prediction band where one applies. Every kernel option passes through; returns the same {@link ForecastResult}, or null when the column is unknown or too short.</td></tr>
9252
9519
  <tr><td class="name">weightedAverage</td><td class="type">(colId: string, weightId: string): number | null</td><td class="desc">A weighted average of one column by another.</td></tr>
9253
9520
  <tr><td class="name">keyOf</td><td class="type">(data: unknown): string | null</td><td class="desc">The key a row's data resolves to.</td></tr>
9254
9521
  <tr><td class="name">maintenance</td><td class="type">Readonly&lt;Record&lt;string, 'maintained' | 'rescan'&gt;&gt;</td><td class="desc">Which reductions can be maintained against a change, and which rescan. <small>(read-only)</small></td></tr>
@@ -9330,6 +9597,18 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
9330
9597
  </tbody>
9331
9598
  </table>
9332
9599
  </div>
9600
+ <h3 id="type-TopValue">TopValue</h3>
9601
+ <p class="section-note">One row of a categorical column's top-values table (BACKLOG-0000959).</p>
9602
+ <div class="table-wrap">
9603
+ <table>
9604
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9605
+ <tbody>
9606
+ <tr><td class="name">value</td><td class="type">unknown</td><td class="desc">The value itself, as it is stored.</td></tr>
9607
+ <tr><td class="name">count</td><td class="type">number</td><td class="desc">How many present rows carry it.</td></tr>
9608
+ <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>
9609
+ </tbody>
9610
+ </table>
9611
+ </div>
9333
9612
  <h3 id="type-TreeConfig">TreeConfig</h3>
9334
9613
  <div class="table-wrap">
9335
9614
  <table>
@@ -9409,6 +9688,34 @@ return `methods ${ROLLING_ANOMALY_METHODS.length}; spike ${roll.flags[10]}; aler
9409
9688
  </tbody>
9410
9689
  </table>
9411
9690
  </div>
9691
+ <h3 id="type-ValidationApi">ValidationApi</h3>
9692
+ <p class="section-note">The runtime face of declarative column validation (BACKLOG-0000956).</p>
9693
+ <div class="table-wrap">
9694
+ <table>
9695
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9696
+ <tbody>
9697
+ <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>
9698
+ <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>
9699
+ <tr><td class="name">errors</td><td class="type">(): ValidationError[]</td><td class="desc">Every cell that currently holds a validation error.</td></tr>
9700
+ <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>
9701
+ <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>
9702
+ <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>
9703
+ </tbody>
9704
+ </table>
9705
+ </div>
9706
+ <h3 id="type-ValidationError">ValidationError</h3>
9707
+ <p class="section-note">One recorded validation error (BACKLOG-0000956).</p>
9708
+ <div class="table-wrap">
9709
+ <table>
9710
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9711
+ <tbody>
9712
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc"></td></tr>
9713
+ <tr><td class="name">colId</td><td class="type">string</td><td class="desc"></td></tr>
9714
+ <tr><td class="name">code</td><td class="type">string</td><td class="desc"></td></tr>
9715
+ <tr><td class="name">message</td><td class="type">string</td><td class="desc"></td></tr>
9716
+ </tbody>
9717
+ </table>
9718
+ </div>
9412
9719
  <h3 id="type-ValueParams">ValueParams</h3>
9413
9720
  <div class="table-wrap">
9414
9721
  <table>