@toclocoinc/lattice-grid 1.52.0 → 1.54.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 (77) hide show
  1. package/README.md +2 -1
  2. package/docs/API.html +91 -2
  3. package/docs/api-detail.html +14 -2
  4. package/lattice-grid.d.ts +336 -7
  5. package/lattice-grid.esm.min.js +76 -26
  6. package/lattice-grid.min.cjs +76 -26
  7. package/lattice-grid.min.js +76 -26
  8. package/modules/ai.esm.min.js +24 -4
  9. package/modules/ai.min.cjs +24 -4
  10. package/modules/ai.min.js +24 -4
  11. package/modules/angular.esm.min.js +2 -2
  12. package/modules/angular.min.cjs +2 -2
  13. package/modules/angular.min.js +2 -2
  14. package/modules/chart-alluvial.esm.min.js +1 -1
  15. package/modules/chart-arc.esm.min.js +1 -1
  16. package/modules/chart-bubblemap.esm.min.js +1 -1
  17. package/modules/chart-bump.esm.min.js +1 -1
  18. package/modules/chart-calendar.esm.min.js +1 -1
  19. package/modules/chart-decomposition.esm.min.js +1 -1
  20. package/modules/chart-diverging.esm.min.js +1 -1
  21. package/modules/chart-dumbbell.esm.min.js +1 -1
  22. package/modules/chart-fan.esm.min.js +1 -1
  23. package/modules/chart-hexbin.esm.min.js +1 -1
  24. package/modules/chart-hexmap.esm.min.js +1 -1
  25. package/modules/chart-icicle.esm.min.js +1 -1
  26. package/modules/chart-parallel.esm.min.js +1 -1
  27. package/modules/chart-ridgeline.esm.min.js +1 -1
  28. package/modules/chart-roc.esm.min.js +1 -1
  29. package/modules/chart-slope.esm.min.js +1 -1
  30. package/modules/chart-splom.esm.min.js +1 -1
  31. package/modules/chart-waffle.esm.min.js +1 -1
  32. package/modules/charts.esm.min.js +143 -17
  33. package/modules/charts.min.cjs +143 -17
  34. package/modules/charts.min.js +143 -17
  35. package/modules/data-router.esm.min.js +4 -4
  36. package/modules/data-router.min.cjs +4 -4
  37. package/modules/data-router.min.js +4 -4
  38. package/modules/devtools.esm.min.js +2 -2
  39. package/modules/devtools.min.cjs +2 -2
  40. package/modules/devtools.min.js +2 -2
  41. package/modules/dhtmlx-compat.esm.min.js +4 -4
  42. package/modules/dhtmlx-compat.min.cjs +4 -4
  43. package/modules/dhtmlx-compat.min.js +4 -4
  44. package/modules/gantt.esm.min.js +63 -4
  45. package/modules/gantt.min.cjs +63 -4
  46. package/modules/gantt.min.js +63 -4
  47. package/modules/htmx.esm.min.js +76 -26
  48. package/modules/htmx.min.cjs +76 -26
  49. package/modules/htmx.min.js +76 -26
  50. package/modules/kanban.esm.min.js +4 -4
  51. package/modules/kanban.min.cjs +4 -4
  52. package/modules/kanban.min.js +4 -4
  53. package/modules/kpi.esm.min.js +277 -28
  54. package/modules/kpi.min.cjs +277 -28
  55. package/modules/kpi.min.js +277 -28
  56. package/modules/layout.esm.min.js +2103 -0
  57. package/modules/layout.min.cjs +2106 -0
  58. package/modules/layout.min.js +2106 -0
  59. package/modules/mock-socket.esm.min.js +2 -2
  60. package/modules/mock-socket.min.cjs +2 -2
  61. package/modules/mock-socket.min.js +2 -2
  62. package/modules/react.esm.min.js +2 -2
  63. package/modules/react.min.cjs +2 -2
  64. package/modules/react.min.js +2 -2
  65. package/modules/svelte.esm.min.js +2 -2
  66. package/modules/svelte.min.cjs +2 -2
  67. package/modules/svelte.min.js +2 -2
  68. package/modules/tabs.esm.min.js +5 -4
  69. package/modules/tabs.min.cjs +5 -4
  70. package/modules/tabs.min.js +5 -4
  71. package/modules/vue.esm.min.js +2 -2
  72. package/modules/vue.min.cjs +2 -2
  73. package/modules/vue.min.js +2 -2
  74. package/modules/webcomponent.esm.min.js +76 -26
  75. package/modules/webcomponent.min.cjs +76 -26
  76. package/modules/webcomponent.min.js +76 -26
  77. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  dependencies, no build step required. Optional adapters for React, Vue, Svelte
5
5
  and Web Components ship alongside it.
6
6
 
7
- Version 1.52.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.54.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
@@ -350,6 +350,7 @@ the UMD build or `.cjs` for CommonJS.
350
350
  | gantt | `@toclocoinc/lattice-grid/modules/gantt` | `modules/gantt.min.js` | `LatticeGridGantt` | Editable, dependency-aware project plan with a computed critical path (`createGantt`). |
351
351
  | kpi | `@toclocoinc/lattice-grid/modules/kpi` | `modules/kpi.min.js` | `LatticeGridKPI` | A grid of stat tiles, each an aggregate over a dataset (`createKPI`). |
352
352
  | tabs | `@toclocoinc/lattice-grid/modules/tabs` | `modules/tabs.min.js` | `LatticeGridTabs` | A tab strip where each tab is its own full grid, optionally derived from another (`createTabs`). |
353
+ | layout | `@toclocoinc/lattice-grid/modules/layout` | `modules/layout.min.js` | `LatticeGridLayout` | A reconfigurable dashboard: windows on a cell grid, moved and resized by drag or keyboard (`createLayout`). |
353
354
  | ai | `@toclocoinc/lattice-grid/modules/ai` | `modules/ai.min.js` | `LatticeGridAI` | Bring-your-own-model narrative and insights grounded on computed figures (`createAI`). |
354
355
  | mock-socket | `@toclocoinc/lattice-grid/modules/mock-socket` | `modules/mock-socket.min.js` | `LatticeGridMockSocket` | A serverless stand-in for a live WebSocket feed (`MockWebSocket`, `opsFeed`). |
355
356
  | devtools | `@toclocoinc/lattice-grid/modules/devtools` | `modules/devtools.min.js` | `LatticeGrid` (extends it) | The in-page diagnostic panel, including the accessibility checks (`createDevtools`). |
package/docs/API.html CHANGED
@@ -420,6 +420,7 @@
420
420
  <a href="#quickfilter">Quick filter</a>
421
421
  <a href="#units">Units of your own</a>
422
422
  <a href="#chartsmodule">The charts module</a>
423
+ <a href="#layout">The dashboard layout</a>
423
424
  <a href="#mocksocket">The mock socket</a>
424
425
  <a href="#charts">In-cell charts</a>
425
426
  <a href="#formulas">Formulas</a>
@@ -5516,6 +5517,7 @@ const board = createKanban(document.querySelector('#board'), {
5516
5517
  <tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">Serialise and restore the board state — collapsed columns/lanes, column order, quick filter, sprint/epic selection and selection. Also accepted as <code>config.state</code> at construction.</td></tr>
5517
5518
  <tr><td class="sig">sla</td><td class="desc">The card-aging / SLA monitor, present only when a <code>sla</code> config is supplied. Read <code>sla.states()</code>, <code>sla.breaches()</code>/<code>sla.warnings()</code> and <code>sla.stateFor(cardOrKey)</code> for each card's age and level; <code>sla.evaluate()</code> re-checks and fires crossings. See the card-aging note below.</td></tr>
5518
5519
  <tr><td class="sig">setLoading(bool) / setError(message)</td><td class="desc">A loading state and a host-supplied error banner; empty columns already render their placeholder.</td></tr>
5520
+ <tr><td class="sig">fields</td><td class="desc">Extra columns of the bound <code>grid</code> to project onto the rows a tile <code>filter</code> sees, beyond the fields the tiles declare. A read of a bound column outside the projection still resolves, and warns once naming the tile and the column; a <code>field</code> naming no column at all is refused by name at first read, and that tile reports <code>unknown</code> rather than an aggregation identity.</td></tr>
5519
5521
  <tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or recompute and re-render.</td></tr>
5520
5522
  <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>, <code>card:move</code>, <code>card:reverted</code>, <code>card:confirmed</code>, <code>card:sla</code>, <code>selection:changed</code>, <code>drag:start</code>, <code>drag:end</code> (plus the vocabulary the later cycles emit). On a grid-bound board a move fires <code>card:move</code> optimistically; the grid's write-back then settles it with <code>card:confirmed</code> or, if the server rejects, <code>card:reverted</code> (the card re-reads and the flow transition log rolls the optimistic move back).</td></tr>
5521
5523
  <tr><td class="sig">readonly(scope)</td><td class="desc">Whether a scope is readonly &mdash; the whole board, a <code>{ column }</code> or a <code>{ card }</code>. A readonly card is not draggable; a move into a readonly column is refused.</td></tr>
@@ -5705,9 +5707,18 @@ const kpi = createKPI(document.querySelector('#kpis'), {
5705
5707
  onTileClick: ({ tile }) =&gt; drillInto(tile.id),
5706
5708
  });</code></pre>
5707
5709
  <p>Each tile names an <strong>aggregation</strong> (<code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>, <code>countDistinct</code>, or a <code>custom</code> reducer <code>(rows, tile) =&gt; value</code>), the <strong>field</strong> it reads, and an optional <strong>filter</strong> predicate. Formatting (<code>number</code>/<code>currency</code>/<code>percent</code>/<code>compact</code>, with decimals, currency and locale), a <strong>baseline</strong> for a delta, and semantic <strong>threshold bands</strong> (two cut points with a direction, or an explicit <code>bands</code> list) are all per tile; the band is a semantic name (<code>good</code>/<code>warn</code>/<code>critical</code>), separate from any accent colour. An optional <strong>sparkline</strong> plots a <code>{ x, y }</code> series in sequence order. Each tile is a labelled <code>&lt;figure&gt;</code>, focusable and keyboard-activatable, its value announced; the sparkline transition respects <code>prefers-reduced-motion</code>.</p>
5708
- <p><strong>A panel with no data says so.</strong> A tile's status is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>null</code> (no thresholds) &mdash; or <code>unknown</code>, which means the panel holds <em>no rows at all</em>. That fourth status is decided from data presence <em>before</em> any threshold is consulted, because an aggregation over nothing returns the identity of its operation (<code>sum</code> and <code>count</code> return 0) and 0 is a number a threshold grades: under <code>lowerIsBetter</code> cut points it grades <code>good</code>, so an empty panel would otherwise read as a healthy one. An <code>unknown</code> tile reports a <code>null</code> value, renders the <code>nullText</code> placeholder rather than a zero, and is marked with a dashed edge and a visible &ldquo;No data&rdquo; caption that also forms part of its accessible name &mdash; the state is never carried by colour alone. Because <code>unknown</code> is an explicit status rather than a severity, anything rolling tiles up can surface &ldquo;not measured&rdquo; instead of inheriting a false green.</p>
5710
+ <p><strong>A panel with no data says so.</strong> A tile's status is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>null</code> (no thresholds) &mdash; or <code>unknown</code>, which means the tile <em>measured nothing</em>. Two things cause that: the panel holds <em>no rows at all</em>, or the tile's <code>field</code> names no column on the bound grid, so it never read a cell to reduce over. That fourth status is decided from data presence <em>before</em> any threshold is consulted, because an aggregation over nothing returns the identity of its operation (<code>sum</code> and <code>count</code> return 0) and 0 is a number a threshold grades: under <code>lowerIsBetter</code> cut points it grades <code>good</code>, so an empty panel would otherwise read as a healthy one. An <code>unknown</code> tile reports a <code>null</code> value, renders the <code>nullText</code> placeholder rather than a zero, and is marked with a dashed edge and a visible &ldquo;No data&rdquo; caption that also forms part of its accessible name &mdash; the state is never carried by colour alone. Because <code>unknown</code> is an explicit status rather than a severity, anything rolling tiles up can surface &ldquo;not measured&rdquo; instead of inheriting a false green.</p>
5709
5711
  <p><strong>A measured zero is still a measurement.</strong> A tile whose <code>filter</code> matches none of the rows the panel <em>does</em> hold is a different thing: no open incidents is genuinely good, so it reads <code>0</code> and is graded on its thresholds exactly as before. Only an empty panel is <code>unknown</code>.</p>
5710
5712
  <p><strong>Staleness is a different question, and a time-bounded source answers it.</strong> <code>unknown</code> is about the absence of rows, not their staleness: a panel over an unbounded store keeps showing the last values it was given when its feed goes quiet, because those rows are still there. Give the underlying source a <a href="#sources">rolling time window</a> (<code>maxAge</code> with <code>ageBy</code>) and its rows age out while the feed is silent, so the panel drains and reports <code>unknown</code> the next time it reads them &mdash; a grid-bound panel when the host calls <code>refresh()</code>, a routed one as the removals reach <code>rows.apply</code>.</p>
5713
+ <p><strong>A grid-bound filter reads the columns you project, and says so when it cannot.</strong> A panel bound to a <code>grid</code> does not see whole grid rows: it materialises a <em>projection</em> of each row through the grid&rsquo;s own value pipeline, carrying the row key plus the fields the tiles declare. That is what keeps a refresh over a large grid cheap &mdash; and it used to mean a <code>filter</code> reading any <em>other</em> column saw <code>undefined</code>, matched nothing, and reported a confident <code>0</code> beside a grid full of rows that matched. Declare the extra columns with <code>fields</code>:</p>
5714
+ <pre><code>createKPI(el, {
5715
+ grid,
5716
+ fields: ['priority'], <span class="cmt">// project it, so the filter can read it</span>
5717
+ tiles: [
5718
+ { label: 'P1 jobs', agg: 'count', filter: (r) =&gt; r.priority === 'P1' },
5719
+ ],
5720
+ });</code></pre>
5721
+ <p>Forget to, and the panel tells you rather than quietly reporting a zero: a read of a column the bound grid <em>has</em> but the projection does not resolves to the real cell <strong>and</strong> warns once &mdash; &ldquo;the &lsquo;P1 jobs&rsquo; tile's filter read <code>priority</code>, which is a column on the bound grid but is not projected &mdash; add it to <code>fields</code>&rdquo; &mdash; keyed on the tile and the field, so one bad filter over a 100,000-row grid produces one line, not 100,000. <code>undefined</code> on its own is deliberately <em>not</em> the trigger: a blank cell in a column you did project is a legal value and stays silent, because a warning that fires on ordinary sparse data gets muted and then deleted. A tile <code>field</code> naming no column on the grid at all is a different fault, and <strong>that tile reports no data rather than a number</strong>: reducing over a column that does not exist gives <code>sum</code> and <code>count</code> a <code>0</code>, which grades <code>good</code> under any <code>lowerIsBetter</code> threshold, so warning in the console while leaving a confident green zero on the dashboard would document the lie rather than fix it &mdash; and the reader of a dashboard is not reading the console. It reports the <code>unknown</code> status above, renders <code>nullText</code>, and contributes an explicit <code>unknown</code> to any roll-up. That is deliberately not the same case as a tile with a real field whose <code>filter</code> simply matches nothing: that tile measured, and its zero is still graded. The refusal is checked at first read rather than at bind time, because a dynamic grid's columns can arrive after the panel does, and the verdict is recomputed at every read, so a tile refused while the grid was still loading is measured again the moment its column lands. A panel over a plain <code>rows</code> array has whole rows already and none of this applies to it.</p>
5711
5722
  <p><strong>Incremental, not recomputed.</strong> Each tile keeps a running accumulator, so a routed <code>rows.apply</code> delta adjusts only the rows it carries &mdash; an add contributes, a remove reverses, an update reverses the old row and contributes the new one &mdash; rather than re-reading the whole dataset per delta. The two bounded exceptions are honest: an extreme (<code>min</code>/<code>max</code>) removed at its current value triggers a rescan of that tile's own value multiset, and a <code>custom</code> reducer is recomputed over the (filtered) store because an arbitrary function has no inverse.</p>
5712
5723
  <div class="table-wrap">
5713
5724
  <table>
@@ -5715,7 +5726,7 @@ const kpi = createKPI(document.querySelector('#kpis'), {
5715
5726
  <tbody>
5716
5727
  <tr><td class="sig">createKPI(el, config)</td><td class="desc">Create a KPI panel. Pass a DOM element to render into, or <code>null</code> for a headless panel that computes the same tile model without a DOM.</td></tr>
5717
5728
  <tr><td class="sig">rows.apply({ add, update, remove })</td><td class="desc">The keyed-diff consumer contract a grid shares, so the panel is a drop-in Data Router target and updates each tile incrementally. Also <code>rows.forEach</code> and <code>rows.count</code>.</td></tr>
5718
- <tr><td class="sig">tiles() / tile(id) / value(id)</td><td class="desc">Every computed tile model, one tile by id (<code>value</code>, <code>formatted</code>, <code>status</code>, <code>delta</code>, <code>deltaPercent</code>, <code>count</code>, <code>sparkline</code>), or a tile's raw value. <code>status</code> is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>unknown</code> (the panel holds no rows) or <code>null</code> (no thresholds configured); <code>value</code> is <code>null</code> whenever the tile measured nothing.</td></tr>
5729
+ <tr><td class="sig">tiles() / tile(id) / value(id)</td><td class="desc">Every computed tile model, one tile by id (<code>value</code>, <code>formatted</code>, <code>status</code>, <code>delta</code>, <code>deltaPercent</code>, <code>count</code>, <code>sparkline</code>, <code>bar</code>), or a tile's raw value. <code>status</code> is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>unknown</code> (the panel holds no rows) or <code>null</code> (no thresholds configured); <code>value</code> is <code>null</code> whenever the tile measured nothing.</td></tr>
5719
5730
  <tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or re-seed and recompute.</td></tr>
5720
5731
  <tr><td class="sig">nodes() / node(key) / visibleNodes()</td><td class="desc">The hierarchy, when <code>tree</code> resolves one: the top-level nodes with their children, one node by key at any depth, or just the nodes on screen. Each node carries <code>label</code>, <code>level</code>, <code>tile</code> (null on a synthesised level), <code>status</code>, <code>rollup</code> (the worst severity at or below it, never <code>unknown</code>), <code>unknown</code> (how many below it measured nothing) and <code>items</code>. Empty on a flat panel, where <code>kpi.tree</code> is <code>false</code>.</td></tr>
5721
5732
  <tr><td class="sig">expand(key) / collapse(key) / toggle(key)</td><td class="desc">Open or close a branch. A key for a branch the panel does not (yet) hold is retained rather than dropped, so a delta that later introduces it finds it already open.</td></tr>
@@ -5747,6 +5758,9 @@ kpi.expand(kpi.nodes()[0].key); <span class="cmt">// open it</span></code><
5747
5758
  <p><strong>No value rolls up; severity does.</strong> A parent shows no aggregated number. That is not a simplification: the running accumulators expose <code>add</code>/<code>remove</code>/<code>value</code> and no merge, so <code>avg</code>, <code>countDistinct</code> and a <code>custom</code> reducer cannot be composed from their children without rescanning, and a per-aggregation exception list would be a number that is right for a sum and wrong for an average. A parent that has a tile of its own still shows <em>that tile's</em> reading. What does roll up is the status: <code>rollup</code> is the worst severity at or below the node, across as many levels as you have, and it is what a collapsed branch reports.</p>
5748
5759
  <p><strong>Nothing measured is not good news, and it does not win the roll-up either.</strong> A leaf that measured nothing is <code>unknown</code> (see above), and <code>unknown</code> is deliberately excluded from <code>rollup</code>: ranking &ldquo;not measured&rdquo; as the worst thing beneath a parent would hide a real warning under it. It is surfaced separately instead &mdash; <code>node.unknown</code> counts the descendants that measured nothing, and the node renders it in words (&ldquo;2 unknown&rdquo;) and puts it in its accessible name. So neither way of being wrong is available: silence cannot read as green, and it cannot bury an amber.</p>
5749
5760
  <p><strong>Accessible by construction.</strong> The rail is a real <code>role="tree"</code> with the APG keyboard model &mdash; <kbd>&rarr;</kbd> opens a closed branch and otherwise steps into it, <kbd>&larr;</kbd> closes an open one and otherwise steps out to its parent, <kbd>&uarr;</kbd>/<kbd>&darr;</kbd> walk what is visible, <kbd>Home</kbd>/<kbd>End</kbd> jump to its ends, <kbd>Enter</kbd>/<kbd>Space</kbd> activate &mdash; with a roving tabindex, and <code>aria-level</code>, <code>aria-posinset</code> and <code>aria-setsize</code> on every node, because a reader cannot count what a collapsed branch has left out of the DOM. Status carries a <strong>shape</strong> as well as a colour (a filled circle, a triangle, a square, a hollow circle), not one dot in three colours, and a parent's rolled-up status is <em>in its accessible name</em>: &ldquo;Compute, 6 items, worst status critical&rdquo;, announced as one string. Every phrase is a catalogue key: pass <code>messages</code> (any <code>{ t(key, params) }</code>, including a grid's own) to translate the panel, and a key your catalogue lacks falls back to English rather than printing the key.</p>
5761
+ <p><strong>Every leaf carries a value <em>and</em> a meter.</strong> A rail exists to be read at a glance, and a column of numbers is not that, so each leaf draws a small fixed-scale bar beside its reading. The scale comes only from what the tile already declares &mdash; there is no new configuration key. A <code>bands</code> list states its own ends and is taken at its word; <code>thresholds</code> states two interior cut points and a lone <code>target</code> states one point, so in those the open end is anchored at the <strong>origin</strong>: <code>{ warn: 70, critical: 90 }</code> measures 0&ndash;90, and <code>target: 4000</code> measures 0 to the target. The scale is never derived from the data &mdash; a bar scaled to the values currently in the panel would mean something different on every refresh &mdash; so a tile with no bands, no thresholds and no target gets <strong>no meter at all</strong>, a value and nothing else. The meter is placed by exactly the mapping the grid's own conditional-formatting data bar uses, <code>clamp((x - lo) / span)</code>, so a reading past the top of its scale fills the bar rather than overflowing it, and it is coloured by the leaf's own <code>good</code>/<code>warn</code>/<code>critical</code> status &mdash; reinforcing the shape glyph, never replacing it. A leaf that measured nothing draws a <em>dashed empty outline with no fill element</em>, which is deliberately not what a measured zero looks like (a solid track with a fill of no length): those are different facts. Each tile model carries the same numbers as <code>bar</code> (<code>{ lo, hi, percent }</code>, or <code>null</code> when no scale is declared) for a host that would rather draw its own. A row that gets no meter <strong>reserves the width of one</strong>, so the readings stay in a single column whether or not there is a bar beside them &mdash; the same reservation a leaf makes on the other side for the <code>+</code>/<code>&minus;</code> control it does not have. A branch draws no meter, because no value rolls up to draw one; and a flat panel of tiles renders exactly the tiles it always did.</p>
5762
+ <p><strong>The panel follows the grid it belongs to, not the page.</strong> Pass <code>grid</code> and the panel takes that grid's <em>resolved</em> type and row rhythm: on a 16px page beside a grid painting its cells at 12.7px, a rail used to draw 16px text. Type reads <code>--lat-kpi-* &gt; --lattice-* &gt; --lat-chrome-* &gt; 13px</code> and row height reads <code>--lat-kpi-row-height &gt; --lat-chrome-row-height &gt; 22px</code>. The <code>--lat-chrome-*</code> rung is mirrored off the mounted grid in JS (<code>modules/shared/chrome.js</code>) because a grid's <code>density</code> lands on the grid's own root, which is a <em>descendant</em> of a panel mounted beside it, and custom properties inherit downward only &mdash; no stylesheet can read it. Measured in Chrome: identical type and a 1.00&times; leaf-row-to-grid-row ratio at <code>compact</code>, <code>comfortable</code> and <code>spacious</code>. It re-mirrors on the grid's <code>config:changed</code>, so <code>grid.set('density', …)</code> moves the panel with it, and <code>destroy()</code> releases everything it wrote. A panel with <strong>no</strong> grid &mdash; a plain <code>rows</code> array, or the router-driven panel &mdash; mirrors nothing and renders at the module's own defaults, and a host's <code>--lattice-font-size</code> on an ancestor still out-ranks the mirror.</p>
5763
+ <p><strong>A heading says it opens.</strong> Each top-level item carries a <code>+</code> when shut and a <code>&minus;</code> when open, in a small bordered box, on a row with a pointer cursor and a hover state &mdash; a disclosure triangle read as decoration rather than as a control. It is purely decoration: <code>aria-expanded</code> on the node is what states the same thing to a screen reader, so the marker is <code>aria-hidden</code> exactly as the status shape is, and the announced name is unchanged. A leaf keeps the marker's width as an empty spacer, so the status shapes and labels stay in one column whether or not the row beside them opens.</p>
5750
5764
  <p><strong>A collapsed branch costs data, not paint.</strong> Every node's status is computed whether or not you can see it &mdash; that is what makes the rail worth having &mdash; while a collapsed branch contributes no DOM at all. It is the same division the grid's grouping already makes between its totals walk and its display walk. Expansion is patched in place, keyed on the node, so a live routed feed does not throw a keyboard user off the node they are standing on.</p>
5751
5765
  <h3 id="kpi-tree-example">A collapsed parent reports the red leaf underneath it, executed</h3>
5752
5766
  <p class="section-note">Two subsystems from dotted tile ids, one of them in breach, with nothing expanded; then the same
@@ -5978,6 +5992,81 @@ tabs.activate('breached'); <span class="cmt">// materialises
5978
5992
  tabs.destroy();
5979
5993
  <span class="kw">return</span> counts.join(','); <span class="cmt">// 4,3,2</span></code></pre>
5980
5994
 
5995
+ <h2 id="layout">The dashboard layout</h2>
5996
+ <p><code>modules/layout</code> is an opt-in <strong>reconfigurable dashboard surface</strong>: a cell grid inside an element, and a set of windows placed on it that a user can move, resize and close &mdash; by drag <em>or</em> by keyboard. It is the thing a customer would otherwise reach for GridStack or react-grid-layout to get, which means a second dependency, a second sizing model, and a seam where the viewers in it do not resize properly.</p>
5997
+ <p><strong>It is payload-agnostic, and that is the whole design.</strong> A window body is a <code>div</code> with an <code>id</code>. The module creates it, sizes it, and never reads or writes its contents &mdash; it does not import <code>createGrid</code>, does not know what a payload is, and never calls into one. What it does instead is emit <code>window:resized</code> with the measured content box, which is the contract. That rule is what keeps its own code to <strong>12,890 bytes gzipped</strong> (measured: a 77,190-byte bundle over a 62,206-byte fixed floor, of which 2,094 bytes are the two shared module helpers) and what makes it usable for a payload we have not written yet.</p>
5998
+ <pre><code>import { createLayout } from '@toclocoinc/lattice-grid/modules/layout';
5999
+
6000
+ const layout = createLayout(document.querySelector('#dash'), {
6001
+ columns: 12, rows: 8, gap: 8,
6002
+ overflowX: 'static', overflowY: 'scroll', rowHeight: '160px',
6003
+ windows: [
6004
+ { id: 'pipeline', title: 'Pipeline', xPos: 1, yPos: 1, xSize: 6, ySize: 4,
6005
+ movable: true, resizable: true, closable: true },
6006
+ { id: 'trend', title: 'Trend', xPos: 7, yPos: 1, xSize: 6, ySize: 4,
6007
+ movable: true, resizable: true },
6008
+ ],
6009
+ onWindowResized: ({ id, width, height }) =&gt; redraw(id, width, height),
6010
+ });
6011
+
6012
+ <span class="cmt">// The module made the container; you fill it and you own what is inside it.</span>
6013
+ createGrid(layout.payload('pipeline'), { rowKey: 'id', rows, columns });</code></pre>
6014
+ <p><strong>Two independent overflow axes, not one setting.</strong> <code>overflowX</code> and <code>overflowY</code> are each <code>'static'</code> or <code>'scroll'</code>, because a dashboard that scrolls both ways is ordinary and a single enum cannot express it. The difference is what happens to a track's size. A <strong>static</strong> axis divides the mounted element with <code>minmax(0, 1fr)</code> &mdash; never a bare <code>1fr</code>, whose implicit <code>auto</code> minimum lets one stubborn payload drag a track past the container. A <strong>scrolling</strong> axis repeats a <em>fixed</em> track (<code>columnWidth</code> / <code>rowHeight</code>) and the canvas extends past the viewport, which then scrolls. That is the owner's "maintaining their sizing", and it is the difference between this and <code>flex-wrap</code>: measured in a real browser, ten 200px columns in a 600px host paint at 200px each over a 2000px canvas, and <em>shrinking the host to 300px leaves the column at 200px</em> and scrolls further.</p>
6015
+ <p><strong>Spacing takes a real CSS length.</strong> <code>gap</code>, <code>padding</code>, <code>columnWidth</code> and <code>rowHeight</code> each accept a number (pixels), or a string: <code>'200px'</code>, <code>'25%'</code>, <code>'1fr'</code>, <code>'2rem'</code>, <code>'10vh'</code>. Percentages that sum past 100 are allowed to overflow and scroll rather than being silently scaled down, which is the honest outcome. Anything outside that vocabulary &mdash; including <code>calc()</code> and <code>var()</code> &mdash; is refused by name with one warning and replaced by the default, because the value is written into an inline style.</p>
6016
+ <p><strong>Rearrangement.</strong> <code>compact: 'vertical'</code> (the default) pushes displaced windows down and then pulls everything up into whatever space that left, so a window dropped into empty space falls to the top of its column &mdash; <code>window:moved</code> carries both <code>to</code> (where it was asked to go) and <code>landed</code> (where it actually ended up). <code>compact: 'none'</code> keeps every window exactly where it is put. There is no horizontal compactor: pushing sideways has no single obviously-correct direction, and getting it wrong silently rearranges a dashboard a user carefully built.</p>
6017
+ <p><strong>Keyboard, to the same standard as the drag.</strong> Every movable and resizable window carries a focusable handle running the full grab / move / drop / cancel model the kanban board established: <kbd>Space</kbd> or <kbd>Enter</kbd> grabs, the arrow keys move a tentative placement, <kbd>Enter</kbd> drops it through the same <code>beforeWindowMove</code> gate the pointer drag uses, and <kbd>Escape</kbd> cancels. A polite live region announces every step &mdash; grabbed, each tentative position with its column and row, dropped, cancelled, and <em>reverted</em> when a handler vetoes the drop &mdash; and focus returns to the handle afterwards. A window with <code>chrome: false</code> still gets a handle, because a movable window a keyboard user cannot move is not movable.</p>
6018
+ <p><strong>An &ldquo;Edit layout&rdquo; button, without rebuilding the dashboard.</strong> <code>closable</code>, <code>movable</code> and <code>resizable</code> also take a <em>layout-level</em> default, so unlocking a twelve-window dashboard is one setting rather than twenty-four, and <code>setInteractive(true|false|{movable, resizable, closable})</code> changes that default at runtime &mdash; unlock, let the user rearrange, lock again and save <code>getLayout()</code>. Nothing is destroyed and nothing is rebuilt, so every grid, chart and board mounted in a window survives the toggle untouched. <strong>The asymmetry is deliberate: you can always take a capability away; you can never grant one where the developer said no.</strong> <code>setInteractive(false)</code> locks every window, including one whose own spec says <code>movable: true</code>, so a dashboard hard-locks in a single call without auditing twelve window specs; <code>setInteractive(true)</code> unlocks only the windows that never opted out, so a masthead declared <code>movable: false</code> stays pinned. Both halves of the enforcement move together &mdash; the handles a window renders <em>and</em> the checks the pointer and keyboard paths make, because removing a handle stops a mouse while only the gesture check stops a keyboard user already standing on one. <strong><code>config.movable: false</code> and <code>setInteractive(false)</code> are deliberately not the same thing:</strong> the config states the <em>default</em> for windows that declare nothing &mdash; and <code>false</code> is already that default, so it takes nothing away from a window that declared <code>movable: true</code> &mdash; while <code>setInteractive(false)</code> is an <em>active lock</em> that pins every window whatever its own spec says. <code>getInteractive()</code> reports all three states rather than two: <code>undefined</code> where no layout-level default is in force, <code>true</code>, or <code>false</code> for a lock. Reporting &ldquo;unset&rdquo; as <code>false</code> would read correctly and round-trip wrongly, so <code>setInteractive(getInteractive())</code> is a no-op in every state, and a key carrying <code>undefined</code> means &ldquo;leave this capability alone&rdquo;. Interactivity is a <em>mode</em>, not part of the arrangement: <code>getLayout()</code> does not carry it, <code>setLayout()</code> does not read it, and no event fires. <strong>A locked layout is not a read-only dashboard:</strong> the module creates the payload container and never reads or writes its contents, so a grid inside a window is made read-only with the grid's own settings &mdash; a dashboard that must not be edited is two decisions, not one.</p>
6019
+ <p><strong>Which payloads re-lay-out on <code>window:resized</code>, honestly.</strong> The grid and the chart each own a <code>ResizeObserver</code> and respond correctly; the Gantt does too since 1.52.0; kanban and KPI do no JS work at all on a resize and need none, because they reflow by CSS construction (a kanban column keeps its 280px and the board starts scrolling). Every one of those is measured in <code>test/layout-browser.test.js</code> rather than asserted. <strong>One exception, named rather than softened:</strong> a grid column declared as a <em>percentage</em> (<code>layout: { width: '50%' }</code>) is resolved against the viewport width once and never re-resolved, so halving the window leaves the column wider than the viewport it sits in (800px host &rarr; 783px viewport, 391px column; 400px host &rarr; 383px viewport, still a 391px column). That is not caused by this module &mdash; it reproduces on a plain grid in a plain resized <code>div</code> &mdash; and until it is fixed, size grid columns inside a resizable window in pixels or with <code>flex</code>, not with percentages.</p>
6020
+ <p><strong>Idle cost, measured.</strong> A twelve-window dashboard is <strong>indistinguishable from a page with no layout module on it at all</strong>. Over 8 seconds of real Chrome (<code>bench/layout-idle.mjs</code>), twelve windows with empty payloads, twelve <em>independent</em> live grids in them, and a single lone grid with no layout module all sit in the same few-millisecond band &mdash; under a tenth of one percent of a core. They are not separated here because they cannot be: seven runs across two machines land between 3.1ms and 9.0ms and the ordering between them inverts run to run, so a stated delta would be reporting the noise floor. The module adds no timer, no frame loop and no polling, and owns exactly one <code>ResizeObserver</code> for the whole layout rather than one per window. <strong>The one figure that is a result rather than noise is not this module's:</strong> twelve grids <em>derived</em> from one shared parent filtered at 20Hz cost <strong>3,155&ndash;3,409ms</strong> over the same 8 seconds &mdash; several hundred times the quiet band, and stable across every run &mdash; because the engine's repaint listener for a derived source's <code>rows:changed</code> calls the renderer directly and bypasses <code>grid.updates.pause()</code>. The bench reports the size of that gap rather than leaving it inferred.</p>
6021
+ <p><strong>Closing a window does not destroy its payload.</strong> <code>window:closed</code> hands the payload container back; whatever you mounted inside it is yours to destroy. Stated plainly because a leaked grid per closed window is the obvious failure, and this module has no way to know that a <code>div</code> contains something with a <code>destroy()</code>.</p>
6022
+ <p><strong>Not in v1:</strong> horizontal compaction; per-frame drag events; nested layouts; window maximise/minimise; tabbed windows (that is <code>modules/tabs</code>); and <strong>responsive breakpoints &mdash; a twelve-window dashboard on a phone is unsolved, and this does not pretend otherwise</strong>. Server-side persistence is the host's, with <code>getLayout()</code>.</p>
6023
+ <div class="table-wrap">
6024
+ <table>
6025
+ <thead><tr><th>Member</th><th>Description</th></tr></thead>
6026
+ <tbody>
6027
+ <tr><td class="sig">createLayout(el, config)</td><td class="desc">Create a dashboard layout. <code>columns</code>/<code>rows</code> (default 12/6) divide the element; <code>overflowX</code>/<code>overflowY</code> are each <code>'static'</code> or <code>'scroll'</code>; <code>columnWidth</code>/<code>rowHeight</code> are the fixed track sizes a scrolling axis uses; <code>gap</code> (8px), <code>padding</code> (5px) and <code>compact</code> (<code>'vertical'</code>) complete it. A second mount on the same element is refused by name.</td></tr>
6028
+ <tr><td class="sig">config.windows[]</td><td class="desc">Each window: <code>id</code> (required, unique), <code>xPos</code>/<code>yPos</code>/<code>xSize</code>/<code>ySize</code> in 1-based cells (auto-placed in the first free cell when omitted), <code>title</code>, <code>chrome</code> (default <code>true</code>), and <code>closable</code>/<code>movable</code>/<code>resizable</code>/<code>maximisable</code>/<code>minimisable</code> (all default <code>false</code>, so a dashboard the developer wants fixed is fixed without opting out of anything; each also takes a layout-level default of the same name, which a window's own boolean overrides). <code>padding</code> and <code>payloadId</code> (default <code>`${id}-body`</code>) override per window.</td></tr>
6029
+ <tr><td class="sig">payload(id) / window(id) / windows()</td><td class="desc">The payload container for a window &mdash; the <code>div</code> carrying its <code>payloadId</code>, which you fill; a copy of a window's current descriptor; every window id in mount order.</td></tr>
6030
+ <tr><td class="sig">add(spec) / move(id, to) / close(id)</td><td class="desc">Add a window after mount (returns its payload container); move or resize one through the same before-events the drag uses; close one through <code>beforeWindowClose</code>. <code>move</code> and <code>close</code> return <code>true</code>/<code>false</code> synchronously with no handler registered, or a <code>Promise&lt;boolean&gt;</code> when a handler deferred.</td></tr>
6031
+ <tr><td class="sig">getLayout() / setLayout(snapshot)</td><td class="desc">The full current arrangement as plain JSON (<code>{columns, rows, windows: [{id, xPos, yPos, xSize, ySize}]}</code>), and its restore. <code>setLayout</code> never throws on garbage, and an entry naming a window that does not exist yet is <em>retained</em> and applied when that window is added.</td></tr>
6032
+ <tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">The versioned persistence pair, following core's and the Gantt's shape: no arguments in, one plain JSON-safe object out, and <code>setState</code> survives whatever is handed to it.</td></tr>
6033
+ <tr><td class="sig">setInteractive(value) / getInteractive()</td><td class="desc">Lock or unlock the whole dashboard at runtime, without destroying it. A boolean sets <code>movable</code>, <code>resizable</code> and <code>closable</code> together; an object sets only the keys it carries, and a key carrying <code>undefined</code> is treated as absent; <code>getInteractive()</code> returns the layout-level values as a copy, three-valued (<code>undefined</code> for unset, <code>true</code>, or <code>false</code> for a lock) so that <code>setInteractive(getInteractive())</code> is a no-op in every state. The config keys of the same name state the <em>default</em>; only this method takes a capability away. Locking always wins and unlocking never overrides an opt-out: <code>setInteractive(false)</code> pins a window that declared <code>movable: true</code>, and <code>setInteractive(true)</code> leaves a window that declared <code>movable: false</code> pinned. No event fires and <code>getLayout()</code> is unchanged &mdash; a mode is not an arrangement. It does not touch <code>maximisable</code> or <code>minimisable</code> either, for the same reason.</td></tr>
6034
+ <tr><td class="sig">maximise(id) / minimise(id) / restore(id)</td><td class="desc"><strong>Maximise fills the layout host</strong> &mdash; the element you mounted on &mdash; not the browser window, and hides every other window for the duration. That is deliberate: filling the viewport means <code>position: fixed</code>, whose containing block is the nearest ancestor carrying a <code>transform</code>, <code>filter</code>, <code>contain</code> or <code>will-change</code>, so the same rule fills the screen on one page and lands in a 300px box on the next; filling the host is a geometry change inside the layout and cannot disturb the page around it. <strong>Nothing moves</strong>: no compaction runs, no placement changes, and the payload container is the same DOM node throughout, so whatever you mounted in it is untouched. <strong>Escape restores it</strong> from anywhere inside the layout, unless something inside has already claimed the key &mdash; and a <em>grid</em> payload claims every Escape whether or not it cancelled anything, at <em>two</em> independent sites (a focused body cell and a focused header cell), so from inside a maximised grid the way back is the restore control, which is where focus already is if you pressed it to get there. <code>minimise(id)</code> draws a window as a single row and hides its payload, keeping the chrome that carries the way back &mdash; so under <code>compact: 'vertical'</code> the windows below <em>pull up</em> on screen, which is the point of minimising one. <strong>In the arrangement, nothing moves at all:</strong> the collapse is a projection of the dashboard, not a change to it, so <code>restore(id)</code> gives back exactly the arrangement that was there &mdash; in <strong>any</strong> order, with any number of other windows still collapsed. All 14,400 minimise/restore orderings of a five-window dashboard are asserted. A window with <code>chrome: false</code> is refused by name: there would be nothing left on screen to restore it with. The controls are opt-in per window (<code>maximisable</code>, <code>minimisable</code>) with a layout-level default of the same name, and <code>setInteractive()</code> does <strong>not</strong> touch them &mdash; a display mode neither moves nor resizes a window in the arrangement, so a locked dashboard can still be blown up to read.</td></tr>
6035
+ <tr><td class="sig">maximised() / minimised()</td><td class="desc">The id of the window filling the host (at most one &mdash; maximising a second restores the first), or <code>null</code>; and the ids of every minimised window in mount order. Neither state is part of <code>getLayout()</code>: a mode is not an arrangement, so <code>getLayout()</code> reports the <em>underlying</em> placement in both states &mdash; where the window will be when restored &mdash; and <code>setLayout()</code> never restores anyone into a mode, moving a minimised window <em>under</em> it instead.</td></tr>
6036
+ <tr><td class="sig">refresh()</td><td class="desc">Re-measure every window and emit <code>window:resized</code> for those that changed. Called automatically; exposed for a host that changed something the module cannot observe, such as revealing an ancestor.</td></tr>
6037
+ <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>window:moved</code>, <code>window:resized</code>, <code>window:closed</code>, <code>layout:changed</code>; the cancellable <code>beforeWindowMove</code>, <code>beforeWindowResize</code> and <code>beforeWindowClose</code> (call <code>preventDefault(reason?)</code> or return <code>false</code>), each paired with <code>windowMove:cancelled</code>, <code>windowResize:cancelled</code> and <code>windowClose:cancelled</code>. <code>'*'</code> subscribes to every past-tense event and is deliberately never delivered a before-event. Config sugar for all ten. Drag progress is <strong>not</strong> emitted per frame.</td></tr>
6038
+ <tr><td class="sig">destroy()</td><td class="desc">Stop observing, drop every listener including any left by a gesture in flight, and remove the DOM the module built. Whatever you mounted in a payload is yours to destroy.</td></tr>
6039
+ </tbody>
6040
+ </table>
6041
+ </div>
6042
+ <h3 id="layout-live-example">Placement, compaction and a saved arrangement, executed</h3>
6043
+ <p class="section-note"><code>createLayout</code> needs a real host element, the same way <code>createGrid</code> does, so this executed example reaches for the same in-tree DOM test double the suite runs the renderer against headlessly (<code>packages/dom/src/renderer/testdom.js</code>). <code>demo/layout.html</code> is the browser version, with a real grid, chart and KPI rail in three windows that you can drag, resize with the keyboard, close, save and restore.</p>
6044
+ <pre data-run="js" data-expect="a@1,1 b@3,1|a@3,1 b@3,2|a@1,1 b@3,1|a-body" data-covers="export:createLayout"><code><span class="kw">const</span> { createTestDom } = <span class="kw">await</span> import('../packages/dom/src/renderer/testdom.js');
6045
+ <span class="kw">const</span> { root } = createTestDom();
6046
+ <span class="kw">const</span> { createLayout } = <span class="kw">await</span> import('../packages/modules/layout/index.js');
6047
+
6048
+ <span class="kw">const</span> layout = createLayout(root, {
6049
+ columns: 4, rows: 4,
6050
+ windows: [
6051
+ { id: 'a', title: 'A', xPos: 1, yPos: 1, xSize: 2, ySize: 1 },
6052
+ { id: 'b', title: 'B', xSize: 2, ySize: 1 }, <span class="cmt">// no coordinates: auto-placed</span>
6053
+ ],
6054
+ });
6055
+ <span class="kw">const</span> shape = () =&gt; layout.getLayout().windows.map((w) =&gt; `${w.id}@${w.xPos},${w.yPos}`).join(' ');
6056
+
6057
+ <span class="kw">const</span> placed = shape(); <span class="cmt">// B landed in the first free cell: 3,1</span>
6058
+ <span class="kw">const</span> saved = JSON.parse(JSON.stringify(layout.getLayout()));
6059
+
6060
+ layout.move('a', { xPos: 3, yPos: 1 }); <span class="cmt">// drop A on top of B</span>
6061
+ <span class="kw">const</span> pushed = shape(); <span class="cmt">// B is pushed down to 3,2</span>
6062
+
6063
+ layout.setLayout(saved); <span class="cmt">// the saved arrangement round-trips</span>
6064
+ <span class="kw">const</span> restored = shape();
6065
+
6066
+ <span class="kw">const</span> payload = layout.payload('a').id; <span class="cmt">// the container you fill: 'a-body'</span>
6067
+ layout.destroy();
6068
+ <span class="kw">return</span> [placed, pushed, restored, payload].join('|');</code></pre>
6069
+
5981
6070
  <h2 id="mocksocket">The mock socket</h2>
5982
6071
  <p><code>modules/mock-socket</code> is a serverless stand-in for a live <code>WebSocket</code> feed, for building and demonstrating a real-time UI with <strong>no backend</strong>. <code>MockWebSocket</code> presents the same surface as the browser's <code>WebSocket</code> &mdash; the same <code>readyState</code> and state constants, the same <code>onopen</code>, <code>onmessage</code>, <code>onclose</code> and <code>onerror</code>, <code>addEventListener</code>, <code>send</code> and <code>close</code> &mdash; so the code that reads it does not change when it is swapped for a real one. It fires an initial snapshot the moment it opens, then a stream of deltas on a timer, all from a generator you hand it. It is a dev and test utility: optional, imports nothing from the grid, and is never pulled into the core bundle. It pairs naturally with the data router (one mock stream, partitioned to many grids), but depends on it no more than a real socket does.</p>
5983
6072
  <pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.52.0</p>
440
+ <p class="rail__sub">Developer guide · v1.54.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -4743,6 +4743,17 @@ createChart({
4743
4743
  <code>kind: 'count'</code> is refused with a warning rather than quietly given a second
4744
4744
  meaning. The x column has to be continuous and carry wall-clock times &mdash; a banded or
4745
4745
  categorical axis has no domain to roll.</p>
4746
+ <p><strong>A producer whose clock runs ahead.</strong> A reading stamped slightly ahead of the
4747
+ viewer&rsquo;s clock carries the end of the domain forward with it, so the newest mark is
4748
+ drawn. How far is bounded: <strong>a quarter of the span</strong> (15&nbsp;s on a
4749
+ 60&nbsp;s window). A reading further ahead than that is treated as a producer whose clock is
4750
+ wrong &mdash; it does not move the window, it is not drawn, and the chart warns once, naming
4751
+ the column, how many readings were left out and how far ahead they were. Without the bound,
4752
+ one device two minutes fast moved a one-minute window past every other device&rsquo;s recent
4753
+ readings and the chart drew a single dot (BACKLOG-0001123). If every reading in the window is
4754
+ that far ahead the chart shows its empty state, with the same warning.
4755
+ <code>chart.data().windowed</code> counts the readings dropped at either edge of the
4756
+ window. The fix for the warning is the producer&rsquo;s clock, not a wider window.</p>
4746
4757
  <p><strong>Idle costs nothing.</strong> Both halves advance on a plain interval &mdash; a
4747
4758
  quarter of the window, clamped to between 50&nbsp;ms and one second &mdash; and never on an
4748
4759
  animation frame. The source&rsquo;s wake returns after a single number comparison unless a
@@ -6991,7 +7002,8 @@ grid.import.apply(preview);</code></pre>
6991
7002
  <tr><td class="name">createKPI</td><td class="desc">A KPI / stat-tile view (module <code>kpi</code>) of a dataset as a panel of aggregate tiles — each tile a <code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>, <code>countDistinct</code> or a <code>custom</code> reducer over the routed rows, with an optional <code>filter</code> predicate, number <code>format</code> (<code>number</code>/<code>currency</code>/<code>percent</code>/<code>compact</code>), a <code>baseline</code> for a delta, semantic threshold bands (<code>thresholds</code> with two cut points and a direction, or an explicit <code>bands</code> list, giving a <code>good</code>/<code>warn</code>/<code>critical</code> status kept separate from any accent), and an optional <code>sparkline</code> series. It is a dataset viewer like any other: it consumes data through the same keyed-diff <code>rows.apply({ add, update, remove })</code> contract a grid exposes, so a Data Router can <code>attach(value, kpi)</code> and drive a KPI panel beside a grid, a kanban and a chart off one feed. Updates are incremental — a delta adjusts each tile's running accumulator by only the rows it carries (an add contributes, a remove reverses, an update reverses-then-contributes), the two bounded exceptions being a <code>min</code>/<code>max</code> whose current extreme is removed (a rescan of that tile's own value multiset) and a <code>custom</code> reducer (recomputed over the filtered store, an arbitrary function having no inverse). Each tile is a labelled <code>&lt;figure&gt;</code>, focusable and keyboard-activatable, its value announced, the sparkline respecting <code>prefers-reduced-motion</code>; a <code>tile:click</code> event (also from the keyboard) lets a host drill down or filter a routed grid. Pass <code>null</code> as the element for a headless panel that computes the same tile model without a DOM. Not a dashboard layout engine (that is the parked dashboard generator) and no charting beyond the minimal sparkline (that is the charts module).</td></tr>
6992
7003
  <tr><td class="name">createAI</td><td class="desc">Create an AI narrative / insights controller (module <code>ai</code>) over a live grid. It produces a short, plain-language narrative of the grid's <em>computed</em> figures — a per-KPI / per-chart / per-column <code>explain</code>, or an <code>insights</code> panel over the current filtered view. The grid makes no AI call of its own: <code>createAI</code> imports no provider SDK, holds no key, and makes no network request; it calls one host callback, <code>ask({ system, messages, prompt, tools?, schema?, signal })</code> — your model, your key, your privacy decision — the same philosophy as the data adapters. Two grounding paths feed one guard: where the provider offers tool-use the model is given a curated read-only tool set (<code>getSchema</code>, <code>getProfile</code>, <code>getStatistics</code>, <code>getForecast</code>, <code>runQuery</code>) and the grid's own engine computes what it asks for; otherwise the module builds a facts packet from <code>grid.statistics</code>/the profile/forecasts/view counts and passes it in the prompt. Every figure in the narrative is reconciled against the values the engine produced this render — an ungrounded number is stripped before display (the number-reconciliation guard), so a hallucinated figure never reaches the user. The prompt is constrained to narrate-only; the layer is read-only and never mutates data. <code>redact</code> (a column id, a list, or a predicate) and <code>maxRows</code> bound what the module hands <code>ask()</code>, and the module sends nothing itself; an <code>ask()</code> error surfaces a friendly message and the grid stays fully usable, AI being additive rather than load-bearing. Complementary to <code>grid.ai</code> (the intent/plan skill layer): pass no <code>ask</code> and it adopts the grid's configured <code>ai.ask</code>, running the facts-packet path over it. Pass a headless grid for a DOM-free narrative; <code>facts(target)</code> returns the exact grounded packet without calling <code>ask()</code>. UMD global <code>LatticeGridAI</code>.</td></tr>
6993
7004
  <tr><td class="name">createTabs</td><td class="desc">Create a tabbed grid (module <code>tabs</code>): a <code>role="tablist"</code> strip above a stack of <code>role="tabpanel"</code> regions, each hosting its own, independently-configured <code>createGrid</code> instance — "configure each tab as per a normal grid" rather than one grid whose state is swapped (<code>ColumnModel#applyState</code> only repositions/hides/resizes existing columns by id; it carries no field, type or row data, so a state-swap only works when every tab shares one schema). <code>createGrid</code> is injected (<code>createTabs(el, { createGrid, tabs })</code>), the same pattern the React/Vue/Svelte adapters use, so the module imports no engine code and adds nothing to a page that does not load it. A tab that names <code>from: '&lt;tabId&gt;'</code> gets a <code>source: { mode: 'derived', from: &lt;the parent tab’s live grid&gt;, where, group, join, … }</code> wired for it automatically — reusing the shipped derived-source mechanism rather than a new config-inheritance one — and activating a derived tab materialises its whole ancestor chain first; a cyclic <code>from</code> graph is refused (naming the exact cycle) when <code>createTabs</code> is called, not at first click. A tab’s grid mounts on first activation and then stays alive, hidden, so its scroll/selection/filters/sort/grouping/expansion — and an open cell/row editor, left exactly as it was, uncommitted and undiscarded — survive a switch natively; <code>destroy()</code> tears every mounted tab down. The strip is a real tablist with <code>aria-selected</code>, a roving <code>tabindex</code>, and manual-activation keyboard handling (arrows/Home/End move focus, Enter/Space or a click activates). Events: <code>tab:changed</code>, a cancellable <code>beforeTabChange</code> paired with <code>tabChange:cancelled</code>. UMD global <code>LatticeGridTabs</code>.</td></tr>
6994
- <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
7005
+ <tr><td class="name">createLayout</td><td class="desc">Create a reconfigurable dashboard layout (module <code>layout</code>): a cell grid inside an element, and a set of windows on it that a user can move, resize and close by drag <em>or</em> by keyboard &mdash; the surface a customer would otherwise reach for GridStack to get. It is <strong>payload-agnostic</strong>: a window body is a <code>div</code> with an <code>id</code> that the module creates, sizes and never reads, so it imports no engine code at all (not even <code>createGrid</code>) and its own code is 12,890 bytes gzipped, measured against a 62,206-byte fixed bundle floor. <code>columns</code>/<code>rows</code> divide the element; <code>overflowX</code> and <code>overflowY</code> are <em>independent</em> axes, each <code>'static'</code> (tracks divide the container with <code>minmax(0, 1fr)</code>) or <code>'scroll'</code> (tracks take a fixed <code>columnWidth</code>/<code>rowHeight</code> and the canvas extends past the viewport, so a column keeps the size it asked for &mdash; measured: shrinking a 600px host to 300px leaves a 200px column at 200px). Spacing takes a real CSS length: a number of pixels, or <code>'200px'</code>, <code>'25%'</code>, <code>'1fr'</code>, <code>'2rem'</code>; anything else is refused by name and replaced by the default, because the value reaches an inline style. Windows are placed by 1-based <code>xPos</code>/<code>yPos</code>/<code>xSize</code>/<code>ySize</code>, or auto-placed in the first free cell; <code>chrome</code> defaults on, and <code>closable</code>/<code>movable</code>/<code>resizable</code> all default <em>off</em>, so a fixed dashboard is fixed without opting out. <code>compact: 'vertical'</code> pushes displaced windows down then pulls them up (<code>window:moved</code> carries both <code>to</code> and <code>landed</code>); <code>'none'</code> keeps every window where it is put. <code>closable</code>/<code>movable</code>/<code>resizable</code> each also take a <em>layout-level</em> default of the same name, which a window's own boolean overrides, and <code>setInteractive(true|false|{movable, resizable, closable})</code> changes that default at runtime &mdash; the &ldquo;Edit layout&rdquo; button &mdash; without destroying the layout or any payload in it, with <code>getInteractive()</code> reading it back. Locking always wins and unlocking never overrides an opt-out: <code>setInteractive(false)</code> pins a window that declared <code>movable: true</code>, while <code>setInteractive(true)</code> leaves a window that declared <code>movable: false</code> pinned. The config key and the method are deliberately different: <code>movable: false</code> in the config states the <em>default</em> for windows that declare nothing and takes nothing away from one that opted in, whereas <code>setInteractive(false)</code> is an <em>active lock</em>. <code>getInteractive()</code> is three-valued &mdash; <code>undefined</code> for unset, <code>true</code>, or <code>false</code> for a lock &mdash; and a key carrying <code>undefined</code> is treated as absent, so <code>setInteractive(getInteractive())</code> is a no-op in every state. It moves both halves of the enforcement, the rendered handles <em>and</em> the pointer and keyboard gesture checks, and fires no event because a mode is not an arrangement &mdash; <code>getLayout()</code> neither carries it nor restores it. A locked layout is not a read-only dashboard: what is inside a window is configured with that payload's own settings. <code>maximise(id)</code>, <code>minimise(id)</code> and <code>restore(id)</code> are the display modes, with <code>maximised()</code> and <code>minimised()</code> reading them back: maximise fills the <em>layout host</em> rather than the browser window (no <code>position: fixed</code>, whose containing block is the nearest ancestor with a <code>transform</code> or a <code>contain</code>; no reparenting; nothing that can disturb the page around the dashboard), hides the other windows, runs <strong>no compaction at all</strong> and keeps the payload container as the very same DOM node &mdash; and <kbd>Escape</kbd> restores it from anywhere inside the layout. <code>minimise</code> draws a window as a single row and hides its payload while its chrome stays to carry the way back, so the windows below pull up on screen; in the arrangement nothing moves at all, because the collapse is a projection of the dashboard rather than a change to it, so restoring gives back exactly the arrangement that was there in <em>any</em> order and with any number of other windows still collapsed. A <code>chrome: false</code> window is refused by name. Both controls are opt-in per window (<code>maximisable</code>, <code>minimisable</code>) with a layout-level default of the same name, and <code>setInteractive()</code> deliberately does not touch either: a mode is not an arrangement, so neither appears in <code>getLayout()</code>, which reports the underlying placement in both states. Keyboard parity with the drag: a focusable handle per window running the kanban board's grab/move/drop/cancel model, with a polite live region announcing grabbed, every tentative position, dropped, cancelled and reverted. It owns exactly <strong>one</strong> <code>ResizeObserver</code> for the whole layout, over two targets, and tells payloads their new content box through <code>window:resized</code> &mdash; it never calls into a payload, because it cannot know what one is. Events: <code>window:moved</code>, <code>window:resized</code>, <code>window:closed</code>, <code>layout:changed</code>, the cancellable <code>beforeWindowMove</code>/<code>beforeWindowResize</code>/<code>beforeWindowClose</code> and their <code>*:cancelled</code> pairs; drag progress is not emitted per frame. <code>getLayout()</code>/<code>setLayout()</code> round-trip the arrangement as plain JSON, and <code>getState()</code>/<code>setState()</code> are the versioned pair. Closing a window does <strong>not</strong> destroy its payload &mdash; the container is handed back on <code>window:closed</code> and the host owns that lifecycle. UMD global <code>LatticeGridLayout</code>.</td></tr>
7006
+ <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
6995
7007
  <tr><td class="name">createGantt</td><td class="desc">Create a project-planning Gantt controller (module <code>gantt</code>) over a task list and a dependency list. A CPM engine (<code>computeSchedule</code>) computes each task's early/late start and finish, its slack and the zero-float critical path, honouring the four link types (<code>LINK_TYPES</code>: FS/SS/FF/SF) with lag, and recomputes on every edit — emitting <code>schedule</code> or, on a dependency cycle or bad input, <code>error</code> (a code from <code>SCHEDULE_ERROR</code>). Milestones are zero-duration points; summary (WBS) tasks are derived from their children (earliest start, latest finish, weighted progress) rather than scheduled; <code>findViolations</code> flags a task placed earlier than its predecessors allow, and <code>toISODate</code> maps an engine day-number back to a calendar date.</td></tr>
6996
7008
  <tr><td class="name">createLatticeGridElement</td><td class="desc">Build the element class without registering it, for a custom registry.</td></tr>
6997
7009
  <tr><td class="name">createMessages</td><td class="desc">Build a message catalogue. A partial set lays over the built-in British English one.</td></tr>