@toclocoinc/lattice-grid 1.61.0 → 1.62.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 (113) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +16 -14
  3. package/docs/api-detail.html +863 -17
  4. package/lattice-grid.d.ts +22 -11
  5. package/lattice-grid.esm.min.js +456 -43
  6. package/lattice-grid.min.cjs +456 -43
  7. package/lattice-grid.min.css +1 -1
  8. package/lattice-grid.min.js +456 -43
  9. package/modules/ai.d.ts +11 -2
  10. package/modules/ai.esm.min.js +6 -4
  11. package/modules/ai.min.cjs +6 -4
  12. package/modules/ai.min.js +6 -4
  13. package/modules/angular.d.ts +1 -1
  14. package/modules/angular.esm.min.js +2 -2
  15. package/modules/angular.min.cjs +2 -2
  16. package/modules/angular.min.js +2 -2
  17. package/modules/chart-alluvial.d.ts +1 -1
  18. package/modules/chart-alluvial.esm.min.js +1 -1
  19. package/modules/chart-arc.d.ts +1 -1
  20. package/modules/chart-arc.esm.min.js +1 -1
  21. package/modules/chart-bubblemap.d.ts +1 -1
  22. package/modules/chart-bubblemap.esm.min.js +1 -1
  23. package/modules/chart-bump.d.ts +1 -1
  24. package/modules/chart-bump.esm.min.js +1 -1
  25. package/modules/chart-calendar.d.ts +1 -1
  26. package/modules/chart-calendar.esm.min.js +1 -1
  27. package/modules/chart-decomposition.d.ts +1 -1
  28. package/modules/chart-decomposition.esm.min.js +1 -1
  29. package/modules/chart-diverging.d.ts +1 -1
  30. package/modules/chart-diverging.esm.min.js +1 -1
  31. package/modules/chart-dumbbell.d.ts +1 -1
  32. package/modules/chart-dumbbell.esm.min.js +1 -1
  33. package/modules/chart-fan.d.ts +1 -1
  34. package/modules/chart-fan.esm.min.js +1 -1
  35. package/modules/chart-hexbin.d.ts +1 -1
  36. package/modules/chart-hexbin.esm.min.js +1 -1
  37. package/modules/chart-hexmap.d.ts +1 -1
  38. package/modules/chart-hexmap.esm.min.js +1 -1
  39. package/modules/chart-icicle.d.ts +1 -1
  40. package/modules/chart-icicle.esm.min.js +1 -1
  41. package/modules/chart-parallel.d.ts +1 -1
  42. package/modules/chart-parallel.esm.min.js +1 -1
  43. package/modules/chart-ridgeline.d.ts +1 -1
  44. package/modules/chart-ridgeline.esm.min.js +1 -1
  45. package/modules/chart-roc.d.ts +1 -1
  46. package/modules/chart-roc.esm.min.js +1 -1
  47. package/modules/chart-slope.d.ts +1 -1
  48. package/modules/chart-slope.esm.min.js +1 -1
  49. package/modules/chart-splom.d.ts +1 -1
  50. package/modules/chart-splom.esm.min.js +1 -1
  51. package/modules/chart-waffle.d.ts +1 -1
  52. package/modules/chart-waffle.esm.min.js +1 -1
  53. package/modules/charts.d.ts +1 -1
  54. package/modules/charts.esm.min.js +17 -11
  55. package/modules/charts.min.cjs +17 -11
  56. package/modules/charts.min.js +17 -11
  57. package/modules/data-router.d.ts +1 -1
  58. package/modules/data-router.esm.min.js +4 -4
  59. package/modules/data-router.min.cjs +4 -4
  60. package/modules/data-router.min.js +4 -4
  61. package/modules/devtools.d.ts +1 -1
  62. package/modules/devtools.esm.min.js +2 -2
  63. package/modules/devtools.min.cjs +2 -2
  64. package/modules/devtools.min.js +2 -2
  65. package/modules/dhtmlx-compat.d.ts +1 -1
  66. package/modules/dhtmlx-compat.esm.min.js +4 -4
  67. package/modules/dhtmlx-compat.min.cjs +4 -4
  68. package/modules/dhtmlx-compat.min.js +4 -4
  69. package/modules/gantt.d.ts +1 -1
  70. package/modules/gantt.esm.min.js +4 -4
  71. package/modules/gantt.min.cjs +4 -4
  72. package/modules/gantt.min.js +4 -4
  73. package/modules/htmx.d.ts +1 -1
  74. package/modules/htmx.esm.min.js +456 -43
  75. package/modules/htmx.min.cjs +456 -43
  76. package/modules/htmx.min.js +456 -43
  77. package/modules/kanban.d.ts +1 -1
  78. package/modules/kanban.esm.min.js +4 -4
  79. package/modules/kanban.min.cjs +4 -4
  80. package/modules/kanban.min.js +4 -4
  81. package/modules/kpi.d.ts +1 -1
  82. package/modules/kpi.esm.min.js +19 -6
  83. package/modules/kpi.min.cjs +19 -6
  84. package/modules/kpi.min.js +19 -6
  85. package/modules/layout.d.ts +1 -1
  86. package/modules/layout.esm.min.js +4 -4
  87. package/modules/layout.min.cjs +4 -4
  88. package/modules/layout.min.js +4 -4
  89. package/modules/mock-socket.d.ts +1 -1
  90. package/modules/mock-socket.esm.min.js +2 -2
  91. package/modules/mock-socket.min.cjs +2 -2
  92. package/modules/mock-socket.min.js +2 -2
  93. package/modules/react.d.ts +1 -1
  94. package/modules/react.esm.min.js +2 -2
  95. package/modules/react.min.cjs +2 -2
  96. package/modules/react.min.js +2 -2
  97. package/modules/svelte.d.ts +1 -1
  98. package/modules/svelte.esm.min.js +2 -2
  99. package/modules/svelte.min.cjs +2 -2
  100. package/modules/svelte.min.js +2 -2
  101. package/modules/tabs.d.ts +1 -1
  102. package/modules/tabs.esm.min.js +4 -4
  103. package/modules/tabs.min.cjs +4 -4
  104. package/modules/tabs.min.js +4 -4
  105. package/modules/vue.d.ts +1 -1
  106. package/modules/vue.esm.min.js +2 -2
  107. package/modules/vue.min.cjs +2 -2
  108. package/modules/vue.min.js +2 -2
  109. package/modules/webcomponent.d.ts +1 -1
  110. package/modules/webcomponent.esm.min.js +456 -43
  111. package/modules/webcomponent.min.cjs +456 -43
  112. package/modules/webcomponent.min.js +456 -43
  113. package/package.json +1 -1
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.61.0</p>
440
+ <p class="rail__sub">Developer guide · v1.62.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -554,7 +554,7 @@
554
554
  <a href="API.html">reference tables</a> are the shorter version for when you already know.
555
555
  </p>
556
556
  <p class="chips">
557
- <span class="chip">Version 1.61.0</span>
557
+ <span class="chip">Version 1.62.0</span>
558
558
  <span class="chip">Zero dependencies</span>
559
559
  <span class="chip">No build step</span>
560
560
  </p>
@@ -1279,7 +1279,7 @@ off(); <span class="cmt">// every subscrip
1279
1279
  </p>
1280
1280
  <div class="example">
1281
1281
  <p class="example__label">Which version am I running?</p>
1282
- <pre><code>grid.getVersion(); <span class="cmt">// '1.61.0'</span>
1282
+ <pre><code>grid.getVersion(); <span class="cmt">// '1.62.0'</span>
1283
1283
  LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid to hand</span></code></pre>
1284
1284
  </div>
1285
1285
  <p class="lead-in">
@@ -4308,32 +4308,110 @@ grid.destroy();
4308
4308
  content over another's.
4309
4309
  </p>
4310
4310
 
4311
- <h2 id="scrollbars">Always-visible scrollbars</h2>
4311
+ <h2 id="scrollbars">Always-visible and grid-drawn scrollbars</h2>
4312
4312
  <p class="lead-in">
4313
4313
  Native scrollbars are overlay bars on most platforms now: they fade away when the pointer is
4314
4314
  idle, which reads as a cleaner surface but hides the affordance &mdash; a touchpad user has no
4315
- standing sign that a grid scrolls at all. <code>scrollbars: 'always'</code> keeps them shown
4316
- whether or not the pointer is over the grid; <code>'auto'</code> (the default) leaves the
4317
- platform's own behaviour alone, so an existing grid is unchanged on upgrade.
4315
+ standing sign that a grid scrolls at all. <code>scrollbars</code> takes three modes.
4316
+ <code>'auto'</code> (the default) leaves the platform's own behaviour alone, so an existing
4317
+ grid is unchanged on upgrade. <code>'always'</code> keeps the platform's bar shown whether or
4318
+ not the pointer is over the grid. <code>'custom'</code> replaces it with a bar the grid draws
4319
+ itself.
4318
4320
  </p>
4319
4321
  <p class="lead-in">
4320
4322
  The two axes are separate decisions, so the object form controls each on its own:
4321
4323
  <code>{ y: 'always' }</code> pins the vertical bar while the horizontal one stays native, and
4322
4324
  <code>{ x: 'always', y: 'always' }</code> is the same as the bare <code>'always'</code>. Under
4323
- the hood the pinned axis switches to <code>overflow: scroll</code> so its track is present even
4324
- when the content fits, and the scrollbar is styled as a classic, always-drawn bar rather than a
4325
- fading overlay (BACKLOG-0000990). The same viewport also suppresses the overscroll rubber-band
4326
- bounce, so a synchronised grid does not spring at its scroll boundary (BACKLOG-0000991).
4327
- </p>
4325
+ <code>'always'</code> the pinned axis switches to <code>overflow: scroll</code> so its track is
4326
+ present even when the content fits, and the scrollbar is styled as a classic, always-drawn bar
4327
+ rather than a fading overlay (BACKLOG-0000990). The same viewport also suppresses the
4328
+ overscroll rubber-band bounce, so a synchronised grid does not spring at its scroll boundary
4329
+ (BACKLOG-0000991).
4330
+ </p>
4331
+
4332
+ <h3>Why <code>'custom'</code> exists, and when to reach for it</h3>
4333
+ <p class="lead-in">
4334
+ <code>'always'</code> pins the <em>native</em> bar, which leaves two things it cannot fix. Its
4335
+ size is still the platform's &mdash; on Chrome/macOS a 7&nbsp;pixel overlay ribbon, small to
4336
+ see and fiddly to grab. And the rules that style it are a WebKit/Blink extension, so Firefox
4337
+ ignores them: <code>'always'</code> is not the same feature there. <code>'custom'</code> makes
4338
+ the grid draw the bar, so its thickness, colour, minimum thumb length and hit area are the
4339
+ same in every browser on every platform, and all of them are theme tokens a host can raise.
4340
+ The default is a 12&nbsp;pixel track with a 32&nbsp;pixel minimum thumb, which is a comfortably
4341
+ larger target than the platform's own (BACKLOG-0001288).
4342
+ </p>
4343
+ <p class="lead-in">
4344
+ <strong>Scrolling is unchanged.</strong> The drawn bars are display and input only: the grid's
4345
+ body still scrolls itself, so the wheel, the trackpad, a finger, the keyboard and
4346
+ <code>scrollToRow</code> behave exactly as they do in the other two modes, with the browser's
4347
+ own momentum and acceleration. The thumb is a readout of the scroll position that happens to
4348
+ be draggable, and it is placed in the same painted frame as the content, so it never trails
4349
+ what is on screen.
4350
+ </p>
4351
+ <p class="lead-in">
4352
+ <strong>Everything a scrollbar does.</strong> The thumb's length is the fraction of the content
4353
+ on screen, never shorter than <code>--lattice-scrollbar-thumb-min</code>; dragging it scrolls;
4354
+ pressing the track above or below it pages by one viewport; and with the bar focused the
4355
+ arrows, <kbd>Page&nbsp;Up</kbd>/<kbd>Page&nbsp;Down</kbd>, <kbd>Home</kbd> and <kbd>End</kbd>
4356
+ all work. Each bar carries <code>role="scrollbar"</code> with
4357
+ <code>aria-orientation</code>, <code>aria-controls</code> and
4358
+ <code>aria-valuenow</code>, and its accessible name comes from the message catalogue, so it is
4359
+ announced in the grid's own language. It is deliberately <em>not</em> in the page's tab order:
4360
+ the grid is a single tab stop and its own arrow keys already scroll.
4361
+ </p>
4362
+ <p class="lead-in">
4363
+ <strong>Two things to know.</strong> The gutter the drawn bar occupies is reserved permanently
4364
+ while the mode is on &mdash; the same 12&nbsp;pixels <code>'always'</code> reserves, so column
4365
+ widths and <code>columns.fit()</code> are unaffected by the change and nothing moves under the
4366
+ pointer as rows come and go. And no browser lets one axis' native scrollbar be hidden on its
4367
+ own, so setting <code>'custom'</code> on one axis hides the native bar on <em>both</em>; the
4368
+ grid warns once if the two axes disagree. Set <code>'custom'</code> on both axes, or on
4369
+ neither.
4370
+ </p>
4371
+
4372
+ <h3>Theming the drawn bar</h3>
4373
+ <div class="table-wrap">
4374
+ <table>
4375
+ <thead><tr><th>Token</th><th>Default</th><th>What it sets</th></tr></thead>
4376
+ <tbody>
4377
+ <tr><td class="name"><code>--lattice-scrollbar-size</code></td><td class="desc"><code>12px</code></td><td class="desc">The thickness of each bar, and the gutter reserved for it. Raise it for a larger grab target.</td></tr>
4378
+ <tr><td class="name"><code>--lattice-scrollbar-thumb-min</code></td><td class="desc"><code>32px</code></td><td class="desc">The shortest the thumb may ever be. Sized strictly in proportion, a very long list gives a thumb of a pixel or two.</td></tr>
4379
+ <tr><td class="name"><code>--lattice-scrollbar-track</code></td><td class="desc"><code>transparent</code></td><td class="desc">The track behind the thumb.</td></tr>
4380
+ <tr><td class="name"><code>--lattice-scrollbar-thumb</code></td><td class="desc"><code>var(--lattice-border-strong)</code></td><td class="desc">The thumb at rest. Derived from the theme's line colour, so dark, high-contrast and terminal themes follow without declaring anything.</td></tr>
4381
+ <tr><td class="name"><code>--lattice-scrollbar-thumb-hover</code></td><td class="desc"><code>var(--lattice-foreground-muted)</code></td><td class="desc">The thumb while the pointer is over its bar.</td></tr>
4382
+ <tr><td class="name"><code>--lattice-scrollbar-thumb-active</code></td><td class="desc"><code>var(--lattice-accent)</code></td><td class="desc">The thumb while it is being dragged, or while the bar has keyboard focus.</td></tr>
4383
+ <tr><td class="name"><code>--lattice-scrollbar-radius</code></td><td class="desc"><code>var(--lattice-radius-pill)</code></td><td class="desc">The thumb's corner radius. Set it to <code>0</code> for a square thumb.</td></tr>
4384
+ </tbody>
4385
+ </table>
4386
+ </div>
4328
4387
 
4329
4388
  <div class="example">
4330
- <p class="example__label">Pinning both axes, and one axis at a time</p>
4331
- <pre data-run="js" data-expect="always: always/always; y-only: auto/always" data-covers="config:scrollbars"><code><span class="cmt">// The renderer resolves `scrollbars` to a per-axis mode it stamps on the root.</span>
4389
+ <p class="example__label">The three modes, and one axis at a time</p>
4390
+ <pre data-run="js" data-expect="always: always/always; custom: custom/custom; y-only: auto/always; mixed: custom/auto" data-covers="config:scrollbars"><code><span class="cmt">// The renderer resolves `scrollbars` to a per-axis mode it stamps on the root.</span>
4332
4391
  <span class="kw">const</span> { resolveScrollbars } = <span class="kw">await</span> import('../packages/dom/src/renderer/renderer.js');
4333
4392
 
4334
- <span class="kw">const</span> both = resolveScrollbars(<span class="str">'always'</span>); <span class="cmt">// pins both axes</span>
4393
+ <span class="kw">const</span> both = resolveScrollbars(<span class="str">'always'</span>); <span class="cmt">// pins both native bars</span>
4394
+ <span class="kw">const</span> drawn = resolveScrollbars(<span class="str">'custom'</span>); <span class="cmt">// the grid draws both</span>
4335
4395
  <span class="kw">const</span> onlyY = resolveScrollbars({ y: <span class="str">'always'</span> }); <span class="cmt">// pins the vertical bar only</span>
4336
- <span class="kw">return</span> `always: ${both.x}/${both.y}; y-only: ${onlyY.x}/${onlyY.y}`;</code></pre>
4396
+ <span class="kw">const</span> mixed = resolveScrollbars({ x: <span class="str">'custom'</span> }); <span class="cmt">// draws x, leaves y native (and warns)</span>
4397
+ <span class="kw">return</span> `always: ${both.x}/${both.y}; custom: ${drawn.x}/${drawn.y}; `
4398
+ + `y-only: ${onlyY.x}/${onlyY.y}; mixed: ${mixed.x}/${mixed.y}`;</code></pre>
4399
+ </div>
4400
+
4401
+ <div class="example">
4402
+ <p class="example__label">A bigger grab target than the platform's</p>
4403
+ <pre><code>createGrid(host, {
4404
+ columns, rows,
4405
+ <span class="cmt">// Always visible, the same in every browser, and drawn by the grid.</span>
4406
+ scrollbars: <span class="str">'custom'</span>,
4407
+ });
4408
+
4409
+ <span class="cmt">/* Wider and darker than the default, in your own stylesheet: */</span>
4410
+ .lattice {
4411
+ --lattice-scrollbar-size: 16px;
4412
+ --lattice-scrollbar-thumb-min: 48px;
4413
+ --lattice-scrollbar-thumb: #8a9199;
4414
+ }</code></pre>
4337
4415
  </div>
4338
4416
 
4339
4417
  <h2 id="cards">Cards, lists and feeds</h2>
@@ -7867,6 +7945,774 @@ grid.import.apply(preview);</code></pre>
7867
7945
  </table>
7868
7946
  </div>
7869
7947
 
7948
+ <h3 id="kanban-config-examples">Kanban config keys, executed</h3>
7949
+ <p class="lead-in">
7950
+ The table above names each Kanban config key on its own line; these six examples run the
7951
+ board (headless, or over the test DOM where a key's effect is visual) and show what each key
7952
+ actually does, grouped by what they configure together.
7953
+ </p>
7954
+
7955
+ <div class="example">
7956
+ <p class="example__label">Board structure: grouping, order and a done set, executed</p>
7957
+ <pre data-run="js" data-expect="done,todo | 2,1 | points 5 | done 5" data-covers="config:columnProperty config:columnOrder config:pointsProperty config:orderProperty config:doneColumns"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
7958
+ const rows = [
7959
+ { id: 1, status: 'todo', epic: 'E1', points: 3, order: 20 },
7960
+ { id: 2, status: 'todo', epic: 'E1', points: 2, order: 10 },
7961
+ { id: 3, status: 'done', epic: 'E1', points: 5, order: 5 },
7962
+ ];
7963
+ const b = createKanban(null, {
7964
+ rows, rowKey: 'id',
7965
+ columnProperty: 'status',
7966
+ columns: [{ id: 'todo' }, { id: 'done' }],
7967
+ columnOrder: ['done', 'todo'],
7968
+ pointsProperty: 'points', showPoints: true,
7969
+ orderProperty: 'order',
7970
+ doneColumns: ['done'],
7971
+ });
7972
+ const ids = b.columns().map((c) =&gt; c.id);
7973
+ const todoOrder = b.column('todo').cards.map((c) =&gt; c.row.id);
7974
+ const donePoints = b.rollup('epic')[0].donePoints;
7975
+ return (`${ids.join(',')} | ${todoOrder.join(',')} | points ${b.points('todo')} | done ${donePoints}`);
7976
+ </code></pre>
7977
+ <p>
7978
+ <code>columnOrder</code> pins <code>done</code> before <code>todo</code> though it was configured
7979
+ second; <code>orderProperty</code> sorts card 2 (order 10) before card 1 (order 20) inside
7980
+ <code>todo</code>; <code>pointsProperty</code> is what makes <code>b.points('todo')</code> a real
7981
+ sum rather than 0 (<code>showPoints</code> only decides whether that sum is drawn into the DOM
7982
+ column header — see the accessible-name example below for that half); and
7983
+ <code>doneColumns: ['done']</code> — set directly, with no column def carrying
7984
+ <code>done: true</code> — is what lets <code>rollup('epic')</code> count card 3's points as done.
7985
+ </p>
7986
+ </div>
7987
+
7988
+ <div class="example">
7989
+ <p class="example__label">Sprint, epic and swimlane selection, executed</p>
7990
+ <pre data-run="js" data-expect="1 | lanes Zed,Ann | empty 0 | sprints S1,S2" data-covers="config:sprintProperty config:sprint config:sprints config:epicProperty config:epic config:swimlaneProperty config:swimlanes config:lanes config:laneOrder"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
7991
+ const rows = [
7992
+ { id: 1, status: 'todo', sprint: 'S1', epic: 'E1', assignee: 'Ann' },
7993
+ { id: 2, status: 'todo', sprint: 'S2', epic: 'E1', assignee: 'Bob' },
7994
+ { id: 3, status: 'todo', sprint: 'S1', epic: 'E2', assignee: 'Cy' },
7995
+ ];
7996
+ const b = createKanban(null, {
7997
+ rows, rowKey: 'id',
7998
+ columnProperty: 'status',
7999
+ columns: [{ id: 'todo' }],
8000
+ sprintProperty: 'sprint', sprint: 'S1',
8001
+ epicProperty: 'epic', epic: 'E1',
8002
+ swimlaneProperty: 'assignee', swimlanes: true,
8003
+ lanes: [{ id: 'Ann', title: 'Ann T' }, { id: 'Zed', title: 'Empty lane' }],
8004
+ laneOrder: ['Zed', 'Ann'],
8005
+ sprints: [{ id: 'S1', title: 'Sprint 1' }, { id: 'S2', title: 'Sprint 2' }],
8006
+ });
8007
+ const visible = [...b.model.cardsByKey.keys()];
8008
+ const laneIds = b.model.lanes.map((l) =&gt; l.id);
8009
+ const emptyLane = b.model.lanes.find((l) =&gt; l.id === 'Zed');
8010
+ return (`${visible.join(',')} | lanes ${laneIds.join(',')} | empty ${emptyLane ? emptyLane.count : 'MISSING'} | sprints ${b.sprints().join(',')}`);
8011
+ </code></pre>
8012
+ <p>
8013
+ The board is constructed already narrowed to sprint S1 and epic E1 — no <code>setSprint</code>/
8014
+ <code>setEpic</code> call — so only card 1 is visible. <code>lanes</code> configures an
8015
+ <code>Ann</code> lane and a <code>Zed</code> lane nobody's <code>assignee</code> matches, kept
8016
+ (count 0) because it is configured, exactly as an empty column is; <code>laneOrder</code> pins
8017
+ it first. <code>sprints</code> lists both configured sprints even though only S1 has a visible
8018
+ card.
8019
+ </p>
8020
+ </div>
8021
+
8022
+ <div class="example">
8023
+ <p class="example__label">Card mapping, quick filter and readonly, executed</p>
8024
+ <pre data-run="js" data-expect="OAuth flow | readonly true" data-covers="config:card config:quickFilter config:readonly"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
8025
+ const rows = [
8026
+ { id: 1, status: 'todo', title: 'Login form' },
8027
+ { id: 2, status: 'todo', title: 'OAuth flow' },
8028
+ ];
8029
+ const b = createKanban(null, {
8030
+ rows, rowKey: 'id',
8031
+ columnProperty: 'status',
8032
+ columns: [{ id: 'todo' }],
8033
+ card: { title: 'title' },
8034
+ quickFilter: 'oauth',
8035
+ readonly: { columns: { todo: true } },
8036
+ });
8037
+ const visibleTitles = [...b.model.cardsByKey.values()].map((c) =&gt; c.fields.title);
8038
+ const isReadonly = b.readonly({ column: 'todo' });
8039
+ return (`${visibleTitles.join(',')} | readonly ${isReadonly}`);
8040
+ </code></pre>
8041
+ <p>
8042
+ <code>card: { title: 'title' }</code> is what makes <code>c.fields.title</code> readable at all;
8043
+ the initial <code>quickFilter</code> narrows the board to the one matching card before anything
8044
+ renders; and the per-column <code>readonly</code> map reports <code>todo</code> as locked.
8045
+ </p>
8046
+ </div>
8047
+
8048
+ <div class="example">
8049
+ <p class="example__label">Accessible name, localised labels and selection, executed</p>
8050
+ <pre data-run="js" data-expect="Sprint board | selectable-click 0 | grab 'Grabbed, A, Todo, 1/1' | empty 'No cards' | points true" data-covers="config:ariaLabel config:selectable config:labels config:emptyText config:showPoints"><code>const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
8051
+ const { createKanban } = await import('../packages/modules/kanban/index.js');
8052
+ const dom = createTestDom({});
8053
+ const el = dom.document.createElement('div');
8054
+ dom.root.appendChild(el);
8055
+ const b = createKanban(el, {
8056
+ rows: [{ id: 1, status: 'todo', title: 'A', points: 5 }],
8057
+ rowKey: 'id', columnProperty: 'status',
8058
+ columns: [{ id: 'todo' }, { id: 'blocked' }],
8059
+ card: { title: 'title' },
8060
+ ariaLabel: 'Sprint board',
8061
+ emptyText: 'No cards',
8062
+ selectable: false,
8063
+ labels: { grabbed: 'Grabbed' },
8064
+ pointsProperty: 'points', showPoints: true,
8065
+ });
8066
+ const cardEl = el.querySelector('.lat-kanban__card');
8067
+ cardEl.dispatchEvent(new TestEvent('click', {}));
8068
+ const selectedAfterClick = b.selection().length;
8069
+ cardEl.setAttribute('tabindex', '0');
8070
+ cardEl.dispatchEvent(new TestEvent('keydown', { key: ' ' }));
8071
+ const liveText = b.live.textContent;
8072
+ const blockedEmpty = [...el.querySelectorAll('.lat-kanban__column')].find(c =&gt; c.dataset.column === 'blocked').querySelector('.lat-kanban__empty').textContent;
8073
+ const ariaLabel = el.getAttribute('aria-label');
8074
+ const todoContent = el.querySelector('.lat-kanban__headcontent');
8075
+ const hasPoints = /lat-kanban__points/.test(todoContent.innerHTML);
8076
+ return (`${ariaLabel} | selectable-click ${selectedAfterClick} | grab '${liveText}' | empty '${blockedEmpty}' | points ${hasPoints}`);
8077
+ </code></pre>
8078
+ <p>
8079
+ <code>ariaLabel</code> replaces the default "Board" name; <code>selectable: false</code> means a
8080
+ plain click leaves the selection empty (the keyboard grab is a separate mechanism, unaffected);
8081
+ <code>labels.grabbed</code> supplies the word the live region announces; <code>emptyText</code>
8082
+ is the placeholder shown in the empty <code>blocked</code> column; and <code>showPoints</code>
8083
+ (with <code>pointsProperty</code>) is what draws the points span into the column header — its
8084
+ real job, distinct from <code>pointsProperty</code> alone computing the sum (see the board
8085
+ structure example above, where <code>showPoints</code> is absent and <code>b.points()</code>
8086
+ still works).
8087
+ </p>
8088
+ </div>
8089
+
8090
+ <div class="example">
8091
+ <p class="example__label">Add-card, edit and move lifecycle, plus WIP enforcement, executed</p>
8092
+ <pre data-run="js" data-expect="created Created by onAddCard | edited Renamed | wip-moved 0 | veto-moved 0 | reverted true | events click:a,dbl:a,ctx:a" data-covers="config:addCard config:onAddCard config:onCardEdit config:onBeforeMove config:onCardMove config:onCardClick config:onCardDblClick config:onCardContextMenu config:enforceWip"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
8093
+ const seen = [];
8094
+ const b = createKanban(null, {
8095
+ rows: [{ id: 'a', status: 'todo' }, { id: 'b', status: 'doing' }],
8096
+ rowKey: 'id', columnProperty: 'status',
8097
+ columns: [{ id: 'todo' }, { id: 'doing', wipLimit: 1 }, { id: 'blocked' }, { id: 'review' }],
8098
+ card: { title: { field: 'title', edit: true } },
8099
+ addCard: true,
8100
+ onAddCard: (columnId) =&gt; ({ id: 'new', status: columnId, title: 'Created by onAddCard' }),
8101
+ onCardEdit: () =&gt; true,
8102
+ onBeforeMove: (card, from, to) =&gt; to !== 'blocked',
8103
+ onCardMove: (e) =&gt; e.keys[0] !== 'a' || e.to !== 'review',
8104
+ onCardClick: (e) =&gt; seen.push(`click:${e.card.key}`),
8105
+ onCardDblClick: (e) =&gt; seen.push(`dbl:${e.card.key}`),
8106
+ onCardContextMenu: (e) =&gt; seen.push(`ctx:${e.card.key}`),
8107
+ enforceWip: true,
8108
+ });
8109
+
8110
+ const newKey = b.addCard('todo');
8111
+ const createdTitle = b.card(newKey).row.title;
8112
+ await b.applyEdit('a', 'title', 'Renamed');
8113
+ const editedTitle = b.card('a').row.title;
8114
+
8115
+ const wipRefused = await b.move('a', 'doing');
8116
+ const vetoRefused = await b.move('a', 'blocked');
8117
+ const revertedMove = await b.move('a', 'review');
8118
+
8119
+ b.fire('card:click', { card: b.card('a') });
8120
+ b.fire('card:dblclick', { card: b.card('a') });
8121
+ b.fire('card:contextmenu', { card: b.card('a') });
8122
+
8123
+ return ([
8124
+ `created ${createdTitle}`,
8125
+ `edited ${editedTitle}`,
8126
+ `wip-moved ${wipRefused.moved.length}`,
8127
+ `veto-moved ${vetoRefused.moved.length}`,
8128
+ `reverted ${revertedMove.reverted}`,
8129
+ `events ${seen.join(',')}`,
8130
+ ].join(' | '));
8131
+ </code></pre>
8132
+ <p>
8133
+ <code>addCard</code> opts the column into add-card at all; <code>onAddCard</code> supplies the
8134
+ new row rather than an auto-generated one; <code>onCardEdit</code> confirms the inline edit.
8135
+ <code>enforceWip</code> refuses the move into <code>doing</code> (already at its limit of 1) before
8136
+ <code>onBeforeMove</code> is ever asked; a separate move into <code>blocked</code> is vetoed by
8137
+ <code>onBeforeMove</code> instead; and a move into <code>review</code> — allowed by both — is
8138
+ still reverted because <code>onCardMove</code> rejects it. <code>onCardClick</code>/
8139
+ <code>onCardDblClick</code>/<code>onCardContextMenu</code> each fire from the matching
8140
+ <code>card:*</code> event.
8141
+ </p>
8142
+ </div>
8143
+
8144
+ <div class="example">
8145
+ <p class="example__label">Card aging / SLA: basis, thresholds, callbacks and the tick, executed</p>
8146
+ <pre data-run="js" data-expect="basis-column ok | basis-board breach | ignore-default null | ignore-false breach | hits warn:warn:w,breach:breach:br | age 3d | tick-fired true" data-covers="config:sla config:basis config:createdProperty config:enteredProperty config:ignoreDone config:now config:warn config:breach config:onWarn config:onBreach config:showAge config:useTransitionLog config:tick"><code>const { createKanban } = await import('../packages/modules/kanban/index.js');
8147
+ const E = 1_700_000_000_000;
8148
+ const DAY = 24 * 60 * 60 * 1000;
8149
+
8150
+ // basis comparison: same row data, two boards differing only in `basis`.
8151
+ const rowData = { id: 'x', status: 'doing', enteredAt: E - 1 * DAY, createdAt: E - 10 * DAY };
8152
+ function makeBoard(basis) {
8153
+ return createKanban(null, {
8154
+ rows: [rowData],
8155
+ rowKey: 'id', columnProperty: 'status',
8156
+ columns: [{ id: 'doing' }],
8157
+ flow: false,
8158
+ sla: {
8159
+ basis, enteredProperty: 'enteredAt', createdProperty: 'createdAt',
8160
+ useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 },
8161
+ now: () =&gt; E,
8162
+ },
8163
+ });
8164
+ }
8165
+ const byColumn = makeBoard('column').sla.stateFor('x').level; // uses enteredAt (1d) -&gt; ok
8166
+ const byBoard = makeBoard('board').sla.stateFor('x').level; // uses createdAt (10d) -&gt; breach
8167
+
8168
+ // ignoreDone
8169
+ const doneRow = { id: 'd', status: 'done', enteredAt: E - 30 * DAY };
8170
+ const withIgnore = createKanban(null, {
8171
+ rows: [doneRow], rowKey: 'id', columnProperty: 'status',
8172
+ columns: [{ id: 'done', done: true }], flow: false,
8173
+ sla: { enteredProperty: 'enteredAt', useTransitionLog: false, warn: { days: 2 }, breach: { days: 5 }, now: () =&gt; E },
8174
+ });
8175
+ const ignoredByDefault = withIgnore.sla.stateFor('d').level;
8176
+ const notIgnored = createKanban(null, {
8177
+ rows: [doneRow], rowKey: 'id', columnProperty: 'status',
8178
+ columns: [{ id: 'done', done: true }], flow: false,
8179
+ sla: { enteredProperty: 'enteredAt', useTransitionLog: false, ignoreDone: false, warn: { days: 2 }, breach: { days: 5 }, now: () =&gt; E },
8180
+ }).sla.stateFor('d').level;
8181
+
8182
+ // onWarn/onBreach + showAge
8183
+ const hits = [];
8184
+ const warnBoard = createKanban(null, {
8185
+ rows: [
8186
+ { id: 'w', status: 'doing', enteredAt: E - 3 * DAY },
8187
+ { id: 'br', status: 'doing', enteredAt: E - 6 * DAY },
8188
+ ],
8189
+ rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
8190
+ sla: {
8191
+ enteredProperty: 'enteredAt', useTransitionLog: false,
8192
+ warn: { days: 2 }, breach: { days: 5 }, now: () =&gt; E, showAge: 'always',
8193
+ onWarn: (level, rows) =&gt; hits.push(`warn:${level}:${rows[0].id}`),
8194
+ onBreach: (level, rows) =&gt; hits.push(`breach:${level}:${rows[0].id}`),
8195
+ },
8196
+ });
8197
+ const showAgeState = warnBoard.sla.stateFor('w').ageText;
8198
+
8199
+ // tick: a real re-check interval that fires without a manual evaluate()/move.
8200
+ const clock = { t: E };
8201
+ const ticks = [];
8202
+ const tickBoard = createKanban(null, {
8203
+ rows: [{ id: 't', status: 'doing', enteredAt: E }],
8204
+ rowKey: 'id', columnProperty: 'status', columns: [{ id: 'doing' }], flow: false,
8205
+ sla: {
8206
+ enteredProperty: 'enteredAt', useTransitionLog: false,
8207
+ breach: { days: 1 }, tick: 15, now: () =&gt; clock.t,
8208
+ },
8209
+ });
8210
+ tickBoard.on('card:sla', (e) =&gt; ticks.push(e.level));
8211
+ clock.t = E + 2 * DAY; // now past breach, but nobody called evaluate()
8212
+ await new Promise((resolve) =&gt; setTimeout(resolve, 60));
8213
+ tickBoard.sla.destroy();
8214
+
8215
+ return ([
8216
+ `basis-column ${byColumn}`,
8217
+ `basis-board ${byBoard}`,
8218
+ `ignore-default ${ignoredByDefault}`,
8219
+ `ignore-false ${notIgnored}`,
8220
+ `hits ${hits.join(',')}`,
8221
+ `age ${showAgeState}`,
8222
+ `tick-fired ${ticks.length &gt; 0}`,
8223
+ ].join(' | '));
8224
+ </code></pre>
8225
+ <p>
8226
+ The same card (an <code>enteredAt</code> of 1 day and a <code>createdAt</code> of 10 days) is
8227
+ <code>ok</code> under <code>basis: 'column'</code> (which prefers <code>enteredProperty</code>) and
8228
+ <code>breach</code> under <code>basis: 'board'</code> (which prefers <code>createdProperty</code>)
8229
+ — the same <code>warn</code>/<code>breach</code> thresholds, only the basis changed.
8230
+ <code>ignoreDone</code> defaults a done card to no ageing at all (<code>null</code>) and
8231
+ <code>ignoreDone: false</code> ages it anyway. <code>onWarn</code> and <code>onBreach</code> each
8232
+ fire, shaped <code>(level, rows)</code>, for the card that reaches that level; <code>showAge:
8233
+ 'always'</code> gives an age chip on a card that is not yet warned. <code>tick</code> re-evaluates
8234
+ on its own timer — the card crosses into breach with no <code>move</code> or manual
8235
+ <code>evaluate()</code> call — and <code>useTransitionLog: false</code> throughout is what keeps
8236
+ every reading on the row timestamps rather than the flow log.
8237
+ </p>
8238
+ </div>
8239
+
8240
+ <div class="example">
8241
+ <p class="example__label">Custom card body, virtualization and a child pop-out, executed</p>
8242
+ <pre data-run="js" data-expect="virtual true | rendered true | custom true | expand-epic true | expand-leaf false" data-covers="config:cardRenderer config:virtualize config:children"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8243
+ const { createKanban } = await import('../packages/modules/kanban/index.js');
8244
+
8245
+ const dom = createTestDom({});
8246
+ const el = dom.document.createElement('div');
8247
+ dom.root.appendChild(el);
8248
+
8249
+ const many = [];
8250
+ for (let i = 0; i &lt; 200; i++) many.push({ id: i, status: 'todo', title: `C${i}`, parent: null });
8251
+ many.push({ id: 'e1', status: 'todo', title: 'Epic', parent: null });
8252
+ many.push({ id: 's1', status: 'todo', title: 'Story', parent: 'e1' });
8253
+
8254
+ const b = createKanban(el, {
8255
+ rows: many, rowKey: 'id', columnProperty: 'status', columns: [{ id: 'todo' }],
8256
+ card: { title: 'title' },
8257
+ cardRenderer: (card) =&gt; `&lt;div class="custom"&gt;${card.fields.title}!&lt;/div&gt;`,
8258
+ virtualize: { rowHeight: 100, viewport: 500, threshold: 50, overscan: 2 },
8259
+ children: { property: 'parent' },
8260
+ });
8261
+
8262
+ const list = el.querySelector('.lat-kanban__cards');
8263
+ const rendered = list.querySelectorAll('.lat-kanban__card').length;
8264
+ const isVirtual = list.dataset.virtual;
8265
+ const first = list.querySelector('.lat-kanban__card');
8266
+ const customRendered = /class="custom"/.test(first.innerHTML);
8267
+ const canExpandEpic = b.canExpand(b.card('e1'));
8268
+ const canExpandLeaf = b.canExpand(b.card('s1'));
8269
+
8270
+ return ([
8271
+ `virtual ${isVirtual}`,
8272
+ `rendered ${rendered &gt; 0 &amp;&amp; rendered &lt; 202}`,
8273
+ `custom ${customRendered}`,
8274
+ `expand-epic ${canExpandEpic}`,
8275
+ `expand-leaf ${canExpandLeaf}`,
8276
+ ].join(' | '));
8277
+ </code></pre>
8278
+ <p>
8279
+ 202 cards in one column, but <code>virtualize</code> renders only a scroll window of them;
8280
+ <code>cardRenderer</code> owns the whole card body, replacing the default template; and
8281
+ <code>children: { property: 'parent' }</code> is what makes the epic card expandable
8282
+ (<code>canExpand</code> true) while a plain story is not.
8283
+ </p>
8284
+ </div>
8285
+
8286
+ <h3 id="layout-config-examples">Layout config keys, executed</h3>
8287
+ <p class="lead-in">
8288
+ The reference table names each Layout config key on its own line; these five examples run a
8289
+ real dashboard over the test DOM and show what each key actually does, grouped by what they
8290
+ configure together.
8291
+ </p>
8292
+
8293
+ <div class="example">
8294
+ <p class="example__label">Geometry: overflow axes, track size, gap and padding, executed</p>
8295
+ <pre data-run="js" data-expect="auto hidden | repeat(6, 200px) | gap 20px | padding 15px" data-covers="config:overflowX config:overflowY config:columnWidth config:gap config:padding config:compact"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8296
+ const { createLayout } = await import('../packages/modules/layout/index.js');
8297
+ const { document, root } = createTestDom({ width: 600, height: 400 });
8298
+ const host = document.createElement('div');
8299
+ root.appendChild(host);
8300
+ const layout = createLayout(host, {
8301
+ columns: 6, rows: 4, overflowX: 'scroll', overflowY: 'static', columnWidth: '200px',
8302
+ gap: '20px', padding: '15px', compact: 'none',
8303
+ windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 2, ySize: 1 }],
8304
+ });
8305
+ const canvas = host.querySelector('.lat-layout__canvas');
8306
+ const viewport = host.querySelector('.lat-layout__viewport');
8307
+ return (`${viewport.style.overflowX} ${viewport.style.overflowY} | ${canvas.style.gridTemplateColumns} | gap ${canvas.style.gap} | padding ${layout.payload('a').style.padding}`);
8308
+ </code></pre>
8309
+ <p>
8310
+ <code>overflowX: 'scroll'</code> is the axis that scrolls; <code>overflowY</code> stays
8311
+ <code>'static'</code> independently; <code>columnWidth</code> is the fixed track a scrolling
8312
+ axis repeats; and <code>gap</code>/<code>padding</code> reach the DOM as the real CSS lengths
8313
+ configured, not their 8px/5px built-in fallbacks. <code>compact: 'none'</code> is what a
8314
+ single-window layout cannot show on its own &mdash; see the before-events example below for
8315
+ <code>compact</code>'s effect on a move.
8316
+ </p>
8317
+ </div>
8318
+
8319
+ <div class="example">
8320
+ <p class="example__label">Layout-level interactivity defaults, executed</p>
8321
+ <pre data-run="js" data-expect="close true | grip true | opted-out-grip false | maximise true | minimise true" data-covers="config:closable config:movable config:resizable config:maximisable config:minimisable"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8322
+ const { createLayout } = await import('../packages/modules/layout/index.js');
8323
+ const { document, root } = createTestDom({ width: 600, height: 400 });
8324
+ const host = document.createElement('div');
8325
+ root.appendChild(host);
8326
+ const layout = createLayout(host, {
8327
+ columns: 3, rows: 1,
8328
+ closable: true, movable: true, resizable: true, maximisable: true, minimisable: true,
8329
+ windows: [
8330
+ { id: 'bare', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
8331
+ { id: 'opted-out', xPos: 2, yPos: 1, xSize: 1, ySize: 1, movable: false },
8332
+ ],
8333
+ });
8334
+ const frame = (id) =&gt; host.querySelector(`[data-window-id="${id}"]`);
8335
+ const bareHasClose = !!frame('bare').querySelector('.lat-layout__close');
8336
+ const bareHasGrip = !!frame('bare').querySelector('.lat-layout__grip');
8337
+ const optedOutHasGrip = !!frame('opted-out').querySelector('.lat-layout__grip');
8338
+ const canMaximise = layout.maximise('bare');
8339
+ const canMinimise = layout.minimise('bare');
8340
+ return (`close ${bareHasClose} | grip ${bareHasGrip} | opted-out-grip ${optedOutHasGrip} | maximise ${canMaximise} | minimise ${canMinimise}`);
8341
+ </code></pre>
8342
+ <p>
8343
+ Every layout-level default (<code>closable</code>, <code>movable</code>, <code>resizable</code>,
8344
+ <code>maximisable</code>, <code>minimisable</code>) applies to <code>bare</code>, which declares
8345
+ none of its own; <code>opted-out</code>'s own <code>movable: false</code> beats the layout-level
8346
+ <code>true</code>, which is why it renders no grip. <code>maximisable</code>/<code>minimisable</code>
8347
+ are what let <code>maximise()</code>/<code>minimise()</code> succeed on a window that never
8348
+ declared either itself.
8349
+ </p>
8350
+ </div>
8351
+
8352
+ <div class="example">
8353
+ <p class="example__label">The window list and a saved arrangement applied at mount, executed</p>
8354
+ <pre data-run="js" data-expect="a,b | a@3,2 | b@2,1" data-covers="config:windows config:layout"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8355
+ const { createLayout } = await import('../packages/modules/layout/index.js');
8356
+ const { document, root } = createTestDom({ width: 600, height: 400 });
8357
+ const host = document.createElement('div');
8358
+ root.appendChild(host);
8359
+ const layout = createLayout(host, {
8360
+ columns: 4, rows: 4, compact: 'none',
8361
+ windows: [
8362
+ { id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 },
8363
+ { id: 'b', xPos: 2, yPos: 1, xSize: 1, ySize: 1 },
8364
+ ],
8365
+ layout: { columns: 4, rows: 4, windows: [{ id: 'a', xPos: 3, yPos: 2, xSize: 1, ySize: 1 }] },
8366
+ });
8367
+ const ids = layout.windows();
8368
+ const snap = layout.getLayout();
8369
+ const aPlaced = snap.windows.find((w) =&gt; w.id === 'a');
8370
+ const bPlaced = snap.windows.find((w) =&gt; w.id === 'b');
8371
+ return (`${ids.join(',')} | a@${aPlaced.xPos},${aPlaced.yPos} | b@${bPlaced.xPos},${bPlaced.yPos}`);
8372
+ </code></pre>
8373
+ <p>
8374
+ <code>windows</code> declares two windows, <code>a</code> at column 1 and <code>b</code> at
8375
+ column 2; the mount-time <code>layout</code> arrangement then moves <code>a</code> to (3, 2),
8376
+ overriding its own declared placement, while <code>b</code> — not named in <code>layout</code> —
8377
+ stays exactly where <code>windows</code> put it.
8378
+ </p>
8379
+ </div>
8380
+
8381
+ <div class="example">
8382
+ <p class="example__label">Move and resize before-events, independently gated and paired with their cancellations, executed</p>
8383
+ <pre data-run="js" data-expect="moved true/false to xPos 2 | resized true/false to xSize 2 | cancelled-move pinned | cancelled-resize too-wide | moved-events 1 | resized-events 1" data-covers="config:onWindowMoved config:onWindowResized config:onBeforeWindowMove config:onWindowMoveCancelled config:onBeforeWindowResize config:onWindowResizeCancelled"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8384
+ const { createLayout } = await import('../packages/modules/layout/index.js');
8385
+ const { document, root } = createTestDom({ width: 600, height: 400 });
8386
+ const host = document.createElement('div');
8387
+ root.appendChild(host);
8388
+ const moved = [];
8389
+ const resized = [];
8390
+ const cancelledMove = [];
8391
+ const cancelledResize = [];
8392
+ const layout = createLayout(host, {
8393
+ columns: 4, rows: 4, compact: 'none',
8394
+ windows: [{ id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1 }],
8395
+ onWindowMoved: (e) =&gt; moved.push(e),
8396
+ onWindowResized: (e) =&gt; resized.push([e.id, e.xSize, e.ySize]),
8397
+ onBeforeWindowMove: (e) =&gt; (e.to.xPos === 4 ? e.preventDefault('pinned') : true),
8398
+ onWindowMoveCancelled: (e) =&gt; cancelledMove.push(e.reason),
8399
+ onBeforeWindowResize: (e) =&gt; (e.to.xSize === 3 ? e.preventDefault('too-wide') : true),
8400
+ onWindowResizeCancelled: (e) =&gt; cancelledResize.push(e.reason),
8401
+ });
8402
+ const okMove = await layout.move('a', { xPos: 2 });
8403
+ const vetoedMove = await layout.move('a', { xPos: 4 });
8404
+ const okResize = await layout.move('a', { xSize: 2 });
8405
+ const vetoedResize = await layout.move('a', { xSize: 3 });
8406
+ return (`moved ${okMove}/${vetoedMove} to xPos ${layout.getLayout().windows[0].xPos} | resized ${okResize}/${vetoedResize} to xSize ${layout.getLayout().windows[0].xSize} | cancelled-move ${cancelledMove.join(',')} | cancelled-resize ${cancelledResize.join(',')} | moved-events ${moved.length} | resized-events ${resized.length}`);
8407
+ </code></pre>
8408
+ <p>
8409
+ <code>move(id, to)</code> resolves to a resize or a move from what changed. The first move and
8410
+ the first resize both succeed and fire <code>onWindowMoved</code>/<code>onWindowResized</code>
8411
+ once each; the second of each is vetoed by its own before-hook — <code>onBeforeWindowMove</code>
8412
+ never sees the resize and <code>onBeforeWindowResize</code> never sees the move — firing
8413
+ <code>onWindowMoveCancelled</code>/<code>onWindowResizeCancelled</code> with the veto's reason
8414
+ and leaving the window exactly where it was.
8415
+ </p>
8416
+ </div>
8417
+
8418
+ <div class="example">
8419
+ <p class="example__label">Close before-event and layout:changed, executed</p>
8420
+ <pre data-run="js" data-expect="closed true/false | ids a | cancelled unsaved | changed-causes close | remaining locked" data-covers="config:onBeforeWindowClose config:onWindowCloseCancelled config:onWindowClosed config:onLayoutChanged"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8421
+ const { createLayout } = await import('../packages/modules/layout/index.js');
8422
+ const { document, root } = createTestDom({ width: 600, height: 400 });
8423
+ const host = document.createElement('div');
8424
+ root.appendChild(host);
8425
+ const closed = [];
8426
+ const cancelledClose = [];
8427
+ const changed = [];
8428
+ const layout = createLayout(host, {
8429
+ columns: 4, rows: 4, compact: 'none',
8430
+ windows: [
8431
+ { id: 'a', xPos: 1, yPos: 1, xSize: 1, ySize: 1, closable: true },
8432
+ { id: 'locked', xPos: 2, yPos: 1, xSize: 1, ySize: 1, closable: true },
8433
+ ],
8434
+ onWindowClosed: (e) =&gt; closed.push(e.id),
8435
+ onBeforeWindowClose: (e) =&gt; (e.id === 'locked' ? e.preventDefault('unsaved') : true),
8436
+ onWindowCloseCancelled: (e) =&gt; cancelledClose.push(e.reason),
8437
+ onLayoutChanged: (e) =&gt; changed.push(e.cause),
8438
+ });
8439
+ const okClose = await layout.close('a');
8440
+ const vetoedClose = await layout.close('locked');
8441
+ return (`closed ${okClose}/${vetoedClose} | ids ${closed.join(',')} | cancelled ${cancelledClose.join(',')} | changed-causes ${changed.join(',')} | remaining ${layout.windows().join(',')}`);
8442
+ </code></pre>
8443
+ <p>
8444
+ Closing <code>a</code> succeeds, firing <code>onWindowClosed</code> and <code>onLayoutChanged</code>
8445
+ (cause <code>'close'</code>); closing <code>locked</code> is vetoed by <code>onBeforeWindowClose</code>,
8446
+ firing <code>onWindowCloseCancelled</code> with the reason instead, and it survives in
8447
+ <code>windows()</code>.
8448
+ </p>
8449
+ </div>
8450
+
8451
+ <h3 id="ai-config-examples">AI config keys, executed</h3>
8452
+ <p class="lead-in">
8453
+ The reference table names each AI config key on its own line; these three examples run a
8454
+ real controller (a MOCK <code>ask()</code> throughout &mdash; never a real provider) and show
8455
+ what each key actually does.
8456
+ </p>
8457
+
8458
+ <div class="example">
8459
+ <p class="example__label">The insights panel: element, tools, enable, maxColumns, reconcile, onNarrative, executed</p>
8460
+ <pre data-run="js" data-expect="There are 2 rows. Confidence 900%. | narrated 1 | flagged 900% | panel true | facts 12/34" data-covers="config:ask config:element config:tools config:enable config:maxColumns config:reconcile config:onNarrative"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8461
+ const { createHeadlessGrid } = await import('../packages/core/src/index.js');
8462
+ const { createAI } = await import('../packages/modules/ai/index.js');
8463
+ const dom = createTestDom({});
8464
+ const el = dom.document.createElement('div');
8465
+ const grid = createHeadlessGrid({
8466
+ rowKey: 'id',
8467
+ columns: [{ field: 'id' }, { field: 'a' }, { field: 'b' }],
8468
+ rows: [{ id: 1, a: 1, b: 2 }, { id: 2, a: 3, b: 4 }],
8469
+ });
8470
+ const narrated = [];
8471
+ const ai = createAI(grid, {
8472
+ ask: async () =&gt; ({ text: 'There are 2 rows. Confidence 900%.' }),
8473
+ element: el,
8474
+ tools: false,
8475
+ maxColumns: 1,
8476
+ reconcile: 'flag',
8477
+ enable: ['narrative'],
8478
+ onNarrative: (r) =&gt; narrated.push(r.text),
8479
+ });
8480
+ ai.insights();
8481
+ const result = await ai.explain({ kind: 'view' });
8482
+ const cappedFacts = ai.facts({ kind: 'view' }).facts.length;
8483
+ const uncapped = createAI(grid, { ask: async () =&gt; ({ text: '' }) }).facts({ kind: 'view' }).facts.length;
8484
+ return (`${result.text} | narrated ${narrated.length} | flagged ${result.flagged.join(',')} | panel ${!!el.querySelector('.lat-ai__btn')} | facts ${cappedFacts}/${uncapped}`);
8485
+ </code></pre>
8486
+ <p>
8487
+ <code>element</code> + <code>tools: false</code> (packet mode, no function-calling) are what
8488
+ make <code>ai.insights()</code> mount a real panel button; <code>enable: ['narrative']</code>
8489
+ is what lets that mounting succeed at all &mdash; leaving it out of <code>enable</code> would
8490
+ return with no panel. <code>enable</code> gates only the three DOM-mounting convenience
8491
+ methods (<code>insights()</code>, <code>askBar()</code>, <code>actorBar()</code>): the
8492
+ <code>ai.explain()</code> call right below still runs and returns a result even though
8493
+ <code>'insights'</code>/<code>'query'</code>/<code>'ask'</code>/<code>'actor'</code> are absent
8494
+ from this <code>enable</code> list &mdash; the programmatic API is never gated, so a host that
8495
+ wants no AI surface at all simply never calls these methods. <code>reconcile: 'flag'</code>
8496
+ keeps the ungrounded "900%" in the text
8497
+ (the default strips it instead) while still reporting it in <code>flagged</code>;
8498
+ <code>onNarrative</code> fires with the same result <code>explain()</code> returns;
8499
+ <code>maxColumns: 1</code> is why the capped run gathers 12 facts against 34 uncapped.
8500
+ </p>
8501
+ </div>
8502
+
8503
+ <div class="example">
8504
+ <p class="example__label">Ask-your-data: autoApply, schemaOptions, redact, router, onQuery, onError, executed</p>
8505
+ <pre data-run="js" data-expect="queried true | schema-cols id,region | chart 1,3 | errors 0" data-covers="config:autoApply config:schemaOptions config:redact config:router config:onQuery config:onError"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
8506
+ const { createDataRouter } = await import('../packages/modules/data-router/index.js');
8507
+ const { createAI } = await import('../packages/modules/ai/index.js');
8508
+
8509
+ const grid = createHeadlessGrid({
8510
+ rowKey: 'id',
8511
+ columns: [{ field: 'id' }, { field: 'region' }, { field: 'ssn' }],
8512
+ rows: [
8513
+ { id: 1, region: 'EMEA', ssn: 'a' },
8514
+ { id: 2, region: 'AMER', ssn: 'b' },
8515
+ { id: 3, region: 'EMEA', ssn: 'c' },
8516
+ ],
8517
+ });
8518
+ const chart = createHeadlessGrid({ rowKey: 'id', columns: [{ field: 'id' }, { field: 'region' }] });
8519
+ const router = createDataRouter({ rowKey: 'id', overlap: true });
8520
+ router.attach(chart, () =&gt; true);
8521
+
8522
+ const errors = [];
8523
+ const queried = [];
8524
+ let capturedSchema = null;
8525
+ const ask = async (req) =&gt; {
8526
+ capturedSchema = req.schema;
8527
+ return { actions: [{ type: 'setFilters', filters: { col: 'region', op: 'eq', value: 'EMEA' } }] };
8528
+ };
8529
+ const ai = createAI(grid, {
8530
+ ask, router, autoApply: true, schemaOptions: { maxColumns: 2 }, redact: ['ssn'],
8531
+ onQuery: (r) =&gt; queried.push(r.ok), onError: (e) =&gt; errors.push(e),
8532
+ });
8533
+ await ai.query('EMEA only');
8534
+ const chartIds = [];
8535
+ for (let i = 0; i &lt; chart.rows.count(); i++) chartIds.push(chart.rows.get(i).key);
8536
+ return (`queried ${queried.join(',')} | schema-cols ${capturedSchema.columns.map((c) =&gt; c.id).join(',')} | chart ${chartIds.sort().join(',')} | errors ${errors.length}`);
8537
+ </code></pre>
8538
+ <p>
8539
+ <code>schemaOptions: { maxColumns: 2 }</code> is why the model is shown only
8540
+ <code>id,region</code> — <code>ssn</code> is also stripped by <code>redact</code>, so it
8541
+ never reaches the schema either way; <code>autoApply</code> runs the safe read the moment it
8542
+ resolves, with no confirm step; <code>router</code>, configured once here rather than passed
8543
+ to every call, is what fans the answer to the chart with no <code>opts.router</code> anywhere
8544
+ in this example; <code>onQuery</code> fires with the result, and <code>onError</code> is wired
8545
+ but silent because nothing failed.
8546
+ </p>
8547
+ </div>
8548
+
8549
+ <div class="example">
8550
+ <p class="example__label">The governed actor: onProposal, executed</p>
8551
+ <pre data-run="js" data-expect="proposals true | diff old 10 new 50 | applied 1 | score-now 50" data-covers="config:onProposal"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
8552
+ const { createAI } = await import('../packages/modules/ai/index.js');
8553
+
8554
+ const grid = createHeadlessGrid({
8555
+ rowKey: 'id',
8556
+ columns: [{ field: 'id' }, { field: 'name' }, { field: 'score', type: 'number', edit: { enabled: true } }],
8557
+ rows: [{ id: 1, name: 'Alpha', score: 10 }],
8558
+ });
8559
+ const proposals = [];
8560
+ const ask = async () =&gt; ({ structured: { edits: [{ match: 'Alpha', column: 'score', value: 50 }] } });
8561
+ const ai = createAI(grid, { ask, onProposal: (r) =&gt; proposals.push(r.ok) });
8562
+ const result = await ai.propose('set Alpha score to 50');
8563
+ const report = await ai.applyProposal(result);
8564
+ return (`proposals ${proposals.join(',')} | diff ${result.diff.map((d) =&gt; `old ${d.oldValue} new ${d.newValue}`).join(',')} | applied ${report.applied} | score-now ${grid.rows.byKey('1').data.score}`);
8565
+ </code></pre>
8566
+ <p>
8567
+ <code>onProposal</code> fires with the built diff before any approval; <code>applyProposal</code>
8568
+ is the separate human-approval step that actually writes, through the grid's own gated edit
8569
+ path.
8570
+ </p>
8571
+ </div>
8572
+
8573
+ <h3 id="kpi-config-examples">KPI config keys, executed</h3>
8574
+ <p class="lead-in">
8575
+ Three examples covering the eleven KPI config keys not already demonstrated elsewhere in this
8576
+ guide.
8577
+ </p>
8578
+
8579
+ <div class="example">
8580
+ <p class="example__label">The tile hierarchy: tiles, an explicit separator, expanded, onNodeToggle, executed</p>
8581
+ <pre data-run="js" data-expect="compute,network | tiles 3 | toggled /compute:false" data-covers="config:tiles config:separator config:expanded config:onNodeToggle"><code>const { createKPI } = await import('../packages/modules/kpi/index.js');
8582
+
8583
+ const HOSTS = [{ id: 1, cpu: 91, mem: 40, lat: 12 }, { id: 2, cpu: 30, mem: 55, lat: 40 }];
8584
+ const toggled = [];
8585
+ const kpi = createKPI(null, {
8586
+ rows: HOSTS, rowKey: 'id',
8587
+ tree: { separator: '/', expanded: true },
8588
+ tiles: [
8589
+ { id: 'compute/cpu', label: 'cpu', aggregation: 'max', field: 'cpu' },
8590
+ { id: 'compute/memory', label: 'memory', aggregation: 'avg', field: 'mem' },
8591
+ { id: 'network/latency', label: 'latency', aggregation: 'max', field: 'lat' },
8592
+ ],
8593
+ onNodeToggle: (e) =&gt; toggled.push([e.key, e.expanded]),
8594
+ });
8595
+ const topLevel = kpi.nodes().map((n) =&gt; n.label).sort();
8596
+ const key = kpi.nodes()[0].key;
8597
+ kpi.toggle(key);
8598
+ return (`${topLevel.join(',')} | tiles ${kpi.model.tiles.length} | toggled ${toggled.map((t) => t.join(':')).join(',')}`);
8599
+ </code></pre>
8600
+ <p>
8601
+ <code>tiles</code> declares the three measured leaves; <code>separator: '/'</code> is what
8602
+ derives <code>compute</code>/<code>network</code> from ids that only contain <code>/</code>,
8603
+ not the default <code>.</code>; <code>expanded: true</code> opens every branch at construction,
8604
+ so the very first <code>toggle()</code> call closes one rather than opening it;
8605
+ <code>onNodeToggle</code> reports exactly that.
8606
+ </p>
8607
+ </div>
8608
+
8609
+ <div class="example">
8610
+ <p class="example__label">Grid-bound events: fields, onTileClick, onTileDblClick, onTileContextMenu, onChange, executed</p>
8611
+ <pre data-run="js" data-expect="p1-jobs 1 | click p1 | dbl p1 | ctx p1 | changes 1" data-covers="config:fields config:onTileClick config:onTileDblClick config:onTileContextMenu config:onChange"><code>const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
8612
+ const { createHeadlessGrid } = await import('../packages/core/src/index.js');
8613
+ const { createKPI } = await import('../packages/modules/kpi/index.js');
8614
+
8615
+ const dom = createTestDom({});
8616
+ const el = dom.document.createElement('div');
8617
+ const grid = createHeadlessGrid({
8618
+ rowKey: 'id',
8619
+ columns: [{ id: 'id', field: 'id' }, { id: 'priority', field: 'priority' }],
8620
+ rows: [{ id: 1, priority: 'P1' }, { id: 2, priority: 'P2' }],
8621
+ });
8622
+ const clicks = [];
8623
+ const dbl = [];
8624
+ const ctx = [];
8625
+ const changes = [];
8626
+ const kpi = createKPI(el, {
8627
+ grid,
8628
+ fields: ['priority'],
8629
+ tiles: [{ id: 'p1', label: 'P1 jobs', aggregation: 'count', filter: (r) =&gt; r.priority === 'P1' }],
8630
+ onTileClick: ({ id }) =&gt; clicks.push(id),
8631
+ onTileDblClick: ({ id }) =&gt; dbl.push(id),
8632
+ onTileContextMenu: ({ id }) =&gt; ctx.push(id),
8633
+ onChange: (e) =&gt; changes.push(e.model.tiles[0].value),
8634
+ });
8635
+ const fig = el.querySelector('.lat-kpi__tile');
8636
+ fig.dispatchEvent(new TestEvent('click', {}));
8637
+ fig.dispatchEvent(new TestEvent('dblclick', {}));
8638
+ fig.dispatchEvent(new TestEvent('contextmenu', {}));
8639
+ return (`p1-jobs ${kpi.value('p1')} | click ${clicks.join(',')} | dbl ${dbl.join(',')} | ctx ${ctx.join(',')} | changes ${changes.length}`);
8640
+ </code></pre>
8641
+ <p>
8642
+ <code>fields: ['priority']</code> is what lets the tile's <code>filter</code> read
8643
+ <code>priority</code> at all on a grid-bound panel — a field a tile does not itself declare is
8644
+ otherwise not projected; the three pointer events each route to their matching config
8645
+ callback, and <code>onChange</code> fires once, at construction.
8646
+ </p>
8647
+ </div>
8648
+
8649
+ <div class="example">
8650
+ <p class="example__label">A host catalogue and an unmeasured tile: messages, nullText, executed</p>
8651
+ <pre data-run="js" data-expect="compute, 2 items, schlimmster Status kritisch | disk-formatted n/a | disk-status null" data-covers="config:messages config:nullText"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
8652
+ const { createKPI } = await import('../packages/modules/kpi/index.js');
8653
+
8654
+ const dom = createTestDom({});
8655
+ const el = dom.document.createElement('div');
8656
+ const HOSTS = [{ id: 1, cpu: 91, mem: 40 }];
8657
+ const kpi = createKPI(el, {
8658
+ rows: HOSTS, rowKey: 'id',
8659
+ tree: { expanded: true },
8660
+ nullText: 'n/a',
8661
+ messages: {
8662
+ t: (key, params) =&gt; {
8663
+ if (key === 'kpi.status.critical') return 'kritisch';
8664
+ if (key === 'kpi.node.worst') return `schlimmster Status ${params.status}`;
8665
+ return key;
8666
+ },
8667
+ },
8668
+ tiles: [
8669
+ { id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
8670
+ // A tile whose field names no column any row carries: measures nothing.
8671
+ { id: 'compute.disk', label: 'disk', aggregation: 'max', field: 'disk' },
8672
+ ],
8673
+ });
8674
+ const compute = kpi.nodes()[0];
8675
+ const diskTile = kpi.tile('compute.disk');
8676
+ return (`${el.querySelectorAll('.lat-kpi__node')[0].getAttribute('aria-label')} | disk-formatted ${diskTile.formatted} | disk-status ${diskTile.status}`);
8677
+ </code></pre>
8678
+ <p>
8679
+ <code>messages</code> translates the node's accessible name (falling back to English for a key
8680
+ the host catalogue omits); <code>nullText</code> is what the unmeasured <code>disk</code> tile
8681
+ renders instead of a fabricated zero.
8682
+ </p>
8683
+ </div>
8684
+
8685
+ <h3 id="route-options-examples">Data Router route options, executed</h3>
8686
+ <div class="example">
8687
+ <p class="example__label">A per-route filter and transform, executed</p>
8688
+ <pre data-run="js" data-expect="o2,o3 | label #o2:30" data-covers="config:filter config:transform"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
8689
+ const { createDataRouter } = await import('../packages/modules/data-router/index.js');
8690
+
8691
+ const grid = createHeadlessGrid({
8692
+ rowKey: 'id',
8693
+ columns: [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }],
8694
+ });
8695
+ const router = createDataRouter({ key: 'type', rowKey: 'id' });
8696
+ router.attach(grid, 'order', {
8697
+ filter: (row) =&gt; row.amt &gt;= 20,
8698
+ transform: (row) =&gt; ({ id: row.id, type: row.type, amt: row.amt, label: `#${row.id}:${row.amt}` }),
8699
+ });
8700
+ router.load([
8701
+ { id: 'o1', type: 'order', amt: 10 },
8702
+ { id: 'o2', type: 'order', amt: 30 },
8703
+ { id: 'o3', type: 'order', amt: 20 },
8704
+ ]);
8705
+ const ids = [];
8706
+ for (let i = 0; i &lt; grid.rows.count(); i++) ids.push(grid.rows.get(i).key);
8707
+ return (`${ids.sort().join(',')} | label ${grid.rows.value('o2', 'label')}`);
8708
+ </code></pre>
8709
+ <p>
8710
+ <code>filter</code> gives the grid only the rows at or above 20 — the route decides what the
8711
+ grid ever sees, before any grid-level filter runs; <code>transform</code> derives the
8712
+ <code>label</code> field the columns declare, reshaping each row on the way in.
8713
+ </p>
8714
+ </div>
8715
+
7870
8716
  <h2 id="webcomponent-guide">Web component</h2>
7871
8717
  <p class="lead-in">
7872
8718
  <code>&lt;lattice-grid&gt;</code> is the grid as a custom element, shipped as a self-contained
@@ -8342,7 +9188,7 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'<
8342
9188
 
8343
9189
  <footer>
8344
9190
  <p>
8345
- Lattice Grid 1.61.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
9191
+ Lattice Grid 1.62.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
8346
9192
  Written against the shipped source. Where this guide and the code disagree, the code wins,
8347
9193
  please <a href="https://www.latticegrid.dev">tell us</a>.
8348
9194
  </p>