@toclocoinc/lattice-grid 1.4.3 → 1.5.5

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.
@@ -0,0 +1,3886 @@
1
+ <!doctype html>
2
+ <html lang="en-GB">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="Lattice Grid — a high-performance JavaScript data grid with no dependencies and no build step. What every part of the API does, and why.">
7
+ <!--
8
+ Lattice Grid — developer guide.
9
+ Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
10
+
11
+ Self-contained: no stylesheet, script or font is fetched, so this opens from
12
+ disk, from a file share or from behind a firewall with nothing else present.
13
+ API.html is the reference; this is the explanation.
14
+ -->
15
+ <title>Lattice Grid — Developer Guide</title>
16
+ <style>
17
+ /* ---------------------------------------------------------------
18
+ Palette lifted from the grid's own theme, so the reference reads as an
19
+ extension of the product it documents rather than a generic doc template.
20
+ --------------------------------------------------------------- */
21
+ :root {
22
+ --ground: #ffffff;
23
+ --surface: #f7f8f9;
24
+ --surface-2: #eef1f3;
25
+ --ink: #1c2126;
26
+ --muted: #5b6670;
27
+ --rule: #dfe3e6;
28
+ --rule-strong: #c4ccd2;
29
+ --accent: #1a6bc7;
30
+ --accent-soft: #e8f0fb;
31
+ --danger: #c22b2b;
32
+ --danger-soft: #fbeaea;
33
+ --warning: #a96a0b;
34
+ --success: #1b7f3b;
35
+ --code-bg: #f2f4f6;
36
+
37
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
38
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
39
+
40
+ --measure: 68ch;
41
+ --rail: 250px;
42
+ }
43
+
44
+ @media (prefers-color-scheme: dark) {
45
+ :root:not([data-theme="light"]) {
46
+ --ground: #14181c;
47
+ --surface: #1b2026;
48
+ --surface-2: #212831;
49
+ --ink: #e4e9ee;
50
+ --muted: #99a4ae;
51
+ --rule: #2a3138;
52
+ --rule-strong: #3a434c;
53
+ --accent: #4e9bea;
54
+ --accent-soft: #17293c;
55
+ --danger: #f08a8a;
56
+ --danger-soft: #33201f;
57
+ --warning: #e0a64a;
58
+ --success: #5fc27e;
59
+ --code-bg: #1e242b;
60
+ }
61
+ }
62
+
63
+ :root[data-theme="dark"] {
64
+ --ground: #14181c;
65
+ --surface: #1b2026;
66
+ --surface-2: #212831;
67
+ --ink: #e4e9ee;
68
+ --muted: #99a4ae;
69
+ --rule: #2a3138;
70
+ --rule-strong: #3a434c;
71
+ --accent: #4e9bea;
72
+ --accent-soft: #17293c;
73
+ --danger: #f08a8a;
74
+ --danger-soft: #33201f;
75
+ --warning: #e0a64a;
76
+ --success: #5fc27e;
77
+ --code-bg: #1e242b;
78
+ }
79
+
80
+ * { box-sizing: border-box; }
81
+
82
+ body {
83
+ margin: 0;
84
+ background: var(--ground);
85
+ color: var(--ink);
86
+ font-family: var(--sans);
87
+ font-size: 15px;
88
+ line-height: 1.6;
89
+ -webkit-font-smoothing: antialiased;
90
+ }
91
+
92
+ /* ---------------------------------------------------------------
93
+ Shell: an index rail that stays put, mirroring the grid's own
94
+ tool-panel dock, and a reading column beside it.
95
+ --------------------------------------------------------------- */
96
+ .shell {
97
+ display: grid;
98
+ grid-template-columns: var(--rail) minmax(0, 1fr);
99
+ gap: 0;
100
+ max-width: 1360px;
101
+ margin: 0 auto;
102
+ }
103
+
104
+ .rail {
105
+ position: sticky;
106
+ top: 0;
107
+ align-self: start;
108
+ max-height: 100vh;
109
+ overflow-y: auto;
110
+ padding: 40px 24px 40px 32px;
111
+ border-right: 1px solid var(--rule);
112
+ }
113
+
114
+ .rail__brand {
115
+ font-family: var(--mono);
116
+ font-size: 15px;
117
+ font-weight: 700;
118
+ letter-spacing: -0.02em;
119
+ margin: 0 0 2px;
120
+ }
121
+
122
+ .rail__sub {
123
+ font-size: 12px;
124
+ color: var(--muted);
125
+ margin: 0 0 28px;
126
+ }
127
+
128
+ .rail nav { display: flex; flex-direction: column; gap: 26px; }
129
+ .rail__group { display: flex; flex-direction: column; gap: 5px; }
130
+
131
+ .rail__label {
132
+ font-size: 10.5px;
133
+ font-weight: 700;
134
+ letter-spacing: 0.1em;
135
+ text-transform: uppercase;
136
+ color: var(--muted);
137
+ margin-bottom: 3px;
138
+ }
139
+
140
+ .rail a {
141
+ font-family: var(--mono);
142
+ font-size: 12.5px;
143
+ color: var(--ink);
144
+ text-decoration: none;
145
+ padding: 2px 0;
146
+ border-left: 2px solid transparent;
147
+ padding-left: 10px;
148
+ margin-left: -12px;
149
+ transition: color 120ms, border-color 120ms;
150
+ }
151
+
152
+ .rail a:hover, .rail a:focus-visible {
153
+ color: var(--accent);
154
+ border-left-color: var(--accent);
155
+ }
156
+
157
+ .main { padding: 40px 40px 120px; min-width: 0; }
158
+
159
+ /* --------------------------------------------------------------- */
160
+
161
+ header.masthead {
162
+ padding-bottom: 28px;
163
+ margin-bottom: 40px;
164
+ border-bottom: 2px solid var(--ink);
165
+ }
166
+
167
+ .eyebrow {
168
+ font-size: 10.5px;
169
+ font-weight: 700;
170
+ letter-spacing: 0.14em;
171
+ text-transform: uppercase;
172
+ color: var(--accent);
173
+ margin: 0 0 12px;
174
+ }
175
+
176
+ h1 {
177
+ font-family: var(--mono);
178
+ font-size: clamp(28px, 4vw, 40px);
179
+ font-weight: 700;
180
+ letter-spacing: -0.03em;
181
+ line-height: 1.1;
182
+ margin: 0 0 14px;
183
+ text-wrap: balance;
184
+ }
185
+
186
+ .lede { max-width: var(--measure); color: var(--muted); margin: 0; font-size: 16px; }
187
+
188
+ h2 {
189
+ font-family: var(--mono);
190
+ font-size: 21px;
191
+ font-weight: 700;
192
+ letter-spacing: -0.02em;
193
+ margin: 56px 0 6px;
194
+ padding-left: 14px;
195
+ border-left: 3px solid var(--accent);
196
+ scroll-margin-top: 24px;
197
+ }
198
+
199
+ h3 {
200
+ font-family: var(--mono);
201
+ font-size: 15.5px;
202
+ font-weight: 700;
203
+ margin: 34px 0 10px;
204
+ scroll-margin-top: 24px;
205
+ }
206
+
207
+ h2 + .section-note { margin: 0 0 22px 17px; max-width: var(--measure); color: var(--muted); font-size: 14px; }
208
+
209
+ p { max-width: var(--measure); }
210
+
211
+ a { color: var(--accent); }
212
+
213
+ /* Inline identifiers — the primary content of the whole document. */
214
+ code {
215
+ font-family: var(--mono);
216
+ font-size: 0.885em;
217
+ background: var(--code-bg);
218
+ padding: 1px 5px;
219
+ border-radius: 3px;
220
+ white-space: nowrap;
221
+ }
222
+
223
+ pre {
224
+ font-family: var(--mono);
225
+ font-size: 13px;
226
+ line-height: 1.65;
227
+ background: var(--surface);
228
+ border: 1px solid var(--rule);
229
+ border-radius: 5px;
230
+ padding: 16px 18px;
231
+ overflow-x: auto;
232
+ margin: 18px 0;
233
+ }
234
+
235
+ pre code { background: none; padding: 0; font-size: inherit; white-space: pre; }
236
+
237
+ .cmt { color: var(--muted); }
238
+ .kw { color: var(--accent); }
239
+
240
+ /* ---------------------------------------------------------------
241
+ Reference tables. Dense, hairline, sticky-headed — the grid's own
242
+ visual language, and the right density for scanning an API.
243
+ --------------------------------------------------------------- */
244
+ .table-wrap {
245
+ overflow-x: auto;
246
+ border: 1px solid var(--rule);
247
+ border-radius: 5px;
248
+ margin: 18px 0 26px;
249
+ }
250
+
251
+ table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
252
+
253
+ thead th {
254
+ position: sticky;
255
+ top: 0;
256
+ background: var(--surface);
257
+ text-align: left;
258
+ font-size: 10.5px;
259
+ font-weight: 700;
260
+ letter-spacing: 0.09em;
261
+ text-transform: uppercase;
262
+ color: var(--muted);
263
+ padding: 9px 14px;
264
+ border-bottom: 1px solid var(--rule-strong);
265
+ white-space: nowrap;
266
+ z-index: 1;
267
+ }
268
+
269
+ tbody td { padding: 9px 14px; border-top: 1px solid var(--rule); vertical-align: top; }
270
+ tbody tr:first-child td { border-top: 0; }
271
+ tbody tr:hover { background: var(--surface); }
272
+
273
+ td.name, td.sig {
274
+ font-family: var(--mono);
275
+ font-size: 12.5px;
276
+ white-space: nowrap;
277
+ font-variant-numeric: tabular-nums;
278
+ }
279
+
280
+ td.name { font-weight: 600; }
281
+ td.type { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
282
+ td.desc { min-width: 20em; }
283
+ td.dflt { font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: nowrap; }
284
+
285
+ /* --------------------------------------------------------------- */
286
+
287
+ .badge {
288
+ display: inline-block;
289
+ font-family: var(--mono);
290
+ font-size: 10px;
291
+ font-weight: 700;
292
+ letter-spacing: 0.05em;
293
+ text-transform: uppercase;
294
+ padding: 2px 6px;
295
+ border-radius: 3px;
296
+ white-space: nowrap;
297
+ vertical-align: 1px;
298
+ }
299
+
300
+ .badge--dom { background: var(--accent-soft); color: var(--accent); }
301
+ .badge--gap { background: var(--danger-soft); color: var(--danger); }
302
+
303
+ .note {
304
+ max-width: var(--measure);
305
+ background: var(--surface);
306
+ border-left: 3px solid var(--rule-strong);
307
+ padding: 14px 18px;
308
+ margin: 22px 0;
309
+ font-size: 14px;
310
+ }
311
+
312
+ .note--warn { border-left-color: var(--warning); }
313
+ .note strong { font-weight: 700; }
314
+ .note p { margin: 0; max-width: none; }
315
+ .note p + p { margin-top: 8px; }
316
+
317
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 26px; }
318
+
319
+ .chip {
320
+ font-family: var(--mono);
321
+ font-size: 12px;
322
+ background: var(--surface);
323
+ border: 1px solid var(--rule);
324
+ border-radius: 3px;
325
+ padding: 3px 8px;
326
+ }
327
+
328
+ .chip--new { border-color: var(--accent); color: var(--accent); }
329
+
330
+ footer {
331
+ margin-top: 72px;
332
+ padding-top: 22px;
333
+ border-top: 1px solid var(--rule);
334
+ font-size: 13px;
335
+ color: var(--muted);
336
+ max-width: var(--measure);
337
+ }
338
+
339
+ @media (max-width: 900px) {
340
+ .shell { grid-template-columns: 1fr; }
341
+ .rail {
342
+ position: static;
343
+ max-height: none;
344
+ border-right: 0;
345
+ border-bottom: 1px solid var(--rule);
346
+ padding: 28px 24px;
347
+ }
348
+ .rail nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px; }
349
+ .main { padding: 28px 24px 80px; }
350
+ }
351
+
352
+ @media (prefers-reduced-motion: reduce) {
353
+ * { transition: none !important; animation: none !important; }
354
+ }
355
+
356
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
357
+ </style>
358
+ <style>
359
+ /* ---------------------------------------------------------------
360
+ Additions for the long-form guide. The reference is a lookup
361
+ table; this document is read in order, so it needs devices the
362
+ reference does not: worked examples with captions, asides that
363
+ explain a decision rather than restate it, and comparisons.
364
+ --------------------------------------------------------------- */
365
+
366
+ .example { margin: 20px 0 24px; }
367
+ .example__label {
368
+ margin: 0 0 6px;
369
+ color: var(--muted);
370
+ font-size: 11.5px;
371
+ font-weight: 650;
372
+ letter-spacing: 0.07em;
373
+ text-transform: uppercase;
374
+ }
375
+ .example pre { margin-top: 0; }
376
+ .example__after {
377
+ margin: -10px 0 0;
378
+ padding: 8px 14px;
379
+ border: 1px solid var(--rule);
380
+ border-top: 0;
381
+ border-radius: 0 0 6px 6px;
382
+ background: var(--surface);
383
+ color: var(--muted);
384
+ font-family: var(--mono);
385
+ font-size: 12px;
386
+ white-space: pre-wrap;
387
+ }
388
+
389
+ /* Why a thing is the way it is. Distinct from .note, which is a
390
+ caveat — this is the reasoning, and it is the part a developer
391
+ evaluating the product actually reads. */
392
+ .why {
393
+ /* Constrained to the reading measure plus its own padding. Left to fill
394
+ the column the tinted panel ran 400px past the text inside it, which
395
+ reads as a mistake rather than as emphasis. */
396
+ max-width: calc(var(--measure) + 36px);
397
+ margin: 18px 0;
398
+ padding: 14px 16px 14px 18px;
399
+ border-left: 3px solid var(--accent);
400
+ border-radius: 0 6px 6px 0;
401
+ background: var(--accent-soft);
402
+ }
403
+ .why p { margin: 0 0 8px; max-width: var(--measure); font-size: 14px; }
404
+ .why p:last-child { margin-bottom: 0; }
405
+ .why strong { font-weight: 650; }
406
+
407
+ .lead-in { max-width: var(--measure); font-size: 15.5px; line-height: 1.65; }
408
+
409
+ /* Two panels side by side, for a before/after or a wrong/right. */
410
+ .split { display: grid; gap: 16px; margin: 18px 0; }
411
+ @media (min-width: 900px) { .split { grid-template-columns: 1fr 1fr; } }
412
+ .split pre { height: 100%; margin: 0; }
413
+ .split__title { margin: 0 0 6px; font-size: 12px; font-weight: 650; }
414
+ .split__title--no { color: var(--danger); }
415
+ .split__title--yes { color: var(--success); }
416
+
417
+ .anchor-list { margin: 0 0 26px; padding: 0; list-style: none; }
418
+ .anchor-list li { margin: 0 0 6px; font-size: 14px; }
419
+ .anchor-list a { color: var(--accent); text-decoration: none; }
420
+ .anchor-list a:hover { text-decoration: underline; }
421
+ .anchor-list span { color: var(--muted); }
422
+
423
+ h3 + .lead-in { margin-top: 6px; }
424
+ .sig-line {
425
+ margin: 22px 0 4px;
426
+ padding: 8px 12px;
427
+ border-radius: 6px;
428
+ background: var(--code-bg);
429
+ font-family: var(--mono);
430
+ font-size: 13px;
431
+ overflow-x: auto;
432
+ }
433
+ </style>
434
+ </head>
435
+ <body>
436
+
437
+ <div class="shell">
438
+ <aside class="rail">
439
+ <p class="rail__brand">Lattice Grid</p>
440
+ <p class="rail__sub">Developer guide · v1.5.4</p>
441
+ <nav>
442
+ <div class="rail__group">
443
+ <span class="rail__label">Start here</span>
444
+ <a href="#what">What it is</a>
445
+ <a href="#install">Install</a>
446
+ <a href="#first-grid">Your first grid</a>
447
+ <a href="#adapters-guide">React, Vue, Svelte</a>
448
+ <a href="#concepts">How it works</a>
449
+ </div>
450
+ <div class="rail__group">
451
+ <span class="rail__label">Columns</span>
452
+ <a href="#columns-guide">Defining columns</a>
453
+ <a href="#types">Types and formatting</a>
454
+ <a href="#renderers">Cells and renderers</a>
455
+ <a href="#styling">Styling</a>
456
+ </div>
457
+ <div class="rail__group">
458
+ <span class="rail__label">Data</span>
459
+ <a href="#loading">Loading and updating</a>
460
+ <a href="#sorting">Sorting</a>
461
+ <a href="#filtering">Filtering</a>
462
+ <a href="#grouping">Grouping and totals</a>
463
+ <a href="#sources">Large data</a>
464
+ </div>
465
+ <div class="rail__group">
466
+ <span class="rail__label">Interaction</span>
467
+ <a href="#editing">Editing</a>
468
+ <a href="#optimistic">Optimistic writes</a>
469
+ <a href="#tree-data">Tree data</a>
470
+ <a href="#master-detail">Master-detail</a>
471
+ <a href="#selection-guide">Selection and ranges</a>
472
+ <a href="#fill-series">Filling a series</a>
473
+ <a href="#updates-guide">Holding live updates</a>
474
+ <a href="#diagnostics-guide">Diagnostics and devtools</a>
475
+ <a href="#presence-guide">Collaborative presence</a>
476
+ <a href="#comments-guide">Cell comments</a>
477
+ <a href="#facets-guide">Header histograms</a>
478
+ <a href="#timeline-guide">Time scrubber</a>
479
+ <a href="#presentation-guide">Presentation mode</a>
480
+ <a href="#density-guide">Density</a>
481
+ <a href="#image-guide">Image columns</a>
482
+ <a href="#twoline-guide">Two-line cells</a>
483
+ <a href="#redaction-guide">Redacting a column</a>
484
+ <a href="#clipboard">Clipboard</a>
485
+ <a href="#keyboard">Keyboard</a>
486
+ <a href="#rules-guide">Conditional formatting</a>
487
+ <a href="#formatting-guide">Formatting a user can change</a>
488
+ <a href="#quickfilter-guide">Quick filter</a>
489
+ <a href="#charts-guide">In-cell charts</a>
490
+ <a href="#formulas-guide">Formulas</a>
491
+ <a href="#custom-menu">Custom items</a>
492
+ <a href="#rail">The left rail</a>
493
+ <a href="#maximise-guide">Maximise</a>
494
+ <a href="#highlighting">Highlighting</a>
495
+ </div>
496
+ <div class="rail__group">
497
+ <span class="rail__label">Product</span>
498
+ <a href="#views-guide">Saved views</a>
499
+ <a href="#history-guide">Undo</a>
500
+ <a href="#permissions-guide">Permissions</a>
501
+ <a href="#diff-guide">Audit mode</a>
502
+ <a href="#ai-guide">AI</a>
503
+ <a href="#export-guide">Export</a>
504
+ </div>
505
+ <div class="rail__group">
506
+ <span class="rail__label">Reference</span>
507
+ <a href="#webcomponent-guide">Web component</a>
508
+ <a href="#events-guide">Events</a>
509
+ <a href="#licensing">Licensing</a>
510
+ <a href="#recipes">Recipes</a>
511
+ <a href="API.html">Full API tables &rarr;</a>
512
+ </div>
513
+ </nav>
514
+ </aside>
515
+
516
+ <main class="main">
517
+ <header>
518
+ <p class="eyebrow">TOCLOCO Inc</p>
519
+ <h1>Lattice Grid</h1>
520
+ <p class="lede">
521
+ A data grid for people who have been let down by data grids. A hundred thousand rows
522
+ that scroll like ten. Two files, no build step, no dependencies. This guide explains
523
+ what each part of the API does and why it works the way it does; the
524
+ <a href="API.html">reference tables</a> are the shorter version for when you already know.
525
+ </p>
526
+ <p class="chips">
527
+ <span class="chip">Version 1.5.4</span>
528
+ <span class="chip">Zero dependencies</span>
529
+ <span class="chip">No build step</span>
530
+ </p>
531
+ </header>
532
+
533
+ <h2 id="what">What it is</h2>
534
+ <p class="lead-in">
535
+ Lattice Grid renders tabular data in a browser. That sentence covers a great many products,
536
+ so here is what is actually different about this one.
537
+ </p>
538
+
539
+ <h3>It holds data in columns, not rows</h3>
540
+ <p class="lead-in">
541
+ A grid of a hundred thousand rows and thirty columns is three million values. Held as row
542
+ objects, that is three million property lookups per pass and a great deal of memory the
543
+ garbage collector has opinions about. Lattice stores each column as one typed array — numbers
544
+ in a <code>Float64Array</code>, repeated strings as integers into a dictionary, booleans as
545
+ bits in a bitset.
546
+ </p>
547
+ <div class="why">
548
+ <p><strong>Why it matters to you:</strong> sorting a column is a sort over one contiguous
549
+ array of numbers, not a walk over a hundred thousand objects. Filtering produces a bitmask
550
+ rather than a new array of rows. Both stay fast at sizes where the row-object approach has
551
+ already given up, and neither allocates much, so the browser is not collecting garbage while
552
+ the user is scrolling.</p>
553
+ </div>
554
+
555
+ <h3>Work is memoised in stages</h3>
556
+ <p class="lead-in">
557
+ Everything between your data and the screen is six stages: filter, sort, group, total, pivot,
558
+ flatten. Each remembers its result and the inputs it was computed from.
559
+ </p>
560
+ <div class="why">
561
+ <p>Change a sort and the filter stage is not recomputed — its inputs did not change. Edit a
562
+ cell in a column nobody sorts, filters or groups on and <em>none</em> of the first five run;
563
+ only the totals move. This is why a grid that is heavily filtered and grouped still feels
564
+ immediate when you type in a cell.</p>
565
+ </div>
566
+
567
+ <h3>The renderer reuses everything</h3>
568
+ <p class="lead-in">
569
+ Rows and cells come from pools. Scrolling reassigns the twenty or so row elements that exist
570
+ rather than creating and destroying thousands, and the only vertical write is a
571
+ <code>transform</code>, which the compositor handles without a layout pass.
572
+ </p>
573
+
574
+ <h3>No dependencies, and no build step</h3>
575
+ <p class="lead-in">
576
+ Not "few dependencies" — none, at runtime and at build time. No lodash, no date library, no
577
+ virtualisation library, no icon font. The bundler and minifier that produce the distribution
578
+ are part of the repository. You can drop two files into a page and be finished.
579
+ </p>
580
+ <div class="why">
581
+ <p>A large share of enterprise line-of-business frontends are built without a bundler, and
582
+ they are usually treated as second-class by grid vendors. Here the script tag is the first
583
+ example in the documentation, not an appendix.</p>
584
+ </div>
585
+
586
+ <h2 id="install">Install</h2>
587
+ <p class="lead-in">Two files. Nothing is fetched at runtime — no CDN, no font, no sprite sheet.</p>
588
+
589
+ <div class="example">
590
+ <p class="example__label">Script tag</p>
591
+ <pre><code>&lt;link rel="stylesheet" href="lattice-grid.min.css"&gt;
592
+ &lt;script src="lattice-grid.min.js"&gt;&lt;/script&gt;
593
+
594
+ &lt;script&gt;
595
+ <span class="kw">const</span> grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
596
+ &lt;/script&gt;</code></pre>
597
+ </div>
598
+
599
+ <div class="example">
600
+ <p class="example__label">ES modules</p>
601
+ <pre><code><span class="kw">import</span> { createGrid } <span class="kw">from</span> './lattice-grid.esm.min.js';
602
+ <span class="kw">const</span> grid = createGrid(document.getElementById('grid'), config);</code></pre>
603
+ </div>
604
+
605
+ <div class="note">
606
+ <p><strong>There is no npm package.</strong> Lattice Grid is not published to any registry, so
607
+ <code>import … from '@latticegrid/dom'</code> will not resolve. Import the built file by path,
608
+ or use the script tag. For editor tooling, point your <code>tsconfig</code> at
609
+ <code>lattice-grid.d.ts</code>.</p>
610
+ </div>
611
+
612
+ <p class="lead-in">Everything else in the distribution is an alternative packaging or a
613
+ development aid:</p>
614
+ <div class="table-wrap">
615
+ <table>
616
+ <thead><tr><th>File</th><th>Gzipped</th><th>What it is</th></tr></thead>
617
+ <tbody>
618
+ <tr><td class="sig">lattice-grid.min.js</td><td class="desc">The whole product as a UMD build. Defines <code>window.LatticeGrid</code>, and works with AMD and CommonJS loaders.</td></tr>
619
+ <tr><td class="sig">lattice-grid.min.css</td><td class="desc">The single stylesheet. Without it the grid is in the DOM and unreadable — no widths, no scrolling, no theme.</td></tr>
620
+ <tr><td class="sig">lattice-grid.esm.min.js</td><td class="desc">The same, as an ES module.</td></tr>
621
+ <tr><td class="sig">lattice-core.esm.js</td><td class="desc">Headless core for Node — no renderer. See <a href="#export-guide">server-side export</a>.</td></tr>
622
+ <tr><td class="sig">lattice-grid.d.ts</td><td class="desc">Type declarations.</td></tr>
623
+ </tbody>
624
+ </table>
625
+ </div>
626
+
627
+ <h2 id="first-grid">Your first grid</h2>
628
+ <p class="lead-in">
629
+ Three things are required: an element to mount into, some columns, and some rows. Everything
630
+ else has a working default.
631
+ </p>
632
+
633
+ <div class="example">
634
+ <p class="example__label">The whole thing</p>
635
+ <pre><code><span class="kw">const</span> grid = LatticeGrid.createGrid(document.getElementById('grid'), {
636
+ rowKey: 'id',
637
+ columns: [
638
+ { field: 'circuitId', title: 'Circuit' },
639
+ { field: 'region' },
640
+ { field: 'monthlyCharge', type: 'number', format: 'currency:GBP:2', total: 'sum' },
641
+ { field: 'installedOn', type: 'date', format: 'date:dd MMM yyyy' },
642
+ ],
643
+ rows: data,
644
+ });</code></pre>
645
+ </div>
646
+
647
+ <p class="lead-in">
648
+ A few things happened there without being asked for. <code>region</code> got a title of
649
+ "Region" — a field name is turned into a readable heading rather than left as-is. The number
650
+ column right-aligned itself, because numbers align right and a grid should not need telling.
651
+ The date column parsed <code>2024-03-11</code> and rendered <code>11 Mar 2024</code>. And
652
+ <code>total: 'sum'</code> put a figure in the totals row.
653
+ </p>
654
+
655
+ <div class="why">
656
+ <p><strong>On <code>rowKey</code>:</strong> it names the field that identifies a row. Set it
657
+ if you have one. Without it the grid assigns keys per row object, which is enough for
658
+ sorting, filtering, selection and copying within a session — but not across a reload, because
659
+ new objects are new rows. Change tracking, streaming dedupe, selection persistence and remote
660
+ reload all want a real key, and the grid warns once, naming them.</p>
661
+ </div>
662
+
663
+ <h2 id="adapters-guide">React, Vue, Svelte</h2>
664
+ <p class="lead-in">
665
+ One optional bundle per framework. They are thin: the grid is
666
+ created once against a host element, prop changes are pushed into it through the same
667
+ public API you would call by hand, and it is destroyed on unmount.
668
+ </p>
669
+ <div class="why">
670
+ <p><strong>You pass the framework in.</strong> Every adapter is a factory taking the
671
+ framework and <code>createGrid</code>, rather than importing either. Lattice ships zero
672
+ dependencies and the bundler rejects bare specifiers outright, so an adapter could not
673
+ <code>import React from 'react'</code> even if it wanted to. The same choice keeps each
674
+ bundle small: the bundler inlines whatever it can resolve, so an adapter that imported the
675
+ grid would carry a second copy of it. Passing both in leaves each adapter a
676
+ few kilobytes of glue, and means the adapter cannot disagree with the grid version you
677
+ already loaded.</p>
678
+ </div>
679
+
680
+ <div class="example">
681
+ <p class="example__label">React</p>
682
+ <pre><code><span class="kw">import</span> React <span class="kw">from</span> 'react';
683
+ <span class="kw">import</span> { createGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
684
+ <span class="kw">import</span> { createLatticeGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid/modules/react';
685
+
686
+ <span class="kw">const</span> LatticeGrid = createLatticeGrid({ React, createGrid });
687
+
688
+ <span class="kw">function</span> Circuits({ rows }) {
689
+ <span class="kw">const</span> ref = React.useRef(null);
690
+ <span class="kw">return</span> (
691
+ &lt;LatticeGrid
692
+ ref={ref}
693
+ className="grid"
694
+ columns={columns}
695
+ rows={rows}
696
+ rowKey="id"
697
+ sort={[{ col: 'name', dir: 'asc' }]}
698
+ onCellChanged={(e) =&gt; save(e.key, e.colId, e.value)}
699
+ /&gt;
700
+ );
701
+ }
702
+
703
+ <span class="cmt">// ref.current.grid is the live grid, for anything without a prop.</span></code></pre>
704
+ </div>
705
+ <div class="why">
706
+ <p><strong>Hold your props steady.</strong> Change detection is reference equality, because
707
+ deep-comparing a million-row array on every render would cost more than the reload it
708
+ avoids. Build <code>columns</code> once outside the component, or memoise it; a fresh array
709
+ literal on each render tells the grid the columns changed and it will rebuild them. Rows are
710
+ the same — hand back a new array when the data actually changes, not before.</p>
711
+ <p>StrictMode is handled. React 18 deliberately mounts, unmounts and mounts again in
712
+ development; the effect cleanup destroys the first grid, so the second starts clean and
713
+ nothing leaks.</p>
714
+ </div>
715
+
716
+ <div class="example">
717
+ <p class="example__label">Vue 3</p>
718
+ <pre><code><span class="kw">import</span> * <span class="kw">as</span> vue <span class="kw">from</span> 'vue';
719
+ <span class="kw">import</span> { createGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
720
+ <span class="kw">import</span> { createLatticeGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid/modules/vue';
721
+
722
+ <span class="kw">const</span> LatticeGrid = createLatticeGrid({ vue, createGrid });</code></pre>
723
+ <pre><code>&lt;LatticeGrid
724
+ :columns="columns"
725
+ :rows="rows"
726
+ row-key="id"
727
+ :sort="[{ col: 'name', dir: 'asc' }]"
728
+ @cell-changed="onCellChanged"
729
+ @selection-changed="onSelectionChanged"
730
+ /&gt;</code></pre>
731
+ </div>
732
+ <div class="why">
733
+ <p>Events are re-emitted under dashed names — <code>cell:changed</code> becomes
734
+ <code>@cell-changed</code> — because a colon in a Vue template is directive syntax and
735
+ cannot be bound. Every event in the table below is declared in <code>emits</code>.</p>
736
+ </div>
737
+
738
+ <div class="example">
739
+ <p class="example__label">Svelte</p>
740
+ <pre><code>&lt;script&gt;
741
+ <span class="kw">import</span> { createGrid } <span class="kw">from</span> '@toclocoinc/lattice-grid';
742
+ <span class="kw">import</span> { createLatticeAction } <span class="kw">from</span> '@toclocoinc/lattice-grid/modules/svelte';
743
+
744
+ <span class="kw">const</span> lattice = createLatticeAction({ createGrid });
745
+ <span class="kw">let</span> rows = [];
746
+ &lt;/script&gt;
747
+
748
+ &lt;div
749
+ use:lattice={{ columns, rows, rowKey: 'id' }}
750
+ on:cell-changed={(e) =&gt; save(e.detail)}
751
+ &gt;&lt;/div&gt;</code></pre>
752
+ </div>
753
+ <div class="why">
754
+ <p><strong>An action, not a component.</strong> Svelte's action contract is
755
+ <code>{ update, destroy }</code> over a node the caller already owns, which is exactly the
756
+ shape of the work: create, push changes, tear down. A component wrapper would add an element
757
+ and a props layer to arrive back at the same three calls. It is also the only adapter here
758
+ that needs nothing but <code>createGrid</code>, since an action is a plain function with no
759
+ framework runtime behind it. Grid events arrive as <code>CustomEvent</code>s on the node,
760
+ with the grid event as <code>detail</code>. If you want a component, four lines around this
761
+ gets you one.</p>
762
+ </div>
763
+
764
+ <div class="why">
765
+ <p><strong>What the adapters do not do.</strong> They add no features and wrap no API — the
766
+ grid instance is the same object the vanilla examples use, and anything without a prop is
767
+ reached through it directly (via the ref in React, <code>expose</code> in Vue, or a
768
+ reference you keep in Svelte). Nothing is proxied, so nothing can lag behind the grid.</p>
769
+ </div>
770
+
771
+ <h3>The web component is self-contained &mdash; use it or the API, not both</h3>
772
+ <p class="lead-in">
773
+ The custom element carries the grid inside it, rather than being handed one. That is the point
774
+ of the format: you add the element and it works, with nothing to wire up.
775
+ </p>
776
+ <div class="why">
777
+ <p><strong>Do not load it alongside <code>createGrid</code> in the same page.</strong> You would
778
+ get two independent copies of the grid, and the cost is not the download &mdash; it is that
779
+ each copy keeps its own registries. A renderer, editor, data type or variant registered
780
+ through one is invisible to the other, and a licence key validated in one is not validated in
781
+ the other. Nothing errors; the custom renderer you registered simply never appears.</p>
782
+ <p>Pick one route per application. The other three adapters take <code>createGrid</code> as an
783
+ argument and so share whatever copy you already loaded, and can be mixed with direct API use
784
+ freely.</p>
785
+ </div>
786
+
787
+ <h2 id="concepts">How it works</h2>
788
+ <p class="lead-in">
789
+ Four ideas explain most of the API. If you read nothing else, read this section — the rest of
790
+ the guide assumes it.
791
+ </p>
792
+
793
+ <h3>The row you see is not the row you supplied</h3>
794
+ <p class="lead-in">
795
+ Your data objects are held by reference and never copied. What the grid hands back is a
796
+ <em>row wrapper</em>: your object under <code>data</code>, plus the identity and position the
797
+ grid needs — <code>key</code>, <code>index</code>, <code>level</code>, whether it is a group
798
+ row, whether it is expanded.
799
+ </p>
800
+ <div class="example">
801
+ <p class="example__label">Reading a value</p>
802
+ <pre><code>grid.rows.get(0).data <span class="cmt">// your object, untouched</span>
803
+ grid.rows.value('r1', 'cap') <span class="cmt">// the raw value</span>
804
+ grid.rows.text('r1', 'cap') <span class="cmt">// the formatted text, as rendered</span>
805
+ grid.rows.values('r1') <span class="cmt">// every readable column, as an object</span></code></pre>
806
+ </div>
807
+ <div class="why">
808
+ <p><code>value</code> and <code>text</code> are different questions and the difference bites
809
+ people. A currency column's value is <code>1234.5</code>; its text is
810
+ <code>£1,234.50</code>. Sorting and filtering use the value. Copying, exporting and searching
811
+ use the text, because that is what the user can see and what they typed against.</p>
812
+ </div>
813
+
814
+ <h3>Display index and row key are different things</h3>
815
+ <p class="lead-in">
816
+ A display index is a position — row 0 is whatever is at the top right now, and it changes when
817
+ you sort. A key identifies a record for as long as it exists. Anything that has to survive a
818
+ sort, a filter or a page change is keyed.
819
+ </p>
820
+ <div class="split">
821
+ <div>
822
+ <p class="split__title split__title--no">Position — fragile</p>
823
+ <pre><code>grid.rows.get(4)
824
+ grid.selection.setRange({
825
+ startRow: 0, endRow: 9, columns: ['cap'],
826
+ })</code></pre>
827
+ </div>
828
+ <div>
829
+ <p class="split__title split__title--yes">Identity — durable</p>
830
+ <pre><code>grid.rows.byKey('CIR-100042')
831
+ grid.edit.setCells([
832
+ { key: 'CIR-100042', colId: 'cap', value: 99 },
833
+ ])</code></pre>
834
+ </div>
835
+ </div>
836
+
837
+ <h3>Everything is on one event bus</h3>
838
+ <p class="lead-in">
839
+ There are no <code>onSomething</code> configuration properties. One bus, one
840
+ <code>grid.on(type, handler)</code>, and every payload carries <code>type</code>,
841
+ <code>origin</code> and <code>grid</code> alongside its own fields.
842
+ </p>
843
+ <div class="example">
844
+ <p class="example__label">Subscribing</p>
845
+ <pre><code><span class="kw">const</span> off = grid.on('cell:changed', e =&gt; save(e.row.data));
846
+ grid.once('ready', init);
847
+ grid.on('*', e =&gt; console.log(e.type, e)); <span class="cmt">// wildcard, for working out what fires</span>
848
+
849
+ off(); <span class="cmt">// every subscription returns its own unsubscribe</span></code></pre>
850
+ </div>
851
+ <div class="why">
852
+ <p><code>origin</code> tells you where a change came from — <code>'user'</code>,
853
+ <code>'api'</code>, <code>'init'</code>, <code>'undo'</code>. It is what stops a feedback
854
+ loop when you persist changes: a handler that writes to a server on
855
+ <code>cell:changed</code> should usually ignore its own <code>'undo'</code> traffic, or at
856
+ least know that is what it is looking at.</p>
857
+ </div>
858
+
859
+ <h3>Two rules hold across the whole API</h3>
860
+ <p class="lead-in">
861
+ <strong>Nothing is a double negative.</strong> There is no <code>suppressX</code>, no
862
+ <code>disableY</code>. Options are positive and say what they enable:
863
+ <code>edit</code>, <code>sortable</code>, <code>allowGroup</code>. Turning something off is
864
+ <code>false</code>.
865
+ </p>
866
+ <p class="lead-in">
867
+ <strong>Every configuration key is settable at runtime</strong> through
868
+ <code>grid.set(key, value)</code> and readable through <code>grid.get(key)</code>. There is no
869
+ separate "you can only pass this at construction" list to memorise.
870
+ </p>
871
+ <div class="example">
872
+ <p class="example__label">Which version am I running?</p>
873
+ <pre><code>grid.getVersion(); <span class="cmt">// '1.5.4'</span>
874
+ LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid to hand</span></code></pre>
875
+ </div>
876
+ <p class="lead-in">
877
+ On the instance as well as the module, because that is where it is wanted: a bug report says
878
+ "the grid on this page", and whoever reads it has a grid rather than the module it was built
879
+ from.
880
+ </p>
881
+ <h2 id="columns-guide">Defining columns</h2>
882
+ <p class="lead-in">
883
+ A column is an object with a <code>field</code> — the path to read from your data — and
884
+ whatever else it needs. Everything except <code>field</code> or <code>id</code> is optional.
885
+ </p>
886
+
887
+ <div class="sig-line">{ field: 'site.address.postcode', title: 'Postcode', type: 'text', width: 110 }</div>
888
+ <p class="lead-in">
889
+ <code>field</code> reads a dot path, so nested data needs no flattening. The column's
890
+ <code>id</code> defaults to the field, which is what you use everywhere else — in
891
+ <code>setCells</code>, in filters, in saved state.
892
+ </p>
893
+
894
+ <h3>Grouped headers</h3>
895
+ <p class="lead-in">Nest columns to get a spanning header row.</p>
896
+ <div class="example">
897
+ <p class="example__label">Two levels</p>
898
+ <pre><code>columns: [
899
+ { field: 'circuitId', title: 'Circuit' },
900
+ { title: 'Location', children: [
901
+ { field: 'region' },
902
+ { field: 'country' },
903
+ { field: 'site.address.postcode', title: 'Postcode' },
904
+ ]},
905
+ ]</code></pre>
906
+ </div>
907
+
908
+ <h3>Computed columns</h3>
909
+ <p class="lead-in">
910
+ A column can compute its value instead of reading one. Declare what it depends on and the
911
+ grid builds a dependency graph, so editing <code>cost</code> invalidates <code>margin</code>
912
+ and nothing else.
913
+ </p>
914
+ <div class="example">
915
+ <p class="example__label">Derived values</p>
916
+ <pre><code>{
917
+ id: 'margin',
918
+ title: 'Margin',
919
+ type: 'number',
920
+ format: 'currency:GBP:2',
921
+ value: {
922
+ deps: ['monthlyCharge', 'cost'],
923
+ compute: (deps) =&gt; deps.monthlyCharge - deps.cost,
924
+ },
925
+ }
926
+
927
+ <span class="cmt">// compute(deps, ctx) — deps holds the resolved values of the columns you</span>
928
+ <span class="cmt">// named; ctx carries { data, row, column, grid, context } when you need more.</span></code></pre>
929
+ </div>
930
+ <div class="why">
931
+ <p><code>compute</code> is handed the values it declared rather than the whole row, which is
932
+ what lets the grid memoise it: the same inputs give the same answer, so the result is cached
933
+ until one of them changes. Reach for <code>ctx.data</code> when you genuinely need the rest of
934
+ the row, and set <code>pure: false</code> if the result depends on something the grid cannot
935
+ see.</p>
936
+ <p>Declaring <code>deps</code> is what makes this cheap. Without it the grid would have to
937
+ assume any change might affect any computed column and recompute all of them on every edit.
938
+ A cycle is caught at compile time with the full path named, rather than becoming a stack
939
+ overflow at render time.</p>
940
+ </div>
941
+
942
+ <h3>Sizing and pinning</h3>
943
+ <div class="example">
944
+ <p class="example__label">Layout</p>
945
+ <pre><code>{ field: 'circuitId', layout: { width: 130, pin: 'start' } }
946
+ { field: 'notes', layout: { flex: 1, min: 200 } }
947
+ { field: 'isActive', layout: { width: 90, pin: 'end' } }</code></pre>
948
+ </div>
949
+ <p class="lead-in">
950
+ Pinned columns are rendered in their own region and do not scroll horizontally.
951
+ <code>grid.columns.fit()</code> distributes the viewport width across visible columns, and
952
+ <code>autoSize</code> measures content.
953
+ </p>
954
+ <p>Both are also on the column menu &mdash; Move left, Move right, Move to start, Move to end, and
955
+ a Width submenu &mdash; and bound to the keyboard with a heading focused: <kbd>Alt</kbd> with a
956
+ left or right arrow resizes, <kbd>Shift</kbd> with one moves the column. Neither operation
957
+ depends on dragging.</p>
958
+ <p>A pinned region holds the edge of the viewport only while there is something to scroll. Where
959
+ the columns are narrower than the grid &mdash; fixed widths, or a <code>flex</code> column that
960
+ has reached its <code>max</code> &mdash; nothing scrolls, so the pinned columns sit directly
961
+ after the centre ones and the spare width falls beyond them all, at the right of the grid.</p>
962
+ <p>To take that space up rather than leave it, give a column <code>flex</code> and no
963
+ <code>max</code>, or call <code>grid.columns.fit()</code>. Note that removing a column&rsquo;s
964
+ <code>width</code> does not do it: a column with neither <code>width</code> nor
965
+ <code>flex</code> takes the default 150 rather than a share of what is free. Absorbing space is
966
+ what <code>flex</code> is for, and <code>min</code> only ever sets a floor.</p>
967
+
968
+ <h2 id="types">Types and formatting</h2>
969
+ <p class="lead-in">
970
+ A type is not a label. It is a bundle of behaviour: how a value is formatted, parsed back from
971
+ text, compared when sorting, stored in the columnar backing, written to Excel, and put on the
972
+ clipboard. Setting <code>type</code> configures all of that at once.
973
+ </p>
974
+
975
+ <div class="table-wrap">
976
+ <table>
977
+ <thead><tr><th>Family</th><th>Types</th></tr></thead>
978
+ <tbody>
979
+ <tr><td class="name">Core</td><td class="desc"><code>text</code>, <code>number</code>, <code>boolean</code>, <code>date</code>, <code>dateString</code>, <code>object</code>, <code>lookup</code></td></tr>
980
+ <tr><td class="name">Temporal</td><td class="desc"><code>datetime</code>, <code>time</code>, <code>duration</code></td></tr>
981
+ <tr><td class="name">Network</td><td class="desc"><code>ipv4</code>, <code>ipv6</code>, <code>cidr</code>, <code>mac</code></td></tr>
982
+ <tr><td class="name">Numeric bases</td><td class="desc"><code>hex</code>, <code>hex8</code>, <code>hex16</code>, <code>hex32</code>, <code>binary</code>, <code>binary8</code>, <code>octal</code></td></tr>
983
+ <tr><td class="name">Units</td><td class="desc"><code>bytes</code>, <code>megabytes</code>, <code>gigabytes</code>, <code>bitrate</code></td></tr>
984
+ <tr><td class="name">Structured</td><td class="desc"><code>json</code>, <code>colour</code>, <code>rating</code>, <code>percent</code></td></tr>
985
+ </tbody>
986
+ </table>
987
+ </div>
988
+
989
+ <h3>Types read from the data</h3>
990
+ <p class="lead-in">
991
+ A column that declares no <code>type</code> takes one from the rows. The first hundred non-empty
992
+ values of that column are sampled, and a type is adopted only if every one of them matches it;
993
+ anything mixed or ambiguous stays <code>text</code> and says so once on the console.
994
+ </p>
995
+ <div class="example">
996
+ <p class="example__label">One key each, and the types arrive</p>
997
+ <pre><code>columns: [
998
+ { field: 'sku' }, <span class="cmt">// text</span>
999
+ { field: 'quantity' }, <span class="cmt">// number — aligned right, numeric filter</span>
1000
+ { field: 'shipped' }, <span class="cmt">// date</span>
1001
+ { field: 'expedited' } <span class="cmt">// boolean — checkbox editor</span>
1002
+ ]</code></pre>
1003
+ </div>
1004
+ <p>Sampling happens once, when rows first arrive. A grid built empty and filled later infers on
1005
+ that first load, so fetching after construction is no reason to declare types you would otherwise
1006
+ leave out. Later loads keep the types already settled on — data that arrives tomorrow cannot
1007
+ change a column's type under a formatter or an editor that was configured around it.</p>
1008
+ <p>Only the built-in names are ever inferred. Candidates are tried in registration order, so every
1009
+ string reaches <code>text</code> and every number reaches <code>number</code> before an extended
1010
+ type is considered. A column of IP addresses or durations is <code>text</code> until you name the
1011
+ type you want.</p>
1012
+ <p>Inference never overrules a decision you made. An explicit <code>type</code>, a
1013
+ <code>preset</code> that carries one, a type in <code>columnDefaults</code>, and a
1014
+ <code>lookup</code> all win; <code>type: false</code> turns sampling off and keeps the column
1015
+ text whatever it holds. Set <code>sampleSize</code> to sample more or fewer than a hundred.</p>
1016
+ <div class="why">
1017
+ <p><strong>What declaring a type buys you, and what it does not.</strong> Sorting is not the
1018
+ reason. The default comparator is value-aware, so an undeclared column of numbers already sorts
1019
+ 2.5 before 10 rather than lexicographically, and an undeclared column of IPv4 addresses already
1020
+ orders correctly across 128.0.0.1.</p>
1021
+ <p>What a type settles is everything around the sort: which editor opens, how typed text is
1022
+ parsed back into a value, how the value is formatted, which filter kind the header offers, how
1023
+ the column is stored, and what lands in Excel and on the clipboard. That is what inference
1024
+ supplies for free on a plain column, and what naming a type explicitly gives you where the data
1025
+ cannot say it — a duration, a byte count, a network address.</p>
1026
+ </div>
1027
+
1028
+ <h3>Dates are stored as strings, deliberately</h3>
1029
+ <p class="lead-in">
1030
+ A <code>date</code> column stores <code>'2024-03-11'</code>, not a <code>Date</code>. This is
1031
+ the single most consequential type decision in the product.
1032
+ </p>
1033
+ <div class="why">
1034
+ <p><strong>The bug it avoids:</strong> <code>new Date('2024-03-11')</code> is midnight
1035
+ <em>UTC</em>. Render that in New York and it is the 10th. A user in London sets a delivery
1036
+ date, a colleague in Mumbai opens the same grid and sees the day before. Storing the wall-clock
1037
+ string means the date a user typed is the date everyone sees, because there is no instant to
1038
+ convert.</p>
1039
+ <p>It is also faster and smaller: ISO 8601 sorts lexicographically in the same order it sorts
1040
+ chronologically, so a date column sorts as text, and repeated dates dictionary-encode well.</p>
1041
+ <p>When you genuinely mean an instant — a log timestamp — use <code>datetime</code> and set
1042
+ <code>format.timeZone</code>.</p>
1043
+ </div>
1044
+
1045
+ <h3>Formats</h3>
1046
+ <div class="example">
1047
+ <p class="example__label">Shorthand and full form</p>
1048
+ <pre><code>format: 'currency:GBP:2' <span class="cmt">// £1,234.50</span>
1049
+ format: 'percent:1' <span class="cmt">// 87.4%</span>
1050
+ format: 'date:dd MMM yyyy' <span class="cmt">// 11 Mar 2024</span>
1051
+
1052
+ format: { <span class="cmt">// when the shorthand runs out</span>
1053
+ type: 'number',
1054
+ style: 'currency', currency: 'GBP', decimals: 2,
1055
+ negative: 'parentheses', <span class="cmt">// (£1,234.50)</span>
1056
+ negativeClass: 'is-loss',
1057
+ nullDisplay: '—',
1058
+ }</code></pre>
1059
+ </div>
1060
+
1061
+ <h3>Lookups</h3>
1062
+ <p class="lead-in">
1063
+ A lookup column stores an id and shows a label. Sorting, filtering, grouping, copying and
1064
+ exporting all use the label, because that is the thing the user is reasoning about — but the
1065
+ data keeps the id.
1066
+ </p>
1067
+ <div class="example">
1068
+ <p class="example__label">A status column</p>
1069
+ <pre><code>{
1070
+ field: 'statusId',
1071
+ title: 'Status',
1072
+ type: 'lookup',
1073
+ lookup: { options: [
1074
+ { id: 1, label: 'Open' },
1075
+ { id: 2, label: 'Pending' },
1076
+ { id: 3, label: 'Closed' },
1077
+ { id: 4, label: 'Escalated', variant: 'danger' },
1078
+ ]},
1079
+ cell: { decoration: 'pill' },
1080
+ }</code></pre>
1081
+ </div>
1082
+ <p class="lead-in">
1083
+ Options may be a function, may return a promise, and may be searched remotely with an
1084
+ <code>AbortSignal</code> so a superseded keystroke cancels its own request.
1085
+ </p>
1086
+
1087
+ <h3>Custom types</h3>
1088
+ <div class="example">
1089
+ <p class="example__label">A 32-bit register column</p>
1090
+ <pre><code>dataTypes: {
1091
+ reg32: LatticeGrid.createRadixType({
1092
+ radix: 2, bitWidth: 32, pad: true, signed: false, group: 8,
1093
+ }),
1094
+ },
1095
+ columns: [{ field: 'flags', type: 'reg32' }],
1096
+
1097
+ <span class="cmt">// 170 → 0b00000000 00000000 00000000 10101010</span></code></pre>
1098
+ </div>
1099
+ <div class="why">
1100
+ <p><strong><code>radix</code> is 2, 8 or 16</strong>, or the names <code>binary</code>,
1101
+ <code>octal</code> and <code>hex</code>. Base 10 is not among them and is not an oversight:
1102
+ a decimal number is what the <code>number</code> type is for, with grouping, decimals,
1103
+ currency and notation that a radix formatter has no concept of. Passing
1104
+ <code>radix: 10</code> names the supported set in the console and falls back to hex rather
1105
+ than producing something that looks like a number column but is not one.</p>
1106
+ </div>
1107
+
1108
+ <h2 id="renderers">Cells and renderers</h2>
1109
+ <p class="lead-in">
1110
+ By default a cell writes text. When you want more, <code>cell</code> takes a decoration, a
1111
+ named renderer, a template or a component.
1112
+ </p>
1113
+
1114
+ <div class="example">
1115
+ <p class="example__label">Decorations — the common cases, without writing a renderer</p>
1116
+ <pre><code>cell: { decoration: 'pill' } <span class="cmt">// a status chip</span>
1117
+ cell: { decoration: 'bar', min: 0, max: 1 } <span class="cmt">// an inline bar</span>
1118
+ cell: { decoration: 'heat', ramp: 'redGreen' } <span class="cmt">// a heat fill</span>
1119
+ cell: { decoration: 'dot' } <span class="cmt">// a leading dot</span></code></pre>
1120
+ </div>
1121
+
1122
+ <div class="example">
1123
+ <p class="example__label">Variants — mapping a value to a semantic colour</p>
1124
+ <pre><code>cell: {
1125
+ decoration: 'pill',
1126
+ variant: { when: [
1127
+ { op: 'eq', value: 'Escalated', use: 'danger' },
1128
+ { op: 'eq', value: 'Pending', use: 'warning' },
1129
+ ], default: 'success' },
1130
+ }</code></pre>
1131
+ </div>
1132
+ <div class="why">
1133
+ <p>Variants are semantic tokens rather than colours — <code>danger</code>, not
1134
+ <code>#c22b2b</code>. The theme decides what danger looks like, and it looks the same in the
1135
+ status pill, the filter chip and the validation message. Changing the palette is one custom
1136
+ property, not a search for hex codes.</p>
1137
+ </div>
1138
+
1139
+ <div class="example">
1140
+ <p class="example__label">Your own renderer</p>
1141
+ <pre><code>components: {
1142
+ sparkline: {
1143
+ render(el, p) { el.appendChild(draw(p.value)); },
1144
+ refresh(el, p) { update(el, p.value); <span class="kw">return</span> true; },
1145
+ release(el) { el.textContent = ''; },
1146
+ },
1147
+ },
1148
+ columns: [{ field: 'history', cell: { render: 'sparkline' } }],</code></pre>
1149
+ </div>
1150
+ <div class="why">
1151
+ <p><code>refresh</code> returning <code>true</code> is the contract that makes recycling work:
1152
+ it means "I updated in place, keep this element". Return <code>false</code> and the grid
1153
+ rebuilds the cell. A renderer that only implements <code>render</code> still works — it is
1154
+ just rebuilt on every reuse.</p>
1155
+ </div>
1156
+
1157
+ <div class="example">
1158
+ <p class="example__label">Templates, and what <code>allowUnsafeTemplates</code> permits</p>
1159
+ <pre><code>cell: { template: '&lt;span class="sku"&gt;{{ value }}&lt;/span&gt;' } <span class="cmt">// escaped</span>
1160
+
1161
+ <span class="cmt">// Raw interpolation needs the grid-level opt-in:</span>
1162
+ allowUnsafeTemplates: true,
1163
+ cell: { template: '&lt;span&gt;{{{ value }}}&lt;/span&gt;' }</code></pre>
1164
+ </div>
1165
+ <div class="why">
1166
+ <p><strong>The flag permits markup, not code.</strong> Without it, <code>{{ }}</code> escapes
1167
+ and a <code>{{{ }}}</code> segment is refused outright. With it, an interpolated value may
1168
+ carry presentational markup — <code>&lt;b&gt;</code>, <code>&lt;a href&gt;</code>, a
1169
+ <code>&lt;span&gt;</code> — and everything executable is still stripped from it:
1170
+ <code>&lt;script&gt;</code>, <code>&lt;iframe&gt;</code>, <code>&lt;style&gt;</code> and the
1171
+ other code-bearing tags, every <code>on*</code> handler attribute, and
1172
+ <code>javascript:</code> or <code>data:</code> URLs including entity-encoded spellings of
1173
+ them. The same rules apply to a string returned from <code>cell.render</code>, which is the
1174
+ same gate.</p>
1175
+ <p><strong>Why the value is treated differently from the template.</strong> You wrote the
1176
+ template and can audit it; the value is row data and usually arrives from somewhere you
1177
+ cannot. A template is refused at compile time for a dangerous tag, but that refusal says
1178
+ nothing about what a value interpolated into it might contain.</p>
1179
+ <p><strong>It is a narrow allowance, not a sanitiser.</strong> It exists so a grid cell can
1180
+ show emphasis and a link. To render arbitrary third-party HTML, sanitise it yourself and
1181
+ return an element from <code>cell.render</code>.</p>
1182
+ </div>
1183
+
1184
+ <h2 id="styling">Styling</h2>
1185
+ <p class="lead-in">
1186
+ Cells, columns and rows can all carry classes and inline styles, static or computed.
1187
+ </p>
1188
+ <div class="example">
1189
+ <p class="example__label">By scope</p>
1190
+ <pre><code><span class="cmt">// Cells and columns — declared on the column</span>
1191
+ { field: 'margin', cell: {
1192
+ class: 'tabular',
1193
+ classWhen: { 'is-loss': (p) =&gt; p.value &lt; 0 },
1194
+ style: (p) =&gt; ({ fontWeight: p.value &gt; 1e6 ? 650 : 400 }),
1195
+ }}
1196
+
1197
+ <span class="cmt">// Rows — on the grid</span>
1198
+ rowClass: (p) =&gt; p.data.slaBreached ? 'row-breach' : null,
1199
+ rowStyle: (p) =&gt; p.data.region === 'AMER' ? { borderLeft: '3px solid #7c3aed' } : null,</code></pre>
1200
+ </div>
1201
+ <div class="why">
1202
+ <p>All of these are re-evaluated on every repaint and remove what they added last time first.
1203
+ That is not caution. Rows and cells come from pools, so an element that carried a class for
1204
+ one row will later carry a different row — a class written once and left alone smears down
1205
+ the grid as the user scrolls.</p>
1206
+ </div>
1207
+
1208
+ <h3 id="theming">Theming</h3>
1209
+ <p class="lead-in">
1210
+ The stylesheet is custom properties throughout. Override the tokens, not the rules.
1211
+ </p>
1212
+ <div class="example">
1213
+ <p class="example__label">A house palette</p>
1214
+ <pre><code>.lattice {
1215
+ --lattice-accent: #7c3aed;
1216
+ --lattice-font-size: 13px;
1217
+ --lattice-space: 8px;
1218
+ --lattice-border-color: #e6e8eb;
1219
+ }</code></pre>
1220
+ </div>
1221
+ <p class="lead-in">
1222
+ Four themes ship. With no <code>theme</code> set, the grid follows the viewer's
1223
+ <code>prefers-color-scheme</code> between light and dark; naming one pins it.
1224
+ Density is separate — <code>compact</code>, <code>standard</code>, <code>comfortable</code>
1225
+ or <code>spacious</code> — and combines with any of them.
1226
+ </p>
1227
+ <div class="example">
1228
+ <p class="example__label">Pinning a theme, at build time or at runtime</p>
1229
+ <pre><code>createGrid(el, { theme: 'high-contrast' });
1230
+ grid.set('theme', 'terminal');
1231
+ grid.set('theme', <span class="kw">null</span>); <span class="cmt">// back to following the viewer</span></code></pre>
1232
+ </div>
1233
+ <div class="table-wrap">
1234
+ <table>
1235
+ <thead><tr><th>Theme</th><th>What it is for</th></tr></thead>
1236
+ <tbody>
1237
+ <tr><td class="name"><code>light</code></td><td class="desc">The default. Follows <code>prefers-color-scheme</code> when <code>theme</code> is unset.</td></tr>
1238
+ <tr><td class="name"><code>dark</code></td><td class="desc">The same palette inverted, with the accent and status hues re-picked for a dark ground rather than reused.</td></tr>
1239
+ <tr><td class="name"><code>high-contrast</code></td><td class="desc">Not "dark with more contrast". Text is 21:1 and borders 6.1:1 against the background, where the other themes sit near 1.3:1 on borders — WCAG 1.4.11 asks for 3:1 on the boundaries a user has to find. Cell borders are drawn rather than implied, selected rows carry an outline as well as a fill, and every status pill has a solid border so it does not depend on hue alone.</td></tr>
1240
+ <tr><td class="name"><code>terminal</code></td><td class="desc">A phosphor console: one hue on near-black, monospaced throughout. Status is carried by brightness rather than colour, so the palette stays a palette.</td></tr>
1241
+ </tbody>
1242
+ </table>
1243
+ </div>
1244
+ <h3>Forced colours</h3>
1245
+ <p class="lead-in">
1246
+ Windows High Contrast Mode replaces the palette outright &mdash; that is the point of it, and
1247
+ no stylesheet should fight it. What the grid does instead is translate every piece of meaning
1248
+ it normally carries in a background tint into something the mode preserves.
1249
+ </p>
1250
+ <p>A selected row takes the system's own selection colours. A pinned region loses its shadow,
1251
+ which forced colours do not render, and gains a rule in its place. Status pills, fill
1252
+ decorations, progress tracks and histogram bars each gain a border, because a fill with no
1253
+ edge is invisible once its colour is discarded. Diff states stop depending on hue altogether:
1254
+ added, removed and changed are told apart by border style &mdash; solid, dashed and doubled
1255
+ &mdash; since the mode offers no way to keep four distinct colours.</p>
1256
+ <p>Two things deliberately keep their colour, declared with
1257
+ <code>forced-color-adjust</code>: a colour swatch, where the colour <em>is</em> the value being
1258
+ shown, and a collaborator's presence colour, which is how one person is told from another.
1259
+ Replacing those would destroy the meaning rather than translate it. Both gain a border so they
1260
+ stay visible against either ground.</p>
1261
+
1262
+ <p class="lead-in">
1263
+ Every theme is the same token set with different values, so an override you write against
1264
+ <code>.lattice</code> applies to all of them, and one written against
1265
+ <code>.lattice[data-theme="terminal"]</code> applies to that one. The attribute is on the
1266
+ grid's own root element, not on <code>&lt;html&gt;</code>.
1267
+ </p>
1268
+
1269
+ <h3>The grid and your page's CSS</h3>
1270
+ <p class="lead-in">
1271
+ Every selector in the stylesheet is namespaced under <code>.lattice</code>, so the grid cannot
1272
+ restyle your page. From 1.4.0 the reverse is also true: the grid gives the elements it builds
1273
+ a floor for the properties a page is most likely to set on a bare tag — margin, padding,
1274
+ border, radius, background, shadow, text transform and letter spacing, plus type and colour on
1275
+ form controls, which inherit neither.
1276
+ </p>
1277
+ <div class="why">
1278
+ <p><strong>Why this is needed at all.</strong> A grid is mounted inside somebody else's
1279
+ stylesheet. A rule as ordinary as <code>section { padding: 5.5rem 0 }</code> — a marketing
1280
+ page, a CMS theme, a Tailwind preflight — used to reach inside and put 88px of padding on every
1281
+ filter row in the tool panel, because the grid builds those rows as <code>&lt;section&gt;</code>
1282
+ elements and its own rules said nothing about padding there.</p>
1283
+ <p>The reset uses no <code>!important</code>. It is specificity (0,1,1) and every rule that
1284
+ dresses a grid element is (0,2,0) or higher, so the grid's own styling always wins and the
1285
+ reset only fills a gap. Yours wins too, on the same terms: a rule aimed at a Lattice class —
1286
+ <code>.lattice .lat-cell { … }</code> — outranks it, so overriding the grid deliberately works
1287
+ exactly as before. Only bare-tag rules are shut out.</p>
1288
+ <p>It touches box model and decoration only. Nothing in it sets <code>display</code>,
1289
+ <code>position</code> or any dimension: those belong to the renderer, and a reset that reached
1290
+ them would break virtualisation rather than protect it.</p>
1291
+ </div>
1292
+ <h2 id="loading">Loading and updating data</h2>
1293
+ <p class="lead-in">
1294
+ Data usually arrives after the grid does. Build it empty, then load — the sort, filters,
1295
+ grouping and column layout you set up in the meantime all survive and apply to the new data.
1296
+ </p>
1297
+ <div class="example">
1298
+ <p class="example__label">The ordinary sequence</p>
1299
+ <pre><code><span class="kw">const</span> grid = createGrid(el, { columns, rowKey: 'id', rows: [] });
1300
+ grid.overlay.show('loading');
1301
+
1302
+ <span class="kw">const</span> data = <span class="kw">await</span> fetch('/api/circuits').then(r =&gt; r.json());
1303
+ grid.rows.load(data);
1304
+ grid.overlay.hide();</code></pre>
1305
+ </div>
1306
+
1307
+ <h3>Incremental changes</h3>
1308
+ <p class="lead-in">
1309
+ <code>rows.load</code> replaces everything. When you have a delta — a websocket message, a
1310
+ save that returned the updated record — apply just that.
1311
+ </p>
1312
+ <div class="example">
1313
+ <p class="example__label">Adds, updates and removals in one call</p>
1314
+ <pre><code>grid.rows.apply({
1315
+ add: [{ id: 4, name: 'd' }, { id: 5, name: 'e' }],
1316
+ update: [{ id: 1, name: 'A' }],
1317
+ remove: ['3'], <span class="cmt">// row keys, or the row objects</span>
1318
+ at: 0, <span class="cmt">// optional insert position for `add`</span>
1319
+ });
1320
+ <span class="cmt">// → { added: [...], updated: [...], removed: [...] }</span></code></pre>
1321
+ </div>
1322
+ <div class="why">
1323
+ <p><strong>An update is a patch.</strong> Fields absent from it are untouched, so a delta
1324
+ arriving from a websocket or coming back from a save can be applied as-is without reading the
1325
+ row first. This has to be said explicitly because the opposite &mdash; assigning the patch
1326
+ over the row &mdash; looks identical for a caller who happens to send whole rows and silently
1327
+ destroys data for one who does not.</p>
1328
+ <p><strong>Coalescing merges fields rather than keeping the last message.</strong> A feed
1329
+ sending <code>{price}</code> and <code>{volume}</code> as separate messages inside one window
1330
+ keeps both. Coalescing may reorder work; it may not lose it.</p>
1331
+ <p><strong>Flushing happens on a frame, with a timer behind it.</strong> A queued batch lands
1332
+ on a paint boundary, which is what makes "ten thousand updates, one repaint" true rather than
1333
+ usually true &mdash; a timer can fire twice between two paints. But
1334
+ <code>requestAnimationFrame</code> is not guaranteed to fire at all: a backgrounded tab stops
1335
+ firing it entirely. So a frame and a timer are armed together and the first to arrive wins. In
1336
+ a foreground tab the frame always wins, at ~16ms against a 50ms fallback; in a hidden tab the
1337
+ timer keeps the feed applying instead of the grid silently stalling with every caller's
1338
+ promise unresolved.</p>
1339
+ <p><strong>A long flush defers rather than blocks.</strong> <code>updates.budgetMs</code> caps
1340
+ how long one flush spends applying; over budget, the remainder returns to the queue and lands
1341
+ next frame, and the promise a caller is holding resolves when their rows actually land rather
1342
+ than when the first slice does. Slicing is by row and only for updates &mdash; a partially
1343
+ applied row is not a state the store should be in, and splitting a structural change would
1344
+ re-run the pipeline twice for one batch. <code>stats().deferrals</code> rising steadily means
1345
+ the feed is arriving faster than the grid can apply it.</p>
1346
+ <p><strong>Rejections are reported, never thrown.</strong> Throwing would abandon the rows
1347
+ that were fine. An update or remove naming a row that is not here is <code>unknown-id</code>;
1348
+ an add whose key already exists is <code>duplicate-id</code> and is refused, because
1349
+ selection, expansion, comments and the key index all resolve one key to one row and admitting
1350
+ a second corrupts every one of them at once.</p>
1351
+ <p>This runs the minimum pipeline. An update touching no sorted, filtered or grouped column
1352
+ skips those stages entirely and only the totals and the affected cells refresh. Adds and
1353
+ removals are structural and re-run everything.</p>
1354
+ <p>Removals tombstone in place rather than compacting, so every existing index stays valid —
1355
+ which is what lets selection, expansion state and cached permutations survive a delete.</p>
1356
+ </div>
1357
+
1358
+ <h3>Patching a single cell</h3>
1359
+ <p class="lead-in">
1360
+ When you have a value rather than a row, <code>setCells</code> patches fields in place.
1361
+ </p>
1362
+ <div class="example">
1363
+ <p class="example__label">One cell, or many, without row objects</p>
1364
+ <pre><code>grid.edit.setCells([{ key: 'CIR-100042', colId: 'capacity', value: 990 }]);
1365
+
1366
+ grid.edit.setCells([
1367
+ { key: 'CIR-100042', colId: 'notes', value: 'Chased' },
1368
+ { key: 'CIR-100043', colId: 'capacity', value: 770 },
1369
+ ]);
1370
+ <span class="cmt">// → the number of cells written</span></code></pre>
1371
+ </div>
1372
+ <p class="lead-in">
1373
+ This is the full path, not a shortcut: it validates, emits <code>cell:changed</code>,
1374
+ re-sorts if the column is sorted on, records one undo entry, and returns <code>0</code> for a
1375
+ column the user may not write.
1376
+ </p>
1377
+
1378
+ <h3>High-frequency updates</h3>
1379
+ <p class="lead-in">
1380
+ For a ticking feed, <code>queue</code> batches changes to the next animation frame so a
1381
+ thousand messages a second produce sixty repaints rather than a thousand.
1382
+ </p>
1383
+ <div class="example">
1384
+ <p class="example__label">A price feed</p>
1385
+ <pre><code>socket.on('tick', (row) =&gt; grid.rows.queue({ update: [row] }));</code></pre>
1386
+ </div>
1387
+
1388
+ <h3>Walking the data rather than the view</h3>
1389
+ <p class="lead-in">
1390
+ <code>rows.forEach</code> walks what is on screen: filtered, sorted, grouped, with collapsed
1391
+ rows left out. That is the right default, and the wrong answer for a caller totalling a column,
1392
+ exporting, or reconciling against another system.
1393
+ </p>
1394
+ <div class="example">
1395
+ <pre><code>let total = 0;
1396
+ grid.rows.forEach(r =&gt; { total += r.data.amount }); <span class="cmt">// what the user can see</span>
1397
+ grid.rows.forEachAll(r =&gt; { total += r.data.amount }); <span class="cmt">// what the grid holds</span></code></pre>
1398
+ </div>
1399
+ <p><code>forEachAll</code> visits leaf rows only, in the order they arrived. Group rows are a
1400
+ product of the current grouping and do not exist in the data, so they are not offered; the sort
1401
+ belongs to the filtered view, so the order here is physical rather than sorted.</p>
1402
+ <div class="why">
1403
+ <p>A remote or paged source holds the page it has fetched, not the whole set, so there is
1404
+ nothing there to walk past the filters. It warns and walks what it has rather than quietly
1405
+ returning the filtered rows &mdash; a caller who asked for everything and silently received a
1406
+ subset gets a number that looks entirely plausible and is wrong.</p>
1407
+ </div>
1408
+
1409
+ <h2 id="sorting">Sorting</h2>
1410
+ <div class="example">
1411
+ <p class="example__label">Setting and reading</p>
1412
+ <pre><code>grid.sort.set([{ col: 'capacity', dir: 'desc' }]);
1413
+
1414
+ <span class="cmt">// A multi-column sort is one call, in priority order.</span>
1415
+ grid.sort.set([
1416
+ { col: 'region', dir: 'asc' },
1417
+ { col: 'capacity', dir: 'desc' },
1418
+ ]);
1419
+
1420
+ grid.sort.get(); <span class="cmt">// [{ col, dir }, …]</span>
1421
+ grid.sort.clear();</code></pre>
1422
+ </div>
1423
+ <p class="lead-in">
1424
+ Clicking a header cycles ascending, descending, none; shift-clicking a second header adds to
1425
+ the sort rather than replacing it. A column can supply its own <code>compare</code>, and a
1426
+ type already has one — dates compare chronologically, IP addresses numerically rather than as
1427
+ strings, durations by length.
1428
+ </p>
1429
+
1430
+ <h2 id="filtering">Filtering</h2>
1431
+ <p class="lead-in">
1432
+ There are two filters and they are separate on purpose. The <strong>quick filter</strong> is
1433
+ one string matched across every readable column. The <strong>filter set</strong> is a
1434
+ structured condition tree.
1435
+ </p>
1436
+
1437
+ <div class="example">
1438
+ <p class="example__label">Quick filter</p>
1439
+ <pre><code>grid.filters.quick('singapore');
1440
+ grid.filters.quick(''); <span class="cmt">// clear</span></code></pre>
1441
+ </div>
1442
+
1443
+ <div class="example">
1444
+ <p class="example__label">A condition tree</p>
1445
+ <pre><code>grid.filters.set({
1446
+ op: 'and',
1447
+ conditions: [
1448
+ { col: 'region', op: 'in', value: ['EMEA', 'APAC'] },
1449
+ { col: 'utilisation', op: 'gt', value: 0.9 },
1450
+ { op: 'or', conditions: [
1451
+ { col: 'slaBreached', op: 'eq', value: true },
1452
+ { col: 'margin', op: 'lt', value: 0 },
1453
+ ]},
1454
+ ],
1455
+ });</code></pre>
1456
+ </div>
1457
+ <div class="why">
1458
+ <p>That structure is a published wire protocol, not an internal detail. It is what
1459
+ <code>grid.state.get()</code> serialises, what a saved view carries, and what you can send to
1460
+ a server to evaluate the same filter against the full dataset. A remote source hands it
1461
+ straight to your backend.</p>
1462
+ </div>
1463
+
1464
+ <p class="lead-in">
1465
+ Operators are per family: text has <code>contains</code>, <code>startsWith</code>,
1466
+ <code>matches</code>; numbers and dates have <code>between</code>, <code>gt</code>,
1467
+ <code>lte</code>; multi-value columns have <code>containsAny</code>,
1468
+ <code>containsAll</code>, <code>containsNone</code>. <code>blank</code> and
1469
+ <code>notBlank</code> work everywhere.
1470
+ </p>
1471
+
1472
+ <h2 id="grouping">Grouping, totals and pivot</h2>
1473
+ <div class="example">
1474
+ <p class="example__label">Group by one or more columns</p>
1475
+ <pre><code>grid.columns.group(['region', 'country']);
1476
+ grid.rows.expandAll();
1477
+ grid.rows.collapse('EMEA');</code></pre>
1478
+ </div>
1479
+
1480
+ <div class="example">
1481
+ <p class="example__label">Totals</p>
1482
+ <pre><code>{ field: 'capacity', type: 'number', total: 'sum' }
1483
+ { field: 'margin', type: 'number', total: 'avg' }
1484
+ { field: 'lastSeen', type: 'datetime', total: 'max' }
1485
+
1486
+ { field: 'weighted', total: (values, rows) =&gt; weightedMean(values, rows) }</code></pre>
1487
+ </div>
1488
+ <p class="lead-in">
1489
+ Totals appear on every group row and on the grand total. <code>grandTotalRow: 'bottom'</code>
1490
+ pins the grand total below the rows instead of leaving it inline.
1491
+ </p>
1492
+
1493
+ <p class="lead-in">
1494
+ On a memory source the grand total is <strong>maintained incrementally</strong>: a cell
1495
+ update moves the running value by the difference rather than re-reducing the column, so a
1496
+ totals row costs the same on a million rows as on a thousand. This applies to
1497
+ <code>sum</code>, <code>avg</code>, <code>countValues</code>, <code>min</code> and
1498
+ <code>max</code> on numeric columns. Everything else re-reduces, and so does the
1499
+ incremental path itself whenever it cannot reach the right answer:
1500
+ </p>
1501
+ <div class="table-wrap">
1502
+ <table>
1503
+ <thead><tr><th>Case</th><th>What happens</th></tr></thead>
1504
+ <tbody>
1505
+ <tr><td class="name">sum, avg, countValues</td><td class="desc">Maintained by difference, with a compensated running sum so a long session does not accumulate floating-point error.</td></tr>
1506
+ <tr><td class="name">min, max</td><td class="desc">Maintained while values move <em>past</em> the extreme. A value moving off the current extreme re-reduces, because a running extreme cannot know what the next one is.</td></tr>
1507
+ <tr><td class="name">count</td><td class="desc">The number of rows in scope, already a single read.</td></tr>
1508
+ <tr><td class="name">A custom <code>total</code> function</td><td class="desc">Re-reduced on every change. A reduction supplied as a function has no inverse, so there is nothing to apply a difference to.</td></tr>
1509
+ <tr><td class="name">Adding or removing rows</td><td class="desc">Re-reduced once, then incremental again.</td></tr>
1510
+ <tr><td class="name">Filtering, sorting or grouping</td><td class="desc">Re-reduced once, because the rows contributing to the total have changed.</td></tr>
1511
+ <tr><td class="name">Totals above ~1e15</td><td class="desc">Re-reduced. Past that magnitude a small change no longer moves a 64-bit float, and a running total would silently stop tracking the data.</td></tr>
1512
+ <tr><td class="name">Group totals</td><td class="desc">Re-reduced per group on every change. Only the grand total is incremental.</td></tr>
1513
+ </tbody>
1514
+ </table>
1515
+ </div>
1516
+ <p class="lead-in">
1517
+ Nothing has to be configured for this, and the reported number is the same either way —
1518
+ where a running value cannot be trusted, the column falls back to a full pass rather than
1519
+ reporting a value it is unsure of.
1520
+ </p>
1521
+
1522
+ <h3 id="show-total-in-header">showTotalInHeader</h3>
1523
+ <p class="lead-in">
1524
+ Under grouping or pivot, a totalled column's cells hold an aggregate rather than a row's own
1525
+ value. On by default, the heading says which — a small <code>SUM</code> line above
1526
+ <code>Capacity</code>, <code>AVERAGE</code> above <code>Margin</code>. The heading returns to
1527
+ the column's own title when grouping and pivot are both off.
1528
+ </p>
1529
+ <div class="example">
1530
+ <p class="example__label">Leave the headings alone</p>
1531
+ <pre><code>showTotalInHeader: <span class="kw">false</span></code></pre>
1532
+ </div>
1533
+ <p class="lead-in">
1534
+ The reduction goes on its own line rather than reading <code>Sum of Capacity</code> across
1535
+ one. A header cell reserves width for its sort, filter and menu buttons whether or not they
1536
+ are showing, so the label gets well under half the column — on a default column, 54px of
1537
+ 129px. One line truncated to <code>Sum o…</code>, trading the column's identity for its
1538
+ reduction. Stacked, it costs no width at all.
1539
+ </p>
1540
+ <p class="lead-in">
1541
+ Such a heading carries <code>data-total</code> on its header cell, naming the reduction, and
1542
+ its two lines are <code>.lat-header-total-fn</code> and
1543
+ <code>.lat-header-total-name</code>. <code>--lattice-header-total-size</code> and
1544
+ <code>--lattice-header-total-color</code> set the reduction line's size and colour; the full
1545
+ phrase is on the label's <code>title</code> for the pointer. Where a pivot has a single value
1546
+ column its leaf is titled with the pivot value rather than the column's name, and that
1547
+ heading is left alone: it is naming the category, not the measure.
1548
+ </p>
1549
+
1550
+ <h3 id="pivot-group-totals">A total beside the pivoted columns</h3>
1551
+ <div class="example">
1552
+ <p class="example__label">The grand total across every pivot value</p>
1553
+ <pre><code>pivot: { groupTotals: 'after' } <span class="cmt">// or 'before'; omitted, none</span>
1554
+ pivot: { groupTotals: 'after', totalsLabel: 'All regions' }</code></pre>
1555
+ </div>
1556
+ <div class="why">
1557
+ <p><strong>It answers the question the pivot took apart.</strong> Pivoting by Country turns
1558
+ one Sales column into one per country, and the number a reader most often wants next is the
1559
+ one that was there before: sales across all of them. <code>'before'</code> puts that group at
1560
+ the near edge, beside the row headings; <code>'after'</code> puts it at the far edge, which is
1561
+ where a spreadsheet puts a grand total.</p>
1562
+ <p><strong>It costs a column, not a pass.</strong> A pivoted group row still carries its
1563
+ reduction over every one of its leaves — which is exactly the total across all pivot values —
1564
+ so these columns read a number that has already been computed. They also count towards
1565
+ <code>maxColumns</code>, since they are columns like any other.</p>
1566
+ <p><strong>Opt in.</strong> Omitted, a pivot has the columns it has always had, so the option
1567
+ cannot quietly change what an existing grid exports or what a saved view restores.</p>
1568
+ </div>
1569
+
1570
+ <h3 id="total-filtered-only">totalFilteredOnly</h3>
1571
+ <div class="example">
1572
+ <p class="example__label">Total the dataset rather than the view</p>
1573
+ <pre><code>totalFilteredOnly: <span class="kw">false</span></code></pre>
1574
+ </div>
1575
+ <p class="lead-in">
1576
+ By default a total describes what is on screen: filter the grid and every total moves with
1577
+ it. Setting this to <code>false</code> makes the filter a lens instead — totals report the
1578
+ whole dataset no matter what is filtered out. Both the grand total and each group total
1579
+ follow the setting, so a group row shows the total for every row belonging to that group,
1580
+ not only the ones currently visible.
1581
+ </p>
1582
+ <p class="lead-in">
1583
+ A group whose every row the filter removed has no row to appear on, but its rows still count
1584
+ toward the totals above it. Editing a hidden row moves the totals, because it is part of the
1585
+ dataset they describe — under the default it does not, because it is not part of the view
1586
+ they describe.
1587
+ </p>
1588
+ <p class="lead-in">
1589
+ The count shown on the grand total row follows the total, so it never reports fewer rows
1590
+ than the total covers. Group row counts stay as the number of rows a user can expand to
1591
+ see, which is what that number is for.
1592
+ </p>
1593
+ <p class="lead-in">
1594
+ The unfiltered row set and its grouping are computed once and reused, so the cost lands when
1595
+ rows are added or removed rather than on every edit or filter change. Reducing over the full
1596
+ dataset is more work than reducing over a filtered subset: on 500,000 rows grouped and
1597
+ half-filtered, a single-cell update measured 2.1ms by default and 3.2ms with this off.
1598
+ </p>
1599
+
1600
+ <h3 id="total-only-changed">totalOnlyChangedColumns</h3>
1601
+ <div class="example">
1602
+ <p class="example__label">Skip the columns an edit did not touch</p>
1603
+ <pre><code>totalOnlyChangedColumns: <span class="kw">true</span></code></pre>
1604
+ </div>
1605
+ <p class="lead-in">
1606
+ Group totals re-reduce every totalled column on every change, including columns the change
1607
+ did not touch. Switching this on reduces only the columns whose values actually moved, and
1608
+ an update that rewrites a field with the value it already held reduces nothing at all —
1609
+ which is what a feed resending unchanged fields looks like.
1610
+ </p>
1611
+ <p class="lead-in">
1612
+ On a million rows in seven groups, a single-cell update with four totalled columns:
1613
+ </p>
1614
+ <div class="table-wrap">
1615
+ <table>
1616
+ <thead><tr><th>The update changes</th><th>Off</th><th>On</th></tr></thead>
1617
+ <tbody>
1618
+ <tr><td class="name">One of the four columns</td><td class="desc">15.4ms</td><td class="desc">7.3ms</td></tr>
1619
+ <tr><td class="name">All four columns</td><td class="desc">15.0ms</td><td class="desc">14.5ms</td></tr>
1620
+ <tr><td class="name">Nothing — same values rewritten</td><td class="desc">15.6ms</td><td class="desc">7.3ms</td></tr>
1621
+ </tbody>
1622
+ </table>
1623
+ </div>
1624
+ <p class="lead-in">
1625
+ The saving is proportional to the totalled columns an update leaves alone, so there is
1626
+ nothing to gain when every totalled column changes on every update.
1627
+ </p>
1628
+ <p class="lead-in">
1629
+ <strong>It is off by default because it is an assertion, not just an optimisation.</strong>
1630
+ Skipping a column assumes its total depends on nothing but that column's own values. That
1631
+ is true of every built-in reduction. It need not be true of a <code>total</code> supplied as
1632
+ a function, which also receives the row, the grid and <code>config.context</code> — such a
1633
+ total is only recomputed when its own column changes, so a function that reads application
1634
+ state outside the column will report the value from the last time that column moved. Leave
1635
+ the option off if any of your total functions work that way.
1636
+ </p>
1637
+ <p class="lead-in">
1638
+ Anything that changes which rows a total covers — adding or removing rows, filtering,
1639
+ sorting, grouping, or changing which columns are totalled — reduces everything again
1640
+ regardless of the option.
1641
+ </p>
1642
+
1643
+ <div class="example">
1644
+ <p class="example__label">Pivot</p>
1645
+ <pre><code>grid.columns.group(['region']);
1646
+ grid.columns.pivot(['statusId']); <span class="cmt">// a column per distinct status</span></code></pre>
1647
+ </div>
1648
+
1649
+ <h2 id="sources">Working with large data</h2>
1650
+ <p class="lead-in">
1651
+ A million rows in memory is fine — that is what the columnar store is for. Beyond that, or
1652
+ when the data lives behind an API, a source takes over.
1653
+ </p>
1654
+ <div class="table-wrap">
1655
+ <table>
1656
+ <thead><tr><th>Source</th><th>For</th></tr></thead>
1657
+ <tbody>
1658
+ <tr><td class="name">memory</td><td class="desc">The default. Everything is present; the grid does all the work.</td></tr>
1659
+ <tr><td class="name">paged</td><td class="desc">A page at a time from a server that paginates.</td></tr>
1660
+ <tr><td class="name">remote</td><td class="desc">Blocks fetched on demand as the user scrolls, with sort and filter pushed to the server.</td></tr>
1661
+ <tr><td class="name">stream</td><td class="desc">Rows arriving over time — a query that streams, a socket. Promotes to memory once complete.</td></tr>
1662
+ </tbody>
1663
+ </table>
1664
+ </div>
1665
+ <div class="example">
1666
+ <p class="example__label">A remote source</p>
1667
+ <pre><code>source: {
1668
+ mode: 'remote',
1669
+ pageSize: 100,
1670
+ <span class="kw">async</span> fetch(req) {
1671
+ <span class="kw">const</span> res = <span class="kw">await</span> api.rows({
1672
+ offset: req.range.start,
1673
+ limit: req.range.end - req.range.start,
1674
+ sort: req.sort, <span class="cmt">// [{ col, dir }, …]</span>
1675
+ filters: req.filters, <span class="cmt">// the condition tree, as documented above</span>
1676
+ quick: req.quick,
1677
+ }, { signal: req.signal }); <span class="cmt">// a superseded request aborts itself</span>
1678
+
1679
+ <span class="kw">return</span> { rows: res.rows, total: res.total };
1680
+ },
1681
+ }</code></pre>
1682
+ </div>
1683
+ <div class="why">
1684
+ <p>The request also carries <code>groupBy</code>, <code>groupPath</code>,
1685
+ <code>pivotBy</code>, <code>totals</code> and your own <code>context</code>, so a server that
1686
+ can group and aggregate does that work instead of the browser. Blocks are fetched as the
1687
+ viewport reaches them and cached; changing the sort or the filter invalidates the cache and
1688
+ re-queries.</p>
1689
+ <p>The <code>filters</code> your callback receives is the same condition tree documented
1690
+ above. You are not handed an opaque object to reverse-engineer — it is the published format,
1691
+ and the same shape you would have written by hand.</p>
1692
+ </div>
1693
+
1694
+ <h2 id="editing">Editing</h2>
1695
+ <div class="example">
1696
+ <p class="example__label">Turning it on</p>
1697
+ <pre><code>edit: { enabled: true }, <span class="cmt">// double-click, the default</span>
1698
+ edit: { enabled: true, start: 'single' },<span class="cmt">// single click</span>
1699
+ editBar: true, <span class="cmt">// a spreadsheet-style input above the grid</span>
1700
+
1701
+ columns: [
1702
+ { field: 'notes', edit: true },
1703
+ { field: 'statusId', edit: { editor: 'select' } },
1704
+ { field: 'quality', edit: { editor: 'rating', props: { max: 5, allowHalf: true } } },
1705
+ { field: 'circuitId', edit: false },
1706
+ ]</code></pre>
1707
+ </div>
1708
+ <p class="lead-in">
1709
+ The editor is chosen from the type unless you name one. Enter commits and steps down, Tab
1710
+ commits and steps across, Escape cancels. A validator can refuse a value:
1711
+ </p>
1712
+ <div class="example">
1713
+ <p class="example__label">Validation</p>
1714
+ <pre><code>{ field: 'capacity', edit: {
1715
+ validate: (p) =&gt; p.value &gt; 0 || 'Capacity must be positive',
1716
+ }}</code></pre>
1717
+ </div>
1718
+
1719
+ <h2 id="optimistic">Optimistic writes and rollback</h2>
1720
+ <p class="lead-in">
1721
+ The grid has always written optimistically without calling it that: an edit lands in the
1722
+ model and is painted before anything else happens. What <code>edit.commit</code> adds is
1723
+ <em>durability</em> — whether the write reached your server, and what to put back when it
1724
+ did not.
1725
+ </p>
1726
+ <div class="why">
1727
+ <p><strong>Nothing changes unless you ask for it.</strong> With no <code>commit</code> hook
1728
+ the grid behaves exactly as before: the value is written, history is recorded,
1729
+ <code>cell:changed</code> fires, and there is no pending state to think about. Subscribe to
1730
+ <code>cell:changed</code>, fire your request and ignore the result — that keeps working and
1731
+ costs nothing.</p>
1732
+ </div>
1733
+ <div class="example">
1734
+ <p class="example__label">The usual case — the promise is the answer</p>
1735
+ <pre><code>edit: {
1736
+ enabled: true,
1737
+ commit: async ({ key, colId, value }) =&gt; {
1738
+ const res = await fetch(`/api/rows/${key}`, {
1739
+ method: 'PATCH',
1740
+ body: JSON.stringify({ [colId]: value }),
1741
+ });
1742
+ if (!res.ok) throw new Error(await res.text()); <span class="cmt">// throw → rolled back</span>
1743
+ },
1744
+ }</code></pre>
1745
+ </div>
1746
+ <p class="lead-in">
1747
+ Resolving confirms the write; throwing rolls it back and fires <code>cell:reverted</code>
1748
+ with your error message as <code>reason</code>. A synchronous hook works too — returning
1749
+ normally confirms, throwing reverts.
1750
+ </p>
1751
+ <div class="example">
1752
+ <p class="example__label">When the answer arrives elsewhere</p>
1753
+ <pre><code>edit: {
1754
+ enabled: true,
1755
+ confirm: 'manual', <span class="cmt">// the return value is ignored</span>
1756
+ commit: ({ id, key, colId, value }) =&gt; {
1757
+ socket.send(JSON.stringify({ id, key, colId, value }));
1758
+ },
1759
+ }
1760
+
1761
+ socket.onmessage = (m) =&gt; {
1762
+ const { id, ok, reason } = JSON.parse(m.data);
1763
+ grid.edit.settle(id, ok, reason);
1764
+ };</code></pre>
1765
+ </div>
1766
+ <div class="why">
1767
+ <p><strong>The mode is declared, never guessed.</strong> A websocket or event-sourced backend
1768
+ acknowledges on a different channel from the one the write went out on, so there is no
1769
+ promise to resolve. <code>confirm: 'manual'</code> says so explicitly. The grid does not infer
1770
+ it from what <code>commit</code> returns, because then a synchronous hook that happens to
1771
+ return nothing would leave every cell pending for ever with nothing in your code that looks
1772
+ wrong. If a write does stay pending, you get a console warning naming the cell — tune the
1773
+ threshold with <code>pendingTimeout</code>.</p>
1774
+ </div>
1775
+
1776
+ <h3>The states a write moves through</h3>
1777
+ <div class="table-wrap">
1778
+ <table>
1779
+ <thead><tr><th>State</th><th>Means</th><th>Event</th></tr></thead>
1780
+ <tbody>
1781
+ <tr><td class="sig">pending</td><td class="desc">Applied and painted, not yet acknowledged.</td><td class="desc"><code>cell:pending</code></td></tr>
1782
+ <tr><td class="sig">confirmed</td><td class="desc">The server accepted it. Nothing is written back.</td><td class="desc"><code>cell:confirmed</code></td></tr>
1783
+ <tr><td class="sig">reverted</td><td class="desc">The server refused it; the cell is rolled back.</td><td class="desc"><code>cell:reverted</code></td></tr>
1784
+ <tr><td class="sig">superseded</td><td class="desc">A newer edit replaced it while it was in flight.</td><td class="desc">either, with <code>superseded: true</code></td></tr>
1785
+ </tbody>
1786
+ </table>
1787
+ </div>
1788
+
1789
+ <div class="why">
1790
+ <p><strong>Rollback goes to the last confirmed value, not the previous one.</strong> This is
1791
+ the part that is easy to get wrong by hand. Suppose a cell holding <code>1</code> is edited to
1792
+ <code>2</code>, then to <code>3</code>, then to <code>4</code>, all before any answer comes
1793
+ back. If the second write fails, restoring “the value before it” would put back
1794
+ <code>2</code> — a value the server never held, and one the user has since replaced twice.
1795
+ So each cell remembers the newest value a confirmation has actually vouched for, and a write
1796
+ that a later edit has superseded reports its failure without writing anything back. You will
1797
+ see <code>cell:reverted</code> with <code>applied: false</code> for those.</p>
1798
+ <p>The rejected value travels on the event as <code>rejected</code>, so you can offer a retry
1799
+ rather than losing what the user typed.</p>
1800
+ </div>
1801
+
1802
+ <div class="why">
1803
+ <p><strong>Two behaviours worth knowing.</strong> An unconfirmed edit goes through the normal
1804
+ pipeline, so if it changes a sorted or filtered column the row moves immediately and moves
1805
+ back if the write fails. And undo of an in-flight edit issues a <em>compensating write</em> —
1806
+ a fresh write back to the previous value, itself tracked — rather than pretending to cancel a
1807
+ request that has already gone out.</p>
1808
+ </div>
1809
+
1810
+ <div class="example">
1811
+ <p class="example__label">Asking what is outstanding</p>
1812
+ <pre><code>grid.edit.pending(); <span class="cmt">// [{ id, key, colId, value, before, state, age }]</span>
1813
+ grid.edit.status('r1', 'cap'); <span class="cmt">// 'pending' | null</span></code></pre>
1814
+ </div>
1815
+ <p class="lead-in">
1816
+ Pending cells are marked with <code>--lattice-pending-background</code> and rolled-back ones
1817
+ flash <code>--lattice-reverted-background</code>; restyle either through the tokens. Both use
1818
+ the same highlight model as everything else, so the marks survive scrolling, sorting and row
1819
+ recycling.
1820
+ </p>
1821
+
1822
+ <h2 id="tree-data">Tree data</h2>
1823
+ <p class="lead-in">
1824
+ Rows that sit under one another rather than in a flat list. Two shapes, and they answer
1825
+ different questions about where the hierarchy lives.
1826
+ </p>
1827
+ <div class="example">
1828
+ <p class="example__label">The row names its parent</p>
1829
+ <pre><code>tree: {
1830
+ parentKey: 'parentId', <span class="cmt">// a field, or a function of the row</span>
1831
+ label: 'name', <span class="cmt">// what the tree column shows</span>
1832
+ orphans: 'Unassigned', <span class="cmt">// or 'root', the default</span>
1833
+ }</code></pre>
1834
+ </div>
1835
+ <div class="example">
1836
+ <p class="example__label">The row carries its own ancestry</p>
1837
+ <pre><code>tree: { path: (row) =&gt; row.hierarchy } <span class="cmt">// ['EMEA', 'UK', 'Colchester']</span></code></pre>
1838
+ </div>
1839
+ <p class="lead-in">
1840
+ <strong>Parent-reference</strong> is what a join or a document store produces. Every node is
1841
+ a real row. A row whose parent is not in the data — filtered away, not loaded, or simply
1842
+ wrong — is an orphan: it goes to the root by default, or into a named bucket. It is never
1843
+ dropped, because hiding a record over a bad reference loses data the user can see in a flat
1844
+ view.
1845
+ </p>
1846
+ <p class="lead-in">
1847
+ <strong>Path-based</strong> rows describe their own place, so intermediate levels may have no
1848
+ row at all — <code>EMEA/UK/Colchester</code> with no <code>EMEA/UK</code> row still needs a
1849
+ <code>UK</code> node to sit under. Those are synthesised, and render as group rows: a heading
1850
+ over the rows beneath it with no record of its own. A real row arriving later for a level
1851
+ already synthesised fills that node rather than appearing beside it.
1852
+ </p>
1853
+ <p class="lead-in">
1854
+ A parent cycle is not a hierarchy and cannot be walked. It is reported once and cut, with the
1855
+ rows shown at the root — wrong place beats vanished.
1856
+ </p>
1857
+ <p class="lead-in">
1858
+ The grid generates a <strong>tree column</strong> to carry the expander and the indent, on the
1859
+ same terms as the auto-group, selection and detail columns: pinned to the start, and absent
1860
+ from <code>columns.visible()</code>, saved views, exports and the tool panel. Its text comes
1861
+ from <code>tree.label</code>; without one it falls back to your first visible column, which
1862
+ then appears twice until you hide it — the grid does not remove a column you did not ask it
1863
+ to remove.
1864
+ </p>
1865
+ <h3 id="tree-lazy">Loading a branch on demand</h3>
1866
+ <div class="example">
1867
+ <p class="example__label">Children fetched when the node is opened</p>
1868
+ <pre><code>tree: {
1869
+ parentKey: 'parentId',
1870
+ hasChildren: (data) =&gt; data.childCount &gt; 0,
1871
+ loadChildren: (row, signal) =&gt; api.children(row.data.id, { signal }),
1872
+ }</code></pre>
1873
+ </div>
1874
+ <p class="lead-in">
1875
+ <code>hasChildren</code> lets a row declare children it does not hold, so the expander is
1876
+ there before anything is fetched — without it there is nothing to click and the branch can
1877
+ never load. Such a node reads as <em>closed</em> even though tree nodes are otherwise expanded
1878
+ by default, because an open branch with nothing under it leaves no gesture to load it.
1879
+ </p>
1880
+ <p class="lead-in">
1881
+ The rows that arrive are added to the data set, so the hierarchy rebuilds through the same
1882
+ pipeline as everything else and the new rows sort, filter and export like any other. A branch
1883
+ is fetched once however often it is toggled; closing it before the rows arrive aborts the
1884
+ request through the <code>signal</code>. A rejection is reported and leaves the branch
1885
+ unloaded, so reopening tries again rather than showing an empty node for good.
1886
+ <code>tree:loading</code>, <code>tree:loaded</code> and <code>tree:loadFailed</code> are on the
1887
+ event bus.
1888
+ </p>
1889
+
1890
+ <p class="lead-in">
1891
+ Expansion is the same state group expansion uses, so <code>rows.expand</code>,
1892
+ <code>rows.collapse</code>, <code>expandAll</code>, <code>collapseAll</code> and saved views
1893
+ all work on it. A collapsed branch is skipped rather than hidden, so it costs nothing.
1894
+ Grouping and <code>tree</code> together is not a combination: the grouping wins and says so
1895
+ once, because two expanders in one row would be two hierarchies claiming the same rows.
1896
+ </p>
1897
+
1898
+ <h2 id="master-detail">Master-detail</h2>
1899
+ <p class="lead-in">
1900
+ A master row expands to reveal a detail region — by default a nested grid over whatever
1901
+ <code>detail.rows(row)</code> returns, which may be a promise. The grid adds an expander
1902
+ column while the feature is on, on the same terms as the auto-group and selection columns:
1903
+ pinned to the start, and absent from <code>columns.visible()</code>, saved views, exports and
1904
+ the tool panel.
1905
+ </p>
1906
+ <div class="example">
1907
+ <p class="example__label">Inline — a detail row beneath its master</p>
1908
+ <pre><code>detail: {
1909
+ rows: (row) =&gt; api.lines(row.data.id), <span class="cmt">// array or promise</span>
1910
+ config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
1911
+ height: 240,
1912
+ isMaster: (data) =&gt; data.lineCount &gt; 0, <span class="cmt">// default: every data row</span>
1913
+ cacheLimit: 10,
1914
+ }
1915
+
1916
+ grid.detail.toggle(key);
1917
+ grid.detail.keys(); <span class="cmt">// every open master</span>
1918
+ grid.detail.closeAll();</code></pre>
1919
+ </div>
1920
+ <p class="lead-in">
1921
+ The detail is a real display row: virtualised, height-managed, and pushing the rows below it
1922
+ down. Any number of masters can be open at once. <code>height</code> takes a number or a
1923
+ function of the row.
1924
+ </p>
1925
+
1926
+ <h3 id="detail-target">A detail pane instead of a detail row</h3>
1927
+ <div class="example">
1928
+ <p class="example__label">Targeted — the list-and-pane layout</p>
1929
+ <pre><code>detail: {
1930
+ target: '#detail-pane', <span class="cmt">// a selector or an element</span>
1931
+ rows: (row) =&gt; api.lines(row.data.id),
1932
+ config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
1933
+ }
1934
+
1935
+ grid.detail.active(); <span class="cmt">// the open master, or null</span>
1936
+ grid.detail.placement(); <span class="cmt">// 'inline' | 'target' | null</span></code></pre>
1937
+ </div>
1938
+ <p class="lead-in">
1939
+ With <code>target</code> the detail renders into an element you own rather than into a row.
1940
+ No detail row is created, so the grid's row count does not change when a master opens, and
1941
+ nothing about the list's geometry moves.
1942
+ </p>
1943
+ <p class="lead-in">
1944
+ <strong>Exactly one master is open at a time in this placement.</strong> One element cannot
1945
+ show two details, and stacking them turns a fixed-height pane into a scrolling list of grids
1946
+ with no rule for how tall each should be. Expanding a second master closes the first;
1947
+ <code>height</code> is ignored, because the pane's height is yours.
1948
+ </p>
1949
+ <p class="lead-in">
1950
+ A <code>target</code> selector that matches no element is reported once and leaves the
1951
+ details unshown — silence there is indistinguishable from a detail that fails to open, and
1952
+ the cause is not visible from the grid.
1953
+ </p>
1954
+ <p class="lead-in">
1955
+ <strong>The control changes with the placement, because the gesture does.</strong> Inline it
1956
+ is a chevron that turns down when the row expands, carrying <code>aria-expanded</code> — the
1957
+ ordinary disclosure pattern. Targeted, nothing expands: the row is being chosen and its
1958
+ detail appears elsewhere, so the control becomes the &ldquo;opens elsewhere&rdquo; glyph and
1959
+ a toggle (<code>aria-pressed</code>) rather than a disclosure. A chevron there would promise
1960
+ an expansion that never comes. The chosen row is marked with
1961
+ <code>lat-row--detail-active</code> and <code>aria-current</code>, since with the detail off
1962
+ to the side nothing else in the grid says which record the pane belongs to.
1963
+ </p>
1964
+
1965
+ <h3 id="detail-editing">An editable detail</h3>
1966
+ <p class="lead-in">
1967
+ The detail is a whole grid, so it edits like one — put <code>edit</code> in
1968
+ <code>detail.config</code> and its cells are editable. The rows it shows are usually a
1969
+ sub-array of the master's own record, so an edit there changes the master's data directly;
1970
+ there is nothing to copy back.
1971
+ </p>
1972
+ <div class="example">
1973
+ <p class="example__label">One listener on the master covers every detail</p>
1974
+ <pre><code>detail: {
1975
+ rows: (row) =&gt; row.data.ports, <span class="cmt">// a sub-array of the record</span>
1976
+ config: {
1977
+ columns: [{ field: 'port' }, { field: 'vlan', type: 'number', edit: <span class="kw">true</span> }],
1978
+ edit: { enabled: <span class="kw">true</span> },
1979
+ },
1980
+ }
1981
+
1982
+ grid.on('detail:cell:changed', (e) =&gt; {
1983
+ e.masterKey; <span class="cmt">// 'C1' — the row the detail belongs to</span>
1984
+ e.path; <span class="cmt">// 'ports.1.vlan' — where it lands on the master's record</span>
1985
+ e.value; <span class="cmt">// 999</span>
1986
+ e.oldValue; <span class="cmt">// 101</span>
1987
+ });</code></pre>
1988
+ </div>
1989
+ <p class="lead-in">
1990
+ A nested grid is created by the grid, not by you, so its own events would otherwise be out of
1991
+ reach. The edit lifecycle — <code>detail:edit:started</code>,
1992
+ <code>detail:edit:stopped</code>, <code>detail:cell:changed</code> — is re-emitted on the
1993
+ master, tagged with the master it came from. You never have to hold the nested grid to hear
1994
+ about an edit inside it.
1995
+ </p>
1996
+ <p class="lead-in">
1997
+ <code>path</code> is the dot notation from the master's record to the value that changed, so
1998
+ a host can persist a detail edit against the master and never think about the nested grid at
1999
+ all. It is worked out by identity: <code>rows(row)</code> usually returns an array that is
2000
+ already a property of the record, and that property is the prefix. A detail fetched from a
2001
+ server is not part of the master's record, so its <code>path</code> is <code>null</code> —
2002
+ set <code>detail.path</code> to name it yourself when you want one anyway.
2003
+ </p>
2004
+ <p class="lead-in">
2005
+ For anything the forwarded events do not cover, <code>detail.onCreate(grid, masterRow)</code>
2006
+ hands you the nested grid itself as it is built.
2007
+ </p>
2008
+
2009
+ <div class="table-wrap">
2010
+ <table>
2011
+ <thead><tr><th>Setting</th><th>What it does</th></tr></thead>
2012
+ <tbody>
2013
+ <tr><td class="name"><code>rows(row)</code></td><td class="desc">The nested grid's rows. May return a promise; the region is built empty and loaded when it settles, so an empty detail and a pending one do not look the same.</td></tr>
2014
+ <tr><td class="name"><code>config</code></td><td class="desc">The nested grid's configuration. It inherits the licence and module registry from the master by construction.</td></tr>
2015
+ <tr><td class="name"><code>render(container, row)</code></td><td class="desc">Draw the region yourself instead of a nested grid. Return anything with a <code>destroy()</code> method.</td></tr>
2016
+ <tr><td class="name"><code>isMaster(data, row)</code></td><td class="desc">Which rows can expand. Group rows and detail rows never can.</td></tr>
2017
+ <tr><td class="name"><code>target</code></td><td class="desc">A selector or element to render the detail into. Omit for inline. One master open at a time.</td></tr>
2018
+ <tr><td class="name"><code>onCreate(grid, row)</code></td><td class="desc">The nested grid, as it is created.</td></tr>
2019
+ <tr><td class="name"><code>path</code></td><td class="desc">The property of the master's record the detail rows live on, when it cannot be worked out by identity.</td></tr>
2020
+ <tr><td class="name"><code>cacheLimit</code></td><td class="desc">How many regions are retained <em>after</em> closing, so collapse and re-expand does not refetch. Open regions are never evicted, whatever the limit. <code>0</code> destroys on collapse. Default 10.</td></tr>
2021
+ </tbody>
2022
+ </table>
2023
+ </div>
2024
+
2025
+ <h2 id="selection-guide">Selection and ranges</h2>
2026
+ <p class="lead-in">
2027
+ Row selection and cell ranges are separate answers to separate questions —
2028
+ <em>which records</em> versus <em>which values</em>. Dragging across cells does not tick row
2029
+ checkboxes, and selecting rows does not build a range.
2030
+ </p>
2031
+ <div class="note">
2032
+ <p><strong>Cell ranges are on by default; row selection is not.</strong> Set
2033
+ <code>selection: 'single'</code> or <code>'multiple'</code> to turn rows on — until you do,
2034
+ <code>selection.set()</code> accepts the call and <code>keys()</code> comes back empty.</p>
2035
+ </div>
2036
+ <div class="example">
2037
+ <p class="example__label">Both</p>
2038
+ <pre><code>selection: 'multiple' <span class="cmt">// rows are off until you ask</span>
2039
+
2040
+ <span class="cmt">// Rows — which records</span>
2041
+ grid.selection.set(['r1', 'r2']);
2042
+ grid.selection.keys(); <span class="cmt">// the selected row keys</span>
2043
+ grid.selection.rows(); <span class="cmt">// the row wrappers</span>
2044
+ grid.selection.all(); <span class="cmt">// select everything that passes the filter</span>
2045
+ grid.selection.clear();
2046
+
2047
+ <span class="cmt">// Cells — which values</span>
2048
+ grid.selection.setRange({ startRow: 0, endRow: 9, columns: ['cap', 'margin'] });
2049
+ grid.selection.cells(); <span class="cmt">// [{ key, colId }, …]</span>
2050
+ grid.selection.summary(); <span class="cmt">// count, sum, min, max, avg over the range</span>
2051
+ grid.selection.clearRange();
2052
+
2053
+ <span class="cmt">// Several blocks at once — ctrl-click, Ctrl+Shift+Arrow, or from the API</span>
2054
+ grid.selection.addRange({ startRow: 20, endRow: 29, columns: ['cap'] });
2055
+ grid.selection.extendRange(34, 'cap'); <span class="cmt">// grows the block just added</span></code></pre>
2056
+ </div>
2057
+ <h3 id="selection-checkbox">The checkbox column</h3>
2058
+ <div class="example">
2059
+ <p class="example__label">A column of checkboxes, with select-all in the header</p>
2060
+ <pre><code>selection: { mode: 'multiple', checkbox: <span class="kw">true</span>, headerCheckbox: <span class="kw">true</span> }</code></pre>
2061
+ </div>
2062
+ <p class="lead-in">
2063
+ <code>checkbox: true</code> adds a narrow column of checkboxes at the start of every row,
2064
+ pinned so it does not scroll away. <code>headerCheckbox: true</code> puts a select-all box in
2065
+ its heading, which shows three states: unchecked when nothing is selected, checked when
2066
+ everything is, and the native indeterminate mark when some are. Clicking it selects
2067
+ everything when it is not already full, and clears when it is — including from the
2068
+ indeterminate state, where the intent is "select the rest".
2069
+ </p>
2070
+ <p class="lead-in">
2071
+ "Everything" means every row the filter currently shows, not every row loaded. The column is
2072
+ generated rather than declared: it does not appear in <code>columns.visible()</code>, in a
2073
+ saved view, in an export or in the tool panel's visibility list, and it disappears when the
2074
+ option is turned off. <code>grid.selection.headerState()</code> returns the same tri-state the
2075
+ header shows, for building your own control.
2076
+ </p>
2077
+ <p class="lead-in">
2078
+ Selected rows carry <code>aria-selected</code> and the class
2079
+ <code>lat-row--selected</code>, which the theme styles.
2080
+ </p>
2081
+
2082
+ <div class="why">
2083
+ <p><strong>Ctrl+Shift+Arrow is the keyboard form of ctrl-dragging.</strong> The first press
2084
+ opens a block at the focused cell without discarding what is already selected; the presses
2085
+ after it extend that block, so holding the chord draws one rectangle rather than a new one
2086
+ per key repeat. A plain Shift+Arrow goes back to extending a single block, and clicking
2087
+ anywhere ends the run.</p>
2088
+ </div>
2089
+ <div class="why">
2090
+ <p><strong>What multiple ranges do and do not support.</strong> Painting, <code>cells()</code>
2091
+ and the status-bar summary all work over the union of the selected blocks. Copy is narrower,
2092
+ because tab-separated text is a rectangle: blocks stacked over the same columns, or joined
2093
+ over the same rows, copy fine — a diagonal pair has no rectangular form, so
2094
+ <code>rangeText()</code> returns <code>''</code> and <code>clipboard:copy</code> reports
2095
+ <code>reason: 'discontiguous'</code> rather than emitting misaligned rows. Filling is
2096
+ narrower still: with more than one block selected there is no single source to extend, so the
2097
+ fill handle is hidden and <code>fillTo</code> and <code>fillDown</code> decline.</p>
2098
+ </div>
2099
+ <div class="example">
2100
+ <p class="example__label">Turning it off</p>
2101
+ <pre><code>selection: 'none' <span class="cmt">// neither</span>
2102
+ selection: { ranges: false } <span class="cmt">// rows only, no drag-select</span></code></pre>
2103
+ </div>
2104
+
2105
+ <h2 id="fill-series">Filling a series</h2>
2106
+ <p class="lead-in">
2107
+ Dragging the fill handle continues what it can recognise rather than repeating the block.
2108
+ Detection is per column, so dragging three columns down continues three independent series.
2109
+ </p>
2110
+ <div class="example">
2111
+ <p class="example__label">What is recognised</p>
2112
+ <pre><code>1, 2, 3 <span class="cmt">// → 4, 5, 6 constant difference, two or more values</span>
2113
+ 5, 10, 15 <span class="cmt">// → 20, 25 any step, including negative and fractional</span>
2114
+ 2026-01-01 … <span class="cmt">// → the next day, week or whatever the gap is</span>
2115
+ 15 Jan, 15 Feb <span class="cmt">// → 15 Mar whole months, holding the day of month</span>
2116
+ 31 Jan, 28 Feb <span class="cmt">// → 31 Mar, 30 Apr month ends stay on the month end</span>
2117
+ 'x', 'y' <span class="cmt">// → x, y, x, y nothing recognised, so the block repeats</span>
2118
+ 7 <span class="cmt">// → 7, 7, 7 one value is a copy, not a series</span></code></pre>
2119
+ </div>
2120
+ <div class="why">
2121
+ <p><strong>Months step as months.</strong> 15 January to 15 February is thirty-one days, and
2122
+ continuing in days would land on 18 March and drift further every step. A month step holds
2123
+ the date the user picked, and clamps where the month is short — 31 January plus a month is
2124
+ 28 February, not 3 March.</p>
2125
+ <p><strong>Month ends are their own case.</strong> <code>31 Jan, 28 Feb</code> is a month
2126
+ series whose second value has already been clamped, so the two share no day of month and the
2127
+ day-preserving rule cannot see it. Where every source value is the last day of its own month,
2128
+ the fill stays on the month end: 31 March, 30 April, 31 May — and 29 February in a leap year.
2129
+ Values that share a day of month keep the day-preserving answer, so <code>30 Apr, 30 Jun</code>
2130
+ still gives 30 August rather than the 31st.</p>
2131
+ <p><strong>One limit worth knowing.</strong> Filling upwards is not supported; the handle
2132
+ extends downwards only.</p>
2133
+ </div>
2134
+ <div class="example">
2135
+ <p class="example__label">Supplying your own series</p>
2136
+ <pre><code>selection: {
2137
+ fill: ({ source, target, direction }) =&gt; target.map((t, i) =&gt; nextCode(source, i)),
2138
+ }</code></pre>
2139
+ </div>
2140
+ <div class="why">
2141
+ <p>A domain series — order codes, fiscal periods, seat numbers — is not something the grid
2142
+ can infer, so <code>selection.fill</code> takes precedence when you supply it. It must return
2143
+ one value per target row; anything else is ignored in favour of the built-in detection,
2144
+ rather than being partly applied.</p>
2145
+ </div>
2146
+
2147
+ <h2 id="updates-guide">Holding live updates</h2>
2148
+ <p class="lead-in">
2149
+ A pause button for incoming data. Changes are held and merged while paused, applied when
2150
+ play is pressed, and counted throughout — so the coalescing that makes a live grid fast is
2151
+ finally visible.
2152
+ </p>
2153
+ <div class="example">
2154
+ <p class="example__label">Pause, play, and the counters</p>
2155
+ <pre><code>grid.updates.pause();
2156
+ grid.updates.resume(); <span class="cmt">// apply everything held</span>
2157
+ grid.updates.flush(); <span class="cmt">// apply what is waiting, stay paused</span>
2158
+
2159
+ grid.updates.stats();
2160
+ <span class="cmt">// { paused, pending, queued, coalesced, coalescedTotal, rows, dropped, flushes, span }</span>
2161
+
2162
+ grid.updates.log({ since: Date.now() - 60000 }); <span class="cmt">// what arrived, in order</span></code></pre>
2163
+ </div>
2164
+ <div class="why">
2165
+ <p><strong>Pausing is a button, not a guess.</strong> Inferring it from whether the user
2166
+ looks busy sounds friendlier and is wrong in both directions: too broad and a mouse resting
2167
+ on the grid freezes the feed until someone reloads, too narrow and rows jump the instant
2168
+ somebody stops moving in order to read. An explicit control has no heuristic to get wrong and
2169
+ no invisible state to explain.</p>
2170
+ <p><strong>Merging continues while paused</strong>, so a long pause costs one entry per
2171
+ changed row rather than one per update. Forty updates to one row are one row of work when
2172
+ play is pressed — and <code>coalesced</code> is the thirty-nine, which is the number nobody
2173
+ could see before.</p>
2174
+ <p><strong>Bounding the rows themselves is separate.</strong> The log bounds change
2175
+ <em>history</em>; a streaming source also needs to bound row <em>retention</em>, or a grid
2176
+ left up overnight holds every row it was ever sent. Set <code>source.maxRows</code> and the
2177
+ stream becomes a sliding window, dropping the oldest as new ones arrive and reporting how
2178
+ many it let go through <code>evicted</code> on the progress report.</p>
2179
+ <p><strong>The log keeps the raw sequence, not the merged one.</strong> Merging is right for
2180
+ applying a backlog quickly and wrong for looking at what happened, because the intermediate
2181
+ states are exactly what a time scrubber would move between. It survives the flush —
2182
+ <code>pending</code> is what is waiting, the log is what happened — and it is capped, so a
2183
+ grid paused over lunch holds the recent past and reports how much it dropped rather than
2184
+ taking the tab with it.</p>
2185
+ </div>
2186
+
2187
+ <h2 id="diagnostics-guide">Diagnostics and devtools</h2>
2188
+ <p class="lead-in">
2189
+ Data grids fail in ways that are hard to diagnose from outside. This is the grid saying
2190
+ what it is actually doing.
2191
+ </p>
2192
+ <div class="example">
2193
+ <p class="example__label">Asserting on DOM writes</p>
2194
+ <pre><code>const before = grid.diagnostics.renders().dom.cellWrites;
2195
+ await doTheThing();
2196
+ expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);</code></pre>
2197
+ </div>
2198
+ <div class="why">
2199
+ <p><strong>The API came first and the panel second, on purpose.</strong> Instrumentation
2200
+ written behind a UI gets shaped by the layout: it reports what is convenient to display
2201
+ rather than what is true, and it cannot be tested. An API that stands on its own can be
2202
+ asserted against, and the assertion above is not one most grids can support.</p>
2203
+ <p><strong>Most of it already existed and was unreachable.</strong> The renderer had counted
2204
+ cell writes, row updates and paints all along; the column store could already report a real
2205
+ byte footprint per column, summing backing array, presence bitset and dictionary. Neither was
2206
+ reachable from the public API. Exposing what a system already knows is usually a better first
2207
+ move than measuring something new.</p>
2208
+ <p><strong>Warnings are mostly collection, not detection.</strong> The grid has 160 places
2209
+ that warn once per cause, each already carrying a stable de-duplication key — which is
2210
+ exactly the stable identifier a support conversation needs. They went to the console and
2211
+ nowhere else. The console interleaves with your own logging, does not survive a reload, and
2212
+ cannot be asked what it has already complained about. They are now kept as records too.</p>
2213
+ <p><strong>Every warning names values, not just a condition.</strong> "Something is slow" is a
2214
+ warning nobody can act on. Each carries the specific numbers, a stable id, and is dismissible
2215
+ for the session but not permanently — a permanently dismissible warning is one nobody sees
2216
+ again after the person who dismissed it leaves.</p>
2217
+ <p><strong>The checks are tested for silence as much as for detection.</strong> A clean grid
2218
+ must raise nothing. A checker that cries wolf is one developers learn to ignore, and then it
2219
+ is worth less than no checker at all. The accessibility checks found two false positives in
2220
+ themselves during development — first comparing <code>aria-rowcount</code> against the row
2221
+ count when ARIA counts header rows too, then counting header row *elements*, of which a
2222
+ single-level header has three because the header is built once per pinned region.</p>
2223
+ <p><strong>Instrumentation must not change what it measures.</strong> Counters are integers
2224
+ incremented where the work already happened. Render phases are four <code>performance.now()</code>
2225
+ marks around existing sections. Timings are sampled — a bounded window of recent operations —
2226
+ and every report names which of its figures are sampled, because a number whose provenance is
2227
+ unclear is worse than no number. Paint wait is the browser's and is deliberately not claimed.</p>
2228
+ <p><strong>Render causes are captured, not inferred.</strong> By the time a paint runs,
2229
+ several distinct causes have collapsed into the same dirty flags, so working backwards gives a
2230
+ plausible answer rather than a true one. The renderer records the structural reason when the
2231
+ invalidation arrives; the semantic one — filter, sort, data — is only knowable from the event
2232
+ that preceded it, so the DOM layer supplies that and the structural reason is the fallback.</p>
2233
+ <p><strong>Providers are wrapped where they are installed</strong>, not at each call site, so
2234
+ one added later is instrumented by construction. The wrapper returns exactly what the original
2235
+ returned and re-throws exactly what it threw. Failures are kept, because a provider failure is
2236
+ usually swallowed by the host application's own error handling before a developer sees it, and
2237
+ "the grid is behaving strangely" is where that conversation otherwise starts.</p>
2238
+ <p><strong>The heat overlay uses two colours because there are two findings.</strong> A cell
2239
+ written with a value it did not hold is work the grid had to do. A cell rewritten with the
2240
+ value already in it is work it did not. Only the second is waste, and a counter alone will
2241
+ never tell you where it is. The overlay records a baseline when switched on, because
2242
+ otherwise the first paint has nothing to compare against and tints nothing, which reads as
2243
+ broken rather than as empty.</p>
2244
+ <p><strong>The module imports nothing.</strong> Not the grid, not a shared helper. The bundler
2245
+ inlines whatever a module imports: the framework adapters stay small because
2246
+ <code>createGrid</code> is handed to them, while the web component carries the grid with it
2247
+ because it imports it. A devtools module that imported anything from core would put the whole grid inside a
2248
+ bundle whose entire promise is that deployments not using it pay nothing.</p>
2249
+ <p><strong>The support bundle carries no row data.</strong> Configuration, query state, timing,
2250
+ warnings, provider statistics, version and environment — and nothing from your data. Stated as
2251
+ a guarantee because a bundle that had to be inspected for confidential values before sending
2252
+ is a bundle that never gets attached to the ticket.</p>
2253
+ <p><strong>It observes and never mutates.</strong> A configuration editor in a debug panel is
2254
+ tempting and would create a second path into grid state that has to be kept correct forever.
2255
+ No telemetry: nothing leaves the browser unless you export a bundle yourself.</p>
2256
+ </div>
2257
+
2258
+ <h2 id="presence-guide">Collaborative presence</h2>
2259
+ <p class="lead-in">
2260
+ Who else is looking at this grid, and what they are doing.
2261
+ </p>
2262
+ <div class="example">
2263
+ <p class="example__label">An in-memory provider, which is all the interface requires</p>
2264
+ <pre><code>const handlers = new Map();
2265
+ const providerFor = (id) =&gt; ({
2266
+ subscribe(fn) { handlers.set(id, fn); return () =&gt; handlers.delete(id); },
2267
+ publish(state) {
2268
+ for (const [peer, fn] of handlers) if (peer !== id) fn(state);
2269
+ },
2270
+ });</code></pre>
2271
+ </div>
2272
+ <div class="why">
2273
+ <p><strong>Presence carries intent, never values.</strong> This is the line that matters most.
2274
+ A peer's committed edit reaches the grid as data, through the channel you already use for
2275
+ data. Presence is throttled, lossy and ephemeral <em>by design</em>, so a value carried on it
2276
+ is a value that can be dropped &mdash; and that is the class of bug that appears once a month
2277
+ in production and cannot be reproduced on demand.</p>
2278
+ <p><strong>Positions are row keys, resolved against your view at render time.</strong> Peers
2279
+ sort and filter independently, so index 12 is a different record on every screen. Publishing
2280
+ an index would put a colleague's cursor on an unrelated row the moment either of you sorted.
2281
+ The cost of this is real &mdash; the grid resolves a key to a position rather than reading one
2282
+ &mdash; and it is the difference between the feature working and the feature lying.</p>
2283
+ <p><strong>Idle is measured from when a message arrived, not from what it says.</strong>
2284
+ Clocks between clients disagree by seconds routinely and by minutes occasionally. Keying idle
2285
+ detection on the sender's timestamp means a peer with a fast clock never goes idle and one
2286
+ with a slow clock is idle immediately. The sender's timestamp is kept for inspection and
2287
+ decides nothing.</p>
2288
+ <p><strong>Publishing throttles rather than debounces.</strong> A debounce sends nothing until
2289
+ the user stops moving, so every peer sees a cursor that teleports on pause instead of moving.
2290
+ The leading edge goes out immediately and the trailing edge carries wherever it settled.</p>
2291
+ <p><strong>Publishing stops while the tab is hidden.</strong> Nobody is moving that cursor.
2292
+ Receiving continues, so coming back to the tab shows the current state rather than an empty
2293
+ roster filling in slowly.</p>
2294
+ <p><strong>A peer's cursor is dashed; your focus ring is solid.</strong> The distinction has to
2295
+ be in the kind of line, not only its colour. Colour alone fails for anyone who cannot separate
2296
+ two hues and fails for everyone at a glance &mdash; and the palette originally contained the
2297
+ exact value of <code>--lattice-focus-color</code>, so the first peer assigned drew a cursor
2298
+ identical to the local user's own selection. An active edit is solid and tinted, because an
2299
+ edit is not a cursor and those two must not be confused with each other either.</p>
2300
+ <p><strong>Nothing is inserted into the grid.</strong> Every treatment is an attribute and a
2301
+ custom property written onto a cell that already exists, drawn with an outline and a
2302
+ pseudo-element. That is what keeps presence from shifting layout, covering an in-cell chart or
2303
+ swallowing a click &mdash; none of which survives an implementation that appends overlay
2304
+ elements. The overlay layer is pointer-transparent and presence deliberately does not opt back
2305
+ in; only the roster does, because it is a control.</p>
2306
+ <p><strong>The roster is the part people use.</strong> More than the cursors, in practice. It
2307
+ carries the name as well as the colour, because colour alone is not a signal everyone can
2308
+ read, and it reports peers whose rows are not in your view rather than omitting them &mdash;
2309
+ an absent peer reads as a disconnection that has not happened.</p>
2310
+ <p><strong>A parked cursor does not fade.</strong> The label does, after a couple of seconds,
2311
+ because permanent labels over a dense grid are unreadable. The border stays, dims at idle, and
2312
+ goes only on removal: a cursor that vanished while its owner was still connected would report
2313
+ exactly the thing this feature exists to prevent.</p>
2314
+ <p><strong>Locking is advisory, and the documentation says so because a developer who
2315
+ believes otherwise will skip the conditional write.</strong> Presence is throttled and can
2316
+ arrive out of order; two clients can enter an edit in the same instant. What actually resolves
2317
+ the conflict is the conditional write in <code>edit.commit</code>, which returns a conflict
2318
+ and rolls the optimistic edit back. Locking narrows the window. It does not close it. A stale
2319
+ claim is disregarded after a much shorter window than peer removal, because a lock held by
2320
+ someone who shut their laptop blocks a cell nobody is editing.</p>
2321
+ <p><strong>Not in this release:</strong> transport, reconnection or authentication; operational
2322
+ transform or CRDT merging; presence history; text-level cursors inside a cell editor; follow
2323
+ mode; chat.</p>
2324
+ </div>
2325
+
2326
+ <h2 id="comments-guide">Cell comments</h2>
2327
+ <p class="lead-in">
2328
+ Threaded discussion attached to individual cells, for reviewing data with other people
2329
+ without leaving the grid.
2330
+ </p>
2331
+ <div class="example">
2332
+ <p class="example__label">Wiring a provider</p>
2333
+ <pre><code>comments: {
2334
+ provider: {
2335
+ loadIndex: (rowIds, fields) =&gt; api.counts(rowIds, fields),
2336
+ loadThread: (cellKey) =&gt; api.thread(cellKey),
2337
+ addComment: (cellKey, body, parentId, ctx) =&gt; api.add(cellKey, body, parentId, ctx.value),
2338
+ editComment: api.edit, deleteComment: api.remove,
2339
+ resolveThread: api.resolve, unresolveThread: api.unresolve
2340
+ },
2341
+ rowLabel: (row) =&gt; row.data.name
2342
+ }</code></pre>
2343
+ </div>
2344
+ <div class="why">
2345
+ <p><strong>Comments are not row data, and never enter the column store.</strong> They have a
2346
+ different lifecycle: append-mostly, sparse against the row count, carrying their own identity
2347
+ and timestamps, subject to their own permissions, and outliving the values they annotate.
2348
+ Putting them in the store would mean columnarising a field that is empty for almost every row
2349
+ and rebuilding it on every write.</p>
2350
+ <p><strong>Two levels of data, and only one of them is cheap.</strong> The index maps a cell
2351
+ to <code>{ count, unresolved, updated }</code> and nothing else; it is consulted on every
2352
+ repaint, so it must never carry bodies. A thread is the bodies for one cell, loaded when the
2353
+ thread opens and dropped when it closes. Holding every thread a user opened would accumulate
2354
+ the whole comment dataset over a long session to render a few triangles.</p>
2355
+ <p><strong>Stable row identity is a hard requirement, enforced rather than documented.</strong>
2356
+ A comment is keyed on row identity plus field. Row index changes under sort, filter and
2357
+ grouping, so a comment keyed on it reattaches to whichever row later occupies that position
2358
+ &mdash; and a comment on the wrong row is worse than no comment at all. A grid with no
2359
+ <code>rowKey</code> disables comments and names them in the same single warning as the other
2360
+ identity-dependent features.</p>
2361
+ <p><strong>Identity has to survive more than the session.</strong> Comments outlive the page
2362
+ that wrote them, so a key that is stable only within one load &mdash; anything derived from
2363
+ arrival order, for instance &mdash; is not enough. Reload the data in a different order and
2364
+ every thread points somewhere else. If you have only used the grid client-side you may never
2365
+ have needed a durable identity before; you do now.</p>
2366
+ <p><strong>Writes are optimistic, and rejections are taken back.</strong> The author sees
2367
+ their own comment at once, faded until the provider confirms. A rejection removes it rather
2368
+ than leaving the grid displaying something the server refused. The same pattern as cell
2369
+ editing, for the same reason.</p>
2370
+ <p><strong>The grid authorises nothing.</strong> A comment may carry
2371
+ <code>can: { edit, delete, resolve }</code> and the affordances follow it, but that is a
2372
+ convenience for the person looking at the screen. It is not a control, and the documentation
2373
+ says so here rather than leaving it to be assumed: hiding a button stops nobody who opens the
2374
+ console. Reject in the provider.</p>
2375
+ <p><strong>A body is user input that has round-tripped through your storage.</strong> That is
2376
+ the exact shape of a stored cross-site script, so the default path sets text and nothing else.
2377
+ Turning on <code>markdown</code> buys emphasis, code and links &mdash; three constructs, built
2378
+ as elements rather than parsed as markup, with any scheme other than <code>http</code>,
2379
+ <code>https</code> and <code>mailto</code> refused. A refused link still shows its label, so
2380
+ nothing the author wrote vanishes without trace.</p>
2381
+ <p><strong>The marker cannot move the cell's contents.</strong> It is a corner triangle drawn
2382
+ with a border on a pseudo-element, so it occupies no space in the layout: no shifted text, no
2383
+ rewrapped number, no displaced sparkline. That constraint is why it is a corner rather than a
2384
+ badge &mdash; every other position in a cell is already spoken for. Only the corner opens a
2385
+ thread; a click elsewhere belongs to selection, and taking it would make commented cells
2386
+ behave unlike every other cell. The hit region is larger than the drawn mark, because at
2387
+ compact density the triangle is about seven pixels across.</p>
2388
+ <p><strong>A comment shows the value it was written against</strong> whenever that no longer
2389
+ matches the cell. Without it, a note reading "this looks too high" sits beside a number it
2390
+ never described and the reader concludes the comment is wrong. Changing a value never deletes
2391
+ or invalidates a comment.</p>
2392
+ <p><strong>Filtered-out comments are hidden, not lost — and the grid says so.</strong>
2393
+ <code>hiddenUnresolved()</code> reports what is still outstanding on rows the filter is
2394
+ hiding, because a user who filters and sees no markers should not conclude there is nothing
2395
+ left to deal with. The status bar carries this: its <code>comments</code> panel reads
2396
+ &ldquo;3 unresolved comments on hidden rows&rdquo; and is silent whenever the count is zero, so
2397
+ a grid with nothing outstanding gains no permanent furniture. The panel is in the default set,
2398
+ and can be placed explicitly like any other:</p>
2399
+ <div class="example">
2400
+ <pre><code>statusBar: { panels: ['rowCount', 'comments'] }</code></pre>
2401
+ </div>
2402
+ <p>The count returns zero rather than a number it cannot stand behind: it is only meaningful
2403
+ once the index covers every row, so it stays at zero &mdash; and the panel stays silent &mdash;
2404
+ until <code>loadAll()</code> has resolved.</p>
2405
+ <p><strong>The comments-only filter is refused rather than approximated.</strong> Restricting
2406
+ the grid to rows carrying comments needs the index to cover the whole row set, not just what
2407
+ has been scrolled past &mdash; a partial answer would hide precisely the rows the user opened
2408
+ it to find. Call <code>loadAll()</code> first; until <code>complete</code> is true,
2409
+ <code>filterToCommented()</code> returns false and does nothing.</p>
2410
+ <p><strong>Comments stay available while streaming</strong>, unlike header histograms: a
2411
+ comment does not move when new rows arrive. Index loads for new rows follow the same
2412
+ debounced viewport path. A thread whose row is evicted by a bounded window closes with a
2413
+ short explanation rather than hovering over a row that has gone.</p>
2414
+ <p><strong>Keyboard and screen reader.</strong> <kbd>Alt</kbd>+<kbd>M</kbd> opens the thread
2415
+ on the focused cell &mdash; Alt because the grid binds nearly every unmodified key to
2416
+ navigation and editing. The panel traps <kbd>Tab</kbd>, which it has to: the grid behind it is
2417
+ still there and still focusable, so without the trap a keyboard user would be moving through
2418
+ cells with a dialog open over them. Focus returns to the originating cell on close rather than
2419
+ being dropped at the top of the document. Cells announce their comment count and unresolved
2420
+ count through <code>aria-description</code> rather than their label, because the label is the
2421
+ cell's value and burying a count inside it would make every commented cell read as something
2422
+ other than what it holds.</p>
2423
+ <p><strong>Not in this release:</strong> mentions, notifications, rich text, attachments,
2424
+ reactions, row-level and column-level comments, and export of comments. The grid opens no
2425
+ transport of its own &mdash; if your application pushes updates, call <code>refresh()</code>
2426
+ and the index reloads.</p>
2427
+ </div>
2428
+
2429
+ <h2 id="facets-guide">Header histograms and facet filtering</h2>
2430
+ <p class="lead-in">
2431
+ The shape of a column, drawn in its heading, and clickable. Explore a dataset by
2432
+ clicking through headers instead of writing queries.
2433
+ </p>
2434
+ <div class="example">
2435
+ <p class="example__label">Turning them on</p>
2436
+ <pre><code>facets: { enabled: true }
2437
+
2438
+ <span class="cmt">// per column, layered over the grid's settings</span>
2439
+ { field: 'price', type: 'number', facet: { strategy: 'quantile' } }
2440
+ { field: 'notes', facet: false }</code></pre>
2441
+ </div>
2442
+ <div class="why">
2443
+ <p><strong>The column being filtered is not counted against its own filter.</strong> Every
2444
+ other active filter applies; that column's own conditions are pruned out of the tree before
2445
+ counting. This is the whole of faceted browsing and it is the part that is easy to get subtly
2446
+ wrong &mdash; a self-filtered chart collapses to a single bar the moment you click one, and
2447
+ there is then no way to see what you excluded or to widen the selection. Getting it wrong
2448
+ does not degrade the feature, it removes it.</p>
2449
+ <p><strong>Pruning is not symmetric across operators.</strong> An <code>and</code> group
2450
+ narrows with each condition, so dropping one widens the result &mdash; the direction faceting
2451
+ wants. An <code>or</code> group widens with each branch, so dropping one would show
2452
+ <em>fewer</em> rows than the user's actual filter. There is no partial answer that is correct,
2453
+ so a disjunction naming the column is dropped whole.</p>
2454
+ <p><strong>Bucket edges are placed once and kept.</strong> They are computed against the
2455
+ unfiltered column and survive every filter change until the data is replaced. Not only an
2456
+ optimisation: bars that resized on every click would make the chart unusable as a control,
2457
+ because the thing you are pointing at would move as you pointed at it.</p>
2458
+ <p><strong>Each bar carries two readings.</strong> Its full height is the bucket's share of
2459
+ the unfiltered column; the solid fill inside is how much survives the current filters. Either
2460
+ alone misleads &mdash; scaling to the filtered maximum draws a full-height chart out of three
2461
+ surviving rows, and scaling everything down together flattens the whole chart into a few
2462
+ pixels the moment anyone filters anything.</p>
2463
+ <p><strong>The filters are ordinary filters.</strong> They go through the same
2464
+ <code>filters.set</code> as everything else, so they undo, serialise into saved views, and
2465
+ appear in whatever filter UI already exists. Nothing downstream can tell a filter made by
2466
+ clicking a bar from one typed into the filter panel. A drag emits a <code>between</code>
2467
+ range rather than a set of bucket indices, so it still means the same thing after the data is
2468
+ replaced and the edges move.</p>
2469
+ <p><strong>Selection is derived, never stored.</strong> Which buckets look selected is read
2470
+ back out of the filter tree. Remove the filter through the filter panel, an undo or a saved
2471
+ view and the chart is correct without anything having to tell it.</p>
2472
+ <p><strong>High-cardinality columns are refused, and it costs nothing to know.</strong> Text
2473
+ columns are dictionary-encoded in the store, so the distinct count is a property read rather
2474
+ than a scan. The first column anyone points this at is a name or an id, and one hairline per
2475
+ customer looks like a rendering fault rather than a distribution. Above
2476
+ <code>cardinalityLimit</code> the chart is suppressed, or shows a top-N with an aggregated
2477
+ remainder if you ask for <code>aboveLimit: 'topN'</code> &mdash; aggregated rather than
2478
+ truncated, because silently dropping the tail would misrepresent the bars it did draw.</p>
2479
+ <p><strong>Nulls are never dropped.</strong> They land in a terminal bucket, always last, and
2480
+ the counts always sum to the row count. A column where nine thousand of ten thousand rows are
2481
+ empty is a fact about the data, and a chart that quietly showed the thousand would be lying
2482
+ about the shape. <code>NaN</code> joins them rather than forming its own bucket: it is the
2483
+ same answer to the same question.</p>
2484
+ <p><strong>Live streams suppress the charts.</strong> Constantly shifting distributions are
2485
+ unreadable, recounting on every batch is wasteful, and a filter control whose buckets move
2486
+ under the pointer is actively hostile. Filters already made stay applied, because they are
2487
+ ordinary filters. Pausing the stream brings the charts back &mdash; a paused stream is a still
2488
+ one &mdash; unless you set <code>whilePaused: false</code>.</p>
2489
+ <p><strong>Counting runs off the main thread above <code>workerThreshold</code>.</strong>
2490
+ Distributions are the only work the grid moves to a Worker. Sorting, filtering and grouping
2491
+ run on the main thread; nothing waits on a histogram, which is what makes this one
2492
+ offloadable. The column is copied, or shared where cross-origin isolation makes
2493
+ <code>SharedArrayBuffer</code> available; it is never transferred, because transferring would
2494
+ detach the buffer the grid is still rendering from.</p>
2495
+ <p><strong>The Worker settings.</strong> <code>useWorker</code> and
2496
+ <code>workerThreshold</code> decide whether and when a distribution is offloaded. Two more
2497
+ control how the Worker is built, and both are settled when it is constructed &mdash; changing
2498
+ either discards the running Worker so the next offload builds a new one.</p>
2499
+ <div class="table-wrap">
2500
+ <table>
2501
+ <thead><tr><th>Setting</th><th>What it does</th></tr></thead>
2502
+ <tbody>
2503
+ <tr><td class="name"><code>workerUrl</code></td><td class="desc">Loads the Worker from a URL you host instead of a <code>blob:</code>. Required under a Content-Security-Policy that forbids <code>blob:</code> workers &mdash; without it the Worker cannot be constructed at all on such a page, and compute stays on the main thread.</td></tr>
2504
+ <tr><td class="name"><code>sharedMemory</code></td><td class="desc">Off by default. Passes columns to the Worker in a <code>SharedArrayBuffer</code> rather than copying them on every message, at the cost of retaining a shared copy of each column that crosses. Needs the page to be cross-origin isolated; where it is not, it falls back to copying and says so once.</td></tr>
2505
+ </tbody>
2506
+ </table>
2507
+ </div>
2508
+ <p><code>grid.diagnostics.renders().worker</code> reports what the Worker host is actually
2509
+ doing &mdash; whether one was spawned, how many calls ran locally versus remotely, and the
2510
+ threshold, <code>sharedMemory</code> and <code>workerUrl</code> it was built with.</p>
2511
+ <p><strong>Server-side sources need a <code>provider</code>, and its absence is silent.</strong>
2512
+ A grid holding one page of data cannot compute a distribution over the whole set. Supply a
2513
+ function and it receives the column, the pruned filter state and the bucketing settings, and
2514
+ returns counts. Without one the charts are simply absent &mdash; no error, because most
2515
+ deployments will never supply one. Be clear-eyed about the load: results are cached against
2516
+ the filter state, but this is one query per column per filter change, and a grid with eight
2517
+ faceted columns asks eight questions every time a filter moves.</p>
2518
+ <p><strong>Keyboard and screen reader.</strong> Focus enters the chart from the header and
2519
+ arrows move between buckets, so a column costs one tab stop rather than twenty. <kbd>Enter</kbd>
2520
+ toggles, <kbd>Shift</kbd> with arrows extends a range on ordered columns, <kbd>Escape</kbd>
2521
+ clears. Selected buckets carry an outline as well as a colour. Beyond per-bucket labels the
2522
+ chart carries a sentence describing the distribution's shape, because twenty bucket readings
2523
+ do not add up to "most of the mass is at the low end" &mdash; and that shape is the entire
2524
+ value of the chart.</p>
2525
+ </div>
2526
+
2527
+ <h2 id="timeline-guide">Time scrubber</h2>
2528
+ <p class="lead-in">
2529
+ Move the grid back through recent data changes — what did this look like a minute ago,
2530
+ before that number moved.
2531
+ </p>
2532
+ <div class="example">
2533
+ <p class="example__label">Scrubbing</p>
2534
+ <pre><code>grid.timeline.attach(); <span class="cmt">// start recording; the window fills from here</span>
2535
+
2536
+ grid.timeline.seek(5); <span class="cmt">// stand five changes back</span>
2537
+ grid.timeline.step(-1); <span class="cmt">// one further back</span>
2538
+ grid.timeline.at(); <span class="cmt">// the moment being shown</span>
2539
+ grid.timeline.toLive(); <span class="cmt">// return, applying everything stepped over</span>
2540
+
2541
+ grid.timeline.detach();</code></pre>
2542
+ </div>
2543
+ <div class="why">
2544
+ <p><strong>Attaching puts a control on the grid.</strong> A slider along the bottom with two
2545
+ readings beside it — how long ago, and the clock time. Relative answers the question actually
2546
+ being asked; absolute is what someone reads out to the person next to them. It moves the grid
2547
+ while the handle is dragged rather than on release, and it turns accent-coloured the moment
2548
+ you are off live, because a grid quietly showing stale data is the failure this control can
2549
+ cause. It removes itself on <code>detach()</code>.</p>
2550
+ <p><strong>It reads the data, not your actions.</strong> Undo history records what the
2551
+ <em>user</em> did — sorts, filters, edits — which is rarely the question. This reads the
2552
+ change log: a bounded, timestamped, deliberately unmerged record of everything that arrived,
2553
+ so the intermediate states are all still there to move between.</p>
2554
+ <p><strong>Nothing is scrubbable before <code>attach()</code>.</strong> What a value used to
2555
+ be is not recoverable after the fact — no other part of the grid remembers it — so recording
2556
+ has to be switched on before there is a past to move through. It is off by default because
2557
+ reading a row per key on every change is real cost on a hot feed, and paying it for a
2558
+ scrubber nobody opened would be the wrong default.</p>
2559
+ <p><strong>Scrubbed back, the grid is not live.</strong> Changes keep being recorded but are
2560
+ not applied, because applying them would fight the position being held. Returning to the
2561
+ present applies everything missed.</p>
2562
+ <p><strong>Value changes reverse; row additions and removals do not.</strong> An add would
2563
+ need a removal and a remove would need re-insertion at its old position, and neither is
2564
+ recoverable from what the log holds. A window containing them scrubs over the value changes
2565
+ and leaves the row set alone — stated plainly because the alternative is a scrubber that
2566
+ silently half-works.</p>
2567
+ <p><strong>What moved is marked.</strong> Seeking compares each affected row before and after
2568
+ and marks the cells whose value changed, in <code>--lattice-timeline-changed</code>. Without
2569
+ it a scrub is nearly unreadable: the grid moves, and on a row twelve columns wide the one
2570
+ number you are hunting for goes past unseen.</p>
2571
+ <p><strong>The mark is held, not flashed.</strong> It stays until the next seek clears it.
2572
+ Every other transient signal in the grid fades on a timer, and this one deliberately does not
2573
+ — a scrub is someone hunting for what changed, and a highlight they can miss while reading the
2574
+ other end of the row helps nobody. <code>timeline:seeking</code> fires before any change is
2575
+ applied, so a five-step drag clears once and marks once rather than strobing per entry.</p>
2576
+ <p><strong>It compares column values, not raw fields.</strong> A computed column has no field
2577
+ of its own; diffing the source row would leave it silently unmarked while its number visibly
2578
+ moved. Reading through the column instead costs a little more and marks what the viewer can
2579
+ actually see change — which is the only definition of "changed" that matters here.</p>
2580
+ <p><strong>The window is bounded by rows, not only by changes.</strong> A cap on entries
2581
+ alone does not bound memory, because an entry is not a fixed size: one carrying a single
2582
+ changed cell and one carrying a fifty-thousand-row batch both count as one. So the log holds
2583
+ at most <code>updates.logLimit</code> changes (2000) <em>and</em> <code>updates.logRows</code>
2584
+ rows between them (100,000), dropping oldest-first on whichever it meets. It matters more
2585
+ here than it looks: the log is what keeps superseded row objects alive after the source has
2586
+ swapped in their replacements, so on a feed delivering five thousand rows a batch an
2587
+ entry-only cap retains twenty million of them. The one exception is a single change larger
2588
+ than the whole cap, which is kept — emptying the log would be worse than being briefly over,
2589
+ and it would drop the newest change rather than the oldest. Watch <code>held</code> against
2590
+ <code>heldLimit</code> in <code>grid.updates.stats()</code>; <code>rows</code> is a lifetime
2591
+ total and says nothing about memory.</p>
2592
+ <p><strong>Charts scrub like anything else.</strong> A sparkline column redraws to the series
2593
+ the row held at that point, and its cell is marked with the rest. The exception is the
2594
+ <code>delta</code> renderer: it samples on a wall-clock timer and compares against its own
2595
+ previous sample, so it reads a seek as a genuine movement and draws an arrow for it. Keep it
2596
+ off a grid you intend to scrub.</p>
2597
+ </div>
2598
+
2599
+ <h2 id="presentation-guide">Presentation mode</h2>
2600
+ <p class="lead-in">
2601
+ Renders the grid for a room: full-screen, application chrome hidden, everything enlarged.
2602
+ The data stays live and queryable throughout, so a question from the audience is answered by
2603
+ filtering in front of them rather than promised as a follow-up.
2604
+ </p>
2605
+ <div class="example">
2606
+ <p class="example__label">Starting and stopping</p>
2607
+ <pre><code>grid.presentation.start(); <span class="cmt">// 1.5x by default</span>
2608
+ grid.presentation.start({ scale: 2 });
2609
+ grid.presentation.start({ chrome: ['statusBar'] }); <span class="cmt">// keep some chrome</span>
2610
+
2611
+ grid.presentation.nudge(1); <span class="cmt">// live, or Ctrl/Cmd +</span>
2612
+ grid.presentation.setScale(1.8);
2613
+ grid.presentation.stop(); <span class="cmt">// or Escape</span></code></pre>
2614
+ </div>
2615
+ <div class="table-wrap">
2616
+ <table>
2617
+ <thead><tr><th>Keys</th><th>Does</th></tr></thead>
2618
+ <tbody>
2619
+ <tr><td class="sig">Escape</td><td class="desc">Leave, restoring the grid exactly as it was.</td></tr>
2620
+ <tr><td class="sig">Ctrl/Cmd + = / -</td><td class="desc">Enlarge or reduce live — a laptop on a call and a projector at the back of a room are different problems.</td></tr>
2621
+ <tr><td class="sig">Ctrl/Cmd + 0</td><td class="desc">Back to the default enlargement.</td></tr>
2622
+ </tbody>
2623
+ </table>
2624
+ </div>
2625
+ <div class="why">
2626
+ <p><strong>The scale multiplies your density, it does not replace it.</strong> A grid built at
2627
+ <code>spacious</code> presented at 1.5x is still recognisably that grid, half as big again —
2628
+ which is what makes a presentation look like the product rather than like a different one.
2629
+ Virtualisation follows the enlargement, so rows are positioned at the size they are drawn.</p>
2630
+ <p><strong>Full-screen is the maximiser, not a second implementation.</strong> A grid the user
2631
+ had already maximised stays maximised when the presentation ends: leaving it would be undoing
2632
+ something the presentation did not do. Chrome hidden on entry is recorded and put back, so an
2633
+ element the host had already hidden is not revealed on exit.</p>
2634
+ <p><strong>Events</strong> are <code>presentation:started</code>,
2635
+ <code>presentation:ended</code>, <code>presentation:scale</code> and
2636
+ <code>presentation:changed</code> — colon-separated like every other grid event rather than
2637
+ the camelCase the original brief used, so a host subscribing to them does not have to
2638
+ remember which family a name belongs to.</p>
2639
+ </div>
2640
+ <div class="example">
2641
+ <p class="example__label">Views are the slides</p>
2642
+ <pre><code>grid.presentation.start({ views: ['escalations', 'at-risk', 'margin-watch'] });
2643
+ grid.presentation.step(1); <span class="cmt">// or an arrow key, space, Page Down</span>
2644
+ grid.presentation.goTo(0); <span class="cmt">// or Home / End</span>
2645
+ grid.presentation.reset(); <span class="cmt">// or R — back to the view as saved</span></code></pre>
2646
+ </div>
2647
+ <div class="table-wrap">
2648
+ <table>
2649
+ <thead><tr><th>Keys</th><th>Does</th></tr></thead>
2650
+ <tbody>
2651
+ <tr><td class="sig">→ ↓ Space PageDown</td><td class="desc">Next view.</td></tr>
2652
+ <tr><td class="sig">← ↑ PageUp</td><td class="desc">Previous view.</td></tr>
2653
+ <tr><td class="sig">Home / End</td><td class="desc">First or last.</td></tr>
2654
+ <tr><td class="sig">R</td><td class="desc">Put the current view back as saved, discarding anything sorted or filtered since arriving at it.</td></tr>
2655
+ </tbody>
2656
+ </table>
2657
+ </div>
2658
+ <div class="why">
2659
+ <p><strong>They are the saved views you already have.</strong> A view captures the column
2660
+ set, order, widths, filters, sorts, grouping and density; stepping applies each through the
2661
+ ordinary <code>views.apply</code>, as a single undo entry. Nothing about presenting changes
2662
+ what a view means.</p>
2663
+ <p><strong>The stepping keys only bind when there is a sequence</strong>, and never while
2664
+ something is being typed into. Without a deck those keys belong to the grid — a presenter
2665
+ with no slides still expects Page Down to scroll — and a quick filter answering a question
2666
+ from the room must not advance the deck on the space bar.</p>
2667
+ <p><strong>Stepping past either end sits there.</strong> It does not wrap: a presenter who
2668
+ sees the first slide again thinks the deck has restarted.</p>
2669
+ <p><strong>Transitions are a cross-fade, not continuous row motion.</strong> Rows are pooled
2670
+ and virtualised, so an element holding a row before a view change may hold a different row
2671
+ after it — only rows visible in <em>both</em> states could be animated between positions, and
2672
+ half a movement draws the eye to whichever rows happened to survive rather than to the change
2673
+ itself. <code>prefers-reduced-motion</code> removes it; a projected fade is far larger than
2674
+ one on a laptop, so someone who asked for less motion meant it.</p>
2675
+ </div>
2676
+ <div class="example">
2677
+ <p class="example__label">Spotlight, redaction and unattended cycling</p>
2678
+ <pre><code><span class="cmt">// light one row across two columns; everything else recedes</span>
2679
+ grid.presentation.setSpotlight({ keys: ['R42'], colIds: ['margin', 'utilisation'] });
2680
+ grid.presentation.setSpotlight(null); <span class="cmt">// after the point is made</span>
2681
+
2682
+ <span class="cmt">// a wall display cycling saved views with nobody at the keyboard</span>
2683
+ grid.presentation.start({ views: [...], autoAdvance: 15000 });
2684
+
2685
+ <span class="cmt">// keep some chrome</span>
2686
+ grid.presentation.start({ chrome: ['statusBar'] });</code></pre>
2687
+ </div>
2688
+ <div class="why">
2689
+ <p><strong>Spotlight dims what it is not on, rather than lighting what it is.</strong> Rows
2690
+ and columns combine as an intersection, so naming both lights the cells where they meet. The
2691
+ dimming stops at 0.28 rather than going further: the audience has to see that there is more
2692
+ data and roughly what shape it is, or the spotlight reads as a filter and the room believes
2693
+ the other rows are gone. It is opacity alone, so a dimmed sparkline keeps its colours instead
2694
+ of flattening to grey.</p>
2695
+ <p><strong>A spotlight does not survive a view change.</strong> It belongs to the point being
2696
+ made, not to the deck — carried forward, it leaves the audience looking at a lit row that no
2697
+ longer means anything.</p>
2698
+ <p><strong>Redaction travels in views and undo.</strong> It is part of grid state, so a saved
2699
+ view carries its own masking and a view that redacts salary redacts it every time it is
2700
+ shown. Toggling is a tracked action, so it undoes like any other change.</p>
2701
+ <p><strong>Auto-advance wraps, unlike a keypress.</strong> An unattended display that stopped
2702
+ on the last view would show one screen for the rest of the day.</p>
2703
+ <p><strong>Escape ends the presentation, not just full screen.</strong> A presentation runs
2704
+ full screen with its chrome hidden, so leaving full screen without ending it would drop a
2705
+ chrome-less enlarged grid back into the page with no control left to turn it off. The
2706
+ maximiser stays the only listener on the key and the presentation follows it, which keeps one
2707
+ Escape doing one thing: an open editor or menu still closes first. A grid built with
2708
+ <code>maximise: false</code> binds the key directly instead.</p>
2709
+ </div>
2710
+ <div class="example">
2711
+ <p class="example__label">Capturing a still</p>
2712
+ <pre><code>const blob = await grid.capture({ scale: 2 });
2713
+ await grid.capture({ scale: 3, fileName: 'q3-margins.png' }); <span class="cmt">// and save it</span>
2714
+
2715
+ grid.on('presentation:captured', (e) =&gt; {
2716
+ console.log(e.width, e.height, e.bytes, e.mimeType); <span class="cmt">// 1800 600 41030 'image/png'</span>
2717
+ });</code></pre>
2718
+ </div>
2719
+ <div class="why">
2720
+ <p><strong>Mounting the bar elsewhere needs the grid's class.</strong> Every rule that styles
2721
+ the prompt bar is scoped under <code>.lattice</code>, and every colour token is declared
2722
+ there, so a bar mounted into your own chrome through <code>ai.element</code> arrives
2723
+ unstyled. Add <code>class="lattice"</code> to the container — and the same
2724
+ <code>data-theme</code> the grid carries, if you have set one — and it picks up the theme.</p>
2725
+ </div>
2726
+
2727
+ <div class="why">
2728
+ <p><strong>It photographs the browser's own rendering.</strong> The grid is cloned, every
2729
+ computed style is inlined onto the clone, and the result is wrapped in an SVG
2730
+ <code>foreignObject</code> and drawn to a canvas — so the picture is what the browser drew,
2731
+ not a second renderer's guess at it. That matters here more than usual: every decoration,
2732
+ sparkline and pill the cell layer produces comes out right without being reimplemented.</p>
2733
+ <p><strong>Virtualisation makes it cheap.</strong> Only the rows on screen exist in the DOM,
2734
+ so capturing a million-row grid clones the thirty rows a camera could have seen anyway.
2735
+ A full-screen capture at <code>scale: 2</code> takes around a second.</p>
2736
+ <p><strong>Cross-origin images are refused before the work starts.</strong> They taint the
2737
+ canvas, and a tainted canvas fails at the very last step with a <code>SecurityError</code>
2738
+ that names nothing — so the check runs first and the error names the offending URL. Serve the
2739
+ image same-origin, inline it as a <code>data:</code> URL, or hide the column.</p>
2740
+ <p><strong>Two further limits</strong>, both inherent to the technique: web fonts need
2741
+ embedding to appear (Lattice's default <code>system-ui</code> stack is unaffected), and CSS
2742
+ pseudo-elements are not captured.</p>
2743
+ </div>
2744
+ <div class="example">
2745
+ <p class="example__label">Drawing over the grid</p>
2746
+ <pre><code>grid.annotate.use('pen'); <span class="cmt">// pen · arrow · rect · highlight</span>
2747
+ grid.annotate.use('arrow', { colour: '#e0245e' });
2748
+ grid.annotate.undo();
2749
+ grid.annotate.clear();
2750
+ grid.annotate.use(null); <span class="cmt">// hand the grid back</span></code></pre>
2751
+ </div>
2752
+ <div class="why">
2753
+ <p><strong>It never touches data.</strong> Nothing in the layer reads a row or writes one. A
2754
+ grid with annotations sorts, filters and exports exactly as one without them.</p>
2755
+ <p><strong>It is inert unless a tool is chosen.</strong> The canvas is not even created until
2756
+ the first <code>use()</code>, and carries <code>pointer-events: none</code> whenever no tool
2757
+ is active, so scrolling, selection and editing pass straight through. A presenter who has
2758
+ finished drawing must not discover the grid has stopped responding.</p>
2759
+ <p><strong>Marks are anchored to the data, not the screen.</strong> They are stored in
2760
+ content coordinates and redrawn with the scroll offset subtracted, so a circle drawn round a
2761
+ cell travels with that cell rather than hanging over whatever scrolled underneath it.</p>
2762
+ <p><strong>They are transient.</strong> Marks annotate a moment, so they are cleared when the
2763
+ presentation ends. A capture taken while they are on screen includes them — the canvas bitmap
2764
+ is carried into the still deliberately, because <code>cloneNode</code> copies a canvas element
2765
+ and not one pixel of what was drawn on it.</p>
2766
+ <p><strong>No tool shortcuts are bound.</strong> The keys a presenter would want are already
2767
+ taken by stepping and by the grid itself, and a shortcut that silently shadows Page Down is
2768
+ worse than one the host chooses. Bind your own to <code>use()</code>.</p>
2769
+ </div>
2770
+
2771
+ <h2 id="accessibility-guide">Accessibility</h2>
2772
+ <p class="lead-in">
2773
+ The grid is built to WCAG 2.2 level AA. What follows is what it does, what it does not do yet,
2774
+ and the keyboard map in full &mdash; stated plainly, because a conformance claim that overstates
2775
+ is worth less than one that admits its edges.
2776
+ </p>
2777
+
2778
+ <h3>Keyboard</h3>
2779
+ <p>Every operation is reachable without a pointer. Resizing and reordering a column were once
2780
+ drag-only; both now have key bindings and menu items, so nothing depends on dragging.</p>
2781
+ <div class="table-wrap">
2782
+ <table>
2783
+ <thead><tr><th>Keys</th><th>Does</th></tr></thead>
2784
+ <!-- keyboard-map:start -->
2785
+ <tbody>
2786
+ <tr><th colspan="2">In the data</th></tr>
2787
+ <tr><td class="name"><kbd>ArrowUp/Down/Left/Right</kbd></td><td class="desc">Move the focused cell</td></tr>
2788
+ <tr><td class="name"><kbd>Home</kbd> <span class="sep">or</span> <kbd>End</kbd></td><td class="desc">First / last cell of the row</td></tr>
2789
+ <tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Home</kbd> <span class="sep">or</span> <kbd>Ctrl</kbd>+<kbd>End</kbd></td><td class="desc">First cell of the first row / last cell of the last row</td></tr>
2790
+ <tr><td class="name"><kbd>PageUp</kbd> <span class="sep">or</span> <kbd>PageDown</kbd></td><td class="desc">Move one viewport of rows</td></tr>
2791
+ <tr><td class="name"><kbd>Tab</kbd> <span class="sep">or</span> <kbd>Shift</kbd>+<kbd>Tab</kbd></td><td class="desc">Next / previous cell, wrapping across rows</td></tr>
2792
+ <tr><td class="name"><kbd>Enter</kbd></td><td class="desc">Start editing the focused cell</td></tr>
2793
+ <tr><td class="name"><kbd>Space</kbd></td><td class="desc">Toggle selection of the focused row</td></tr>
2794
+ <tr><td class="name"><kbd>Escape</kbd></td><td class="desc">Cancel the current edit or drag</td></tr>
2795
+ <tr><td class="name"><kbd>Alt</kbd>+<kbd>ArrowRight</kbd> <span class="sep">or</span> <kbd>Alt</kbd>+<kbd>ArrowLeft</kbd></td><td class="desc">Expand / collapse a group or tree row</td></tr>
2796
+ <tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd></td><td class="desc">Move focus to the column header</td></tr>
2797
+ <tr><td class="name"><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd></td><td class="desc">Move focus to the tool panel</td></tr>
2798
+ <tr><td class="name"><kbd>Shift</kbd>+<kbd>F10</kbd> <span class="sep">or</span> <kbd>ContextMenu</kbd></td><td class="desc">Open the context menu for the focused cell</td></tr>
2799
+ <tr><th colspan="2">On a column heading</th></tr>
2800
+ <tr><td class="name"><kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>ArrowRight</kbd></td><td class="desc">Move between headings</td></tr>
2801
+ <tr><td class="name"><kbd>Ctrl</kbd>+<kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>Ctrl</kbd>+<kbd>ArrowRight</kbd></td><td class="desc">First / last heading</td></tr>
2802
+ <tr><td class="name"><kbd>Enter or Space</kbd></td><td class="desc">Sort by the column, Shift to add to the sort</td></tr>
2803
+ <tr><td class="name"><kbd>Alt</kbd>+<kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>Alt</kbd>+<kbd>ArrowRight</kbd></td><td class="desc">Resize the column, Ctrl for a coarse step</td></tr>
2804
+ <tr><td class="name"><kbd>Shift</kbd>+<kbd>ArrowLeft</kbd> <span class="sep">or</span> <kbd>Shift</kbd>+<kbd>ArrowRight</kbd></td><td class="desc">Move the column</td></tr>
2805
+ <tr><td class="name"><kbd>Alt</kbd>+<kbd>ArrowDown</kbd></td><td class="desc">Open the column menu</td></tr>
2806
+ <tr><td class="name"><kbd>ArrowDown or Escape</kbd></td><td class="desc">Return focus to the data</td></tr>
2807
+ <tr><th colspan="2">In the tool panel's column list</th></tr>
2808
+ <tr><td class="name"><kbd>G</kbd></td><td class="desc">Group by the column, or stop grouping by it</td></tr>
2809
+ <tr><td class="name"><kbd>V</kbd></td><td class="desc">Add the column to values, or take it out</td></tr>
2810
+ <tr><td class="name"><kbd>P</kbd></td><td class="desc">Pivot by the column, or stop pivoting by it</td></tr>
2811
+ <tr><td class="name"><kbd>Shift</kbd>+<kbd>ArrowUp</kbd> <span class="sep">or</span> <kbd>Shift</kbd>+<kbd>ArrowDown</kbd></td><td class="desc">Move the column</td></tr>
2812
+ </tbody>
2813
+ <!-- keyboard-map:end -->
2814
+ </table>
2815
+ </div>
2816
+ <p class="note">This table is generated from the bindings the build ships and checked on every
2817
+ build, so it cannot drift from what the grid actually does.</p>
2818
+
2819
+ <h3>What a screen reader is told</h3>
2820
+ <p>The grid reports itself as a <code>grid</code>, or a <code>treegrid</code> when it holds a
2821
+ hierarchy, and the role follows the configuration rather than being fixed when the grid is
2822
+ created. Rows and cells carry their position in the <em>dataset</em>, not in the rendered
2823
+ window: a reader on row 500,000 of a virtualised grid is told exactly that, which is the point
2824
+ most grids get wrong. Rows in a hierarchy also carry their position among their siblings, since
2825
+ a reader cannot count siblings that were never rendered.</p>
2826
+ <p>Focus is real focus, moved onto the cell, rather than <code>aria-activedescendant</code>. It
2827
+ is restored after a row is recycled or scrolled out and back, and the grid holds a single tab
2828
+ stop, so tabbing in and out crosses it once.</p>
2829
+ <p>State changes are announced: sorting, filtering, selection, grouping, expanding and
2830
+ collapsing, paging, undo and redo, pasting, and rows arriving or leaving on a live feed. A feed
2831
+ is summarised on an interval rather than narrated, because a reader queues what it is given and
2832
+ a fast feed would leave someone listening to counts that are no longer true. A rejected edit is
2833
+ announced with the value that was put back, which is the change users most need to hear about
2834
+ and the one a visual marker alone cannot convey.</p>
2835
+
2836
+ <h3>Colour and contrast</h3>
2837
+ <p>No information is carried by hue alone. The <code>high-contrast</code> theme runs text at 21:1
2838
+ and borders at 6.1:1. In Windows High Contrast Mode the grid translates its state into borders
2839
+ and system colours rather than fighting the palette &mdash; see
2840
+ <a href="#theming">theming</a> for what that means in detail.</p>
2841
+
2842
+ <h3>How this is checked</h3>
2843
+ <p>The grid carries its own accessibility rules, and they run on every build against each
2844
+ configuration that differs structurally &mdash; flat, grouped, tree, pinned, editing, paginated
2845
+ and with a tool panel &mdash; rather than against one sample grid. The same rules are available
2846
+ live from the devtools panel, where they can also read colour and measure targets.</p>
2847
+ <p>Be clear about what that proves. These are our own rules covering what a data grid gets wrong,
2848
+ not a general-purpose engine, and automated checking of any kind catches a minority of real
2849
+ problems. They are a regression net &mdash; they stop a fix being undone silently &mdash; and
2850
+ not evidence of conformance.</p>
2851
+
2852
+ <h3>Bigger targets for touch</h3>
2853
+ <p class="lead-in">
2854
+ The grid meets the minimum target size on its own. That minimum is a conformance floor, not a
2855
+ comfortable size for a finger &mdash; both mobile platforms recommend nearer 44 pixels.
2856
+ </p>
2857
+ <div class="example">
2858
+ <pre><code>targetSize: 'large'</code></pre>
2859
+ </div>
2860
+ <p>This raises the hit areas and leaves the type where it is, which is the distinction that
2861
+ matters: a touch user wants a larger target, and a low-vision user wants larger text. Density
2862
+ is the control for the second, and the two combine &mdash; a compact grid with large targets is
2863
+ a reasonable thing to want on a tablet.</p>
2864
+ <p>It applies by itself under a coarse pointer, since the person holding one is both who the
2865
+ criterion is for and the least likely to go looking for a setting. Pass
2866
+ <code>targetSize: 'default'</code> to opt out of that.</p>
2867
+ <div class="why">
2868
+ <p>Density alone does not do this. It scales the header, the rows and the type, and leaves the
2869
+ affordances inside them exactly as they were &mdash; measured at every preset, the menu button
2870
+ stays 24 pixels, the filter 16 and the resize grip 10. A spacious grid has the room going spare
2871
+ and controls no larger than a compact one, which is the gap this fills.</p>
2872
+ </div>
2873
+
2874
+ <h3>Known limits</h3>
2875
+ <p>Stated because a report that claims everything invites the one question it cannot answer.</p>
2876
+ <ul>
2877
+ <li><strong>Two-dimensional scrolling.</strong> A grid scrolls horizontally at narrow widths.
2878
+ WCAG 1.4.10 Reflow explicitly permits this for data tables, so it is conforming rather than a
2879
+ gap &mdash; but it is worth knowing before you meet it.</li>
2880
+ <li><strong>Pinned columns do not release at narrow widths.</strong> At around 320 pixels, two
2881
+ pinned columns of ordinary width can leave under 60 pixels for the scrolling middle. The grid
2882
+ stays operable and nothing is lost, but a layout that pins columns is worth reviewing if you
2883
+ expect it to be used at that size.</li>
2884
+ <li><strong>The filter icon is a small target, deliberately.</strong> It is 16&nbsp;&times;&nbsp;16,
2885
+ below the 24-pixel minimum of WCAG 2.5.8, and conforms under that criterion's
2886
+ <em>equivalent</em> allowance: filtering is also a column-menu item, and the menu button
2887
+ meets the size on its own. Worth knowing if you are pointing at it on a touch screen &mdash;
2888
+ the menu is the larger route to the same thing.</li>
2889
+ </ul>
2890
+
2891
+ <h2 id="density-guide">Density</h2>
2892
+ <p class="lead-in">
2893
+ One number drives the grid's geometry. A preset sets it; every token derives from it.
2894
+ </p>
2895
+ <div class="example">
2896
+ <p class="example__label">Four presets, or a number between them</p>
2897
+ <pre><code>createGrid(el, { density: 'spacious' });
2898
+ createGrid(el, { density: 1.4 }); <span class="cmt">// anything between the presets</span>
2899
+ grid.set('density', 'compact'); <span class="cmt">// live</span></code></pre>
2900
+ </div>
2901
+ <div class="table-wrap">
2902
+ <table>
2903
+ <thead><tr><th>Preset</th><th>Scale</th><th>Row</th><th>Font</th><th>For</th></tr></thead>
2904
+ <tbody>
2905
+ <tr><td class="sig">compact</td><td>0.85</td><td>23.8px</td><td>12.7px</td><td class="desc">The default. Dense operational and financial grids; most rows on screen.</td></tr>
2906
+ <tr><td class="sig">standard</td><td>1</td><td>28px</td><td>13px</td><td class="desc">Roomier than the default, and what the grid rendered before density was connected.</td></tr>
2907
+ <tr><td class="sig">comfortable</td><td>1.5</td><td>42px</td><td>14px</td><td class="desc">Roomier application tables.</td></tr>
2908
+ <tr><td class="sig">spacious</td><td>2</td><td>56px</td><td>15px</td><td class="desc">Modern app listings with avatars and thumbnails.</td></tr>
2909
+ </tbody>
2910
+ </table>
2911
+ </div>
2912
+ <div class="why">
2913
+ <p><strong>Type does not scale with the rows.</strong> The row doubles from
2914
+ <code>compact</code> to <code>spacious</code> while the font moves about 18%. Scaling text
2915
+ against row height reads as a children's book long before the rows look generous, so height
2916
+ and spacing follow the scale directly and type is damped against it.</p>
2917
+ <p><strong>Virtualisation follows the token, not a separate number.</strong> Row positions
2918
+ are computed from the resolved <code>--lattice-row-height</code>, so a host that overrides
2919
+ that token by hand gets the virtualisation to agree. An explicit <code>rowHeight</code> in
2920
+ config outranks both — a host that names a number means it.</p>
2921
+ <p><strong>Height alone will not reproduce a modern app listing.</strong> Those designs pair
2922
+ generous rows with two-line cells — a bold title over a grey sub-label — and an avatar or
2923
+ thumbnail. <code>spacious</code> gives the room, an <a href="#image-guide">image column</a>
2924
+ gives the picture, and <a href="#twoline-guide">twoline</a> gives the second line.</p>
2925
+ </div>
2926
+
2927
+ <h2 id="image-guide">Image columns</h2>
2928
+ <p class="lead-in">
2929
+ A column whose value is a URL, drawn as a picture. Avatars beside a name, product
2930
+ thumbnails, company logos.
2931
+ </p>
2932
+ <div class="example">
2933
+ <p class="example__label">A circular avatar</p>
2934
+ <pre><code>{ field: 'avatar', type: 'image', layout: { width: 72 },
2935
+ cell: { props: { shape: 'circle' } } }</code></pre>
2936
+ </div>
2937
+ <div class="table-wrap">
2938
+ <table>
2939
+ <thead><tr><th>Prop</th><th>Type</th><th>Description</th></tr></thead>
2940
+ <tbody>
2941
+ <tr><td class="sig">shape</td><td class="type">'rounded' | 'circle' | 'square'</td><td class="desc">Defaults to <code>rounded</code>. <code>circle</code> is the avatar case.</td></tr>
2942
+ <tr><td class="sig">size</td><td class="type">number</td><td class="desc">Pixels. Omit and the box follows the density scale at 0.68 of the row height.</td></tr>
2943
+ <tr><td class="sig">fit</td><td class="type">'cover' | 'contain'</td><td class="desc"><code>cover</code> by default, so a mixed set of aspect ratios still forms a tidy column.</td></tr>
2944
+ <tr><td class="sig">alt</td><td class="type">string | function</td><td class="desc">Alternative text. Defaults to the cell's formatted text.</td></tr>
2945
+ <tr><td class="sig">loading</td><td class="type">'lazy' | 'eager'</td><td class="desc"><code>lazy</code> by default.</td></tr>
2946
+ </tbody>
2947
+ </table>
2948
+ </div>
2949
+ <div class="why">
2950
+ <p><strong>Only image URLs load.</strong> <code>http</code>, <code>https</code>,
2951
+ <code>blob:</code> and <code>data:image/</code> are permitted; everything else is refused,
2952
+ including a <code>data:</code> URL claiming to be anything other than an image. Relative URLs
2953
+ pass, since they cannot name a scheme. A grid drawing URLs that arrived in a data feed is
2954
+ exactly where a bad one gets through. Note this differs from the <code>link</code> renderer,
2955
+ which refuses <code>data:</code> outright — correct for an anchor, wrong for a picture.</p>
2956
+ <p><strong>A missing or broken image never moves the column.</strong> The box is sized from
2957
+ the row height whether or not the picture loads, and a failed load leaves it empty rather
2958
+ than showing the browser's broken-image glyph, which is a different size in every engine.</p>
2959
+ <p><strong>Exports carry the URL.</strong> CSV, Excel and the clipboard all get the text, not
2960
+ markup. Values are dictionary-encoded, which pays unusually well here: one URL per user or
2961
+ per company repeats down the whole column.</p>
2962
+ </div>
2963
+
2964
+ <h2 id="twoline-guide">Two-line cells</h2>
2965
+ <p class="lead-in">
2966
+ A bold primary line over a quieter secondary one, taken from a second property of the same
2967
+ row. A name over an email, a title over a category, a company over its sector.
2968
+ </p>
2969
+ <div class="example">
2970
+ <p class="example__label">Naming the second line</p>
2971
+ <pre><code>{ field: 'name', cell: { render: 'twoline', props: { secondary: 'email' } } }
2972
+
2973
+ <span class="cmt">// a dot path reaches a nested field without a callback</span>
2974
+ { field: 'name', cell: { render: 'twoline', props: { secondary: 'contact.email' } } }
2975
+
2976
+ <span class="cmt">// a function for anything the row does not already hold</span>
2977
+ { field: 'name', cell: { render: 'twoline', props: {
2978
+ secondary: (p) =&gt; `${p.data.city}, ${p.data.country}`,
2979
+ } } }
2980
+
2981
+ <span class="cmt">// `format` decorates whatever `secondary` produced</span>
2982
+ props: { secondary: 'user', format: (v) =&gt; v ? `User: ${v}` : '' }</code></pre>
2983
+ </div>
2984
+ <div class="why">
2985
+ <p><strong>It needs the room.</strong> Two lines do not fit in a 28px row. Pair it with
2986
+ <code>density: 'comfortable'</code> or <code>'spacious'</code>, or a <code>rowHeight</code>
2987
+ of 40 or more. Nothing stops you using it in a shorter row; the second line is simply
2988
+ clipped.</p>
2989
+ <p><strong>The second line reads the row's data, not another column.</strong> It is usually a
2990
+ field nobody wants a column of, and requiring one would mean declaring a column purely to
2991
+ hide it. A path that does not resolve leaves the line empty rather than throwing, and a cell
2992
+ with no second line collapses to one centred line rather than leaving a gap.</p>
2993
+ <p><strong>Both lines truncate; neither wraps.</strong> A wrapped second line would change the
2994
+ row height, and in a fixed-height grid that means being cut off mid-descender instead. The
2995
+ accessible name carries both lines as one string, so a screen reader gets the half of the
2996
+ cell that disambiguates the first.</p>
2997
+ </div>
2998
+
2999
+ <h2 id="redaction-guide">Redacting a column</h2>
3000
+ <p class="lead-in">
3001
+ For presenting and screen sharing: obscure the values in a column while everything that
3002
+ makes the grid readable — the row count, the sort, the filters, the column layout — stays
3003
+ exactly as it was. Right-click a column heading and choose <strong>Redact column</strong>.
3004
+ </p>
3005
+ <div class="example">
3006
+ <p class="example__label">From the API</p>
3007
+ <pre><code>grid.redaction.toggle('salary');
3008
+ grid.redaction.set(['salary', 'bonus']);
3009
+ grid.redaction.clear(); <span class="cmt">// when the call ends</span></code></pre>
3010
+ </div>
3011
+ <div class="why">
3012
+ <p><strong>This is not a security control, and the difference matters.</strong> The values
3013
+ are still in the model, still in the DOM, still on the clipboard and still in every export.
3014
+ Anyone looking at the page can read them from devtools, or by turning off a single CSS rule.
3015
+ What redaction defeats is a camera and a screen recorder — which is the threat a presenter
3016
+ actually has, and the only one it claims to answer. For a value that must never reach the
3017
+ browser, use <code>permissions</code> with <code>writeOnly</code>: there the value is not
3018
+ sent, so there is nothing to reveal.</p>
3019
+ <p><strong>Headings stay readable, totals do not.</strong> The column heading is left alone
3020
+ deliberately — a redacted column still has to be identifiable, by the presenter who wants to
3021
+ turn it back on and by the audience who need to know what they are not being shown. The
3022
+ pinned totals row <em>is</em> redacted, because the sum of a column is not a hint at its
3023
+ values: filter to one row and the total is the value.</p>
3024
+ <p><strong>Swapping the treatment.</strong> <code>--lattice-redaction-filter</code> defaults
3025
+ to <code>blur(5px) contrast(0.85)</code> and takes anything the CSS <code>filter</code>
3026
+ property does. A blur is the default because it is GPU-composited and stays cheap across a
3027
+ scrolling viewport; point the token at an SVG mosaic filter if you prefer classic
3028
+ pixellation and can afford it per cell.</p>
3029
+ </div>
3030
+
3031
+ <h2 id="clipboard">Clipboard</h2>
3032
+ <p class="lead-in">
3033
+ Copy produces the tab-separated form Excel, Numbers and Sheets all read, so a range pastes as
3034
+ cells rather than as one lump of text. Values go through each column's clipboard hook — a
3035
+ lookup copies its label, a date copies an unambiguous form.
3036
+ </p>
3037
+ <div class="example">
3038
+ <p class="example__label">Copy and paste</p>
3039
+ <pre><code>grid.export.rangeText(); <span class="cmt">// the text, without writing it</span>
3040
+ grid.export.clipboard({ rows: 'range' }); <span class="cmt">// copy the range</span>
3041
+ grid.export.clipboard({ headers: true, rows: 'selected' });
3042
+
3043
+ grid.edit.pasteInto(text); <span class="cmt">// Excel's tiling rules</span></code></pre>
3044
+ </div>
3045
+ <div class="why">
3046
+ <p>Pasting follows the spreadsheet convention: one cell into a range fills the range, one row
3047
+ into several rows repeats down, and a block larger than the target extends past it. People
3048
+ have twenty years of muscle memory for this and a grid that invents its own rules is a grid
3049
+ people fight.</p>
3050
+ </div>
3051
+
3052
+ <h2 id="keyboard">Keyboard</h2>
3053
+ <div class="table-wrap">
3054
+ <table>
3055
+ <thead><tr><th>Keys</th><th>Does</th></tr></thead>
3056
+ <tbody>
3057
+ <tr><td class="sig">Arrows</td><td class="desc">Move the focused cell.</td></tr>
3058
+ <tr><td class="sig">Shift + arrows</td><td class="desc">Extend the range.</td></tr>
3059
+ <tr><td class="sig">Ctrl/Cmd + Shift + arrows</td><td class="desc">Open a second range at the focused cell, keeping the first; further presses extend it.</td></tr>
3060
+ <tr><td class="sig">Ctrl/Cmd + C, V, D</td><td class="desc">Copy, paste, fill down.</td></tr>
3061
+ <tr><td class="sig">Delete / Backspace</td><td class="desc">Clear the selected cells.</td></tr>
3062
+ <tr><td class="sig">Enter</td><td class="desc">Start editing; commit and step down.</td></tr>
3063
+ <tr><td class="sig">Tab</td><td class="desc">Commit and step across.</td></tr>
3064
+ <tr><td class="sig">Escape</td><td class="desc">Cancel the edit; restore a maximised grid once nothing else wants it.</td></tr>
3065
+ <tr><td class="sig">Space</td><td class="desc">Toggle the row's selection.</td></tr>
3066
+ <tr><td class="sig">Home / End, Page Up / Down</td><td class="desc">Jump; with Ctrl, to the ends of the grid.</td></tr>
3067
+ </tbody>
3068
+ </table>
3069
+ </div>
3070
+ <div class="why">
3071
+ <p>None of these fire while you are typing into an input — a filter box, an open editor, the
3072
+ view-name field. The grid checks where the keystroke came from before claiming it, which
3073
+ sounds obvious and is the sort of thing that is usually wrong.</p>
3074
+ </div>
3075
+
3076
+ <h2 id="rules-guide">Conditional formatting</h2>
3077
+ <p class="lead-in">
3078
+ A rule is a condition and the styling it produces. <code>compileRules()</code> turns a list
3079
+ into the function <code>cell.style</code> already accepts.
3080
+ </p>
3081
+ <div class="example">
3082
+ <pre><code><span class="kw">import</span> { compileRules } <span class="kw">from</span> '@toclocoinc/lattice-grid';
3083
+
3084
+ { field: 'margin', cell: { style: compileRules([
3085
+ { when: { op: 'lt', value: 0 }, style: { background: '#fdecea', colour: '#b91c1c' } },
3086
+ { when: { op: 'gt', value: 20 }, style: { weight: 600 }, stopIfTrue: <span class="kw">false</span> },
3087
+ { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } },
3088
+ ]) } }</code></pre>
3089
+ </div>
3090
+ <div class="why">
3091
+ <p><strong>The operators are the filter's operators.</strong> <code>gt</code>,
3092
+ <code>between</code> and <code>contains</code> mean here exactly what they mean in the grid's
3093
+ filters. Anyone
3094
+ who has built a filter has already learned this, and two vocabularies for one idea is how a
3095
+ product ends up explaining itself twice.</p>
3096
+ <p><strong>First match wins, by default.</strong> "Red if overdue, amber if due this week"
3097
+ reads top to bottom and stops — the spreadsheet convention, and the one people expect.
3098
+ <code>stopIfTrue: false</code> lets rules combine: weight from one, colour from another.</p>
3099
+ <p><strong>A blank cell satisfies no comparison.</strong> <code>Number(null)</code> is zero, so
3100
+ a naive implementation sweeps every empty cell into "less than 100" and formats half a column
3101
+ that has no data in it.</p>
3102
+ <p><strong>Text still compares numerically.</strong> A rules panel produces strings — the box
3103
+ the user typed into yields <code>"100"</code>, not <code>100</code> — and comparing those as
3104
+ text puts <code>"9"</code> above <code>"100"</code>.</p>
3105
+ <p><strong>A scale's bounds are given, not derived.</strong> Deriving them means scanning the
3106
+ column per cell, and a scale that rescaled as rows were filtered would change a cell's colour
3107
+ without its value changing — the opposite of what the colour is for.</p>
3108
+ </div>
3109
+
3110
+ <h2 id="formatting-guide">Formatting an end user can change</h2>
3111
+ <p class="lead-in">
3112
+ <code>compileRules()</code> above compiles at configuration time into <code>cell.style</code>,
3113
+ which you write and a user cannot reach. <code>grid.formatting</code> holds the same rules as
3114
+ runtime state instead: the cell layer asks it on every paint, so a rule added while the grid
3115
+ is running takes effect on the next frame.
3116
+ </p>
3117
+ <div class="example">
3118
+ <p class="example__label">Rules as state</p>
3119
+ <pre><code>grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } });
3120
+ grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } });
3121
+
3122
+ grid.formatting.list('margin'); <span class="cmt">// [{ id, when, style }, …] in evaluation order</span>
3123
+ grid.formatting.move('margin', id, 0); <span class="cmt">// order decides which rule wins</span>
3124
+ grid.formatting.update('margin', id, { enabled: false });
3125
+ grid.formatting.clear('margin');</code></pre>
3126
+ </div>
3127
+ <div class="why">
3128
+ <p><strong>Two scopes, one ordered list.</strong> A rule sits on a column id or on
3129
+ <code>'*'</code> for every column. Evaluation joins them — grid-wide first, then the column's
3130
+ own — so a column rule can override a grid-wide one, and <code>stopIfTrue</code> means the
3131
+ same thing across the join as it does within either half.</p>
3132
+ <p><strong>Saved views and undo came free.</strong> The rules are a section of
3133
+ <code>GridState</code>, and both saved views and the undo timeline are built on that. Nothing
3134
+ in the formatting model knows either exists.</p>
3135
+ <p><strong>Rules must be JSON.</strong> <code>style</code> cannot be a function here, because
3136
+ the rules are serialised into views and undo slices. Config-time <code>cell.style</code> still
3137
+ takes one, which is the right home for a rule a user should not be able to change.</p>
3138
+ <p><strong>Both paths coexist.</strong> Where a column has a <code>cell.style</code> and a
3139
+ runtime rule matches, the two are merged and written once; the runtime rule wins for the
3140
+ properties it names and leaves the rest of your styling alone.</p>
3141
+ <p><strong>Group rows are not formatted.</strong> A group row summarises many values rather
3142
+ than being an instance of one, which is the same reason decoration is dropped for it.</p>
3143
+ </div>
3144
+ <div class="example">
3145
+ <p class="example__label">The panel</p>
3146
+ <pre><code>createGrid(el, { toolPanel: { side: 'right', panels: ['columns', 'filters', 'formatting'] } });</code></pre>
3147
+ </div>
3148
+ <div class="why">
3149
+ <p>The panel is a form over that array and nothing more — every control is one call into
3150
+ <code>grid.formatting</code>, which is what makes each gesture undoable without the panel
3151
+ knowing undo exists. It exposes ordering because ordering is meaning: dragging a rule up can
3152
+ change which of two colours a cell takes.</p>
3153
+ <p><strong>Not yet in the panel:</strong> icon sets and data bars. Both are column decorations
3154
+ rather than cell styles — the <code>bar</code> and <code>icon</code> decorations already
3155
+ render them, and driving those from the panel needs a runtime column-decoration API that
3156
+ persists and undoes alongside the rules. Building it as a second bar implementation inside
3157
+ the rule engine was the alternative, and the wrong one.</p>
3158
+ </div>
3159
+
3160
+ <h2 id="quickfilter-guide">Quick filter modes</h2>
3161
+ <p class="lead-in">
3162
+ One box, four ways to match: <code>contains</code> (the default), <code>words</code>,
3163
+ <code>fuzzy</code> and <code>regex</code>.
3164
+ </p>
3165
+ <div class="example">
3166
+ <pre><code>grid.filters.quick('acme london', { mode: 'words' }); <span class="cmt">// every term, any column</span>
3167
+ grid.filters.quick('crc', { mode: 'fuzzy' }); <span class="cmt">// characters in order</span>
3168
+ grid.filters.quick('^CIR-[12]', { mode: 'regex' });
3169
+
3170
+ grid.filters.quick('acme'); <span class="cmt">// mode persists — still 'regex' here</span>
3171
+ grid.filters.quickState(); <span class="cmt">// { text, mode }</span></code></pre>
3172
+ </div>
3173
+ <div class="why">
3174
+ <p><strong>Compiled once per query, not per row.</strong> A regular expression rebuilt for each
3175
+ of a hundred thousand rows is a hundred thousand compiles for one keystroke. The predicate is
3176
+ built in the filter stage and applied to a cached text blob per row, which is why typing stays
3177
+ responsive at scale.</p>
3178
+ <p><strong>An unfinished pattern does not blank the grid.</strong> <code>foo(</code> is what
3179
+ <code>foo(bar)</code> looks like halfway through typing. An invalid expression falls back to a
3180
+ literal search, so the list stays sensible until the pattern is valid again.</p>
3181
+ <p><strong>Fuzzy does not rank.</strong> Subsequence matching decides what stays; it never
3182
+ reorders. Sorting results by match quality would fight the sort the user chose, and a filter
3183
+ that quietly re-sorts is worse than one that matches too much.</p>
3184
+ <p><strong>Permissions still apply.</strong> The text blob is built only from columns the
3185
+ viewer may see. A hidden column is not searchable, or the row count becomes a way to probe the
3186
+ value behind it.</p>
3187
+ </div>
3188
+
3189
+ <h2 id="charts-guide">In-cell charts</h2>
3190
+ <p class="lead-in">
3191
+ Seven chart types for a cell: <code>line</code>, <code>area</code>, <code>column</code>,
3192
+ <code>winloss</code>, <code>pie</code>, <code>donut</code>, <code>bullet</code>,
3193
+ <code>stacked</code>, <code>range</code>, <code>gauge</code> and <code>delta</code>. Most read an array;
3194
+ <code>bullet</code> and <code>gauge</code> read a number.
3195
+ </p>
3196
+ <div class="example">
3197
+ <p class="example__label">A trend, a mix and a target</p>
3198
+ <pre><code>columns: [
3199
+ { id: 'trend', field: 'readings', cell: 'line' },
3200
+ { id: 'mix', field: 'split', cell: { render: 'donut', props: { hole: 0.55 } } },
3201
+ { id: 'sla', field: 'uptime',
3202
+ cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } },
3203
+
3204
+ <span class="cmt">// Two views of one field: give each an explicit id.</span>
3205
+ { id: 'shape', field: 'readings', cell: 'line' },
3206
+ { id: 'detail', field: 'readings', cell: 'column' },
3207
+ ]</code></pre>
3208
+ </div>
3209
+ <div class="why">
3210
+ <p><strong>A cell is a few hundred pixels seen for a second</strong>, so there are no axes, no
3211
+ gridlines and no legend. The chart carries one idea — a shape, a share, a comparison — and the
3212
+ number beside it carries the precision. Hide the number with <code>label: false</code> when the
3213
+ column next to it already says the same thing.</p>
3214
+ <p><strong>Pin <code>min</code> and <code>max</code> when columns are meant to compare.</strong>
3215
+ A sparkline scaled to its own data fills its cell whatever the magnitude, so two rows differing
3216
+ by an order of magnitude draw identically. Pinning the scale is what makes the column readable
3217
+ down its length rather than only across it.</p>
3218
+ <p><strong>A gap is not a zero.</strong> Entries that are not numbers break the line and omit
3219
+ the bar, rather than being drawn at the baseline — joining across a missing reading would draw
3220
+ a trend nobody measured, and drawing it at zero invents a dip.</p>
3221
+ <p><strong>Cost.</strong> Each chart is one SVG built once, with the paths' <code>d</code>
3222
+ attributes the only thing a repaint writes, and a bar chart is two paths rather than one
3223
+ element per bar. Rows recycle as you scroll, so this is what keeps a chart column the same
3224
+ price as a text one. Nothing measures the DOM — the drawing happens in a fixed coordinate
3225
+ space that CSS scales.</p>
3226
+ <p><strong>Accessibility.</strong> The chart is <code>aria-hidden</code> and the cell carries a
3227
+ summary: "12 points, 9 to 20, ending 18". A path cannot be read aloud, and a series announced
3228
+ value by value tells a listener less than the sentence does.</p>
3229
+ </div>
3230
+
3231
+ <h2 id="formulas-guide">Formulas</h2>
3232
+ <p class="lead-in">
3233
+ A leading <code>=</code> in a numeric cell is a formula. Type
3234
+ <code>=quantity * unitPrice</code> and the grid stores 119.88.
3235
+ </p>
3236
+ <div class="example">
3237
+ <p class="example__label">What a user can type</p>
3238
+ <pre><code>=5 + 5
3239
+ =quantity * unitPrice <span class="cmt">// by field name</span>
3240
+ =[Unit Price] * 1.2 <span class="cmt">// by title, when it has spaces</span>
3241
+ =ROUND(quantity * unitPrice, 2)
3242
+ =IF(quantity &gt; 10, "bulk", "single")
3243
+ =SUM(readings) <span class="cmt">// an array property on the row</span>
3244
+ =MAX(readings) - MIN(readings)</code></pre>
3245
+ </div>
3246
+ <div class="why">
3247
+ <p><strong>References name columns, not cells.</strong> A spreadsheet can say <code>A1</code>
3248
+ because its rows do not move. A grid sorts, filters, groups, pages and virtualises, so the row
3249
+ at position 1 is a different row a moment later and a formula written against it would silently
3250
+ change meaning. <code>quantity * price</code> means the same thing wherever the row goes.</p>
3251
+ <p><strong>No <code>eval</code>, no <code>new Function</code>.</strong> This is text a
3252
+ <em>user</em> typed into a cell. Handing it to the JavaScript engine would let anyone who can
3253
+ edit a cell read your cookies, call your API with your credentials, or post the grid's contents
3254
+ anywhere. It is a hand-written tokeniser and recursive-descent parser, and the only callable
3255
+ things are the built-in functions — <code>constructor</code>, <code>globalThis</code> and
3256
+ <code>constructor.constructor("return 1")()</code> all simply fail to resolve.</p>
3257
+ <p><strong>The result is stored, not the expression.</strong> A formula is a way of
3258
+ <em>entering</em> a value, exactly like <code>1,200</code> or <code>(50)</code> or
3259
+ <code>12%</code>. It commits as one undo step, fires one <code>cell:changed</code>, and passes
3260
+ through the column's own validation.</p>
3261
+ <p><strong>The result is stored, not the formula.</strong> The expression is evaluated once, at
3262
+ the moment you commit it, and what lands in the cell is a value like any other — so it does not
3263
+ recalculate when a cell it referred to changes later. For a value that must stay in step with
3264
+ its inputs, use a computed column, which is re-evaluated whenever its dependencies move.</p>
3265
+ </div>
3266
+ <div class="example">
3267
+ <p class="example__label">Adding your own functions</p>
3268
+ <pre><code>createGrid(el, {
3269
+ formulaFunctions: {
3270
+ MARGIN: ([revenue, cost]) =&gt; (revenue - cost) / revenue,
3271
+ BAND: ([value]) =&gt; (value &gt; 1000 ? 'A' : 'B'),
3272
+ },
3273
+ });
3274
+
3275
+ <span class="cmt">// Or evaluate one yourself, anywhere.</span>
3276
+ <span class="kw">import</span> { evaluateFormula } <span class="kw">from</span> '@toclocoinc/lattice-grid';
3277
+ <span class="kw">const</span> r = evaluateFormula('=a * b', { data: { a: 6, b: 7 } });
3278
+ r.ok ? r.value : r.error; <span class="cmt">// 42</span></code></pre>
3279
+ </div>
3280
+ <p class="lead-in">
3281
+ A formula that cannot be read rejects the edit and the cell keeps its old value, the same as any
3282
+ other unparseable text. Failures are returned rather than thrown — this runs on the commit path,
3283
+ where an exception would abandon the commit half-done.
3284
+ </p>
3285
+ <div class="why">
3286
+ <p><strong>Bare arithmetic is deliberately not a formula.</strong> <code>2-1</code> is a
3287
+ plausible product code and <code>1/2</code> a plausible date. Evaluating either on a guess would
3288
+ repeat the bug this replaced, where the number reader stripped the operator and stored
3289
+ <code>2*3</code> as <strong>23</strong>. Arithmetic without a leading <code>=</code> is now
3290
+ refused outright, so the cell keeps what it had rather than taking a plausible wrong number.</p>
3291
+ </div>
3292
+
3293
+ <h2 id="custom-menu">Your own menu items and buttons</h2>
3294
+ <p class="lead-in">
3295
+ The cell menu's function form is handed the cell that was clicked <strong>and</strong> the
3296
+ built-in items. Adding one entry does not mean reproducing the other thirteen.
3297
+ </p>
3298
+ <div class="example">
3299
+ <p class="example__label">An item that acts on the cell it was opened on</p>
3300
+ <pre><code>createGrid(el, {
3301
+ contextMenu: (params, defaults) =&gt; [
3302
+ ...defaults,
3303
+ { separator: <span class="kw">true</span> },
3304
+ {
3305
+ name: `Open ${params.value} in CRM`,
3306
+ action: (ctx) =&gt; window.open(`/crm/${ctx.data.accountId}`),
3307
+ },
3308
+ ],
3309
+ });</code></pre>
3310
+ </div>
3311
+ <p class="lead-in">
3312
+ <code>params</code> and the <code>action</code>'s argument are the same shape:
3313
+ <code>{ key, colId, value, row, data, column, index, grid }</code>. <code>data</code> is your
3314
+ original row object, so an item can reach fields the grid never displayed.
3315
+ </p>
3316
+ <div class="why">
3317
+ <p><strong>Handed the defaults, rather than replacing them.</strong> A builder that had to
3318
+ return every item in order to append one would be written once as a copy of the built-ins and
3319
+ would then drift from them — the copy keeps the menu it was forked from, and stops gaining
3320
+ whatever the grid adds later. Spreading <code>defaults</code> costs one line and never goes
3321
+ stale.</p>
3322
+ <p>Return the array you want shown: add, remove, reorder, or replace outright. An empty array
3323
+ suppresses the menu deliberately. Returning <em>nothing</em> leaves the defaults alone, because
3324
+ a missing <code>return</code> is a typo and deleting the whole menu is a harsh reading of
3325
+ one.</p>
3326
+ </div>
3327
+ <div class="example">
3328
+ <p class="example__label">A button of your own on the rail</p>
3329
+ <pre><code>createGrid(el, {
3330
+ toolPanel: {
3331
+ side: 'left',
3332
+ <span class="cmt">// A string names a built-in; an object is yours. Order is respected, so</span>
3333
+ <span class="cmt">// yours can sit between built-ins rather than only after them.</span>
3334
+ actions: ['undo', 'redo', {
3335
+ name: 'sync',
3336
+ title: 'Sync to the server',
3337
+ icon: 'restore',
3338
+ run: ({ grid, keys, cells }) =&gt; api.sync(keys),
3339
+ enabled: () =&gt; grid.state.modified(),
3340
+ }],
3341
+ },
3342
+ });</code></pre>
3343
+ </div>
3344
+ <p class="lead-in">
3345
+ <code>title</code> and <code>icon</code> may each be a function, re-read on every repaint, for
3346
+ a control whose meaning changes — that is how maximise becomes restore. <code>enabled</code> is
3347
+ a predicate rather than a flag, so a button that cannot do anything greys itself out instead of
3348
+ doing nothing when clicked.
3349
+ </p>
3350
+
3351
+ <h2 id="rail">The left rail</h2>
3352
+ <p class="lead-in">
3353
+ <code>toolPanel: { side: 'left' }</code> docks the panels as an icon rail and turns on the
3354
+ action buttons: undo, redo, export to CSV, export to Excel, copy to the clipboard, print,
3355
+ restore the default view, and maximise. Each is a single click on the thing you came for, and
3356
+ all four export destinations are in the cell context menu as well.
3357
+ </p>
3358
+ <div class="example">
3359
+ <p class="example__label">Picking a subset</p>
3360
+ <pre><code>createGrid(el, {
3361
+ toolPanel: {
3362
+ side: 'left',
3363
+ panels: ['columns', 'filters', 'views', 'quick'],
3364
+ <span class="cmt">// Omit `actions` entirely to take all seven, including any added later.</span>
3365
+ actions: ['undo', 'redo', 'export', 'excel', 'clipboard', 'print', 'restore', 'maximise'],
3366
+ exportName: 'circuits',
3367
+ },
3368
+ });</code></pre>
3369
+ </div>
3370
+ <div class="why">
3371
+ <p><strong>An explicit <code>actions</code> array replaces the default rather than extending
3372
+ it.</strong> That is what makes it useful — you choose the set and the order — but it also
3373
+ means a config written against an earlier version keeps exactly the buttons it named and
3374
+ silently misses anything added since. If you want whatever the current version offers, leave
3375
+ the key out.</p>
3376
+ <p>Every action is gated on a predicate rather than always offered, so undo greys out when
3377
+ there is nothing to undo and "restore the default view" greys out when nothing has changed. A
3378
+ rail of seven icons where three do nothing is worse than a rail of four.</p>
3379
+ </div>
3380
+
3381
+ <h2 id="maximise-guide">Maximise</h2>
3382
+ <p class="lead-in">
3383
+ A grid usually lives in whatever box the page layout gave it, and that box is usually too
3384
+ small for the job. The left rail's last button fills the browser window with the grid, and
3385
+ clicking it again puts the grid back exactly where it was. <kbd>Esc</kbd> also restores.
3386
+ </p>
3387
+ <div class="example">
3388
+ <p class="example__label">Your own control, or a keyboard shortcut</p>
3389
+ <pre><code><span class="cmt">// The rail button is on by default. This is the same thing.</span>
3390
+ grid.maximise.toggle();
3391
+ grid.maximise.active(); <span class="cmt">// true while it fills the window</span>
3392
+
3393
+ document.addEventListener('keydown', (e) =&gt; {
3394
+ <span class="kw">if</span> (e.key === 'F11' &amp;&amp; !e.ctrlKey) { e.preventDefault(); grid.maximise.toggle(); }
3395
+ });
3396
+
3397
+ <span class="cmt">// Or take the button away, if the application has its own full-screen mode.</span>
3398
+ createGrid(el, { maximise: <span class="kw">false</span> });</code></pre>
3399
+ </div>
3400
+ <p class="lead-in">
3401
+ One button in two states rather than two buttons: the icon and its label turn round when the
3402
+ grid is maximised, so the control always describes what it is about to do.
3403
+ </p>
3404
+ <div class="why">
3405
+ <p><strong>The element is moved, not just restyled.</strong> A <code>position: fixed</code>
3406
+ element is positioned against the nearest ancestor carrying a <code>transform</code>,
3407
+ <code>filter</code>, <code>contain</code> or <code>will-change</code> — which is to say any
3408
+ card, any animated panel, any sticky app shell — and against the viewport only when there is
3409
+ no such ancestor. A class alone would therefore fill the window on one page and land in a
3410
+ 300px box on the next, and would still be clipped by an <code>overflow: hidden</code> or
3411
+ buried by a stacking context. Reparenting to <code>&lt;body&gt;</code> removes every ancestor
3412
+ that could do any of that.</p>
3413
+ <p>Coming back is a hidden placeholder left in the element's place, not a remembered parent
3414
+ and index — an index goes stale the moment your application inserts a sibling while the grid
3415
+ is away, and then silently reinserts in the wrong slot. The placeholder also holds the vacated
3416
+ space open at the size the grid had, so the page behind neither reflows nor loses its scroll
3417
+ position while you are looking at the grid.</p>
3418
+ <p>The geometry is written as inline styles and every displaced property is handed back
3419
+ exactly as it was found, because the element being restyled is <em>yours</em> — most often one
3420
+ with an inline <code>height</code> on it, which is the ordinary way a grid gets sized and
3421
+ which nothing but an inline style can beat. While maximised, the element carries
3422
+ <code>.lat-maximised</code> and <code>&lt;body&gt;</code> carries
3423
+ <code>.lat-maximised-host</code>, as hooks for your own CSS.</p>
3424
+ </div>
3425
+
3426
+ <h2 id="highlighting">Highlighting</h2>
3427
+ <p class="lead-in">
3428
+ One mechanism for two jobs: the flash a changed cell makes, and a marker you paint
3429
+ deliberately.
3430
+ </p>
3431
+ <div class="example">
3432
+ <p class="example__label">On change, and on demand</p>
3433
+ <pre><code>highlightOnChange: { colour: '#ffe08a', duration: 1200 },
3434
+
3435
+ grid.highlight({ key: 'r1', colId: 'cap' }, { colour: 'green', duration: 800 });
3436
+ grid.highlight({ key: 'r3' }, { colour: '#fdeaea', duration: 0 }); <span class="cmt">// until cleared</span>
3437
+ grid.highlight({ colId: 'margin' },{ colour: '#e7f1fd', duration: 0 });
3438
+ grid.highlight.clear({ key: 'r3' });
3439
+ grid.highlight.clear();</code></pre>
3440
+ </div>
3441
+ <p class="lead-in">
3442
+ Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over
3443
+ it. A highlight belongs to the row rather than the element, so it survives scrolling, sorting
3444
+ and paging.
3445
+ </p>
3446
+ <h2 id="views-guide">Saved views</h2>
3447
+ <p class="lead-in">
3448
+ A view is a named grid state — sort, filters, grouping, column order, widths, visibility.
3449
+ There are two kinds and the picker keeps them apart.
3450
+ </p>
3451
+ <div class="example">
3452
+ <p class="example__label">Views you ship, and views the user saves</p>
3453
+ <pre><code>views: {
3454
+ saved: [
3455
+ { id: 'escalations', name: 'Escalations',
3456
+ description: 'Escalated circuits, worst SLA first',
3457
+ state: {
3458
+ filters: { col: 'statusId', op: 'eq', value: 4 },
3459
+ sort: [{ col: 'utilisation', dir: 'desc' }],
3460
+ }},
3461
+ { id: 'commercial', name: 'Commercial', isDefault: true,
3462
+ state: { columns: [{ id: 'notes', hidden: true }] }},
3463
+ ],
3464
+ allowSave: true,
3465
+ }</code></pre>
3466
+ </div>
3467
+ <p class="lead-in">
3468
+ Views in <code>saved</code> are <strong>defined views</strong>: part of the application,
3469
+ listed under their own heading, and neither renamable nor deletable — refused by the model as
3470
+ well as hidden in the interface. A view flagged <code>isDefault</code> is applied on load.
3471
+ Everything the user saves sits below, with rename, share, make-default and delete.
3472
+ </p>
3473
+ <div class="why">
3474
+ <p><strong>Applying a view is a destination, not a patch.</strong> A view's state names only
3475
+ the sections it cares about, so applying one resets to the grid's starting state first. Without
3476
+ that, clicking "APAC capacity" after "Commercial" would inherit Commercial's hidden columns —
3477
+ the same view giving a different grid depending on what preceded it, which is the one thing a
3478
+ named view must not do.</p>
3479
+ </div>
3480
+
3481
+ <h3>Persisting them</h3>
3482
+ <p class="lead-in">
3483
+ <strong>The grid makes no network calls.</strong> It tells you what happened and you decide
3484
+ what that means.
3485
+ </p>
3486
+ <div class="example">
3487
+ <p class="example__label">To a server</p>
3488
+ <pre><code>grid.on('view:saved', e =&gt; api.post('/views', e.view));
3489
+ grid.on('view:renamed', e =&gt; api.patch(`/views/${e.view.id}`, { name: e.view.name }));
3490
+ grid.on('view:removed', e =&gt; api.delete(`/views/${e.view.id}`));
3491
+ grid.on('view:default', e =&gt; api.patch(`/views/${e.view.id}`, { isDefault: true }));</code></pre>
3492
+ </div>
3493
+ <p class="lead-in">
3494
+ Each event carries the one view that moved, so you send a single record rather than diffing
3495
+ two lists. For a synchronous mirror — <code>localStorage</code> — use
3496
+ <code>views.storage</code> instead. Since the grid does not track whether your write landed,
3497
+ catch the failure and call <code>grid.views.reload()</code>.
3498
+ </p>
3499
+
3500
+ <h2 id="history-guide">Undo</h2>
3501
+ <p class="lead-in">
3502
+ Undo covers the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied
3503
+ view and a restore all record an entry — and each carries a label written for a button.
3504
+ </p>
3505
+ <div class="example">
3506
+ <p class="example__label">Naming the action</p>
3507
+ <pre><code>grid.history.peek('undo'); <span class="cmt">// { type: 'sort', label: 'sort by Region' }</span>
3508
+ grid.history.undo();
3509
+ grid.history.list(); <span class="cmt">// the timeline, newest first</span></code></pre>
3510
+ </div>
3511
+ <div class="why">
3512
+ <p>A button that says only "Undo" makes the user press it to find out what it does — and
3513
+ pressing it is the thing they were unsure about. "Undo sort by Region" is decided before the
3514
+ click rather than after.</p>
3515
+ </div>
3516
+ <p class="lead-in">
3517
+ Grouping is by user action, not by internal operation. A multi-cell paste is one entry, not
3518
+ one per cell. An AI plan is one entry however many actions it contains.
3519
+ </p>
3520
+ <div class="example">
3521
+ <p class="example__label">Grouping your own changes</p>
3522
+ <pre><code>grid.history.transaction('apply the quarterly template', () =&gt; {
3523
+ grid.sort.set([{ col: 'margin', dir: 'desc' }]);
3524
+ grid.filters.set({ col: 'region', op: 'eq', value: 'EMEA' });
3525
+ grid.columns.hide(['notes', 'mgmtIp']);
3526
+ });
3527
+ <span class="cmt">// one press of undo reverses all three</span></code></pre>
3528
+ </div>
3529
+
3530
+ <h2 id="permissions-guide">Column permissions</h2>
3531
+ <p class="lead-in">
3532
+ Four levels, resolved per column from configuration or a callback. They are not a ladder —
3533
+ reading and writing are independent, so they are the four corners of a 2×2.
3534
+ </p>
3535
+ <div class="table-wrap">
3536
+ <table>
3537
+ <thead><tr><th>Level</th><th>Visible</th><th>Readable</th><th>Editable</th><th>For</th></tr></thead>
3538
+ <tbody>
3539
+ <tr><td class="sig">hidden</td><td>—</td><td>—</td><td>—</td><td class="desc">Absent from the grid, the tool panel, exports, the clipboard, saved state and the filter model.</td></tr>
3540
+ <tr><td class="sig">read</td><td>yes</td><td>yes</td><td>—</td><td class="desc">No editor opens; paste, fill and clear skip it.</td></tr>
3541
+ <tr><td class="sig">writeOnly</td><td>yes</td><td>—</td><td>yes</td><td class="desc">A secret. The cell shows a mask, the editor opens empty.</td></tr>
3542
+ <tr><td class="sig">write</td><td>yes</td><td>yes</td><td>yes</td><td class="desc">The default, so the feature is opt-in.</td></tr>
3543
+ </tbody>
3544
+ </table>
3545
+ </div>
3546
+ <div class="example">
3547
+ <p class="example__label">Every accepted form</p>
3548
+ <pre><code>permissions: 'read' <span class="cmt">// blanket</span>
3549
+ permissions: { salary: 'read', ssn: 'hidden' } <span class="cmt">// '*' sets the default</span>
3550
+ permissions: (column, ctx) =&gt;
3551
+ ctx.context.role === 'admin' ? 'write' : 'read'
3552
+
3553
+ permissions: {
3554
+ default: 'read',
3555
+ columns: { name: 'write' },
3556
+ resolve: (column, ctx) =&gt; ctx.context.role === 'admin' ? 'write' : undefined,
3557
+ }
3558
+
3559
+ grid.permissions.setContext({ role: 'clerk' }); <span class="cmt">// re-resolves everything</span></code></pre>
3560
+ </div>
3561
+ <div class="note note--warn">
3562
+ <p><strong>For three of the four this is a usability control, not a security boundary.</strong>
3563
+ Anything the grid can render it has already loaded, and devtools reaches it. Hiding a column
3564
+ removes it from the interface, not from the process — which is worth a great deal for the way
3565
+ data actually leaks, which is an export mailed onward or a shared view carrying a column a
3566
+ colleague should not see. It is worth nothing against someone determined.</p>
3567
+ <p><code>writeOnly</code> is the exception, and the reason it exists. Nothing in the grid needs
3568
+ the value, so your server can send <code>null</code> for that field and the column still
3569
+ works — at which point the secret is genuinely not on the page. Enforce everything else
3570
+ server-side; <code>permittedColumns</code> and <code>permittedExport</code> are pure and
3571
+ dependency-free so the same policy object runs in Node against a request that arrived over
3572
+ the wire.</p>
3573
+ </div>
3574
+
3575
+ <h2 id="diff-guide">Audit mode</h2>
3576
+ <p class="lead-in">
3577
+ Give the grid a prior snapshot and every row reports whether it was added, removed or
3578
+ changed, and which cells moved.
3579
+ </p>
3580
+ <div class="example">
3581
+ <p class="example__label">Before and after</p>
3582
+ <pre><code>diff: { snapshot: lastApprovedVersion },
3583
+
3584
+ grid.diff.summary(); <span class="cmt">// { added, removed, changed, unchanged }</span>
3585
+ grid.diff.statusOf('CIR-100042'); <span class="cmt">// 'changed'</span>
3586
+ grid.diff.changedColumns('CIR-100042');
3587
+ grid.diff.before('CIR-100042', 'capacity');</code></pre>
3588
+ </div>
3589
+ <p class="lead-in">
3590
+ Changed rows get a band down the leading edge and changed cells a tint with the prior value on
3591
+ the cell as <code>data-before</code>. The row band and the cell tint are deliberately different
3592
+ devices: "which rows moved" and "what changed in this row" are different questions and one
3593
+ highlight cannot serve both.
3594
+ </p>
3595
+
3596
+ <h3 id="removed-rows">Showing what was deleted</h3>
3597
+ <div class="example">
3598
+ <p class="example__label">A deletion is a change too</p>
3599
+ <pre><code>diff: { snapshot: lastApprovedVersion, removedRows: 'pinned' } <span class="cmt">// or 'data'</span></code></pre>
3600
+ </div>
3601
+ <div class="why">
3602
+ <p><strong>The row is gone from the data and still in the snapshot.</strong> That is the only
3603
+ place it exists, and it is what these rows are built from — so they carry the values they had
3604
+ when the snapshot was taken, not any current ones.</p>
3605
+ <p><strong>One option answers both questions:</strong> whether to show it, and whether it
3606
+ counts. <code>'pinned'</code> puts it beneath the rows, struck through and dimmed, outside the
3607
+ row set — so <code>rows.count()</code>, exports and selection all pass over it.
3608
+ <code>'data'</code> appends it to the set instead, and all three include it. Omitted, nothing
3609
+ is shown and the grid is the one you already had.</p>
3610
+ <p><strong>Neither sorts or filters it among the live rows.</strong> A removed row's values
3611
+ are yesterday's; ordering them among today's presents two data sets as one, and lets a filter
3612
+ written for current values decide the fate of historical ones. Neither permits an edit either
3613
+ — a write aimed at a removed row is refused and returns <code>0</code>, rather than being
3614
+ counted as applied against a record that is not there.</p>
3615
+ </div>
3616
+
3617
+ <h2 id="ai-guide">Driving the grid with a model</h2>
3618
+ <p class="lead-in">
3619
+ The grid describes its own columns and operators, you send that to whichever model you like,
3620
+ and it validates the reply before anything is applied. It makes no network call and has no
3621
+ default provider.
3622
+ </p>
3623
+ <div class="example">
3624
+ <p class="example__label">The loop</p>
3625
+ <pre><code>ai: {
3626
+ <span class="kw">async</span> ask({ message }) {
3627
+ <span class="kw">const</span> res = <span class="kw">await</span> yourModelClient.complete({
3628
+ model: 'your-model-of-choice', messages: [{ role: 'user', content: message }],
3629
+ });
3630
+ <span class="kw">return</span> res.text;
3631
+ },
3632
+ }</code></pre>
3633
+ </div>
3634
+ <p class="lead-in">
3635
+ That mounts a prompt bar. The user types "EMEA circuits over 500 gigs, biggest first"; the
3636
+ grid composes a message including its schema; your callback returns the model's reply; the
3637
+ grid validates it and shows what it would do in plain English —
3638
+ <em>"Filter Region is EMEA and Capacity is more than 500, sort Capacity descending"</em> —
3639
+ with Apply and Discard.
3640
+ </p>
3641
+ <div class="why">
3642
+ <p><strong>Nothing is executed on trust.</strong> The vocabulary is seven actions —
3643
+ <code>setFilters</code>, <code>setSort</code>, <code>groupBy</code>,
3644
+ <code>showColumns</code>, <code>hideColumns</code>, <code>setQuick</code>,
3645
+ <code>clear</code> — and a reply naming a column that does not exist is rejected with a
3646
+ reason while the valid actions in the same reply are kept. A model cannot be talked into an
3647
+ operation the vocabulary does not contain, because there is nothing else to call.</p>
3648
+ </div>
3649
+ <p class="lead-in">
3650
+ Applying is one undo entry, labelled with what it did. <code>docs/AI-SKILL.md</code> is the
3651
+ reference to hand your model.
3652
+ </p>
3653
+
3654
+ <h2 id="export-guide">Export</h2>
3655
+ <div class="example">
3656
+ <p class="example__label">CSV, Excel, clipboard, print</p>
3657
+ <pre><code>grid.export.csv({ download: true, fileName: 'circuits' });
3658
+ grid.export.excel({ download: true, sheetName: 'Circuits' });
3659
+ grid.export.clipboard({ headers: true, rows: 'range' });
3660
+ grid.export.print();</code></pre>
3661
+ </div>
3662
+ <p class="lead-in">
3663
+ Exports follow what the user is looking at — the current filter, sort and column order,
3664
+ formatted values, and only the columns they may read.
3665
+ </p>
3666
+
3667
+ <h3>On the server</h3>
3668
+ <p class="lead-in">
3669
+ A user asks to export half a million rows. You can ship them all to the browser to be
3670
+ formatted, or write the export server-side and watch it drift from what the grid shows. The
3671
+ headless core is the third option.
3672
+ </p>
3673
+ <div class="example">
3674
+ <p class="example__label">The same code, in Node</p>
3675
+ <pre><code><span class="kw">import</span> { createHeadlessGrid } <span class="kw">from</span> './lattice-core.esm.js';
3676
+
3677
+ <span class="kw">const</span> grid = createHeadlessGrid({ columns, rows: fromDatabase });
3678
+ grid.state.apply(savedView.state);
3679
+ <span class="kw">return</span> grid.export.csv();</code></pre>
3680
+ </div>
3681
+ <div class="why">
3682
+ <p>A saved view is a serialisable state object, so the server applies exactly what the user
3683
+ set up, using exactly the code the browser uses. The £1,234.50 in the file is the £1,234.50 on
3684
+ the screen because it came out of the same formatter. 50,000 rows filtered, sorted and written
3685
+ to CSV takes about 100ms.</p>
3686
+ </div>
3687
+
3688
+ <h2 id="webcomponent-guide">Web component</h2>
3689
+ <p class="lead-in">
3690
+ <code>&lt;lattice-grid&gt;</code> is the grid as a custom element, shipped as a self-contained
3691
+ module bundle. It exists for pages without a bundler — a Rails, Django or Laravel template
3692
+ that wants a grid without adopting a front-end build.
3693
+ </p>
3694
+ <div class="example">
3695
+ <p class="example__label">The whole integration</p>
3696
+ <pre><code>&lt;link rel="stylesheet" href="dist/lattice-grid.min.css"&gt;
3697
+ &lt;script type="module" src="dist/modules/webcomponent.esm.min.js"&gt;&lt;/script&gt;
3698
+
3699
+ &lt;lattice-grid row-key="id"
3700
+ columns='[{"field":"id"},{"field":"city"}]'
3701
+ rows='[{"id":"A","city":"Leeds"},{"id":"B","city":"Cardiff"}]'&gt;&lt;/lattice-grid&gt;</code></pre>
3702
+ </div>
3703
+ <div class="why">
3704
+ <p><strong>Load this or the main bundle, not both.</strong> The module is self-contained — it
3705
+ carries the grid with it, so a page that also loads <code>lattice-grid.esm.js</code>
3706
+ downloads and evaluates the grid twice.</p>
3707
+ </div>
3708
+
3709
+ <div class="example">
3710
+ <p class="example__label">Driven from script</p>
3711
+ <pre><code><span class="cmt">// Structures are properties; scalars are attributes.</span>
3712
+ const el = document.createElement('@toclocoinc/lattice-grid');
3713
+ el.setAttribute('row-key', 'id');
3714
+ el.columns = [{ field: 'id' }, { field: 'charge', type: 'number' }];
3715
+ el.rows = data;
3716
+ document.body.appendChild(el);
3717
+
3718
+ <span class="cmt">// The full imperative API is on `.grid`.</span>
3719
+ el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
3720
+ </div>
3721
+ <div class="why">
3722
+ <p><strong>Assign before appending where you can.</strong> The element builds its grid once,
3723
+ at the end of the task in which it connects, so everything set in that task arrives as one
3724
+ configuration rather than as a series of updates. Setting properties later still works — they
3725
+ go through the live configuration path — but the grid is built empty first and repainted
3726
+ after, which is a visible flash on a large set.</p>
3727
+ <p>Reading <code>.grid</code> forces the build immediately, so the property is never briefly
3728
+ null.</p>
3729
+ </div>
3730
+
3731
+ <div class="example">
3732
+ <p class="example__label">Events</p>
3733
+ <pre><code>el.addEventListener('lattice-cell-changed', (e) =&gt; {
3734
+ console.log(e.detail.key, e.detail.colId, e.detail.value);
3735
+ });</code></pre>
3736
+ </div>
3737
+ <div class="why">
3738
+ <p>Every grid event is re-dispatched as a <code>CustomEvent</code> named <code>lattice-</code>
3739
+ plus the grid name with colons hyphenated, so <code>cell:edit:start</code> becomes
3740
+ <code>lattice-cell-edit-start</code>. The payload is <code>event.detail</code>. The prefix is
3741
+ not decoration: the grid emits an event called <code>scroll</code>, and an unprefixed
3742
+ <code>CustomEvent</code> of that name would be indistinguishable from the platform's own.
3743
+ Forwarding is a wildcard subscription, so events added to the grid later appear here with no
3744
+ change to the component.</p>
3745
+ <p><strong>Light DOM, deliberately.</strong> The element renders into itself rather than a
3746
+ shadow root, because the grid's generated decoration rules are injected into
3747
+ <code>document.head</code> and the theme stylesheet is a <code>&lt;link&gt;</code> the page
3748
+ owns — neither crosses a shadow boundary. A shadowed grid would be structurally correct and
3749
+ completely unstyled, and subtly so: the <code>--lattice-*</code> tokens <em>do</em> inherit
3750
+ through a shadow root, so the colours would arrive while every pill, bar and heat cell stayed
3751
+ bare. Light DOM keeps every documented theming route working unchanged.</p>
3752
+ </div>
3753
+
3754
+ <h2 id="events-guide">Events</h2>
3755
+ <p class="lead-in">
3756
+ One bus, forty-odd events. These are the ones most applications actually use; the
3757
+ <a href="API.html#events">reference</a> lists them all.
3758
+ </p>
3759
+ <div class="table-wrap">
3760
+ <table>
3761
+ <thead><tr><th>Event</th><th>Carries</th><th>Use it for</th></tr></thead>
3762
+ <tbody>
3763
+ <tr><td class="name">ready</td><td class="type">{}</td><td class="desc">First render is done. Fires on a future turn, so you can subscribe on the line after <code>createGrid</code>.</td></tr>
3764
+ <tr><td class="name">cell:changed</td><td class="type">{ row, key, colId, value, oldValue, undo }</td><td class="desc">Persisting an edit. <code>undo</code> distinguishes a rollback from a fresh change.</td></tr>
3765
+ <tr><td class="name">selection:changed</td><td class="type">{ keys }</td><td class="desc">Enabling a bulk action.</td></tr>
3766
+ <tr><td class="name">range:changed</td><td class="type">{ ranges }</td><td class="desc">A status bar showing the sum of what is selected — see <code>selection.summary()</code>.</td></tr>
3767
+ <tr><td class="name">sort:changed / filter:changed</td><td class="type">{ sort } / { filters }</td><td class="desc">Reflecting the view in the URL.</td></tr>
3768
+ <tr><td class="name">history:changed</td><td class="type">{ canUndo, canRedo, undo, redo }</td><td class="desc">Driving your own undo button. Emitted <em>after</em> the entry is pushed, so the label is right.</td></tr>
3769
+ <tr><td class="name">history:applied</td><td class="type">{ direction, step }</td><td class="desc">An action was undone or redone, with which and what. <code>history:changed</code> also fires when a new action is pushed, so it cannot distinguish the two.</td></tr>
3770
+ <tr><td class="name">view:saved / :removed</td><td class="type">{ view, views }</td><td class="desc">Persisting saved views to a server.</td></tr>
3771
+ <tr><td class="name">render:done</td><td class="type">{ first, last }</td><td class="desc">Decorating cells from outside. The cell layer rewrites class names on every paint, so anything added before this is erased.</td></tr>
3772
+ </tbody>
3773
+ </table>
3774
+ </div>
3775
+
3776
+ <h2 id="licensing">Licensing</h2>
3777
+ <p class="lead-in">
3778
+ <strong>There is one Lattice Grid and every copy is feature-identical.</strong> No community
3779
+ edition, no pro tier, no feature held back behind a key. A licence removes the trial
3780
+ watermark; that is the whole of what it does.
3781
+ </p>
3782
+ <p class="lead-in">
3783
+ Free to develop against, licensed to deploy. A grid on <strong>localhost</strong> — or any
3784
+ loopback host — needs no key at all. On any other domain an unlicensed grid still renders
3785
+ everything and carries a small trial watermark linking to latticegrid.dev.
3786
+ </p>
3787
+ <div class="table-wrap">
3788
+ <table>
3789
+ <thead><tr><th>Where it runs</th><th>No key</th><th>Valid key</th></tr></thead>
3790
+ <tbody>
3791
+ <tr><td class="sig">localhost, *.localhost, 127.0.0.0/8, ::1</td><td>everything, no mark</td><td>everything, no mark</td></tr>
3792
+ <tr><td class="sig">any other domain</td><td>everything, trial watermark</td><td>everything, no mark</td></tr>
3793
+ </tbody>
3794
+ </table>
3795
+ </div>
3796
+ <div class="note"><p><code>.local</code>, <code>.internal</code> and private IP ranges are <strong>not</strong> exempt. They are ordinary LAN names, and a corporate intranet is a deployment like any other.</p></div>
3797
+ <div class="example">
3798
+ <p class="example__label">Installing a key</p>
3799
+ <pre><code>LatticeGrid.setLicence('LG1.…'); <span class="cmt">// your key; setLicense also works</span>
3800
+ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'</span></code></pre>
3801
+ </div>
3802
+ <p class="lead-in">
3803
+ Call it once, before creating a grid. Setting a key later still works — the watermark comes
3804
+ off and <code>licence:changed</code> fires — but the first frames of the grid will carry it.
3805
+ </p>
3806
+ <p class="lead-in">
3807
+ Keys come from <a href="https://www.latticegrid.dev">latticegrid.dev</a> and are issued per
3808
+ deployment rather than per developer or per seat: name the domains the grid will run on and
3809
+ one key covers every developer, every build and every user on them. A key names the domains
3810
+ it covers as you would expect — <code>*.acme.com</code> matches <code>app.acme.com</code>,
3811
+ <code>a.b.acme.com</code> and <code>acme.com</code> itself.
3812
+ </p>
3813
+ <p class="lead-in">
3814
+ Checking a key needs no network. There is no licence server, no call home, and nothing that
3815
+ can fail at three in the morning — a key carries its own answer and the grid reads it
3816
+ locally, so a grid on an air-gapped network behaves exactly like one on the open internet.
3817
+ </p>
3818
+ <div class="why">
3819
+ <p><strong>Nothing ever refuses to render, and nothing is ever withheld.</strong> An expired
3820
+ key, a wrong domain, a key that will not read — all of them log one warning and show the
3821
+ watermark. Every feature keeps working. The failure to avoid is a customer's production
3822
+ screen going blank because a licence lapsed over a weekend, and a grid that quietly drops a
3823
+ feature is the same failure wearing a disguise.</p>
3824
+ </div>
3825
+
3826
+ <h2 id="recipes">Recipes</h2>
3827
+
3828
+ <h3>Put the view in the URL</h3>
3829
+ <div class="example">
3830
+ <pre><code>grid.on('state:changed', () =&gt; {
3831
+ <span class="kw">const</span> encoded = btoa(JSON.stringify(grid.state.get()));
3832
+ history.replaceState(null, '', `?view=${encoded}`);
3833
+ });
3834
+
3835
+ <span class="kw">const</span> saved = new URLSearchParams(location.search).get('view');
3836
+ <span class="kw">if</span> (saved) grid.state.apply(JSON.parse(atob(saved)));</code></pre>
3837
+ </div>
3838
+
3839
+ <h3>Save edits as they happen</h3>
3840
+ <div class="example">
3841
+ <pre><code>grid.on('cell:changed', <span class="kw">async</span> (e) =&gt; {
3842
+ <span class="kw">if</span> (e.undo) <span class="kw">return</span>; <span class="cmt">// a rollback, not a new change</span>
3843
+ grid.highlight({ key: e.key, colId: e.colId }, { colour: '#fff3cd', duration: 0 });
3844
+ <span class="kw">try</span> {
3845
+ <span class="kw">await</span> api.patch(`/rows/${e.key}`, { [e.colId]: e.value });
3846
+ grid.highlight({ key: e.key, colId: e.colId }, { colour: '#d4edda', duration: 900 });
3847
+ } <span class="kw">catch</span> {
3848
+ grid.highlight({ key: e.key, colId: e.colId }, { colour: '#f8d7da', duration: 0 });
3849
+ grid.history.undo();
3850
+ }
3851
+ });</code></pre>
3852
+ </div>
3853
+
3854
+ <h3>A read-only grid</h3>
3855
+ <div class="example">
3856
+ <pre><code>createGrid(el, {
3857
+ columns, rows, rowKey: 'id',
3858
+ edit: false,
3859
+ contextMenu: false, <span class="cmt">// the default menu offers Paste, Clear and Fill down</span>
3860
+ columnMenu: false, <span class="cmt">// optional: the header's 3-dot menu</span>
3861
+ selection: { ranges: false },
3862
+ });</code></pre>
3863
+ </div>
3864
+
3865
+ <h3>A dashboard grid, no chrome</h3>
3866
+ <div class="example">
3867
+ <pre><code>createGrid(el, {
3868
+ columns, rows, rowKey: 'id',
3869
+ edit: false, contextMenu: false, selection: 'none',
3870
+ rowHeight: 24, density: 'compact',
3871
+ grandTotalRow: 'bottom',
3872
+ });</code></pre>
3873
+ </div>
3874
+
3875
+ <footer>
3876
+ <p>
3877
+ Lattice Grid 1.5.4 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
3878
+ Written against the shipped source. Where this guide and the code disagree, the code wins —
3879
+ please <a href="https://www.latticegrid.dev">tell us</a>.
3880
+ </p>
3881
+ </footer>
3882
+ </main>
3883
+ </div>
3884
+
3885
+ </body>
3886
+ </html>