@toclocoinc/lattice-grid 1.58.0 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +188 -18
  3. package/docs/api-detail.html +55 -4
  4. package/lattice-grid.d.ts +233 -14
  5. package/lattice-grid.esm.min.js +595 -95
  6. package/lattice-grid.min.cjs +595 -95
  7. package/lattice-grid.min.css +1 -1
  8. package/lattice-grid.min.js +595 -95
  9. package/modules/ai.esm.min.js +4 -4
  10. package/modules/ai.min.cjs +4 -4
  11. package/modules/ai.min.js +4 -4
  12. package/modules/angular.esm.min.js +3 -2
  13. package/modules/angular.min.cjs +3 -2
  14. package/modules/angular.min.js +3 -2
  15. package/modules/chart-alluvial.esm.min.js +1 -1
  16. package/modules/chart-arc.esm.min.js +1 -1
  17. package/modules/chart-bubblemap.esm.min.js +1 -1
  18. package/modules/chart-bump.esm.min.js +1 -1
  19. package/modules/chart-calendar.esm.min.js +1 -1
  20. package/modules/chart-decomposition.esm.min.js +1 -1
  21. package/modules/chart-diverging.esm.min.js +1 -1
  22. package/modules/chart-dumbbell.esm.min.js +1 -1
  23. package/modules/chart-fan.esm.min.js +1 -1
  24. package/modules/chart-hexbin.esm.min.js +1 -1
  25. package/modules/chart-hexmap.esm.min.js +1 -1
  26. package/modules/chart-icicle.esm.min.js +1 -1
  27. package/modules/chart-parallel.esm.min.js +1 -1
  28. package/modules/chart-ridgeline.esm.min.js +1 -1
  29. package/modules/chart-roc.esm.min.js +1 -1
  30. package/modules/chart-slope.esm.min.js +1 -1
  31. package/modules/chart-splom.esm.min.js +1 -1
  32. package/modules/chart-waffle.esm.min.js +1 -1
  33. package/modules/charts.esm.min.js +4 -4
  34. package/modules/charts.min.cjs +4 -4
  35. package/modules/charts.min.js +4 -4
  36. package/modules/data-router.esm.min.js +4 -4
  37. package/modules/data-router.min.cjs +4 -4
  38. package/modules/data-router.min.js +4 -4
  39. package/modules/devtools.esm.min.js +2 -2
  40. package/modules/devtools.min.cjs +2 -2
  41. package/modules/devtools.min.js +2 -2
  42. package/modules/dhtmlx-compat.esm.min.js +4 -4
  43. package/modules/dhtmlx-compat.min.cjs +4 -4
  44. package/modules/dhtmlx-compat.min.js +4 -4
  45. package/modules/gantt.esm.min.js +109 -33
  46. package/modules/gantt.min.cjs +109 -33
  47. package/modules/gantt.min.js +109 -33
  48. package/modules/htmx.esm.min.js +595 -95
  49. package/modules/htmx.min.cjs +595 -95
  50. package/modules/htmx.min.js +595 -95
  51. package/modules/kanban.esm.min.js +4 -4
  52. package/modules/kanban.min.cjs +4 -4
  53. package/modules/kanban.min.js +4 -4
  54. package/modules/kpi.esm.min.js +4 -4
  55. package/modules/kpi.min.cjs +4 -4
  56. package/modules/kpi.min.js +4 -4
  57. package/modules/layout.esm.min.js +59 -6
  58. package/modules/layout.min.cjs +59 -6
  59. package/modules/layout.min.js +59 -6
  60. package/modules/mock-socket.esm.min.js +2 -2
  61. package/modules/mock-socket.min.cjs +2 -2
  62. package/modules/mock-socket.min.js +2 -2
  63. package/modules/react.esm.min.js +3 -2
  64. package/modules/react.min.cjs +3 -2
  65. package/modules/react.min.js +3 -2
  66. package/modules/svelte.esm.min.js +3 -2
  67. package/modules/svelte.min.cjs +3 -2
  68. package/modules/svelte.min.js +3 -2
  69. package/modules/tabs.esm.min.js +411 -9
  70. package/modules/tabs.min.cjs +411 -9
  71. package/modules/tabs.min.js +411 -9
  72. package/modules/vue.esm.min.js +3 -2
  73. package/modules/vue.min.cjs +3 -2
  74. package/modules/vue.min.js +3 -2
  75. package/modules/webcomponent.esm.min.js +595 -95
  76. package/modules/webcomponent.min.cjs +595 -95
  77. package/modules/webcomponent.min.js +595 -95
  78. 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.58.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.59.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -360,7 +360,7 @@
360
360
  <div class="shell">
361
361
  <aside class="rail">
362
362
  <p class="rail__brand">Lattice Grid</p>
363
- <p class="rail__sub">API reference · v1.58.0</p>
363
+ <p class="rail__sub">API reference · v1.59.0</p>
364
364
  <nav>
365
365
  <div class="rail__group">
366
366
  <span class="rail__label">Start</span>
@@ -443,7 +443,7 @@
443
443
  </header>
444
444
 
445
445
  <p class="chips">
446
- <span class="chip">Version 1.58.0</span>
446
+ <span class="chip">Version 1.59.0</span>
447
447
  <span class="chip">Zero dependencies</span>
448
448
  <span class="chip"><a href="api-detail.html">Developer guide &rarr;</a></span>
449
449
  </p>
@@ -944,7 +944,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
944
944
  <tbody>
945
945
  <tr><td class="name">statusBar</td><td class="type">boolean | { panels }</td><td class="desc">Composable panels along the bottom. Default set: <code>rowCount</code>, <code>selectedCount</code>, <code>aggregation</code>, <code>comments</code>, <code>updates</code>, <code>progress</code>. Each is silent when it has nothing to report.</td></tr>
946
946
  <tr><td class="name">maximise</td><td class="type">boolean</td><td class="dflt">true</td><td class="desc"><code>false</code> removes the rail button and <code>grid.maximise</code>, for an application with its own full-screen mode.</td></tr>
947
- <tr><td class="name">toolPanel</td><td class="type">boolean | object</td><td class="desc">Side dock. <code>panels</code>: <code>columns</code>, <code>filters</code>, <code>views</code>, <code>quick</code>, <code>formatting</code>, <code>statistics</code>, <code>regression</code>. <code>side: 'left'</code> makes it the icon rail, which also turns on <code>actions</code> (<code>undo</code>, <code>redo</code>, <code>pause</code>, <code>restore</code>, <code>maximise</code>, then the export group: <code>export</code>, <code>excel</code>, <code>clipboard</code>, <code>print</code>: nine in all, and an array takes these names rather than the button labels) and <code>icons</code>. An explicit array <em>replaces</em> that list rather than extending it; a bare <code>'-'</code> in it renders a divider between groups. <code>exportName</code> names the CSV. <code>annotate: true</code> adds the native annotation tools — <code>pen</code>, <code>arrow</code>, <code>rect</code>, <code>highlight</code> — to the rail as toggle buttons (pressed while in use, pressed again to exit); they also appear automatically for the duration of a presentation.</td></tr>
947
+ <tr><td class="name">toolPanel</td><td class="type">boolean | object</td><td class="desc">Side dock. <code>panels</code>: <code>columns</code>, <code>filters</code>, <code>views</code>, <code>quick</code>, <code>formatting</code>, <code>statistics</code>, <code>regression</code>. <code>side: 'left'</code> makes it the icon rail, which also turns on <code>actions</code> (<code>undo</code>, <code>redo</code>, <code>pause</code>, <code>restore</code>, <code>maximise</code>, then the export group: <code>export</code>, <code>excel</code>, <code>clipboard</code>, <code>print</code>: nine in all, and an array takes these names rather than the button labels) and <code>icons</code>. An explicit array <em>replaces</em> that list rather than extending it; a bare <code>'-'</code> in it renders a divider between groups. <code>exportName</code> names the CSV. <code>annotate</code> is a three-state option, not a boolean flag. <code>annotate: true</code> adds the native annotation tools — <code>pen</code>, <code>arrow</code>, <code>rect</code>, <code>highlight</code> — to the rail as toggle buttons (pressed while in use, pressed again to exit), and they stay put whether or not a presentation is running. <code>annotate: false</code> opts OUT: none of the four tools are ever added, presentation or not. Omitting <code>annotate</code> keeps the default: the tools are off until a presentation starts, appear for its duration, and leave when it ends.</td></tr>
948
948
  <tr><td class="name">groupPanel</td><td class="type">boolean | object</td><td class="desc">A drag-and-drop group-by strip above the column header — the row-group panel. Drag a heading into it to group by that column; the active groups show as removable, reorderable chips, and reordering the chips changes the nesting order. It is keyboard-operable — arrows move between chips, <code>Shift</code> with an arrow reorders, <code>Delete</code> ungroups, and an add control groups any column — and every change is announced through the live region. Off by default and non-breaking; it drives the same model as <code>grid.columns.group()</code> and reimplements nothing. The object form takes <code>hint</code>, the placeholder shown while nothing is grouped.</td></tr>
949
949
  <tr><td class="name">kpis</td><td class="type">StatConfig[]</td><td class="desc">A built-in KPI/stat strip: a labelled band of stat tiles the grid places for you above the column header. Each entry is a <code>createStat</code> spec — <code>of</code>, <code>fn</code>, <code>title</code>, <code>interval</code>, <code>footer</code>, <code>format</code> and the rest, minus <code>grid</code> and <code>container</code>, which the grid supplies — so a strip tile and a hand-placed one are the same object. The tiles follow the grid's filters, recomputing on every change like a stand-alone stat does. Off by default and non-breaking; it reuses <code>createStat</code> and reimplements no compute.</td></tr>
950
950
  <tr><td class="name">timeZone</td><td class="type">string</td><td class="desc">An IANA zone every date column formats and parses in, so a grid shows one zone whatever the viewer's machine says. Individual columns may override it.</td></tr>
@@ -968,6 +968,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
968
968
  <tr><td class="name">cornerRadius</td><td class="type">boolean | number | string</td><td class="dflt">, </td><td class="desc">Round the grid's outer corners. <code>true</code> adopts the theme's radius, a number is pixels, a string is used as written.</td></tr>
969
969
  <tr><td class="name">stripedRows</td><td class="type">boolean</td><td class="dflt">false</td><td class="desc">Shade alternate data rows (zebra striping). Strictly opt-in, so an existing grid is unchanged on upgrade. Parity follows each row's logical index, so a stripe survives a scroll; group headings, footers and the grand total are never striped; selection and hover still win. Uses the theme's <code>--lattice-surface-alt</code>, so dark, high-contrast and terminal come for free.</td></tr>
970
970
  <tr><td class="name">verticalAlign</td><td class="type">'top' | 'middle' | 'bottom'</td><td class="dflt">, </td><td class="desc">Vertical alignment of cell content within a row, as a default for every column &mdash; the vertical counterpart to the per-column <code>align</code>. A column's own <code>verticalAlign</code> (or <code>cell.verticalAlign</code>) overrides it. Omitted, the grid keeps its historical placement (centred in a fixed-height row, top in an <code>autoHeight</code> row), so an existing grid is unchanged on upgrade. Setting a value aligns every column uniformly, including auto-height rows, unless a column opts out. See <a href="api-detail.html#vertical-align">Vertical alignment</a>.</td></tr>
971
+ <tr><td class="name">tooltip</td><td class="type">TooltipConfig</td><td class="dflt">, </td><td class="desc"><code>{ delay, maxWidth }</code> &mdash; grid-level defaults for the rich cell tooltip. <code>delay</code> is how long the pointer or the keyboard cursor must rest on a cell before anything is built, 400ms by default; <code>maxWidth</code> is how wide the tooltip may grow (a number is pixels, a string is used as written). Defaults only: it switches nothing on, and a grid whose columns declare no <code>cell.tooltip</code> has no tooltips whatever is set here. See <a href="#cell-tooltips">Rich cell tooltips</a>.</td></tr>
971
972
  <tr><td class="name">scrollbars</td><td class="type">'auto' | 'always' | { x, y }</td><td class="dflt">'auto'</td><td class="desc">Keep the scroll viewport's scrollbars visible. <code>'auto'</code> is the platform's native behaviour, where overlay scrollbars fade when idle; <code>'always'</code> keeps both axes shown whether or not the pointer is over the grid. The object form <code>{ x, y }</code> pins each axis on its own, so <code>{ y: 'always' }</code> keeps the vertical bar while the horizontal one stays native. Omitted, the grid is unchanged on upgrade. See <a href="api-detail.html#scrollbars">Always-visible scrollbars</a>.</td></tr>
972
973
  <tr><td class="name">columnTagFilter</td><td class="type">boolean | { multiple, label }</td><td class="dflt">, </td><td class="desc">A bar above the headings for showing only the columns carrying a chosen tag. Draws nothing unless some column has <code>tags</code>. See <a href="api-detail.html#column-tags">Column tags</a>.</td></tr>
973
974
  <tr><td class="name">rowTemplate</td><td class="type">string | { template, cardsPerRow, maxCardWidth, gap, className, role, itemRole }</td><td class="dflt">, </td><td class="desc">Draw each row with a template instead of dividing it into columns, a card list, a feed, a search-result list. Compiles once; binds with <code>{{data.field}}</code>. <code>cardsPerRow</code> or <code>maxCardWidth</code> puts several on a line. The pipeline underneath is unchanged. See <a href="api-detail.html#cards">Cards, lists and feeds</a>.</td></tr>
@@ -1091,7 +1092,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
1091
1092
  <tr><td class="name">class</td><td class="type">string | string[] | (p) =&gt; …</td><td class="desc">Classes for this column's cells.</td></tr>
1092
1093
  <tr><td class="name">classWhen</td><td class="type">{ [class]: (p) =&gt; boolean }</td><td class="desc">A class per predicate, re-evaluated as values change.</td></tr>
1093
1094
  <tr><td class="name">style / css</td><td class="type">CellStyle | (p) =&gt; CellStyle</td><td class="desc">Inline styles, static or computed.</td></tr>
1094
- <tr><td class="name">tooltip</td><td class="type">string | (p) =&gt; string</td><td class="desc"></td></tr>
1095
+ <tr><td class="name">tooltip</td><td class="type">string | (p) =&gt; string | ColumnTooltipSpec</td><td class="desc">A string or a function is the plain-text case and becomes the browser's own <code>title</code>. An <strong>object</strong> is a tooltip the grid draws itself &mdash; <code>{ render, mount, unmount }</code> &mdash; which can carry structure, markup or live content, is shown on keyboard focus as well as hover, and can be dismissed with Escape. See <a href="#cell-tooltips">Rich cell tooltips</a>.</td></tr>
1095
1096
  <tr><td class="name">align</td><td class="type">'start' | 'center' | 'end' | 'left' | 'right'</td><td class="desc">Horizontal alignment; also accepted at the top level of the column. <code>start</code>, <code>center</code> and <code>end</code> are <strong>logical</strong>: they follow the writing direction, so an <code>end</code>-aligned number column sits on the right edge in a left-to-right grid and on the left edge in a right-to-left one (<code>direction</code>). <code>left</code> and <code>right</code> are <strong>physical</strong>: they name an edge and keep it in both directions. <code>centre</code> is accepted for <code>center</code>. Omitted, the column takes its data type's default (numbers <code>end</code>, booleans <code>center</code>, text <code>start</code>). The heading follows the cell unless <code>header.align</code> says otherwise.</td></tr>
1096
1097
  <tr><td class="name">wrap / autoHeight</td><td class="type">, </td><td class="desc">Presentation flags.</td></tr>
1097
1098
  <tr><td class="name">spanColumns / spanRows</td><td class="type">(p) =&gt; number</td><td class="desc">Spanned cells render in their own layer so row recycling cannot clip them.</td></tr>
@@ -1119,12 +1120,97 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
1119
1120
  <tr><td class="name">edit</td><td class="desc"><code>enabled</code> (boolean or predicate), <code>editor</code>, <code>props</code>, <code>popup</code>, <code>validate</code></td></tr>
1120
1121
  <tr><td class="name">sort</td><td class="desc"><code>enabled</code>, <code>direction</code>, <code>order</code>, <code>nullsFirst</code></td></tr>
1121
1122
  <tr><td class="name">filter</td><td class="desc"><code>enabled</code>, <code>type</code>, <code>props</code></td></tr>
1122
- <tr><td class="name">layout</td><td class="desc"><code>width</code>, <code>min</code>, <code>max</code>, <code>flex</code>, <code>pin</code>, <code>hidden</code>, <code>resizable</code>, <code>movable</code>, <code>lockVisible</code>, <code>lockPosition</code>. <code>width</code> is a pixel number or a percentage string (<code>'25%'</code>): a share of the grid's inner width that <strong>follows the viewport</strong> &mdash; after the container changes size the column is re-resolved against the new width, clamped to its <code>min</code>/<code>max</code>, so a <code>'50%'</code> column is half of an 800px grid and half of the same grid at 400px. Percentages summing past 100 overflow and scroll rather than being scaled down. A <code>pin</code> of <code>&#39;start&#39;</code> or <code>&#39;end&#39;</code> holds the viewport edge while there is something to scroll; where the columns do not fill the grid, spare width falls beyond the last column rather than in front of it.</td></tr>
1123
+ <tr><td class="name">layout</td><td class="desc"><code>width</code>, <code>fit</code>, <code>min</code>, <code>max</code>, <code>flex</code>, <code>pin</code>, <code>hidden</code>, <code>resizable</code>, <code>movable</code>, <code>lockVisible</code>, <code>lockPosition</code>. <code>fit: 'content'</code> is the declarative form of <code>columns.autoSize()</code>: the column is sized to what it is showing on the first paint and measured again whenever the rows change, the columns are shown, hidden, reordered or pinned, or the grid is resized &mdash; but <strong>not</strong> as it scrolls, which would make the columns jitter. It measures the mounted rows and the heading, as <code>autoSize()</code> does, so it sizes to visible content rather than to the widest value in the dataset. A declared <code>width</code> outranks it, and so does a width the user drags to, a resize being recorded as a <code>width</code>; <code>flex</code> is resolved first and wins. <code>width</code> is a pixel number or a percentage string (<code>'25%'</code>): a share of the grid's inner width that <strong>follows the viewport</strong> &mdash; after the container changes size the column is re-resolved against the new width, clamped to its <code>min</code>/<code>max</code>, so a <code>'50%'</code> column is half of an 800px grid and half of the same grid at 400px. Percentages summing past 100 overflow and scroll rather than being scaled down. A <code>pin</code> of <code>&#39;start&#39;</code> or <code>&#39;end&#39;</code> holds the viewport edge while there is something to scroll; where the columns do not fill the grid, spare width falls beyond the last column rather than in front of it.</td></tr>
1123
1124
  <tr><td class="name">header</td><td class="desc"><code>template</code>, <code>render</code>, <code>props</code>, <code>class</code>, <code>tooltip</code>, <code>align</code>. <code>render</code> draws a custom heading and may be a <strong>function</strong> or a <strong>component</strong> (a class with a <code>render</code> method); the two forms are interchangeable and each may either append to the passed heading element itself (returning nothing) or <em>return</em> an <code>Element</code> (attached for you) or a <code>string</code> (used as the heading text). <code>class</code> adds a class to the heading cell; <code>template</code> is not read.</td></tr>
1124
1125
  </tbody>
1125
1126
  </table>
1126
1127
  </div>
1127
1128
 
1129
+ <h2 id="cell-tooltips">Rich cell tooltips</h2>
1130
+ <p class="lead-in">
1131
+ <code>cell.tooltip</code> as a string or a function gives you the browser's own
1132
+ <code>title</code>: one line of plain text, on the browser's schedule, unstyled, and invisible
1133
+ to a keyboard user. The <strong>object</strong> form declares a tooltip the grid draws instead,
1134
+ so a cell can show a related record, a small chart, a list of validation errors or an edit
1135
+ history (BACKLOG-0001204). The plain-text form is untouched and still becomes a
1136
+ <code>title</code>, so an existing grid behaves exactly as it did.
1137
+ </p>
1138
+ <p class="lead-in">
1139
+ <code>render(params)</code> returns one of four things, and the difference between the last two
1140
+ is a <strong>security property</strong> rather than a matter of taste:
1141
+ </p>
1142
+ <div class="table-wrap">
1143
+ <table>
1144
+ <thead><tr><th>Return</th><th>Rendered as</th></tr></thead>
1145
+ <tbody>
1146
+ <tr><td class="name">an <code>HTMLElement</code></td><td class="desc">Attached as it is. Your DOM, your responsibility.</td></tr>
1147
+ <tr><td class="name"><code>{ title, rows, note }</code></td><td class="desc">A <code>TooltipSpec</code>, drawn by the grid: a heading, label/value lines, and a closing note. <strong>Every field is written as text</strong>, so a spec built out of row values needs no escaping.</td></tr>
1148
+ <tr><td class="name"><code>{ html: '&hellip;' }</code></td><td class="desc">The <strong>only</strong> wrapper that inserts markup, scrubbed of script by the same rules the cell layer applies to <code>allowUnsafeTemplates</code> output.</td></tr>
1149
+ <tr><td class="name">a string</td><td class="desc"><strong>Always text</strong>, whatever it contains. A string holding <code>&lt;b&gt;bold&lt;/b&gt;</code> shows those characters; it does not embolden.</td></tr>
1150
+ </tbody>
1151
+ </table>
1152
+ </div>
1153
+ <p class="lead-in">
1154
+ That last rule is the load-bearing one. The most natural tooltip anyone writes is
1155
+ <code>render: (p) =&gt; p.value</code>, and a value comes from row data &mdash; data the developer
1156
+ did not write and usually cannot audit. If a bare string were treated as markup, a name field
1157
+ holding <code>&lt;img src=x onerror=&hellip;&gt;</code> would execute and nothing in the code
1158
+ would have looked dangerous. Markup therefore has to be asked for in the source, where a
1159
+ reviewer can see it, and no value arriving from data can promote itself.
1160
+ </p>
1161
+ <p class="lead-in">
1162
+ <code>mount(el, params)</code> and <code>unmount(el)</code> carry live content. Inside
1163
+ <code>mount</code> you call <code>createChart</code> or <code>createKPI</code> from a module
1164
+ bundle <em>your</em> application loaded &mdash; the grid core never imports a module &mdash; and
1165
+ <code>unmount</code> is called every time the tooltip closes, so nothing keeps running behind a
1166
+ hidden box. One tooltip element is built and re-used for every cell.
1167
+ </p>
1168
+ <p class="lead-in">
1169
+ <strong>Accessibility.</strong> Nothing is built until the pointer or the keyboard cursor has
1170
+ rested on the cell for <code>delay</code> (400ms by default), so sweeping across the grid mounts
1171
+ nothing. <strong>Focusing a cell shows the same tooltip</strong> after the same delay and the
1172
+ cell points at it with <code>aria-describedby</code>; the tooltip can be hovered without
1173
+ closing, and <strong>Escape dismisses it</strong> (WCAG 2.2 AA, 1.4.13). Escape is only consumed
1174
+ while a tooltip is open, so it still reaches the editor, the menu and the maximised view.
1175
+ </p>
1176
+ <p class="lead-in">
1177
+ <strong>It closes on scroll.</strong> Rows and cells are pooled and re-used, so a tooltip left
1178
+ open across a scroll would be anchored to a node that is now showing a <em>different row</em>.
1179
+ Closing is the honest answer and costs nothing: the browser re-hit-tests after a scroll, so a
1180
+ pointer parked over the grid simply gets a fresh tooltip for the row that is actually there.
1181
+ Content is resolved when the tooltip opens rather than when the pointer arrived, so it always
1182
+ names the row that node is showing at the moment it opens.
1183
+ </p>
1184
+
1185
+ <div class="example">
1186
+ <p class="example__label">Grid-level defaults, and a column declaring a spec tooltip</p>
1187
+ <pre data-run="js" data-expect="250, 360, Ada, a" data-covers="config:tooltip config:delay config:maxWidth"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
1188
+
1189
+ <span class="kw">const</span> grid = createHeadlessGrid({
1190
+ tooltip: { delay: <span class="num">250</span>, maxWidth: <span class="num">360</span> }, <span class="cmt">// the defaults for every tooltip</span>
1191
+ columns: [
1192
+ { field: <span class="str">'name'</span> },
1193
+ {
1194
+ field: <span class="str">'owner'</span>,
1195
+ cell: {
1196
+ tooltip: {
1197
+ <span class="cmt">// Returned as a spec: the grid writes every field as text.</span>
1198
+ render: (p) =&gt; ({ title: p.value, rows: [{ label: <span class="str">'Row'</span>, value: p.key }] }),
1199
+ },
1200
+ },
1201
+ },
1202
+ ],
1203
+ rows: [{ name: <span class="str">'a'</span>, owner: <span class="str">'Ada'</span> }],
1204
+ rowKey: <span class="str">'name'</span>,
1205
+ });
1206
+
1207
+ <span class="kw">const</span> defaults = grid.get(<span class="str">'tooltip'</span>);
1208
+ <span class="kw">const</span> spec = grid.columns.get(<span class="str">'owner'</span>).cell.tooltip;
1209
+ <span class="kw">const</span> content = spec.render({ value: <span class="str">'Ada'</span>, key: <span class="str">'a'</span> });
1210
+ grid.destroy();
1211
+ <span class="kw">return</span> `${defaults.delay}, ${defaults.maxWidth}, ${content.title}, ${content.rows[0].value}`;</code></pre>
1212
+ </div>
1213
+
1128
1214
  <h2 id="grid-methods">Grid methods</h2>
1129
1215
  <p class="section-note">Top-level members. Everything else hangs off a namespace.</p>
1130
1216
 
@@ -1132,7 +1218,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
1132
1218
  <table>
1133
1219
  <thead><tr><th>Member</th><th>Returns</th><th>Description</th></tr></thead>
1134
1220
  <tbody>
1135
- <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.58.0'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
1221
+ <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.59.0'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
1136
1222
  <tr><td class="sig">get(key)</td><td class="type">unknown</td><td class="desc">Read any configuration key.</td></tr>
1137
1223
  <tr><td class="sig">set(key, value)</td><td class="type">void</td><td class="desc">Write one key. Every key is live; nothing needs a rebuild.</td></tr>
1138
1224
  <tr><td class="sig">setAll(values)</td><td class="type">void</td><td class="desc">Write several in one pass. Emits one <code>config:changed</code> for the batch, not one per key.</td></tr>
@@ -4260,6 +4346,8 @@ off(); <span class="cmt">// on() returns i
4260
4346
  <tr><td class="name">cell:edit:end</td><td class="type">{ row, key, colId, valid, errors }</td><td class="desc">It closed: committed or cancelled.</td></tr>
4261
4347
  <tr><td class="name">cell:clicked</td><td class="type">{ row, key, index, colId, column, value, text, event }</td><td class="desc">A cell was clicked. Announcement only: nothing is consumed, so editing and selection behave unchanged.</td></tr>
4262
4348
  <tr><td class="name">cell:dblclicked</td><td class="type">{ ...as cell:clicked }</td><td class="desc"></td></tr>
4349
+ <tr><td class="name">cell:mouseover</td><td class="type">{ ...as cell:clicked, target }</td><td class="desc">The pointer entered a cell, once per cell. <code>target</code> is the cell element. Moving between children of one cell fires nothing; moving straight to the next cell fires <code>cell:mouseout</code> then <code>cell:mouseover</code>. Delegated on the viewport, so it is correct over pooled rows — a row re-used after a scroll reports the row it shows now. Announcement only, and nothing in the grid is gated on hover.</td></tr>
4350
+ <tr><td class="name">cell:mouseout</td><td class="type">{ ...as cell:mouseover }</td><td class="desc">The pointer left a cell, once per cell — including when it left the grid entirely.</td></tr>
4263
4351
  <tr><td class="name">row:clicked</td><td class="type">{ row, key, index, event }</td><td class="desc">Emitted alongside the cell event, cell first.</td></tr>
4264
4352
  <tr><td class="name">row:dblclicked</td><td class="type">{ row, key, index, event }</td><td class="desc"></td></tr>
4265
4353
  <tr><td class="name">row:edit:start</td><td class="type">{ row, key, colId, column }</td><td class="desc">Replaces the cell pair when <code>edit.mode</code> is <code>'row'</code>.</td></tr>
@@ -5474,6 +5562,7 @@ g.destroy(); router.destroy();
5474
5562
 
5475
5563
  <h2 id="ganttmodule">The Gantt module</h2>
5476
5564
  <p><code>modules/gantt</code> is a separate, opt-in project-planning module &mdash; its own bundle, imported only when you want it, changing nothing in the grid core. It turns a task list into a real schedule: a <strong>CPM (Critical Path Method) engine</strong> computes each task's early/late start and finish, its slack (total float), and the zero-float <strong>critical path</strong>, recomputing on every edit. <code>computeSchedule(tasks, deps)</code> is the pure engine; <code>createGantt(opts)</code> is a controller that holds the model, recomputes on <code>setTasks</code>/<code>setDependencies</code>/<code>applyEdit</code>, and emits <code>schedule</code> (or <code>error</code>). Dependencies are the four standard link types &mdash; <code>LINK_TYPES</code> is <code>['FS','SS','FF','SF']</code> &mdash; each with optional lag/lead. A <strong>milestone</strong> is a zero-duration task scheduled as a point; a <strong>summary</strong> task (any task named as another's <code>parent</code>) is derived from its children (start = earliest child, end = latest child, duration-weighted progress) and is not scheduled itself. Bad input never throws or loops: a dependency cycle is refused and reported with a code from <code>SCHEDULE_ERROR</code>, and <code>findViolations</code> flags any task placed earlier than its predecessors allow. <code>toISODate</code> converts an engine day-number back to a calendar date for display.</p>
5565
+ <p><strong>Feed it the rows you already have.</strong> <code>fields</code> names your own task properties for the scheduler &mdash; <code>{ id: 'taskId', start: 'startDate', name: 'jobName', duration: 'dur' }</code>, each a field name or a reader <code>(row) =&gt; value</code> &mdash; so a task list arrives as it is rather than being renamed first. The vocabulary is <code>id</code>, <code>name</code>, <code>start</code>, <code>end</code>, <code>duration</code>, <code>milestone</code>, <code>percentComplete</code>, <code>parent</code>, <code>baselineStart</code>, <code>baselineEnd</code>, <code>constraint</code> and <code>constraintDate</code>; anything you leave unmapped reads its canonical name, so an existing plan is unaffected. <code>rowKey</code> reaches the scheduler too, so a row carrying <code>taskId</code> and no <code>id</code> is identified by it &mdash; a task's own <code>id</code> still wins where it has one. It is a <strong>read</strong> mapping: <code>applyEdit</code> and <code>level()</code> write the canonical property, so each says so plainly rather than writing to a field the schedule is not read from, and <code>assignee</code>/<code>cost</code>/<code>actualCost</code> belong to the resource and earned-value layers rather than to this list.</p>
5477
5566
  <pre><code>import { createGantt, computeSchedule } from '@toclocoinc/lattice-grid/modules/gantt';
5478
5567
 
5479
5568
  const plan = createGantt({
@@ -5493,7 +5582,8 @@ plan.applyEdit({ id: 'design', duration: 7 }); // recomputes; the critical path
5493
5582
  <table>
5494
5583
  <thead><tr><th>Function</th><th>What it does</th></tr></thead>
5495
5584
  <tbody>
5496
- <tr><td class="sig">createGantt({ tasks?, dependencies?, projectStart?, autoSchedule?, grid? })</td><td class="desc">Create a controller over a task list and a dependency list. Computes the CPM schedule immediately and on every edit; <code>on('schedule'|'error', fn)</code> subscribes; <code>applyEdit</code>/<code>setTasks</code>/<code>setDependencies</code> mutate and recompute; <code>grid</code> is kept for the write-back binding.</td></tr>
5585
+ <tr><td class="sig">createGantt({ tasks?, dependencies?, projectStart?, autoSchedule?, grid?, fields?, rowKey? })</td><td class="desc">Create a controller over a task list and a dependency list. Computes the CPM schedule immediately and on every edit; <code>on('schedule'|'error', fn)</code> subscribes; <code>applyEdit</code>/<code>setTasks</code>/<code>setDependencies</code> mutate and recompute; <code>grid</code> is kept for the write-back binding. <code>fields</code> maps your own task property names onto the ones the scheduler reads, and <code>rowKey</code> identifies a task for both <code>rows.apply</code> and the schedule.</td></tr>
5586
+ <tr><td class="sig">rows.apply(change) / rows.forEach(fn) / rows.count</td><td class="desc">The live consumer surface, the same keyed diff a grid, a board and a KPI panel accept, so one feed &mdash; or one tab strip &mdash; drives them all: <code>apply({ add, update, remove })</code> upserts by <code>rowKey</code> and recomputes, <code>forEach</code> visits every task, and <code>count</code> is how many the plan holds.</td></tr>
5497
5587
  <tr><td class="sig">computeSchedule(tasks, deps?, { projectStart? })</td><td class="desc">The pure CPM engine: forward/backward passes over the leaf tasks honouring FS/SS/FF/SF + lag, slack/float and the zero-float critical path, with summaries derived and cycles refused. Returns <code>{ ok, tasks, critical, criticalPaths, projectDuration, ... }</code> or <code>{ ok:false, error }</code>.</td></tr>
5498
5588
  <tr><td class="sig">findViolations(tasks, schedule)</td><td class="desc">The tasks whose user-placed start begins earlier than CPM allows (the manual-with-validation flag). Summaries, whose dates are derived, are skipped.</td></tr>
5499
5589
  <tr><td class="sig">toISODate(day)</td><td class="desc">Format an engine day-number as an ISO calendar date (<code>YYYY-MM-DD</code>, UTC).</td></tr>
@@ -6100,24 +6190,37 @@ import { createTabs } from '@toclocoinc/lattice-grid/modules/tabs';
6100
6190
 
6101
6191
  const tabs = createTabs(document.querySelector('#tabs'), {
6102
6192
  createGrid, <span class="cmt">// injected -- see below</span>
6193
+ createHeadlessGrid, <span class="cmt">// optional: lets an unvisited tab still carry a count</span>
6103
6194
  tabs: [
6104
6195
  { id: 'all', label: 'All', config: { rowKey: 'id', rows, columns } },
6105
6196
  { id: 'open', label: 'Open', from: 'all', where: (r) =&gt; r.stage === 'Open',
6106
6197
  follow: 'filtered', refresh: 'live', config: { columns } },
6107
6198
  { id: 'breached', label: 'Breached', from: 'open', <span class="cmt">// derives from Open, not All -- a chain</span>
6108
- where: (r) =&gt; r.daysOverdue &gt; 0, config: { columns } },
6199
+ where: (r) =&gt; r.daysOverdue &gt; 0, config: { columns },
6200
+ icon: '⚠️', badge: true, <span class="cmt">// "Breached 14" -- and it follows Open's filter</span>
6201
+ badgeTone: (count) =&gt; (count &gt; 0 ? 'bad' : 'good') },
6109
6202
  ],
6110
6203
  onBeforeTabChange: ({ id }) =&gt; !hasUnsavedEdit(), <span class="cmt">// veto a switch</span>
6111
6204
  });</code></pre>
6112
6205
  <p><strong>Lifecycle.</strong> A tab's grid mounts on <em>first activation</em>, not up front, and then stays alive &mdash; hidden, never destroyed &mdash; until the whole strip is. Per-tab scroll, selection, filters, sort, grouping, expansion — and an open cell/row editor — therefore survive a switch away and back natively, by simply not touching that grid instance, rather than through a lossy serialise/restore round-trip: leave a tab mid-edit, switch away, switch back, and the editor is exactly as it was left, uncommitted and undiscarded. Activating a derived tab materialises its whole ancestor chain first (mounted, hidden), and a cyclic <code>from</code> graph is refused &mdash; naming the exact cycle &mdash; when <code>createTabs</code> is called, not at first click.</p>
6113
6206
  <p><strong>A hidden tab costs nothing this module can spend.</strong> An inactive panel carries the <code>hidden</code> attribute (<code>display:none</code>); the module runs no timer, observer or repaint of its own against it. Measured with a real browser (<code>bench/tabs-idle.mjs</code>): several mounted-but-hidden, untouched tabs cost the same idle CPU as none at all. The one honestly-reported exception is not this module's: a <em>derived</em> tab's row model still re-derives on every change to its (possibly hidden) parent &mdash; by design, so reactivating it is instant rather than a stale flash &mdash; and the engine's own repaint listener for a derived source's <code>rows:changed</code> calls the renderer directly, bypassing <code>grid.updates.pause()</code> (which only holds the streaming-ingestion path). A hidden derived tab therefore still runs a read/compute/write pass on every parent change, even though the write phase paints a zero-size viewport; the bench measures and reports the size of that gap rather than leaving it inferred.</p>
6207
+ <p><strong>A tab can say what is waiting on it.</strong> <code>badge: true</code> puts that tab's own live row count on it &mdash; and for a derived tab the count <em>follows</em>, so filtering the parent restates the child's badge with it. A number or a string is a static badge; a function is handed the live count and returns what to show (<code>null</code> hides it). The tone is declared, never inferred from a threshold this module would have had to invent: <code>badgeTone</code> takes <code>good</code>, <code>warn</code>, <code>bad</code> or <code>unknown</code> &mdash; the same vocabulary a statistic tile grades to, so one <code>data-tone</code> rule in a theme dresses both &mdash; or a function of the count. <code>icon</code> adds a leading glyph: a single character or emoji, or an element you built. <strong>Never a markup string</strong>; nothing in this module parses HTML, and anything it cannot use is warned about once and ignored rather than coerced. Badges are <strong>off by default</strong>, and a tab that asks for none renders exactly the DOM it did before.</p>
6208
+ <p><strong>A tab nobody has clicked can still carry a count.</strong> Tabs mount lazily, so the tab most worth badging &mdash; the one you want to glance at &mdash; is precisely the one with no grid behind it. Inject the optional <code>createHeadlessGrid</code>, the same way <code>createGrid</code> is already injected and for the same reason, and an unactivated tab's count is computed with no element and no renderer, correct from first paint and still following its parent's filter. Leave it out and nothing breaks: that tab simply shows no badge until it is first activated, and the module says so once, naming the option. When the strip is narrow it is the <strong>label</strong> that gives way &mdash; it ellipsises while the count and the icon stay whole &mdash; and the strip still wraps rather than growing a scroll affordance.</p>
6209
+ <p><strong>A tab body does not have to be a grid.</strong> Give a tab a <code>view</code> &mdash; the factory that mounts its body, called as <code>(el, config) =&gt; instance</code> &mdash; and the tab hosts a <strong>kanban board, a KPI strip or a Gantt</strong> instead. <code>createKanban</code> and <code>createKPI</code> already have that signature, so they are passed straight in; the Gantt takes a single options object and mounts itself when given an element, so it is adapted in a line: <code>view: (el, config) =&gt; createGantt({ ...config, element: el })</code>. The factory is <strong>injected, never imported</strong>, for the same reason <code>createGrid</code> is. Such a tab's <code>config</code> is that viewer's own config.</p>
6210
+ <pre><code>{ id: 'board', label: 'Board', from: 'open', <span class="cmt">// derives from the Open tab</span>
6211
+ where: (r) =&gt; r.owner === me, follow: 'filtered', refresh: 'live',
6212
+ view: createKanban, badge: true,
6213
+ config: { rowKey: 'id', columnProperty: 'stage' } }</code></pre>
6214
+ <p><strong>And it derives exactly as a grid tab does.</strong> That is the point rather than a bonus: <code>from</code> plus the same narrowing (<code>where</code>, <code>group</code>, <code>join</code>, <code>follow</code>, &hellip;) applies to a board or a KPI strip just as it does to a grid, so filtering the parent tab restates the board beside it. The mechanism is the derived source you already have: for a non-grid body the module materialises a <strong>headless grid</strong> carrying that same derived source and pipes its rows into the viewer through <code>rows.apply({ add, update, remove })</code> &mdash; the keyed diff the grid, the board, the KPI panel and the Gantt all already accept from a Data Router. Nothing new is invented, the viewer stays unaware it is in a tab, and the module still imports no engine code. Deriving into a viewer therefore needs <code>createHeadlessGrid</code> injected alongside <code>createGrid</code>; without it the body mounts with its own rows and follows nothing, and the module says so once. A <code>view</code> tab with no <code>from</code> simply holds its own rows.</p>
6215
+ <p><strong>A body you have never opened is complete the moment you open it.</strong> Tabs mount lazily, so a board first shown after its parent has been filtered twice has a lot to catch up on &mdash; and because the headless grid holds the rows, there is no feed to join late: the body is seeded with the current set at mount and agrees exactly with a sibling that was mounted earlier. A count badge works on all three the same way, so a Gantt tab can read "Plan 14" as readily as a grid tab.</p>
6114
6216
  <p><strong>Accessibility.</strong> A real <code>role="tablist"</code>/<code>"tab"</code>/<code>"tabpanel"</code> with <code>aria-selected</code> and a roving <code>tabindex</code>, imitating the grid's own column-header keyboard model rather than the tool panel's tablist (which has the roles but no arrow-key handling). This is <strong>manual activation</strong>: <kbd>&larr;</kbd>/<kbd>&rarr;</kbd> and <kbd>Home</kbd>/<kbd>End</kbd> move the roving tab stop without switching the panel or mounting a grid; <kbd>Enter</kbd>/<kbd>Space</kbd>, or a click, activates. The newly active tab's label is announced through a polite live region.</p>
6115
6217
  <div class="table-wrap">
6116
6218
  <table>
6117
6219
  <thead><tr><th>Member</th><th>Description</th></tr></thead>
6118
6220
  <tbody>
6119
- <tr><td class="sig">createTabs(el, config)</td><td class="desc">Create a tabbed grid. <code>config.createGrid</code> is required (injected, not imported); <code>config.tabs</code> is a non-empty array of tab descriptors, each an <code>id</code>, a <code>label</code>, a grid <code>config</code>, and optionally <code>from</code> plus the derivation narrowing (<code>where</code>, <code>group</code>, <code>groupBy</code>, <code>bucket</code>, <code>join</code>, <code>unnest</code>, <code>refresh</code>, <code>crossFilter</code>, <code>follow</code>, <code>limit</code>, <code>sort</code>, <code>profile</code>) forwarded onto the derived source built for it.</td></tr>
6120
- <tr><td class="sig">tabs() / tab(id) / isMounted(id)</td><td class="desc">The configured tab ids, in order; a tab's live grid instance (or <code>null</code> before its first activation); whether a tab has been materialised yet.</td></tr>
6221
+ <tr><td class="sig">createTabs(el, config)</td><td class="desc">Create a tabbed grid. <code>config.createGrid</code> is required (injected, not imported); <code>config.createHeadlessGrid</code> is optional, and is what gives a never-activated tab a live count and lets a non-grid body derive. <code>config.tabs</code> is a non-empty array of tab descriptors, each an <code>id</code>, a <code>label</code>, a <code>config</code>, optionally an <code>icon</code> and a <code>badge</code>/<code>badgeTone</code>, and optionally <code>from</code> plus the derivation narrowing (<code>where</code>, <code>group</code>, <code>groupBy</code>, <code>bucket</code>, <code>join</code>, <code>unnest</code>, <code>refresh</code>, <code>crossFilter</code>, <code>follow</code>, <code>limit</code>, <code>sort</code>, <code>profile</code>) forwarded onto the derived source built for it.</td></tr>
6222
+ <tr><td class="sig">tab.view</td><td class="desc">Mount something other than a grid in this tab: the factory that builds it, called as <code>(el, config) =&gt; instance</code> &mdash; <code>createKanban</code>, <code>createKPI</code>, or a one-line Gantt adapter. The tab's <code>config</code> is then that viewer's own config, and the tab derives from <code>from</code> exactly as a grid tab does (which needs <code>createHeadlessGrid</code>).</td></tr>
6223
+ <tr><td class="sig">tabs() / tab(id) / isMounted(id)</td><td class="desc">The configured tab ids, in order; the live instance mounted in a tab &mdash; the grid, or the viewer for a <code>view</code> tab (or <code>null</code> before its first activation); whether a tab has been materialised yet.</td></tr>
6121
6224
  <tr><td class="sig">activate(id, opts)</td><td class="desc">Switch the active tab, gated by <code>beforeTabChange</code>. Returns <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>
6122
6225
  <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>tab:changed</code>, the cancellable <code>beforeTabChange</code> (call <code>preventDefault(reason?)</code> or return <code>false</code> to veto), and its paired <code>tabChange:cancelled</code>. Config sugar: <code>onTabChange</code>, <code>onBeforeTabChange</code>, <code>onTabChangeCancelled</code>.</td></tr>
6123
6226
  <tr><td class="sig">destroy()</td><td class="desc">Tear the whole strip down; destroys every mounted tab's grid (each isolated, so one throwing does not strand the rest).</td></tr>
@@ -6175,25 +6278,25 @@ const layout = createLayout(document.querySelector('#dash'), {
6175
6278
  createGrid(layout.payload('pipeline'), { rowKey: 'id', rows, columns });</code></pre>
6176
6279
  <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>
6177
6280
  <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>
6178
- <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>
6281
+ <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. <code>compact: 'horizontal'</code> floats windows <strong>left</strong> into vacated space instead, so dragging a window out of a row closes the hole sideways rather than leaving it open. It is a third <em>value</em>, not a second pass over the other axis: one gravity direction, never two, because two directions each vacate space the other wants and the arrangement would then depend on which ran last. It settles windows in a canonical <strong>left-to-right, then top-to-bottom</strong> order, exactly mirroring vertical's <strong>top-to-bottom, then left-to-right</strong>, so the result depends only on where the windows are and never on the order they were declared or dragged in &mdash; the same guarantee vertical has always made, now made on both axes and asserted over all 720 declaration orders of a six-window dashboard on each axis. A value that is none of the three warns once, naming what it was given, and falls back to <code>'vertical'</code>. <strong>The axis a mode compacts along is the axis that can overflow:</strong> under <code>'horizontal'</code> a window that genuinely does not fit the columns settles in an implicit track and can be clipped on a <code>static</code> horizontal axis &mdash; exactly as a <code>'vertical'</code> dashboard can be clipped past its rows on a <code>static</code> vertical axis today. <code>overflowX: 'scroll'</code> is to <code>'horizontal'</code> what <code>overflowY: 'scroll'</code> is to the default.</p>
6179
6282
  <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>
6180
6283
  <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>
6181
6284
  <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, including a grid column declared as a <em>percentage</em> (<code>layout: { width: '50%' }</code>), which follows the window (BACKLOG-0001117): half of the viewport at 800px, half of it again at 400px.</p>
6182
6285
  <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>
6183
6286
  <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>
6184
- <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>
6287
+ <p><strong>Not in v1:</strong> per-frame drag events; nested layouts; 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>
6185
6288
  <div class="table-wrap">
6186
6289
  <table>
6187
6290
  <thead><tr><th>Member</th><th>Description</th></tr></thead>
6188
6291
  <tbody>
6189
- <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>
6292
+ <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>, <code>'horizontal'</code> or <code>'none'</code>, default <code>'vertical'</code>) complete it. A second mount on the same element is refused by name.</td></tr>
6190
6293
  <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>
6191
6294
  <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>
6192
6295
  <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>
6193
6296
  <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>
6194
6297
  <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>
6195
6298
  <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>
6196
- <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 &mdash; a focused grid body cell or column heading included &mdash; unless something inside has already claimed the key: an open cell editor, a filter menu or a column menu closes first, and the next Escape restores the window. A grid claims only an Escape it actually used, so a maximised grid never keeps the key (BACKLOG-0001143). Afterwards focus lands on the window's maximise control, so a keyboard user is somewhere they can act rather than wherever the payload left them. <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>
6299
+ <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 &mdash; a focused grid body cell or column heading included &mdash; unless something inside has already claimed the key: an open cell editor, a filter menu or a column menu closes first, and the next Escape restores the window. A grid claims only an Escape it actually used, so a maximised grid never keeps the key (BACKLOG-0001143). Afterwards focus lands on the window's maximise control, so a keyboard user is somewhere they can act rather than wherever the payload left them. <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 the rest of the dashboard closes up around it under whichever <code>compact</code> mode is configured &mdash; the windows below <em>pull up</em> under <code>'vertical'</code>, the windows beside it float <em>left</em> under <code>'horizontal'</code> &mdash; 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>
6197
6300
  <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>
6198
6301
  <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>
6199
6302
  <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>
@@ -7179,7 +7282,7 @@ grid.destroy();
7179
7282
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
7180
7283
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
7181
7284
  has not fired yet — so the name is checked rather than left to be discovered.</p>
7182
- <pre data-run="js" data-expect="112" data-covers="event:rowDrag:started event:rowDrag:moved event:rowDrag:left event:rowDrag:ended event:find:changed event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
7285
+ <pre data-run="js" data-expect="114" data-covers="event:cell:mouseover event:cell:mouseout event:rowDrag:started event:rowDrag:moved event:rowDrag:left event:rowDrag:ended event:find:changed event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:profile:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
7183
7286
 
7184
7287
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
7185
7288
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -7188,6 +7291,7 @@ grid.destroy();
7188
7291
  <span class="kw">const</span> documented = [
7189
7292
  'cell:changed', 'cell:clicked', 'cell:confirmed', 'cell:conflict',
7190
7293
  'cell:contextmenu', 'cell:dblclicked', 'cell:edit:end', 'cell:edit:start',
7294
+ 'cell:mouseover', 'cell:mouseout',
7191
7295
  'cell:pending', 'cell:reverted', 'clipboard:copy', 'column:filter:open', 'column:profile:open', 'column:grouped',
7192
7296
  'column:menu:open', 'column:pivoted', 'column:resized', 'columns:changed',
7193
7297
  'columns:tagged', 'comment:added', 'comment:deleted', 'comment:edited',
@@ -8021,7 +8125,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8021
8125
  <tr><td class="name">class</td><td class="type">string | string[] | ((p: CellParams) =&gt; string | string[])</td><td class="desc"><small>(optional)</small></td></tr>
8022
8126
  <tr><td class="name">classWhen</td><td class="type">Record&lt;string, string | ((p: CellParams) =&gt; boolean)&gt;</td><td class="desc"><small>(optional)</small></td></tr>
8023
8127
  <tr><td class="name">style</td><td class="type">CellStyle | ((p: CellParams) =&gt; CellStyle)</td><td class="desc"><small>(optional)</small></td></tr>
8024
- <tr><td class="name">tooltip</td><td class="type">string | ((p: CellParams) =&gt; string)</td><td class="desc"><small>(optional)</small></td></tr>
8128
+ <tr><td class="name">tooltip</td><td class="type">string | ((p: CellParams) =&gt; string) | ColumnTooltipSpec</td><td class="desc">A tooltip for this column's cells. A string or a function is the plain-text case and becomes the browser's own `title`. An object is a {@link ColumnTooltipSpec}: a tooltip the grid draws, which can carry structure, markup or live content and which a keyboard user can reach (BACKLOG-0001204). <small>(optional)</small></td></tr>
8025
8129
  <tr><td class="name">align</td><td class="type">Align</td><td class="desc"><small>(optional)</small></td></tr>
8026
8130
  <tr><td class="name">verticalAlign</td><td class="type">VAlign</td><td class="desc">Vertical alignment of this column's cell content, overriding the grid-level `verticalAlign` for this column alone (BACKLOG-0000989). Accepted at the top level of the column too, as `align` is. <small>(optional)</small></td></tr>
8027
8131
  <tr><td class="name">wrap</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
@@ -8172,6 +8276,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8172
8276
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8173
8277
  <tbody>
8174
8278
  <tr><td class="name">width</td><td class="type">number | string</td><td class="desc">A pixel width, or a percentage of the grid's inner width as a string, `'25%'`. A percentage is a share of the *whole* grid. `flex` divides only the space left over after fixed columns, so the two are not interchangeable: `flex: 25` on four columns is a quarter of the remainder, which is a quarter of the grid only when nothing else is fixed. <small>(optional)</small></td></tr>
8279
+ <tr><td class="name">fit</td><td class="type">'content'</td><td class="desc">`'content'` sizes the column to what it is actually showing, the way `columns.autoSize()` does, and keeps doing it: on the first paint, and again whenever the rows change, the columns are shown, hidden, reordered or pinned, or the grid is resized. It is the declarative form of the imperative call, so a host no longer has to re-issue `autoSize()` after every data change. Sized to the *visible* content, not to the widest value in the dataset: the measurement reads the rows the renderer has mounted, because measuring a million rows is not a plan. It measures the heading too, so a column whose title is longer than its values widens to show the title. **Anything the caller states outranks it.** A declared `width` wins, and so does a width the user drags to — a resize is recorded as a `width`, so from that moment the column is that wide and the fit no longer touches it. `min` and `max` clamp the fitted width as they clamp any other. `flex` is resolved before this and wins, the two being contradictory instructions: `flex` fits the column to the *grid*, this fits it to the *content*. Not re-measured on scroll, deliberately: different rows mount as the grid scrolls, and re-fitting against them would make the columns jitter under the reader. <small>(optional)</small></td></tr>
8175
8280
  <tr><td class="name">min</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
8176
8281
  <tr><td class="name">max</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
8177
8282
  <tr><td class="name">flex</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
@@ -8289,6 +8394,18 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8289
8394
  </tbody>
8290
8395
  </table>
8291
8396
  </div>
8397
+ <h3 id="type-ColumnTooltipSpec">ColumnTooltipSpec</h3>
8398
+ <p class="section-note">A rich, keyboard-accessible tooltip for a column's cells (BACKLOG-0001204) — the object form of `cell.tooltip`, drawn by the grid rather than handed to the browser as a native `title`. Shown after a delay (`tooltip.delay`, 400ms by default) on hover *and* on keyboard focus; the cell points at it with `aria-describedby`; it can be hovered without closing and Escape dismisses it (WCAG 2.2 AA, 1.4.13). It closes on scroll, because rows are pooled and a bubble left open would be anchored to a node that is now showing a different row.</p>
8399
+ <div class="table-wrap">
8400
+ <table>
8401
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8402
+ <tbody>
8403
+ <tr><td class="name">render</td><td class="type">(params: TooltipParams) =&gt; HTMLElement | TooltipSpec | { html: string } | string | null | undefined</td><td class="desc">Produce the content. Four shapes, and the difference between the last two is a security property rather than a style choice: - an **element** — your own DOM, attached as it is; - a **{@link TooltipSpec}** — `{ title, rows, note }`, rendered as text; - **`{ html }`** — the only wrapper that inserts markup, scrubbed of script the same way `allowUnsafeTemplates` output is; - a **string** — *always* text, never markup. The last rule is what makes `render: (p) =&gt; p.value` safe: a value comes from row data, and data must not be able to promote itself to HTML. <small>(optional)</small></td></tr>
8404
+ <tr><td class="name">mount</td><td class="type">(el: HTMLElement, params: TooltipParams) =&gt; void</td><td class="desc">Put live content in the tooltip — a sparkline, a KPI tile — by calling into a module bundle your application loaded. The grid core never imports a module, so anything live is mounted here by you. <small>(optional)</small></td></tr>
8405
+ <tr><td class="name">unmount</td><td class="type">(el: HTMLElement) =&gt; void</td><td class="desc">Tear down whatever `mount` built. Called every time the tooltip closes, so nothing keeps running behind a hidden box. <small>(optional)</small></td></tr>
8406
+ </tbody>
8407
+ </table>
8408
+ </div>
8292
8409
  <h3 id="type-ColumnValidation">ColumnValidation</h3>
8293
8410
  <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>
8294
8411
  <div class="table-wrap">
@@ -9373,6 +9490,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9373
9490
  <tr><td class="name">cornerRadius</td><td class="type">boolean | number | string</td><td class="desc">Round the grid's outer corners. Square by default. `true` adopts the theme's own radius; a number is pixels; a string is used as written, so a host can pass its own token or a relative unit. <small>(optional)</small></td></tr>
9374
9491
  <tr><td class="name">stripedRows</td><td class="type">boolean</td><td class="desc">Shade alternate data rows (zebra striping). Off by default, and strictly opt-in: an existing grid must look exactly the same on upgrade. When `true`, every other data row takes the theme's `--lattice-surface-alt` background, which every palette already defines, so dark, high-contrast and terminal stripe correctly without extra work. Parity follows the row's *logical* index, not its position in the DOM, so a row keeps its stripe across a scroll even though the rows are recycled. Structural rows — group headings, group footers and the grand total — are never striped, and both selection and hover still win over the stripe. <small>(optional)</small></td></tr>
9375
9492
  <tr><td class="name">verticalAlign</td><td class="type">VAlign</td><td class="desc">Vertical alignment of cell content within a row, as a default for every column (BACKLOG-0000989). `top`, `middle` or `bottom`; a column's own `verticalAlign` overrides it for that column. The horizontal counterpart is the per-column `align`. Omitted, the grid keeps its historical placement — content centred in a fixed-height row and top-aligned in an `autoHeight` row — so an existing grid is unchanged on upgrade. Setting a value aligns every column uniformly, including `autoHeight` rows, unless a column opts out. <small>(optional)</small></td></tr>
9493
+ <tr><td class="name">tooltip</td><td class="type">TooltipConfig</td><td class="desc">Defaults for the rich cell tooltip (BACKLOG-0001204). The tooltip itself is declared per column, on `cell.tooltip`; this only carries the settings that are a house style rather than a per-column decision. It switches nothing on: a column with no `cell.tooltip` has no tooltip whatever is set here. <small>(optional)</small></td></tr>
9376
9494
  <tr><td class="name">scrollbars</td><td class="type">ScrollbarMode | { x?: ScrollbarMode; y?: ScrollbarMode }</td><td class="desc">Keep the scroll viewport's scrollbars visible (BACKLOG-0000990). `'auto'` (the default) is the platform's native behaviour, where overlay scrollbars fade when idle. `'always'` keeps both axes shown whether or not the pointer is over the grid. The object form controls each axis on its own — `{ y: 'always' }` pins the vertical bar while the horizontal one stays native. Omitted, the grid is unchanged on upgrade. <small>(optional)</small></td></tr>
9377
9495
  <tr><td class="name">columnTagFilter</td><td class="type">boolean | { multiple?: boolean; label?: string }</td><td class="desc">Show a bar above the column headings for filtering columns by tag. Off by default, and it draws nothing unless some column carries a `tags` entry. `multiple: true` lets more than one tag be chosen at once. Only tagged columns are ever hidden, so an untagged account or total column stays visible whatever is selected. <small>(optional)</small></td></tr>
9378
9496
  <tr><td class="name">anomalySummary</td><td class="type">boolean | { column?: string; label?: string }</td><td class="desc">Show a small chip in the grid chrome that reads how many rows an anomaly shadow column has flagged, and filters the grid to exactly those when it is clicked (BACKLOG-0000799). Off by default, and it draws nothing unless a column declares a `shadow: { kind: 'anomalyFlag' }`. The count and the filter both read that one shadow column, so the number on the chip is the number of rows the click reveals. `column` names the base column to summarise when more than one anomaly-flag shadow is present; `label` overrides the chip's wording. <small>(optional)</small></td></tr>
@@ -9409,7 +9527,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9409
9527
  <tr><td class="name">workerUrl</td><td class="type">string</td><td class="desc">Where to load the worker kernel from, when hosting it yourself. <small>(optional)</small></td></tr>
9410
9528
  <tr><td class="name">sharedMemory</td><td class="type">boolean</td><td class="desc">Use a shared buffer for the worker, where the page's headers allow it. <small>(optional)</small></td></tr>
9411
9529
  <tr><td class="name">groupFooter</td><td class="type">boolean</td><td class="desc">A totals line at the foot of each group as well as the grid. <small>(optional)</small></td></tr>
9412
- <tr><td class="name">groupRenderer</td><td class="type">(params: GroupRowParams): string | Node | void</td><td class="desc">Draw the group row yourself. The grid's own group row is an expander, a label and a count. A host that needs more — a section header with a points rollup, a done/total count and a progress bar — supplies this instead, and owns the whole row: it is drawn as one band across every column, and no ordinary cells are mounted for it. Return an HTML string, or a node, or write into `params.element` and return nothing. Unlike `fullWidth.render`, a string here **is** inserted as markup, on the same footing as the board's `cardRenderer`: this is your own template for a row the grid synthesised, not a value out of your data. The chevron is yours to draw and yours to wire: give any element in your markup `data-lat-group-toggle` and a click on it expands or collapses the group, or call `params.toggle()` from a node you built yourself. <small>(optional)</small></td></tr>
9530
+ <tr><td class="name">groupRenderer</td><td class="type">(params: GroupRowParams) =&gt; string | Node | void</td><td class="desc">Draw the group row yourself. The grid's own group row is an expander, a label and a count. A host that needs more — a section header with a points rollup, a done/total count and a progress bar — supplies this instead, and owns the whole row: it is drawn as one band across every column, and no ordinary cells are mounted for it. Return an HTML string, or a node, or write into `params.element` and return nothing. Unlike `fullWidth.render`, a string here **is** inserted as markup, on the same footing as the board's `cardRenderer`: this is your own template for a row the grid synthesised, not a value out of your data. The chevron is yours to draw and yours to wire: give any element in your markup `data-lat-group-toggle` and a click on it expands or collapses the group, or call `params.toggle()` from a node you built yourself. <small>(optional)</small></td></tr>
9413
9531
  <tr><td class="name">groupDefaultExpanded</td><td class="type">boolean | number | ((group: GroupInfo) =&gt; boolean)</td><td class="desc">Which groups start expanded, before anyone has opened or closed one. `true` (the default) opens every group, `false` closes every group, a number opens the first N levels (`0` closes everything, a negative opens every level), and a predicate answers per group — the current sprint's section open while the rest start closed. Only ever consulted for a group nobody has touched: once the user or your code expands or collapses one, that decision stands. <small>(optional)</small></td></tr>
9414
9532
  <tr><td class="name">grandTotalRow</td><td class="type">boolean | 'bottom'</td><td class="desc">Where the grand total goes. `true` adds it as the last display row, counted by `rows.count()` like any other. `'bottom'` pins it beneath the viewport instead, so it stays in view while the rows scroll and is *not* part of `rows.count()`. Omitted or `false` means no grand total row. <small>(optional)</small></td></tr>
9415
9533
  <tr><td class="name">pinnedTopRows</td><td class="type">unknown[]</td><td class="desc">Rows pinned above the scrolling body. The objects are rendered through the ordinary column pipeline but are not part of the data: not counted by `rows.count()`, not sorted, filtered, grouped, selectable or exported. Use it for a totals line or a units row that must stay against the header. <small>(optional)</small></td></tr>
@@ -10993,6 +11111,58 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10993
11111
  </tbody>
10994
11112
  </table>
10995
11113
  </div>
11114
+ <h3 id="type-TooltipConfig">TooltipConfig</h3>
11115
+ <p class="section-note">Grid-level defaults for the rich cell tooltip (BACKLOG-0001204), set once for every column rather than repeated on each. Defaults only: it switches nothing on. A tooltip exists because a column declares `cell.tooltip`, and a grid whose columns declare none has no tooltips whatever is set here.</p>
11116
+ <div class="table-wrap">
11117
+ <table>
11118
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11119
+ <tbody>
11120
+ <tr><td class="name">delay</td><td class="type">number</td><td class="desc">How long the pointer or the keyboard cursor must rest on a cell before the tooltip is built, in milliseconds. 400 by default. The delay is why a pointer sweeping across the grid mounts nothing: a tooltip that built a chart on every cell it crossed would be unusable, and `0` asks for exactly that. <small>(optional)</small></td></tr>
11121
+ <tr><td class="name">maxWidth</td><td class="type">number | string</td><td class="desc">How wide the tooltip may grow. A number is pixels; a string is used as written. <small>(optional)</small></td></tr>
11122
+ </tbody>
11123
+ </table>
11124
+ </div>
11125
+ <h3 id="type-TooltipParams">TooltipParams</h3>
11126
+ <p class="section-note">What a tooltip's `render` and `mount` are given: the same identification `cell:clicked` carries, plus the cell element itself and the grid. Resolved from the DOM at the moment the tooltip opens rather than when the pointer arrived, so a pooled row re-used in between names the row it is showing now.</p>
11127
+ <div class="table-wrap">
11128
+ <table>
11129
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11130
+ <tbody>
11131
+ <tr><td class="name">row</td><td class="type">Row</td><td class="desc">The row under the pointer or the keyboard cursor.</td></tr>
11132
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc">That row's key.</td></tr>
11133
+ <tr><td class="name">index</td><td class="type">number</td><td class="desc">Its display index.</td></tr>
11134
+ <tr><td class="name">colId</td><td class="type">string</td><td class="desc">The column the cell belongs to.</td></tr>
11135
+ <tr><td class="name">column</td><td class="type">Column</td><td class="desc">The resolved column.</td></tr>
11136
+ <tr><td class="name">value</td><td class="type">unknown</td><td class="desc">The cell's value.</td></tr>
11137
+ <tr><td class="name">text</td><td class="type">string</td><td class="desc">The cell's formatted text.</td></tr>
11138
+ <tr><td class="name">cell</td><td class="type">HTMLElement</td><td class="desc">The cell element the tooltip is anchored to.</td></tr>
11139
+ <tr><td class="name">grid</td><td class="type">Grid</td><td class="desc">The grid.</td></tr>
11140
+ </tbody>
11141
+ </table>
11142
+ </div>
11143
+ <h3 id="type-TooltipRow">TooltipRow</h3>
11144
+ <p class="section-note">One label/value line in a {@link TooltipSpec}. Both halves are written as text by the grid, whatever they contain.</p>
11145
+ <div class="table-wrap">
11146
+ <table>
11147
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11148
+ <tbody>
11149
+ <tr><td class="name">label</td><td class="type">unknown</td><td class="desc">The line's label, drawn on the leading edge. <small>(optional)</small></td></tr>
11150
+ <tr><td class="name">value</td><td class="type">unknown</td><td class="desc">The line's value, drawn on the trailing edge. <small>(optional)</small></td></tr>
11151
+ </tbody>
11152
+ </table>
11153
+ </div>
11154
+ <h3 id="type-TooltipSpec">TooltipSpec</h3>
11155
+ <p class="section-note">Structured tooltip content the grid renders for you (BACKLOG-0001204): a heading, a list of label/value lines, and a closing note. Every field is written as **text**, never as markup, so a spec built out of row values needs no escaping and cannot become HTML by accident. Return `{ html }` from `render` when markup is genuinely wanted.</p>
11156
+ <div class="table-wrap">
11157
+ <table>
11158
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11159
+ <tbody>
11160
+ <tr><td class="name">title</td><td class="type">unknown</td><td class="desc">A heading for the tooltip. <small>(optional)</small></td></tr>
11161
+ <tr><td class="name">rows</td><td class="type">TooltipRow[]</td><td class="desc">Label/value lines, in order. <small>(optional)</small></td></tr>
11162
+ <tr><td class="name">note</td><td class="type">unknown</td><td class="desc">A closing note under the lines, drawn quieter than them. <small>(optional)</small></td></tr>
11163
+ </tbody>
11164
+ </table>
11165
+ </div>
10996
11166
  <h3 id="type-TopValue">TopValue</h3>
10997
11167
  <p class="section-note">One row of a categorical column's top-values table (BACKLOG-0000959).</p>
10998
11168
  <div class="table-wrap">
@@ -11230,7 +11400,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
11230
11400
  <!-- END GENERATED TYPE REFERENCE -->
11231
11401
 
11232
11402
  <footer>
11233
- Lattice Grid 1.58.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
11403
+ Lattice Grid 1.59.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
11234
11404
  This document describes the behaviour of the shipped library. Where this guide and the code
11235
11405
  disagree, the code wins: please <a href="https://www.latticegrid.dev">tell us</a>.
11236
11406
  </footer>