@toclocoinc/lattice-grid 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.7.1</p>
440
+ <p class="rail__sub">Developer guide · v1.13.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -468,6 +468,13 @@
468
468
  <a href="#row-transfer">Rows between grids</a>
469
469
  <a href="#column-tags">Column tags</a>
470
470
  <a href="#aligned-grids">Aligned grids</a>
471
+ </div>
472
+ <div class="rail__group">
473
+ <span class="rail__label">Connected grids</span>
474
+ <a href="#pushdown">Querying an engine</a>
475
+ <a href="#derived">Derived grids</a>
476
+ <a href="#cross-filter">Cross-filtering</a>
477
+ <a href="#joins">Joining two grids</a>
471
478
  <a href="#aggregate-safety">Aggregate safety</a>
472
479
  <a href="#sticky-group-headings">Sticky group headings</a>
473
480
  <a href="#sources">Large data</a>
@@ -500,6 +507,13 @@
500
507
  <a href="#formatting-guide">Formatting a user can change</a>
501
508
  <a href="#quickfilter-guide">Quick filter</a>
502
509
  <a href="#charts-guide">In-cell charts</a>
510
+ </div>
511
+ <div class="rail__group">
512
+ <span class="rail__label">Statistics</span>
513
+ <a href="#statistics">Profiling</a>
514
+ <a href="#spc">Process control</a>
515
+ <a href="#intervals">Confidence intervals</a>
516
+ <a href="#stat-tiles">Statistic tiles</a>
503
517
  <a href="#formulas-guide">Formulas</a>
504
518
  <a href="#custom-menu">Custom items</a>
505
519
  <a href="#rail">The left rail</a>
@@ -884,7 +898,7 @@ createGrid(element, { direction: 'rtl' }); <span class="cmt">// or say so out
884
898
  });
885
899
 
886
900
  grid.events.on('cellClick', (row, column, event) =&gt; {
887
- <span class="cmt">// row and column carry .id, row's own fields sit alongside it , </span>
901
+ <span class="cmt">// row and column carry .id, row's own fields sit alongside it,</span>
888
902
  <span class="cmt">// the same shape dhtmlx's own IRow/ICol declare.</span>
889
903
  });</code></pre>
890
904
  </div>
@@ -915,7 +929,7 @@ grid.events.on('cellClick', (row, column, event) =&gt; {
915
929
  handler with dhtmlx's own positional arguments: <code>(row, column, event)</code>,
916
930
  not Lattice's own event object, because that is dhtmlx's own documented signature for
917
931
  them. <code>afterRowDrop</code> calls your handler with <code>(data, event)</code>, firing
918
- from either a same-grid reorder settling or a row landing here from another grid ,
932
+ from either a same-grid reorder settling or a row landing here from another grid,
919
933
  dhtmlx has one event name for what Lattice models as two. Every other mapped event calls your
920
934
  handler with Lattice's own event object, under Lattice's own field names, since a wrong guess
921
935
  at a fabricated positional shape is worse than an honest one.</p>
@@ -1076,7 +1090,7 @@ driveInfiniteScroll(grid, document.getElementById('sentinel'), { columns: COLUMN
1076
1090
  read at call time, so loading this module never requires htmx to already be on the
1077
1091
  page, only to be present by the time a driven request actually fires. It ships as
1078
1092
  ESM and as a plain <code>&lt;script src&gt;</code> build with no bundler required,
1079
- with zero runtime dependencies beyond the grid itself and, at call time, htmx ,
1093
+ with zero runtime dependencies beyond the grid itself and, at call time, htmx,
1080
1094
  but because it references the grid's own internals directly rather than the copy
1081
1095
  already on the page, the bundle carries a full copy of the grid core alongside its
1082
1096
  own code, the same trade-off the web component and dhtmlx wrappers already make.</p>
@@ -1658,7 +1672,7 @@ grid.set('theme', <span class="kw">null</span>); <span class="cmt">// back to
1658
1672
  lands on every one of them.</p>
1659
1673
  <p>The reset uses no <code>!important</code>. It is specificity (0,1,1) and every rule that
1660
1674
  dresses a grid element is (0,2,0) or higher, so the grid's own styling always wins and the
1661
- reset only fills a gap. Yours wins too, on the same terms: a rule aimed at a Lattice class ,
1675
+ reset only fills a gap. Yours wins too, on the same terms: a rule aimed at a Lattice class,
1662
1676
  <code>.lattice .lat-cell { … }</code>: outranks it, so overriding the grid deliberately works
1663
1677
  exactly as before. Only bare-tag rules are shut out.</p>
1664
1678
  <p>It touches box model and decoration only. Nothing in it sets <code>display</code>,
@@ -1727,7 +1741,7 @@ grid.overlay.hide();</code></pre>
1727
1741
  <p>This runs the minimum pipeline. An update touching no sorted, filtered or grouped column
1728
1742
  skips those stages entirely and only the totals and the affected cells refresh. Adds and
1729
1743
  removals are structural and re-run everything.</p>
1730
- <p>Removals tombstone in place rather than compacting, so every existing index stays valid ,
1744
+ <p>Removals tombstone in place rather than compacting, so every existing index stays valid,
1731
1745
  which is what lets selection, expansion state and cached permutations survive a delete.</p>
1732
1746
  </div>
1733
1747
 
@@ -1890,7 +1904,7 @@ grid.rows.collapse('EMEA');</code></pre>
1890
1904
  </table>
1891
1905
  </div>
1892
1906
  <p class="lead-in">
1893
- Nothing has to be configured for this, and the reported number is the same either way ,
1907
+ Nothing has to be configured for this, and the reported number is the same either way,
1894
1908
  where a running value cannot be trusted, the column falls back to a full pass rather than
1895
1909
  reporting a value it is unsure of.
1896
1910
  </p>
@@ -1936,7 +1950,7 @@ pivot: { groupTotals: 'after', totalsLabel: 'All regions' }</code></pre>
1936
1950
  the near edge, beside the row headings; <code>'after'</code> puts it at the far edge, which is
1937
1951
  where a spreadsheet puts a grand total.</p>
1938
1952
  <p><strong>It costs a column, not a pass.</strong> A pivoted group row still carries its
1939
- reduction over every one of its leaves, which is exactly the total across all pivot values ,
1953
+ reduction over every one of its leaves, which is exactly the total across all pivot values,
1940
1954
  so these columns read a number that has already been computed. They also count towards
1941
1955
  <code>maxColumns</code>, since they are columns like any other.</p>
1942
1956
  <p><strong>Opt in.</strong> Omitted, a pivot has the columns it has always had, so the option
@@ -1981,7 +1995,7 @@ pivot: { groupTotals: 'after', totalsLabel: 'All regions' }</code></pre>
1981
1995
  <p class="lead-in">
1982
1996
  Group totals re-reduce every totalled column on every change, including columns the change
1983
1997
  did not touch. Switching this on reduces only the columns whose values actually moved, and
1984
- an update that rewrites a field with the value it already held reduces nothing at all ,
1998
+ an update that rewrites a field with the value it already held reduces nothing at all,
1985
1999
  which is what a feed resending unchanged fields looks like.
1986
2000
  </p>
1987
2001
  <p class="lead-in">
@@ -2078,7 +2092,7 @@ grid.setPinnedRows([], { edge: 'top' }); <span class="cmt">// clear</span
2078
2092
  <h2 id="full-width-rows">Full-width rows</h2>
2079
2093
  <p class="lead-in">
2080
2094
  A row drawn as a single band across every column instead of being divided into them: a
2081
- section banner, an explanatory note, an empty-group message, a “load more” affordance ,
2095
+ section banner, an explanatory note, an empty-group message, a “load more” affordance,
2082
2096
  anything that belongs between rows and is not itself divided by the columns.
2083
2097
  </p>
2084
2098
 
@@ -2481,6 +2495,275 @@ createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// stack
2481
2495
  and the same shape you would have written by hand.</p>
2482
2496
  </div>
2483
2497
 
2498
+ <h2 id="pushdown">Querying an engine directly</h2>
2499
+ <p class="lead-in">
2500
+ A remote source hands you the whole request and leaves the translation to you. A pushdown
2501
+ adapter inverts that: you declare what your engine can answer, and the grid works out what to
2502
+ send and finishes the rest itself.
2503
+ </p>
2504
+ <div class="example">
2505
+ <p class="example__label">An OData endpoint, with nothing to write</p>
2506
+ <pre><code><span class="kw">import</span> { createPushdownSource, odataAdapter } <span class="kw">from</span> 'lattice-grid';
2507
+
2508
+ createGrid(el, {
2509
+ columns,
2510
+ rowKey: 'id',
2511
+ source: createPushdownSource({
2512
+ adapter: odataAdapter({ url: 'https://services.odata.org/V4/Northwind/Northwind.svc/Orders' }),
2513
+ pageSize: 100,
2514
+ }),
2515
+ });</code></pre>
2516
+ </div>
2517
+ <p>
2518
+ Four adapters ship. <code>odataAdapter</code> writes <code>$filter</code>,
2519
+ <code>$orderby</code>, <code>$top</code> and <code>$skip</code>, and follows
2520
+ <code>@odata.nextLink</code> when the server pages on its own terms.
2521
+ <code>restAdapter</code> covers an ordinary JSON endpoint whose parameter names are yours to
2522
+ give. <code>dfqlAdapter</code> speaks DemandFlow's query API. <code>duckdbAdapter</code>
2523
+ takes a live DuckDB connection.
2524
+ </p>
2525
+
2526
+ <h3>Declaring what an engine can do</h3>
2527
+ <p class="lead-in">
2528
+ No real engine answers the whole query. An adapter says what it can take, and everything
2529
+ undeclared stays with the grid.
2530
+ </p>
2531
+ <div class="example">
2532
+ <p class="example__label">An endpoint that pages and sorts, but does not filter</p>
2533
+ <pre><code>restAdapter({
2534
+ url: '/api/readings',
2535
+ params: { offset: 'from', limit: 'size', sort: 'orderBy', order: 'dir' },
2536
+ capabilities: { sort: 'single', range: <span class="kw">true</span>, total: <span class="kw">true</span> },
2537
+ })</code></pre>
2538
+ </div>
2539
+ <div class="table-wrap">
2540
+ <table>
2541
+ <thead><tr><th>Capability</th><th>Values</th><th>Means</th></tr></thead>
2542
+ <tbody>
2543
+ <tr><td class="name">filter</td><td class="type">false | 'term' | 'flat' | 'tree'</td><td class="desc">Nothing, one field and term, a flat conjunction, or a full condition tree.</td></tr>
2544
+ <tr><td class="name">operators</td><td class="type">string[]</td><td class="desc">Which comparisons the engine genuinely applies. Declare only those it does.</td></tr>
2545
+ <tr><td class="name">sort</td><td class="type">false | 'single' | 'multi'</td><td class="desc">No ordering, one column, or several.</td></tr>
2546
+ <tr><td class="name">quick</td><td class="type">boolean</td><td class="desc">Whether a free-text search across columns can be pushed.</td></tr>
2547
+ <tr><td class="name">range</td><td class="type">boolean</td><td class="desc">Whether the engine can return a window rather than the whole result.</td></tr>
2548
+ <tr><td class="name">total</td><td class="type">boolean</td><td class="desc">Whether it can report how many rows matched.</td></tr>
2549
+ <tr><td class="name">group</td><td class="type">boolean</td><td class="desc">Whether it can group and aggregate.</td></tr>
2550
+ </tbody>
2551
+ </table>
2552
+ </div>
2553
+ <div class="why">
2554
+ <p><strong>Everything is off unless declared.</strong> An adapter that declares nothing still
2555
+ works: the grid fetches and does all the work itself. That is the safe direction to be wrong
2556
+ in. Declaring an operator the engine does not really apply is the unsafe one, because the
2557
+ grid will trust it and stop checking.</p>
2558
+ <p><strong>Splitting a filter is not symmetric.</strong> An <code>and</code> group narrows
2559
+ with each condition, so the supported conjuncts go to the engine and the rest stay behind:
2560
+ the engine returns a superset and the grid narrows it. An <code>or</code> group widens with
2561
+ each branch, so pushing only the supported branches would return fewer rows than the filter
2562
+ allows, and the grid cannot recover rows that were never fetched. A disjunction that is not
2563
+ fully supported therefore stays whole on the client. The same asymmetry governs column
2564
+ pruning in the facet path.</p>
2565
+ <p><strong>Residual work needs the whole result.</strong> When anything is left over, the
2566
+ source stops asking for windows and asks for everything, applies the remainder, and pages
2567
+ from what it holds. Filtering a window on the client is not a slower route to the right
2568
+ answer, it is a fast route to a wrong one: the rows that belong on page one may sit on page
2569
+ nine, and the total is whatever the engine happened to count.</p>
2570
+ </div>
2571
+
2572
+ <h3>Seeing what was pushed</h3>
2573
+ <p class="lead-in">
2574
+ The split is reported rather than hidden, which is the difference between a slow query you
2575
+ can diagnose and a slow query you cannot.
2576
+ </p>
2577
+ <div class="example">
2578
+ <p class="example__label">Asking after the last request</p>
2579
+ <pre><code><span class="kw">const</span> source = createPushdownSource({ adapter });
2580
+ createGrid(el, { columns, rowKey: 'id', source });
2581
+
2582
+ <span class="kw">const</span> plan = source.lastPlan();
2583
+ plan.pushed; <span class="cmt">// the query the adapter was given</span>
2584
+ plan.residual; <span class="cmt">// { filters, sort, quick } the grid applied after</span>
2585
+ plan.unpushed; <span class="cmt">// ['filter'], the parts that stayed behind</span>
2586
+ plan.needsAll; <span class="cmt">// true when the whole result had to be fetched</span></code></pre>
2587
+ </div>
2588
+ <p>
2589
+ The grid also warns once, naming the predicate that could not be pushed, because the fix is
2590
+ usually a better adapter rather than a bigger machine. It warns again if an adapter reports a
2591
+ <code>total</code> larger than the rows it returned while residual work is outstanding: that
2592
+ combination silently produces wrong answers, and it is worth knowing about.
2593
+ </p>
2594
+
2595
+ <h3>A full analytical engine, without carrying one</h3>
2596
+ <p class="lead-in">
2597
+ <code>duckdbAdapter</code> takes a connection you created and imports nothing, so the grid
2598
+ can drive DuckDB while this package stays at zero dependencies.
2599
+ </p>
2600
+ <div class="example">
2601
+ <p class="example__label">Parquet in the browser, no server</p>
2602
+ <pre><code><span class="kw">const</span> db = <span class="kw">await</span> makeDuckDB(); <span class="cmt">// yours: @duckdb/duckdb-wasm</span>
2603
+ <span class="kw">const</span> conn = <span class="kw">await</span> db.connect();
2604
+
2605
+ createGrid(el, {
2606
+ columns,
2607
+ source: createPushdownSource({
2608
+ adapter: duckdbAdapter({
2609
+ connection: conn,
2610
+ from: <span class="str">"read_parquet('readings.parquet')"</span>,
2611
+ }),
2612
+ }),
2613
+ });</code></pre>
2614
+ </div>
2615
+ <div class="why">
2616
+ <p><code>from</code> is any FROM expression, so
2617
+ <code>read_parquet('s3://bucket/*.parquet')</code> is as valid as a table name. Values are
2618
+ bound through prepared statements; a connection without <code>prepare</code> is used only for
2619
+ unfiltered queries, because interpolating a user's filter into SQL is the one thing worse
2620
+ than not filtering at all.</p>
2621
+ <p>Column and table names are checked against an identifier pattern rather than escaped, and
2622
+ a name that fails is refused. Integers past the safe range are kept as strings instead of
2623
+ being rounded into a plausible lie.</p>
2624
+ <p><code>demo/duckdb.html</code> runs this against a Parquet file of several million readings
2625
+ with no server involved.</p>
2626
+ </div>
2627
+
2628
+ <h2 id="derived">Grids built from other grids</h2>
2629
+ <p class="lead-in">
2630
+ A derived grid takes its rows from another grid rather than from a load: grouped and
2631
+ aggregated, unnested, filtered, ranked or profiled. It has its own element and its own
2632
+ columns, and it follows its source live.
2633
+ </p>
2634
+ <div class="example">
2635
+ <p class="example__label">A summary panel beside the detail grid</p>
2636
+ <pre><code><span class="kw">const</span> detail = createGrid(left, { columns, rowKey: 'id', rows });
2637
+
2638
+ createGrid(right, {
2639
+ columns: [
2640
+ { id: 'region', header: 'Region' },
2641
+ { id: 'total', header: 'Capacity', type: 'number' },
2642
+ { id: 'n', header: 'Sites', type: 'number' },
2643
+ ],
2644
+ source: {
2645
+ mode: 'derived',
2646
+ from: detail,
2647
+ groupBy: 'region',
2648
+ select: {
2649
+ total: { of: 'capacity', fn: 'sum' },
2650
+ n: { fn: 'count' },
2651
+ },
2652
+ sort: [{ col: 'total', dir: 'desc' }],
2653
+ },
2654
+ });</code></pre>
2655
+ </div>
2656
+ <p>
2657
+ <code>follow</code> chooses which of the source's rows are read:
2658
+ <code>filtered</code> by default, or <code>all</code>, <code>selected</code> or
2659
+ <code>grouped</code>. The pipeline runs <code>unnest</code>, then <code>join</code>, then
2660
+ <code>where</code>, then <code>bucket</code> and <code>groupBy</code>, then
2661
+ <code>select</code>, then <code>sort</code> and <code>limit</code>, so a condition or a total
2662
+ can read a field that an earlier stage produced.
2663
+ </p>
2664
+ <div class="why">
2665
+ <p><strong>A change is patched, not re-derived.</strong> When a row changes in the source, the
2666
+ derived grid updates the groups that row belongs to rather than rebuilding the lot. Five
2667
+ hundred updates against a two hundred thousand row source cost under 300 ms in total. Set
2668
+ <code>refresh</code> to <code>live</code>, <code>manual</code> or a number of milliseconds to
2669
+ change the coalescing; <code>idle</code> is the default and settles to a frame.</p>
2670
+ <p><strong>Derived grids are read-only.</strong> There is one copy of the data and it lives in
2671
+ the source. Write there and the derived grid follows.</p>
2672
+ <p><strong>The key comes for free.</strong> A derived grid keys on <code>__key</code>, which
2673
+ the source writes onto every row it produces: the group value, the profiled column, or the
2674
+ source row's own key when nothing is grouped. Set <code>rowKey</code> only to override it.</p>
2675
+ </div>
2676
+
2677
+ <h3>Other shapes</h3>
2678
+ <div class="table-wrap">
2679
+ <table>
2680
+ <thead><tr><th>Option</th><th>Does</th></tr></thead>
2681
+ <tbody>
2682
+ <tr><td class="name">unnest</td><td class="desc">Expands an array property, one row per element, before anything else runs.</td></tr>
2683
+ <tr><td class="name">bucket</td><td class="desc">Rounds a date column down to a day, week, month, quarter or year and groups on that.</td></tr>
2684
+ <tr><td class="name">limitPer</td><td class="desc">Applies <code>limit</code> within each value of a column rather than overall: a top three per region.</td></tr>
2685
+ <tr><td class="name">cumulative</td><td class="desc">Keeps rows until their running share of the total reaches a fraction: the Pareto head.</td></tr>
2686
+ <tr><td class="name">profile</td><td class="desc">One row per column with the statistics as columns, or one row per statistic with <code>orient: 'metrics'</code>.</td></tr>
2687
+ </tbody>
2688
+ </table>
2689
+ </div>
2690
+
2691
+ <h2 id="cross-filter">Cross-filtering</h2>
2692
+ <p class="lead-in">
2693
+ A derived panel can filter the grid it summarises. Click a region in the summary and the
2694
+ detail grid narrows to it.
2695
+ </p>
2696
+ <div class="example">
2697
+ <p class="example__label">Click to filter, click again to release</p>
2698
+ <pre><code>source: {
2699
+ mode: 'derived',
2700
+ from: detail,
2701
+ groupBy: 'region',
2702
+ select: { total: { of: 'capacity', fn: 'sum' } },
2703
+ crossFilter: <span class="kw">true</span>, <span class="cmt">// or a source column name</span>
2704
+ }
2705
+
2706
+ summary.events.on('rowClick', (e) =&gt; summary.crossFilter.toggle(e.key));</code></pre>
2707
+ </div>
2708
+ <p>
2709
+ <code>crossFilter.set</code>, <code>toggle</code>, <code>clear</code>, <code>get</code> and
2710
+ <code>column</code> make up the API. <code>true</code> filters through whatever the grid
2711
+ groups by; a string names a different source column when the two do not share a name.
2712
+ </p>
2713
+ <div class="why">
2714
+ <p><strong>A panel does not filter itself.</strong> The filter a summary pushes onto its
2715
+ source is excluded when that same summary re-derives. Without that, clicking one region
2716
+ collapses the panel to the single row you just clicked, and there is nothing left to click
2717
+ next. With it, the panel keeps its full set of regions with the chosen one marked, which is
2718
+ what makes a second click possible at all.</p>
2719
+ <p><strong>Several panels compose.</strong> Each pushes its own filter onto the shared source
2720
+ and each excludes only its own, so region and status narrow the detail together while both
2721
+ panels stay navigable.</p>
2722
+ </div>
2723
+
2724
+ <h2 id="joins">Joining two grids</h2>
2725
+ <p class="lead-in">
2726
+ Two grids holding their own data, and a third showing where they meet. Both sides stay live.
2727
+ </p>
2728
+ <div class="example">
2729
+ <p class="example__label">Bringing an owner's fields across</p>
2730
+ <pre><code>source: {
2731
+ mode: 'derived',
2732
+ from: sites,
2733
+ join: {
2734
+ with: owners,
2735
+ on: { left: 'ownerId', right: 'id' },
2736
+ type: 'left',
2737
+ select: ['name', 'tier'],
2738
+ prefix: 'owner', <span class="cmt">// owner.name, owner.tier</span>
2739
+ },
2740
+ }</code></pre>
2741
+ </div>
2742
+ <div class="table-wrap">
2743
+ <table>
2744
+ <thead><tr><th>Option</th><th>Does</th></tr></thead>
2745
+ <tbody>
2746
+ <tr><td class="name">on</td><td class="desc">One field name when both sides use it, or <code>{ left, right }</code> when they differ.</td></tr>
2747
+ <tr><td class="name">type</td><td class="desc"><code>inner</code> keeps only rows that matched; <code>left</code> keeps them all.</td></tr>
2748
+ <tr><td class="name">select</td><td class="desc">Which of the partner's fields to bring across. All of them by default.</td></tr>
2749
+ <tr><td class="name">prefix</td><td class="desc">Renames the brought-across fields, for when both sides have a <code>name</code> worth keeping.</td></tr>
2750
+ <tr><td class="name">follow</td><td class="desc">Which of the partner's rows to read: <code>all</code> by default, or <code>filtered</code>.</td></tr>
2751
+ </tbody>
2752
+ </table>
2753
+ </div>
2754
+ <div class="why">
2755
+ <p><strong>A left join is usually the one you want.</strong> An inner join quietly drops the
2756
+ rows that did not match, and those are often the finding: the site with no owner, the payment
2757
+ with no invoice. <code>left</code> keeps them visible with the partner's fields empty, so the
2758
+ gap is something you can see and sort by rather than something you have to notice is
2759
+ missing.</p>
2760
+ <p><strong>First match wins.</strong> The join is a lookup, not a cross product: a row on the
2761
+ left produces exactly one row out, so a grid of ten thousand rows stays a grid of ten thousand
2762
+ rows and cannot silently multiply.</p>
2763
+ <p><strong>Both sides are live.</strong> A change on either grid updates the join, and it is
2764
+ patched from whichever side changed rather than rebuilt.</p>
2765
+ </div>
2766
+
2484
2767
  <h2 id="editing">Editing</h2>
2485
2768
  <div class="example">
2486
2769
  <p class="example__label">Turning it on</p>
@@ -2620,8 +2903,8 @@ columns: [
2620
2903
  <p class="lead-in">
2621
2904
  <strong>Sorting and filtering need a home</strong> when there are no column headings to click,
2622
2905
  and the tool panel is it: set <code>toolPanel: true</code> and its rail stays available in card
2623
- presentation with the columns and filter panels behind it. <strong>Export is unaffected</strong>
2624
- , the columns are still the data model, so a CSV or an Excel file from a collapsed grid holds
2906
+ presentation with the columns and filter panels behind it. <strong>Export is unaffected</strong>:
2907
+ the columns are still the data model, so a CSV or an Excel file from a collapsed grid holds
2625
2908
  every column, including ones the card does not show.
2626
2909
  </p>
2627
2910
 
@@ -2697,7 +2980,7 @@ rowTemplate: { template: CARD, maxCardWidth: 260 } <span class="cmt">// as ma
2697
2980
  </div>
2698
2981
 
2699
2982
  <p class="lead-in">
2700
- Tiles are a fixed height, taken from <code>rowHeight</code> ,
2983
+ Tiles are a fixed height, taken from <code>rowHeight</code>,
2701
2984
  <code>rowHeight: 'auto'</code> measures a rendered row and cannot describe a line holding
2702
2985
  several of different heights. Variable-height tiles flowing into the shortest column is a
2703
2986
  masonry layout, which is a different thing and is not offered.
@@ -2780,7 +3063,7 @@ rowTemplate: { template: CARD, maxCardWidth: 260 } <span class="cmt">// as ma
2780
3063
  <p><strong>And a load that never answers is a failure too.</strong> A promise that neither
2781
3064
  resolves nor rejects is what a dropped request looks like from the page; left alone it spins
2782
3065
  until the user gives up, which reads as an application that has hung rather than a request
2783
- that failed. After <code>timeout</code> milliseconds, two seconds unless you say otherwise ,
3066
+ that failed. After <code>timeout</code> milliseconds, two seconds unless you say otherwise,
2784
3067
  the form stops waiting and shows the same message and retry as any other failure. Set
2785
3068
  <code>timeout: false</code> to wait indefinitely, which is right only where your own loader
2786
3069
  already has a limit and would rather report that one. A record that turns up after the form
@@ -2951,7 +3234,7 @@ socket.onmessage = (m) =&gt; {
2951
3234
  <div class="why">
2952
3235
  <p><strong>Two behaviours worth knowing.</strong> An unconfirmed edit goes through the normal
2953
3236
  pipeline, so if it changes a sorted or filtered column the row moves immediately and moves
2954
- back if the write fails. And undo of an in-flight edit issues a <em>compensating write</em> ,
3237
+ back if the write fails. And undo of an in-flight edit issues a <em>compensating write</em>,
2955
3238
  a fresh write back to the previous value, itself tracked: rather than pretending to cancel a
2956
3239
  request that has already gone out.</p>
2957
3240
  </div>
@@ -3129,7 +3412,7 @@ grid.detail.placement(); <span class="cmt">// 'inline' | 'targe
3129
3412
  }
3130
3413
 
3131
3414
  grid.on('detail:cell:changed', (e) =&gt; {
3132
- e.masterKey; <span class="cmt">// 'C1' , the row the detail belongs to</span>
3415
+ e.masterKey; <span class="cmt">// 'C1', the row the detail belongs to</span>
3133
3416
  e.path; <span class="cmt">// 'ports.1.vlan': where it lands on the master's record</span>
3134
3417
  e.value; <span class="cmt">// 999</span>
3135
3418
  e.oldValue; <span class="cmt">// 101</span>
@@ -3147,7 +3430,7 @@ grid.on('detail:cell:changed', (e) =&gt; {
3147
3430
  a host can persist a detail edit against the master and never think about the nested grid at
3148
3431
  all. It is worked out by identity: <code>rows(row)</code> usually returns an array that is
3149
3432
  already a property of the record, and that property is the prefix. A detail fetched from a
3150
- server is not part of the master's record, so its <code>path</code> is <code>null</code> ,
3433
+ server is not part of the master's record, so its <code>path</code> is <code>null</code>,
3151
3434
  set <code>detail.path</code> to name it yourself when you want one anyway.
3152
3435
  </p>
3153
3436
  <p class="lead-in">
@@ -3173,7 +3456,7 @@ grid.on('detail:cell:changed', (e) =&gt; {
3173
3456
 
3174
3457
  <h2 id="selection-guide">Selection and ranges</h2>
3175
3458
  <p class="lead-in">
3176
- Row selection and cell ranges are separate answers to separate questions ,
3459
+ Row selection and cell ranges are separate answers to separate questions,
3177
3460
  <em>which records</em> versus <em>which values</em>. Dragging across cells does not tick row
3178
3461
  checkboxes, and selecting rows does not build a range.
3179
3462
  </p>
@@ -3327,7 +3610,7 @@ grid.updates.log({ since: Date.now() - 60000 }); <span class="cmt">// what arr
3327
3610
  many it let go through <code>evicted</code> on the progress report.</p>
3328
3611
  <p><strong>The log keeps the raw sequence, not the merged one.</strong> Merging is right for
3329
3612
  applying a backlog quickly and wrong for looking at what happened, because the intermediate
3330
- states are exactly what a time scrubber would move between. It survives the flush ,
3613
+ states are exactly what a time scrubber would move between. It survives the flush,
3331
3614
  <code>pending</code> is what is waiting, the log is what happened, and it is capped, so a
3332
3615
  grid paused over lunch holds the recent past and reports how much it dropped rather than
3333
3616
  taking the tab with it.</p>
@@ -3371,7 +3654,7 @@ expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);</c
3371
3654
  single-level header has three because the header is built once per pinned region.</p>
3372
3655
  <p><strong>Instrumentation must not change what it measures.</strong> Counters are integers
3373
3656
  incremented where the work already happened. Render phases are four <code>performance.now()</code>
3374
- marks around existing sections. Timings are sampled, a bounded window of recent operations ,
3657
+ marks around existing sections. Timings are sampled, a bounded window of recent operations,
3375
3658
  and every report names which of its figures are sampled, because a number whose provenance is
3376
3659
  unclear is worse than no number. Paint wait is the browser's and is deliberately not claimed.</p>
3377
3660
  <p><strong>Render causes are captured, not inferred.</strong> By the time a paint runs,
@@ -3427,8 +3710,8 @@ const providerFor = (id) =&gt; ({
3427
3710
  <p><strong>Positions are row keys, resolved against your view at render time.</strong> Peers
3428
3711
  sort and filter independently, so index 12 is a different record on every screen. Publishing
3429
3712
  an index would put a colleague's cursor on an unrelated row the moment either of you sorted.
3430
- The cost of this is real, the grid resolves a key to a position rather than reading one
3431
- , and it is the difference between the feature working and the feature lying.</p>
3713
+ The cost of this is real: the grid resolves a key to a position rather than reading one,
3714
+ and it is the difference between the feature working and the feature lying.</p>
3432
3715
  <p><strong>Idle is measured from when a message arrived, not from what it says.</strong>
3433
3716
  Clocks between clients disagree by seconds routinely and by minutes occasionally. Keying idle
3434
3717
  detection on the sender's timestamp means a peer with a fast clock never goes idle and one
@@ -3454,7 +3737,7 @@ const providerFor = (id) =&gt; ({
3454
3737
  in; only the roster does, because it is a control.</p>
3455
3738
  <p><strong>The roster is the part people use.</strong> More than the cursors, in practice. It
3456
3739
  carries the name as well as the colour, because colour alone is not a signal everyone can
3457
- read, and it reports peers whose rows are not in your view rather than omitting them ,
3740
+ read, and it reports peers whose rows are not in your view rather than omitting them,
3458
3741
  an absent peer reads as a disconnection that has not happened.</p>
3459
3742
  <p><strong>A parked cursor does not fade.</strong> The label does, after a couple of seconds,
3460
3743
  because permanent labels over a dense grid are unreadable. The border stays, dims at idle, and
@@ -3503,8 +3786,8 @@ const providerFor = (id) =&gt; ({
3503
3786
  the whole comment dataset over a long session to render a few triangles.</p>
3504
3787
  <p><strong>Stable row identity is a hard requirement, enforced rather than documented.</strong>
3505
3788
  A comment is keyed on row identity plus field. Row index changes under sort, filter and
3506
- grouping, so a comment keyed on it reattaches to whichever row later occupies that position
3507
- , and a comment on the wrong row is worse than no comment at all. A grid with no
3789
+ grouping, so a comment keyed on it reattaches to whichever row later occupies that position,
3790
+ and a comment on the wrong row is worse than no comment at all. A grid with no
3508
3791
  <code>rowKey</code> disables comments and names them in the same single warning as the other
3509
3792
  identity-dependent features.</p>
3510
3793
  <p><strong>Identity has to survive more than the session.</strong> Comments outlive the page
@@ -3549,7 +3832,7 @@ const providerFor = (id) =&gt; ({
3549
3832
  <pre><code>statusBar: { panels: ['rowCount', 'comments'] }</code></pre>
3550
3833
  </div>
3551
3834
  <p>The count returns zero rather than a number it cannot stand behind: it is only meaningful
3552
- once the index covers every row, so it stays at zero, and the panel stays silent ,
3835
+ once the index covers every row, so it stays at zero, and the panel stays silent,
3553
3836
  until <code>loadAll()</code> has resolved.</p>
3554
3837
  <p><strong>The comments-only filter is refused rather than approximated.</strong> Restricting
3555
3838
  the grid to rows carrying comments needs the index to cover the whole row set, not just what
@@ -3718,8 +4001,8 @@ grid.timeline.detach();</code></pre>
3718
4001
  it a scrub is nearly unreadable: the grid moves, and on a row twelve columns wide the one
3719
4002
  number you are hunting for goes past unseen.</p>
3720
4003
  <p><strong>The mark is held, not flashed.</strong> It stays until the next seek clears it.
3721
- Every other transient signal in the grid fades on a timer, and this one deliberately does not
3722
- , a scrub is someone hunting for what changed, and a highlight they can miss while reading the
4004
+ Every other transient signal in the grid fades on a timer, and this one deliberately does not,
4005
+ a scrub is someone hunting for what changed, and a highlight they can miss while reading the
3723
4006
  other end of the row helps nobody. <code>timeline:seeking</code> fires before any change is
3724
4007
  applied, so a five-step drag clears once and marks once rather than strobing per entry.</p>
3725
4008
  <p><strong>It compares column values, not raw fields.</strong> A computed column has no field
@@ -3773,7 +4056,7 @@ grid.presentation.stop(); <span class="cmt">// or Escape</span
3773
4056
  </div>
3774
4057
  <div class="why">
3775
4058
  <p><strong>The scale multiplies your density, it does not replace it.</strong> A grid built at
3776
- <code>spacious</code> presented at 1.5x is still recognisably that grid, half as big again ,
4059
+ <code>spacious</code> presented at 1.5x is still recognisably that grid, half as big again,
3777
4060
  which is what makes a presentation look like the product rather than like a different one.
3778
4061
  Virtualisation follows the enlargement, so rows are positioned at the size they are drawn.</p>
3779
4062
  <p><strong>Full-screen is the maximiser, not a second implementation.</strong> A grid the user
@@ -4034,7 +4317,7 @@ grid.annotate.use(null); <span class="cmt">// hand the grid
4034
4317
  <li><strong>The filter icon is a small target, deliberately.</strong> It is 16&nbsp;&times;&nbsp;16,
4035
4318
  below the 24-pixel minimum of WCAG 2.5.8, and conforms under that criterion's
4036
4319
  <em>equivalent</em> allowance: filtering is also a column-menu item, and the menu button
4037
- meets the size on its own. Worth knowing if you are pointing at it on a touch screen ,
4320
+ meets the size on its own. Worth knowing if you are pointing at it on a touch screen,
4038
4321
  the menu is the larger route to the same thing.</li>
4039
4322
  </ul>
4040
4323
 
@@ -4385,6 +4668,171 @@ grid.filters.quickState(); <span class="cmt">// { text, mode }</span></co
4385
4668
  value by value tells a listener less than the sentence does.</p>
4386
4669
  </div>
4387
4670
 
4671
+ <h2 id="statistics">Profiling and statistics</h2>
4672
+ <p class="lead-in">
4673
+ <code>grid.statistics</code> answers questions about the rows the filters left, so every
4674
+ figure describes what the user is looking at rather than the whole table.
4675
+ </p>
4676
+ <div class="example">
4677
+ <p class="example__label">Everything worth knowing about a column, in one pass</p>
4678
+ <pre><code><span class="kw">const</span> p = grid.statistics.profile('capacity');
4679
+
4680
+ p.count; p.missing; p.distinct;
4681
+ p.min; p.q1; p.median; p.q3; p.max;
4682
+ p.mean; p.stdDev;
4683
+ p.outliers; <span class="cmt">// by the interquartile rule</span>
4684
+ p.histogram; <span class="cmt">// bins, ready to draw</span>
4685
+ p.alerts; <span class="cmt">// what is worth looking at</span></code></pre>
4686
+ </div>
4687
+ <p>
4688
+ <code>alerts</code> is the part that saves time: a column that never varies, a key that turns
4689
+ out not to be unique, a fifth of the rows missing. A profile that reports only numbers leaves
4690
+ the reader to notice those, and readers reliably do not.
4691
+ </p>
4692
+ <div class="table-wrap">
4693
+ <table>
4694
+ <thead><tr><th>Method</th><th>Answers</th></tr></thead>
4695
+ <tbody>
4696
+ <tr><td class="name">reduce</td><td class="desc">A column through any of the thirty-eight named kernels, or one of your own.</td></tr>
4697
+ <tr><td class="name">correlation</td><td class="desc">Pearson's r between two columns. <code>spearman</code> resists an outlier; <code>kendall</code> is tau-b.</td></tr>
4698
+ <tr><td class="name">regression</td><td class="desc">A least-squares fit of one column on another, with slope, intercept and r².</td></tr>
4699
+ <tr><td class="name">weightedAverage</td><td class="desc">One column averaged by another. <code>weightedQuantile</code> for the median and beyond.</td></tr>
4700
+ <tr><td class="name">series</td><td class="desc">How a column varies along an ordering. <code>by</code> is required and never guessed.</td></tr>
4701
+ <tr><td class="name">shadow</td><td class="desc">What the grid knows about a row over time: updates, delta, rate, rank, percentile, streak.</td></tr>
4702
+ </tbody>
4703
+ </table>
4704
+ </div>
4705
+ <div class="why">
4706
+ <p><strong>The filters are part of the question.</strong> Every one of these reads the
4707
+ filtered rows. Narrow the grid and the statistics narrow with it, which is the behaviour you
4708
+ want when the filter <em>is</em> the analysis.</p>
4709
+ <p><strong>Kernels see arrival order, not display order.</strong> Anything order-dependent
4710
+ takes an explicit <code>by</code> rather than inferring one from the current sort, so the
4711
+ answer does not change when a user clicks a column header.</p>
4712
+ </div>
4713
+
4714
+ <h2 id="spc">Process control and capability</h2>
4715
+ <p class="lead-in">
4716
+ Whether a process sits inside the tolerance it was given, and whether it is behaving or
4717
+ drifting. The tolerance is declared once, on the column.
4718
+ </p>
4719
+ <div class="example">
4720
+ <p class="example__label">The specification lives with the column</p>
4721
+ <pre><code>{ id: 'diameter', type: 'number', spec: { lower: 9.95, upper: 10.05, target: 10 } }</code></pre>
4722
+ </div>
4723
+ <div class="example">
4724
+ <p class="example__label">Asking for the capability</p>
4725
+ <pre><code><span class="kw">const</span> c = grid.statistics.capability('diameter', { rules: 'nelson' });
4726
+
4727
+ c.cp; c.cpk; <span class="cmt">// short-term spread, from the moving range</span>
4728
+ c.pp; c.ppk; <span class="cmt">// overall spread</span>
4729
+ c.outOfSpec; <span class="cmt">// parts outside the customer's tolerance</span>
4730
+ c.limits; <span class="cmt">// { centre, upper, lower, sigma }</span>
4731
+ c.violations; <span class="cmt">// [{ index, rule, description }, …]</span>
4732
+ c.interval; <span class="cmt">// a confidence interval for cpk</span></code></pre>
4733
+ </div>
4734
+ <div class="why">
4735
+ <p><strong>One declared tolerance, so nothing can disagree.</strong> The indices, the charts
4736
+ and any conditional format all read the same <code>spec</code>. A tolerance passed separately
4737
+ to each is a tolerance that eventually differs between them, and a capability report that
4738
+ contradicts the cell colouring is worse than neither.</p>
4739
+ <p><strong>Cp and Cpk use short-term variation, Pp and Ppk overall.</strong> The first pair
4740
+ comes from the moving range, which is what the process can do when it is behaving; the second
4741
+ from the whole spread, which is what it actually delivered. Ppk well below Cpk is the signal
4742
+ that the process drifted rather than that it is incapable.</p>
4743
+ <p><strong>A baseline finds a shift instead of absorbing it.</strong> <code>baseline: 30</code>
4744
+ fixes the limits over the first thirty readings. Limits recomputed over all the data widen to
4745
+ accommodate the very shift you are looking for, and then report no violation.</p>
4746
+ <p><strong>The point estimate alone overstates the case.</strong> A Cpk of 1.35 measured on
4747
+ thirty parts has a lower bound below 1.0, so a process that has "passed" a 1.33 requirement on
4748
+ thirty parts has demonstrated very little. <code>interval</code> is reported alongside it for
4749
+ that reason.</p>
4750
+ </div>
4751
+
4752
+ <h3>Drawing it</h3>
4753
+ <p class="lead-in">
4754
+ Three chart types complete the picture, and they read the same specification.
4755
+ </p>
4756
+ <div class="table-wrap">
4757
+ <table>
4758
+ <thead><tr><th>Type</th><th>Shows</th></tr></thead>
4759
+ <tbody>
4760
+ <tr><td class="name">control</td><td class="desc">Readings against the centre line and control limits, with every rule break numbered.</td></tr>
4761
+ <tr><td class="name">movingRange</td><td class="desc">The companion chart: variation between consecutive readings.</td></tr>
4762
+ <tr><td class="name">capability</td><td class="desc">The distribution against the tolerance, with a curve for each of the two spreads.</td></tr>
4763
+ </tbody>
4764
+ </table>
4765
+ </div>
4766
+ <p>
4767
+ Rule breaks are numbered rather than merely marked, under Western Electric's four rules or
4768
+ Nelson's eight. The two sets number differently, so the chart names which it applied: a
4769
+ "rule 3" that could mean either is not a finding anyone can act on.
4770
+ </p>
4771
+
4772
+ <h2 id="intervals">Confidence intervals</h2>
4773
+ <p class="lead-in">
4774
+ How firmly the data pins a figure down. An interval narrows as the grid does, because it
4775
+ describes the filtered rows and not the whole table.
4776
+ </p>
4777
+ <div class="example">
4778
+ <p class="example__label">A mean and a rate</p>
4779
+ <pre><code>grid.statistics.interval('capacity');
4780
+ <span class="cmt">// { lower, upper, mean, n, confidence }, by Student's t</span>
4781
+
4782
+ grid.statistics.interval('status', {
4783
+ kind: 'proportion',
4784
+ where: (v) =&gt; v === 'failed',
4785
+ });
4786
+ <span class="cmt">// Wilson score, which stays sensible at small n and near 0 or 1</span></code></pre>
4787
+ </div>
4788
+ <p>
4789
+ Intervals are also available on a regression slope and on a capability index. Each uses the
4790
+ method that suits it: Student's <em>t</em> for a mean, the Wilson score for a proportion, and
4791
+ Bissell's approximation for Cpk.
4792
+ </p>
4793
+ <div class="why">
4794
+ <p><strong>The line the product draws.</strong> Lattice quantifies uncertainty. It does not
4795
+ adjudicate hypotheses: there are no p-values and no significance tests. An interval says how
4796
+ precisely a figure is known and leaves the judgement where it belongs. A tool that returns a
4797
+ verdict invites it to be read as one, and a grid is the wrong place for that.</p>
4798
+ <p><strong>Wilson, not the textbook formula.</strong> The normal approximation gives bounds
4799
+ below zero and above one at small counts, which is visibly wrong to anyone who reads it. The
4800
+ Wilson score stays inside the interval it is describing.</p>
4801
+ </div>
4802
+
4803
+ <h2 id="stat-tiles">Statistic tiles</h2>
4804
+ <p class="lead-in">
4805
+ A headline figure over a grid, with the change since a baseline, a tone from thresholds, and
4806
+ the interval underneath.
4807
+ </p>
4808
+ <div class="example">
4809
+ <p class="example__label">A tile that follows the grid</p>
4810
+ <pre><code><span class="kw">import</span> { createStat } <span class="kw">from</span> 'lattice-grid';
4811
+
4812
+ createStat({
4813
+ grid,
4814
+ container: tile,
4815
+ title: 'Total capacity',
4816
+ value: { of: 'capacity', fn: 'sum' },
4817
+ baseline: (g) =&gt; lastMonth,
4818
+ bands: { good: 5000, warn: 3000, direction: 'up' },
4819
+ interval: (v, g) =&gt; g.statistics.interval('capacity'),
4820
+ });</code></pre>
4821
+ </div>
4822
+ <div class="why">
4823
+ <p><strong><code>bands</code> and <code>goodWhen</code> judge different things.</strong>
4824
+ <code>goodWhen</code> says whether a rise is good news, and colours the change indicator.
4825
+ <code>bands</code> judge the value itself. They are separate because a Cpk of 0.9 is bad news
4826
+ whether it rose or fell to get there.</p>
4827
+ <p><strong>The tile follows the grid by default.</strong> Filter the grid and the figure
4828
+ updates. <code>scope</code> chooses filtered, all or selected rows; <code>live: false</code>
4829
+ detaches it and leaves <code>refresh()</code> to you.</p>
4830
+ <p><code>value</code> also takes <code>show</code>, which reports a field from the row holding
4831
+ an extreme rather than the extreme itself: <code>{ of: 'sales', fn: 'max', show: 'rep' }</code>
4832
+ is the <em>name</em> of the best rep. It needs <code>min</code> or <code>max</code>, because no
4833
+ single row holds an average.</p>
4834
+ </div>
4835
+
4388
4836
  <h2 id="formulas-guide">Formulas</h2>
4389
4837
  <p class="lead-in">
4390
4838
  A leading <code>=</code> in a numeric cell is a formula. Type
@@ -4658,7 +5106,7 @@ grid.highlight.clear();</code></pre>
4658
5106
  <div class="why">
4659
5107
  <p><strong>Applying a view is a destination, not a patch.</strong> A view's state names only
4660
5108
  the sections it cares about, so applying one resets to the grid's starting state first. Without
4661
- that, clicking "APAC capacity" after "Commercial" would inherit Commercial's hidden columns ,
5109
+ that, clicking "APAC capacity" after "Commercial" would inherit Commercial's hidden columns,
4662
5110
  the same view giving a different grid depending on what preceded it, which is the one thing a
4663
5111
  named view must not do.</p>
4664
5112
  </div>
@@ -4682,7 +5130,7 @@ grid.highlight.clear();</code></pre>
4682
5130
  </div>
4683
5131
 
4684
5132
  <div class="why">
4685
- <p><strong>Applying a view never throws and never refuses.</strong> It returns a report ,
5133
+ <p><strong>Applying a view never throws and never refuses.</strong> It returns a report,
4686
5134
  <code>{ applied, skipped }</code>: naming each thing it could not use and why. Refusing the
4687
5135
  whole view because one column has gone would lose a layout the user built deliberately, and
4688
5136
  throwing during a page load would lose the page. So a view degrades to as much of itself as
@@ -4737,7 +5185,7 @@ grid.on('view:default', e =&gt; api.patch(`/views/${e.view.id}`, { isDefault: tr
4737
5185
  grid has one to plug in, and a picker offering "Save" that quietly does nothing until a backend
4738
5186
  exists is worse than not offering it. <code>views.local: true</code> is the no-backend answer:
4739
5187
  saved views live in this browser's own <code>localStorage</code>, under a default key shared by
4740
- every grid on the origin unless you pass one of your own ,
5188
+ every grid on the origin unless you pass one of your own,
4741
5189
  <code>views: { local: { key: 'orders-grid-views' } }</code>: to keep two grids' views apart.
4742
5190
  Given alongside an explicit <code>storage</code>, the explicit adapter always wins and
4743
5191
  <code>local</code> is silently (well, not silently: it warns once) ignored, so a page cannot
@@ -4779,7 +5227,7 @@ grid.history.list(); <span class="cmt">// the timeline, newest first</sp
4779
5227
 
4780
5228
  <h2 id="permissions-guide">Column permissions</h2>
4781
5229
  <p class="lead-in">
4782
- Four levels, resolved per column from configuration or a callback. They are not a ladder ,
5230
+ Four levels, resolved per column from configuration or a callback. They are not a ladder,
4783
5231
  reading and writing are independent, so they are the four corners of a 2×2.
4784
5232
  </p>
4785
5233
  <div class="table-wrap">
@@ -4859,8 +5307,8 @@ grid.diff.before('CIR-100042', 'capacity');</code></pre>
4859
5307
  is shown and the grid is the one you already had.</p>
4860
5308
  <p><strong>Neither sorts or filters it among the live rows.</strong> A removed row's values
4861
5309
  are yesterday's; ordering them among today's presents two data sets as one, and lets a filter
4862
- written for current values decide the fate of historical ones. Neither permits an edit either
4863
- , a write aimed at a removed row is refused and returns <code>0</code>, rather than being
5310
+ written for current values decide the fate of historical ones. Neither permits an edit either,
5311
+ a write aimed at a removed row is refused and returns <code>0</code>, rather than being
4864
5312
  counted as applied against a record that is not there.</p>
4865
5313
  </div>
4866
5314
 
@@ -4884,12 +5332,12 @@ grid.diff.before('CIR-100042', 'capacity');</code></pre>
4884
5332
  <p class="lead-in">
4885
5333
  That mounts a prompt bar. The user types "EMEA circuits over 500 gigs, biggest first"; the
4886
5334
  grid composes a message including its schema; your callback returns the model's reply; the
4887
- grid validates it and shows what it would do in plain English ,
4888
- <em>"Filter Region is EMEA and Capacity is more than 500, sort Capacity descending"</em> ,
5335
+ grid validates it and shows what it would do in plain English,
5336
+ <em>"Filter Region is EMEA and Capacity is more than 500, sort Capacity descending"</em>,
4889
5337
  with Apply and Discard.
4890
5338
  </p>
4891
5339
  <div class="why">
4892
- <p><strong>Nothing is executed on trust.</strong> The vocabulary is seven actions ,
5340
+ <p><strong>Nothing is executed on trust.</strong> The vocabulary is seven actions,
4893
5341
  <code>setFilters</code>, <code>setSort</code>, <code>groupBy</code>,
4894
5342
  <code>showColumns</code>, <code>hideColumns</code>, <code>setQuick</code>,
4895
5343
  <code>clear</code>, and a reply naming a column that does not exist is rejected with a
@@ -5127,7 +5575,7 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'<
5127
5575
  <footer>
5128
5576
  <p>
5129
5577
  Lattice Grid 1.7.1 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
5130
- Written against the shipped source. Where this guide and the code disagree, the code wins ,
5578
+ Written against the shipped source. Where this guide and the code disagree, the code wins,
5131
5579
  please <a href="https://www.latticegrid.dev">tell us</a>.
5132
5580
  </p>
5133
5581
  </footer>