changelog
Changelog: Every Lattice Grid Release
Last updated 15 October 2026 (v1.103.2)
Entries are written for the person deciding whether to upgrade: what changed, and what it means for a grid already in production.
[1.103.2] - 2026-10-15
Fixed
- A clearable form select’s clear control no longer covers the selected value. It keeps the size of a control beside the arrow, and a long value is shortened before it.
- Your own markup in a form
htmlitem keeps its own size. Buttons, inputs, links and tables there no longer take the form’s field sizing and font. - A narrow single-select combo keeps its toggle on the input’s line. The field stays one row tall at any width.
- A multi combo’s open list follows the field. It moves with the control when a chip is added or removed, and the same holds for a select and the date picker.
- Typed text in a date-time or time picker commits when you click away. Code that reads the value on that click now sees what was typed.
- A number outside its limits keeps the value you typed. The field is marked invalid and the message names the limit.
- An optional date can be cleared. Emptying the text commits an empty value, and a clear control is available on request.
- A sorted narrow grid column no longer leaves its menu and filter controls over the title. They show only on hover or keyboard focus.
[1.103.1] - 2026-10-15
Fixed
- A form combo that allows new values now creates them from a click or a tap. The “Create” row used to respond only to Enter. It now adds and selects the new value when clicked or tapped as well.
[1.103.0] - 2026-10-15
Added
- Dark mode and the four design-system presets now reach every view. The form, Gantt, Kanban board, calendar, scheduler, Designer, shell, charts, KPI tiles and map view draw every surface from the theme tokens, so a dark theme and each preset apply in light and dark. Chart palettes come from the preset, and a Gantt export matches the theme on screen. Colours you set in a chart’s or map’s own config stay data and are never themed.
- A form content container item. Show your own HTML in a form, or attach another view to it after the form is created. It carries no value and is skipped by validation.
- A form avatar control. A profile picture field with preset or pixel sizes, a circle option, a fallback for an empty value, picking by click, keyboard or drag and drop, a remove control and an optional upload.
- A form toggle group control. One two-state button per option, either mutually exclusive or independent, with the value held as a map of option to pressed state.
- A form time field. A clock picker under its own control type, with 12 or 24 hour display, typed entry, an optional Save and Close and an optional icon and placeholder.
Fixed
- A dark form inside a light page no longer paints invisible labels. The form now paints its own surface, so labels, legends and help text stay readable in both themes.
[1.101.8] - 2026-10-15
Fixed
- An optional combo can be emptied by clearing its text and leaving the field or pressing Enter. A clearable option adds a clear control to combo and select fields.
[1.101.7] - 2026-10-14
Fixed
- A select, combo, date picker or other form control now shows its invalid state on the field itself after a failed validation. The field carries the invalid border and the message link, and both clear once the value is valid.
- Enter in a field commits the typed text before the form submits. The submitted value is what was typed, so a required field filled a moment ago passes.
- A validation message clears as soon as the field becomes valid. It no longer waits for the next validation.
- Pinned grid columns stay in step with the body when focus lands on a row below the fold while a detail panel is open.
[1.101.6] - 2026-10-14
Fixed
- Every form on a page keeps its own element ids. Labels and screen-reader links point at the right control, even with several forms on one page.
- A typed date is accepted in the field’s own display format and the locale’s short form. Text that cannot be read keeps the previous value and shows a message.
- The date-time picker keeps the typed time when a day is picked. The value commits on Done or an outside click.
- Enter in a single-line field now submits a form that has a submit button. Text areas, open lists and forms without a submit button behave as before.
- A combo search with no matches shows an empty state. The stale options no longer linger on a collapsed list.
- Combo and select items can refresh their options. Call
setOptionsto replace them orreloadto fetch them again. - Typing into a combo that holds a value replaces the label and filters the list. Escape restores the label.
- The form’s font and control styling now reach the option lists in the page-level layer. Select, combo and date picker popups match the form in light and dark themes.
- An item width inside rows now sets its width. The item takes the width you give it and its height from its content.
- A form event handler that throws is now reported as an error. Your error monitoring sees it instead of it being swallowed.
- Horizontal bar charts given several measures draw their bars again. Grouped and stacked bars both work.
- The detail renderer option is typed as the documented function, and anything else warns. A string or class is reported once instead of failing silently.
- The master-detail panel spans the full width whatever is pinned. Pinned columns on either side no longer trap the panel in one strip.
- Detail height
autosizes the panel to its content. An optional maximum height caps the growth and scrolls inside the panel.
[1.101.5] - 2026-10-14
Fixed
- The date picker’s Previous and Next month buttons now move the calendar and keep it open. A date in any month can be picked, and the button keeps keyboard focus. The date range picker’s navigation works the same way.
[1.101.4] - 2026-10-14
Fixed
- A select, combo or date picker list now opens above whatever contains the field. A slide-out panel, a floating window or a modal dialog can no longer cover it, and a page can set its own stacking order with a theme token.
- A select now shows a choice whose value is the empty string. A leading “- Select -” entry is shown when the field’s value is empty, instead of a blank box.
- The colour item lays out correctly on a page with only the Lattice stylesheet. The swatch, the hex input and the toggle now fit the field.
[1.101.3] - 2026-10-14
Fixed
- A form wizard now checks the current step before it moves forward. A step with a required field no longer advances empty: the first problem field is focused and marked, and the wizard stays put. Going back never checks, and you can switch the check off if you validate the form yourself.
- A select or combo list now opens above everything on the page. A short toolbar or a scrolling panel can no longer cut the list off.
- Setting the columns breakpoint to 0 keeps side-by-side items on one line at any width, and full-width buttons share a row. Two full-width buttons in a row now sit next to each other instead of stacking.
- A select now returns its option values with their own type. A number option comes back as a number, a true or false option as a boolean, and an empty option as null.
- The date picker’s hour and minute boxes always show two digits. A time of 21:05 now reads 21 and 05 instead of 21 and 5.
[1.101.2] - 2026-10-13
Changed
- A partial row update now costs only the rows and cells it touched, so a live board with thousands of visible cells repaints only what changed. A few hundred ticking values cost a few hundred cell writes instead of a full-grid repaint; behaviour is unchanged and no option changed.
[1.101.1] - 2026-10-13
Changed
- Conditional formatting style rules now cost the same as unstyled cells on a bulk update, so thousands of styled cells update in one frame. A grid with a rule on every cell no longer slows a live colour-coded board down; formatting behaves exactly as before and no option changed.
[1.101.0] - 2026-10-13
Added
- Date range picker: start and end in one field, two months side by side, presets such as last 7 days and this month, minimum and maximum dates, span limits, week numbers, a month-range mode, and Apply and Cancel.
- List box: an always-visible option list with single, multiple and checkbox selection, a filter box, group headers, sorting, drag or button reordering, and two connected lists that pass items between them.
- Speed dial: a floating action button anchored to a corner or edge that opens its actions in a strip or a radial fan, with a modal backdrop option, seven colour variants and full keyboard access; also available on its own outside a form.
- Toggle switch: on and off text inside or beside the track, three sizes and three track variants. A toggle that sets none of the new options looks and behaves exactly as before.
- Barcode: Code 39, Code 93, Code 128, EAN, UPC and Data Matrix among others, drawn as inline SVG in a grid cell, as a form display control or on their own. A value that would not scan shows a placeholder instead of a wrong code, and the QR encoder now picks a smaller version for short numeric and alphanumeric codes.
[1.100.1] - 2026-10-13
Added
- The form gains a colour control: a swatch with the hex value, a palette of preset shades and greys, remembered custom colours, a custom colour picker, typed hex entry and a clear control. It works from the keyboard and in both themes.
Fixed
- A combo that loads options a page at a time and opens with a value already selected now draws the selected value and its chips with the resolved option. A custom selected renderer sees the real label instead of the raw value.
- A custom control registered through the form now receives the settings written in its item’s
props.
[1.100.0] - 2026-10-12
Added
- Form groups: several forms placed in different layout cells read, write, validate and lock as one form. A field’s rules can watch a field in another form of the group, and a form or a whole group binds to a data router record and saves back through it.
- Touch: Kanban cards, Gantt bars and their handles, calendar events and scheduler assignments drag on phones and tablets with a press-and-hold, while a swipe still scrolls. A two-finger pinch zooms the Gantt, scheduler and calendar time axis.
[1.99.1] - 2026-10-12
Fixed
- Clearing a number field in a form and tabbing away now leaves it blank. The box no longer redraws the old number, so what you see agrees with what the form saves and validates.
- A form’s validation message now sits under its field in every label layout instead of beside it. The field keeps its width, and a long message wraps within it.
[1.99.0] - 2026-10-11
Breaking
- A shell’s
rowsnow stack top to bottom andcolssit side by side, the same as the form module. If you wrote shell configs on the previous version, swaprowsandcols. In htmx shell markup, swap thedata-lattice-axisvalues too.
Added
- A form module for laying out controls and collecting input. Arrange controls in rows and columns with percentage widths, format numbers with units, pick dates, date-times and times, and take files on a drop area. Controls can be disabled or read-only, rules can show, hide or require a control from other values, and a form can bind to a grid row or to the data router. It works with React, Vue, Svelte, Angular and htmx.
[1.98.3] - 2026-10-11
Fixed
- htmx pages build their grids, boards, Gantts and calendars on every visit. This includes reloads on a warm browser cache and pages that load the htmx module late, which could previously be left showing the raw server tables until a hard reload.
- A grouped resource scheduler shows each team once, with no console warning. The empty extra group column is gone, so the resource columns get their full width.
- The scheduler’s Day, Week and Month zoom presets are fixed scales, distinct from Fit. Choosing Fit no longer changes what Day gives you afterwards.
[1.98.2] - 2026-10-10
Fixed
- The resource scheduler’s resource pane is resizable, and grouped names stay readable. Set the pane’s width with
resourceWidth, or drag the divider (it also works from the keyboard). When resources are grouped, person names are no longer clipped mid-word. - The scheduler’s time-axis labels never collide, at any zoom. Labels now shorten or thin out to fit, and a new
'fit'zoom sizes the timeline so your plan fills it edge to edge.
[1.98.1] - 2026-10-10
Fixed
- Long cell text, htmx boards and the resource scheduler. Text that does not fit a grid cell now ends in an ellipsis, with the full value as its tooltip. Out-of-band anchors kept beside an htmx board no longer take space. The resource scheduler’s rows line up with their timeline lanes, and its workload band flags only real over-allocation.
[1.98.0] - 2026-10-10
Added
- A shell module for application layouts. Lay a page out as nested rows and columns of panels. Drag a splitter or use the arrow keys to resize, collapse a panel to a header strip and expand it again, hide, show, add and remove panels, and group content into tabbed panels. One
attach()call puts any content in a panel, and it keeps its state through hide, collapse and tab switches. Save the arrangement and restore it later. It works with React, Vue, Svelte, Angular and htmx.
Improved
- htmx. Grids built from server tables size their columns to fill the page, and a header can set
data-widthordata-flex. Any row of a table grid can be updated on its own, and one licence call now covers htmx. - Kanban columns fill the board. Columns share the board’s width instead of sitting at a fixed 280px. To keep the old fixed width, pass
columnWidth: 'auto'.
Fixed
- Dropping a card on a calendar event now lands it on that event’s day.
- Designer Gantt widgets open on the dates of their tasks.
[1.97.0] - 2026-10-10
Added
- A resource scheduler module. See who is doing what over time: resources run down the left and each event is a bar on a shared timeline. Drag bars to reschedule or reassign, resize them, and draw new ones. A workload band flags over-allocated resources, resources can be grouped and searched, and a Gantt plan can be viewed by resource. It works with React, Vue, Svelte, Angular and htmx.
- Pick the worksheet when importing an Excel workbook. A workbook with several sheets shows a sheet picker in the import dialog, and the import API can read any sheet by name or position.
Improved
- htmx. A server-rendered table becomes a grid with editable columns, Back restores views bound to a grid, and a calendar or Kanban board built from a table updates one row at a time. A Kanban board now raises a change event for each moved card, calendars respect their time zone, and every script build shares one licence call.
Fixed
- A Gantt built from a server table’s header columns now gives the task name a sensible width instead of squeezing it.
[1.96.0] - 2026-10-09
Added
- Board, Calendar and Gantt widgets in the Designer palette. Add a Kanban board, a calendar or a Gantt over any source and set it up in the properties panel. Each follows page filters and cross-filtering like the other widgets, and a saved dashboard reloads it as you left it.
[1.95.0] - 2026-10-09
Added
- Kanban workflow rules and required fields. Say which columns a card may move to, and which fields a card must carry before it enters a column; a disallowed column does not accept the drop, and a missing field opens a small form.
- Server-side boards over databases. Bind a board straight to a database source and every column’s count and total come from the database, with cards loaded a page at a time.
- Blocked cards and dependencies. Mark a card as blocked, show what blocks it, and filter the board down to blocked cards.
- Richer cards. Assignee avatars, due dates that turn red when overdue, a progress bar and a checklist count, with a compact density for fitting more cards in a column.
- Undo and redo on the board. Every move, reorder, add and edit steps back and forward, with Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z.
[1.94.0] - 2026-10-09
Breaking
- The Gantt split view no longer defaults to a fixed 420px height. It now fills its container when the container has a height, and otherwise sizes to its rows, up to 600px. If you relied on the old height, pass
height: 420to keep it.
Added
- Every view can start from server-rendered HTML with htmx. A calendar or Kanban board builds straight from a table inside it, the Designer is declared in markup, and grids and calendars send change events an
hx-postcan pick up. A Gantt drag sends one commit event, however many tasks it moves.
Fixed
- The Gantt timeline now fills its area, with the date header, row backgrounds and today line running to the edge, and the zoom buttons no longer cover dates.
- Going Back to a Gantt over htmx no longer logs a warning.
[1.93.0] - 2026-10-09
Added
- htmx for the Kanban board and map views. Declare either in markup; it survives a swap and comes back on Back.
Fixed
- The Gantt table header and columns no longer spill or squeeze. Headers stay inside the table pane, and the task name column keeps a readable width.
- Layout windows never overlap. A resize or move stops at the neighbouring window.
[1.92.0] - 2026-10-09
Changed
- Smaller downloads. The core now ships English only; every other language is a one-line import (
import '@toclocoinc/lattice-grid/locales/fr-FR', or.../locales/allfor every language). The charts, maps, KPI and AI modules no longer carry their own copies of core code, so a page that loads several of them downloads that code once.- If you use a language other than English, add the import for it. A grid set to a locale you have not imported renders English and prints one warning naming the import to add. The
messagesoption works exactly as before. The catalogues (FR_FR,EN_US,LOCALES) are no longer named exports of the main bundle.
- If you use a language other than English, add the import for it. A grid set to a locale you have not imported renders English and prints one warning naming the import to add. The
[1.91.0] - 2026-10-08
Added
- Gantt data bands and a standalone resource view. Stack bands under the plan that show a measure per task per week, such as forecast cost, scrolling and zooming with the timeline. The workload band can also be drawn on its own, with no task plan, for a resource availability view by department or portfolio.
- A full set of Gantt events and built-in task actions. Pointer events on bars and links, table cell and row events, view and scroll events, row drops and workload cells all raise events a host can listen to or veto. Add, delete and convert-to-milestone are built in, each one undo step, with default context-menu items and keys.
- A declarative Gantt for htmx. Wrap a server-rendered table in
data-lattice-ganttand it becomes a Gantt, with server-sent row updates and a change event to post back. - A 3D scatter chart, with orbit and zoom, a colour ramp and per-point hover.
- Running totals and multi-level sunbursts in charts. Set
cumulative: trueon a measure to draw it as a running total. - Multi-line and vertical column headers. Long titles can wrap onto more lines, and narrow matrix columns can run their titles bottom to top.
- Designer components for React, Vue, Svelte and Angular, and live data updates to a running Designer without rebuilding it.
Changed
- The Designer has a redesigned palette, data panel and top bar. Chart types are grouped into six families with thumbnails, datasets are collapsible groups of field chips, and the top bar and filter bar use labelled icon controls.
- A 5,000-task plan scrolls smoothly. The Gantt draws only the bars and arrows in view.
- The Gantt workload band is built on real grid columns, with the same events, tooltips and exports as before.
- The zebra stripe has its own
--lattice-row-stripetoken, and the grid’s default fonts, colours and sizes now give way to any plain CSS rule you write for the same property, whatever the load order. If you themed the stripe through--lattice-surface-alt, move that override to--lattice-row-stripe.
Fixed
- A sunburst drew only its first ring. It now draws every level you give it.
[1.90.0] - 2026-10-08
Added
- The Gantt gets the planning features MS Project users expect. Tasks carry WBS codes that renumber as the plan changes; predecessor and successor columns read and edit links as MS Project text (
3FS+2); a task or resource can have its own calendar; tasks can be read-only or inactive; copy, cut and paste work on tasks and subtrees; and a bar resizes from either end. Ten more task-table column kinds are built in, and weeks start on Monday for UK and most European locales.
Fixed
- The Gantt task table now edits the way a spreadsheet does, without looking like one. Double-click, F2 or just typing opens a cell’s editor, and it stays open. Widening a column now widens the table, which scrolls sideways, instead of squeezing the Task column. Clicking a cell no longer draws a spreadsheet focus box and fill handle.
[1.89.1] - 2026-10-08
Fixed
- The Designer’s editing screen now holds a clear layout, inside any host page. The palette, data and properties panels are bordered sections with their own headers, the properties panel is a real form with a labelled control for every setting, every heading and label reads from one consistent type scale, and every choice is named in plain English.
- A filtered grid built from a data source now shows its rows, instead of staying empty.
[1.89.0] - 2026-10-07
Added
- The Lattice Designer: build and edit a dashboard by hand, not just by spec. A new opt-in module adds edit and view modes over a canvas of windows you add, move and resize by pointer or keyboard, a collapsible palette of every widget and chart type your guardrails allow, and a properties panel generated from each widget’s own fields - source, roles, formatting - with a field list you drag or tab onto a role.
- Build new data without writing a query. A data panel browses your sources, fields and the relationships between them; derived grids chain filter, group-and-roll-up, pivot, join and sort/top-N steps into a new grid other widgets can chart from; calculated fields add an expression column with the grid’s own sandboxed formula language, autocomplete and a live preview.
- Six statistics widgets, and analysis that runs in the engine where it can. Column profile, distribution, correlation, group comparison, forecast and model-error widgets drop onto a page from the properties panel, each stating its method and sample size. Over a pushdown source (DuckDB, ClickHouse, Elasticsearch, Splunk, REST, OData) the Designer reads the schema without loading it, computes in the engine where the adapter allows, and shows an info badge saying where each figure was computed - never a silent guess.
- Pages, filters and click-to-filter links, undo for every edit, and guardrails that restrict what an author can use. Add, rename, reorder and delete pages; a filter bar scoped per-page or globally; a grid or chart’s “clicking this filters…” setting links widgets together. Every committed change is one undo step, and
guardrailshides the sources, fields, widgets, chart types and formatting an author may not touch rather than merely disabling them. - AI assist proposes a layout from a prompt; you accept or reject it. The host supplies its own model; the reply is checked twice - once against what the data and guardrails actually allow - before anything is shown as a preview with Accept and Reject, and nothing is applied until you accept it. The chrome follows the site’s four theme presets in light and dark, and ships with a documented keyboard map, an accessible live region for state changes and a built-in audit for unnamed controls.
Fixed
- A derived grid’s bucket step without a groupBy now buckets and groups on its own instead of passing every row through unbucketed.
- A monthly or yearly chart time axis now labels each tick in the same time zone it is positioned in, so a viewer away from UTC no longer reads a point under the wrong month.
- A monthly-stepped time axis now ticks on real calendar month starts instead of a fixed day count from the epoch, so clean month-start data no longer reads “7 Jan”, “6 Feb” instead of “Jan”, “Feb”.
[1.88.4] - 2026-10-07
Added
- Create, edit and delete Gantt dependencies with the pointer. Drag from one task’s connector handle to another’s to link them; the drag decides the link type, and a drag that would close a cycle is refused before anything changes.
- Drag a Gantt row to reorder or re-parent it, with keyboard indent/outdent. A drag handle in the name column moves a task above, below or onto a sibling; Alt+Up/Down reorders and Tab/Shift+Tab indents or outdents a focused row.
- The Gantt split view’s task table is now a real Lattice grid. Custom and overridable columns, select and bounded-number editors, sort, frozen columns and cell styling; filtering the table now hides the non-matching tasks and their bars, the sort is tree-aware, and print, PDF and Excel export follow the table’s own resolved columns rather than a fixed layout.
- Lock a task so the scheduler never moves it. A stronger guarantee than a dated constraint: a locked task holds its placement regardless of its predecessors, and an unhonoured dependency is reported as a conflict rather than silently floating the task.
- Vertical date markers and a per-task deadline marker. Draw a labelled line through the plot at any date, or set a
deadlineon a task to mark it and flag the task late once it is missed. - A task can carry its own bar colour, and a resource can carry an avatar image. Per-task colour overrides the theme’s accent on that task’s bar; a resource’s avatar is drawn wherever the module shows that person, falling back to initials with nothing shifting while the picture loads.
- Workload thresholds colour the workload band by utilisation, and the workload and histogram bands take their own column definitions, independent of the task table, with load from outside the plan counted but never drawn as a row or a bar.
- A fuller event model: a
drawevent after every completed paint,datesChangedfor date-only edits, achangebatch naming every event an action caused (including pushed successors),beforeTaskDrag/taskDragStart, viewport events (zoomChange,visibleRangeChange), tree-aware selection that survives a filter, and dirty tracking (gantt.changes(),gantt.hasChanges). - Resizing, showing, hiding or pinning several grid columns in one call is now one layout pass and one history entry, instead of one of each per column.
Fixed
- Saved views no longer restore scroll position. Scroll is transient UI state; reopening a saved view now opens at the top unless the view explicitly asked to keep it.
[1.88.3] - 2026-10-07
Added
- The Gantt split view’s table columns and the table/timeline divider are now resizable by drag or keyboard, with
minWidth/maxWidthclamps, and every width round-trips through the controller’s saved state.
[1.88.2] - 2026-10-06
Added
- The Gantt task bar shows a positioned tooltip card on hover and keyboard focus, in both the plain and split views, replacing a one-line reading that in the plain view was in fact drawing at the wrong position entirely.
Fixed
- The split view’s timeline scrolled out of sync with its task table past the first screenful. Both panes now scroll vertically together, so a row stays aligned with its table row however far down the plan you scroll.
[1.88.1] - 2026-10-06
Fixed
- A band chart’s responsive label thinning in a narrow pane no longer logs a console warning on every render; this was ordinary responsive layout, not a misconfiguration.
- A filter set from code on a pushdown or remote grid now reaches the engine even when it names a column the grid does not display as one of its own.
[1.88.0] - 2026-10-06
Breaking
- A task that can never fit is left where it is and reported, rather than pushed far into the future.
When one task needs more of a resource than that resource can ever give,
level()used to push it back day after day. It now leaves the task in place, returnsresolved: falseand lists it inremaining, so you can show it. Plans whose clashes can be resolved level exactly as before.
Added
- Each person can have their own working calendar, and capacity in hours. Give a resource its own working days, hours per day, holidays and leave. A part-time person shows as over-allocated by the hours they cannot cover, and leveling never moves work onto a day they do not work.
- A resource histogram band sits beneath the plan. Bars per person and per period show workload in hours, as a percentage of capacity, or as cost, with a capacity line, a different colour for the part over capacity, and a tooltip naming the tasks behind each bar.
- Schedule by effort as well as by duration. Hold the duration, the effort or the units fixed per task: adding a person to a fixed-effort task shortens it, and a part-timer’s task runs longer.
- The full set of MS Project date constraints. Start or finish no earlier than, start or finish no later than, and as soon as possible join must start on, must finish on and as late as possible. A task that misses a ceiling is reported and marked on its bar.
- An S-curve and a progress line. See planned value, earned value and actual cost build up over time, with a status date and the SPI and CPI. In the split view, a progress line jogs each task to where it actually is.
- An assignment picker that warns before you commit. Search the people, set each person’s share, and see their load over the task’s dates. An over-allocation is announced before anything changes, and one Apply is one change.
- Resource rates and a cost rollup. Price people per hour, materials per unit and flat costs, with dated rate changes, and see cost roll up from tasks to summaries and the whole project.
- Undo and redo for every plan edit. Moves, resizes, cell edits, links, assignments, baselines and a whole leveling run each undo in one step, with Ctrl or Cmd+Z and Ctrl or Cmd+Shift+Z on the focused plan.
- Print, PDF, PNG and SVG of the split view. The table, timeline and resource bands go to paper or an image across paginated pages with repeating headers, or as a single picture of the date range you choose.
Fixed
- Leveling finishes in milliseconds. A 30-task plan levels in well under 200 ms and a 300-task plan in under 2 s, and a plan that cannot be fully levelled stops promptly with what is left over.
[1.87.0] - 2026-10-05
Added
- The data router can explain why rows were or were not routed. Call
explain()to see, per source and per route, how many rows were loaded, matched, filtered out or left without a partner, with sample values from the rows that did not match. - Follow one row through the router.
trace()shows every step a single row passed on its way to a grid, andtrace.missing()answers why a row is not in your grid with a named reason.
Fixed
- Chart labels stay inside the chart and apart from each other at every size. This holds across the chart families, from polar and cartesian charts to chords, Sankeys, networks, races and Gantt task names. A name that cannot fit is cut with an ellipsis and the full text stays in the tooltip.
- The statistics panel’s distribution shows each range and its count. Every bar carries its value range and how many rows fall in it.
- Regression charts plot every row. The fitted line and R² now match the model, even where many rows share a value.
- A Regression panel can be pre-filled with a model. Name the predictors and the response in the panel’s entry and it opens with that model.
- Inactive tabs stay hidden whatever CSS the page uses. A page’s own display rule can no longer let one tab show through another.
- Filters set from code on large DuckDB data land promptly again. The match count arrives with the rows.
[1.86.7] - 2026-10-05
Fixed
- Filtering, sorting and pivoting large DuckDB and other pushdown data show their rows and count much sooner. After any of those changes, the grid’s own page and count are asked for first, so the rows you want appear without waiting on facet counts.
- Ridgeline and diverging chart tick labels stay inside the chart. A ridgeline’s last label no longer runs past the right edge, and a diverging chart’s labels no longer sit against the bottom edge.
- On a Gantt whose task ids come from a mapped field, Delete removes the task and the split-view editor opens with the task’s values. Row heights in the split view are right as well.
[1.86.6] - 2026-10-04
Fixed
- With single-click editing, an accidental double-click keeps the editor open. The editor stays focused and holds whatever you had typed, and the cell stays editable. Moving to a different cell still saves the one you left and opens the next.
- A chord diagram’s ring fills wide and tall panes, and long names stay inside the chart. A name too long for the room beside the ring is cut with an ellipsis, and the full name is in the tooltip.
[1.86.5] - 2026-10-04
Fixed
- The in-cell editor fills the whole cell. Typed text now sits exactly where the value was, with no extra inset around it, so editing a cell no longer shifts what you are reading.
- Hexbin, fan and Gantt tick labels sit below the plot, inside the chart. A hexbin’s x labels no longer print over its hexagons, and a fan’s and a Gantt’s bottom labels no longer touch or cross the chart’s edge, at every width and in light and dark.
- Tree and force-tree names no longer print over each other or the links. Names near the root stay readable; a name with no clear side is left out rather than drawn on top of a link or another name.
- Capability and control rule labels sit clear of the data. The “Target” label on a capability chart and the “CL” label on a control chart now sit beside the rule, end-aligned, instead of on the curve or the last points.
[1.86.4] - 2026-10-03
Breaking
- A waterfall’s closing total now appears only when you ask for it.
Every category is now a step, the last one included: a twelve-month walk
draws twelve steps, where it used to draw eleven and stand the last month on
the baseline as a total. For a closing bar, add
waterfall: { total: true }(labelled “Total”) orwaterfall: { total: 'Closing balance' }. A chart that relied on the old closing bar addswaterfall: { total: true }. - Marker and bubble maps with no basemap now draw a reference outline. A
markermaporbubblemapgiven noshapespack draws the same plain continent outline ageomapdraws, under its points and fitted to the extent of your data. It is keyless and fetches nothing. A map given ashapespack, or drawn as a globe, is unchanged. If you drew your own background behind a map with no pack, setoutline: falseto switch the outline off. source:totalnow fires on first load too. On every remote and paged source the event fires whenever the known total becomes a number or changes: once when the total arrives with the first rows, and again after a refresh or a change of filter, sort, quick search, grouping or pivot. It never fires twice for one settle, and its payload is unchanged. A handler that assumed the event meant “a deferred count just landed” should readgrid.rows.totalPending()instead.
Fixed
- Rows and the total now appear in tens of milliseconds on a large DuckDB file, not seconds. On a file of about three million rows, the first page and the row count used to wait behind the statistics panel and the facet counts, so the status bar said “Counting rows…” for several seconds. The grid now fetches its own page and count first and runs the facet, statistics, chart and profile queries after them; first rows and the count arrive in about 0.15 seconds instead of 3.7.
- A gauge reads the measure you asked for, and its needle stays off the
reading.
y: { col, fn }on a gauge now appliesfn(avg,min,max,median,countand the rest): a gauge averaging 10, 20, 30 and 60 reads 30, not 120. With a needle, the figure and its caption sit inside the dial’s opening, so the needle never crosses them at any value; in a small dial the figure is drawn smaller. The accessible summary names the reduction. The arc gauge, and a gauge with nofnorfn: 'sum', read as before. - Chart labels no longer collide or get cut off on ten chart types. A marimekko’s column labels are held to their own columns; a calendar’s month and weekday labels no longer overprint, and a calendar that starts mid-week names its first month correctly; a hexbin’s ticks stay clear of its legend; a Venn diagram’s set names no longer overlap on a short chart; capability and control charts keep their limit labels apart; a polar area chart’s ring values are readable on the wedges; and the outermost tick on a dumbbell, forest or ECDF chart is no longer clipped. Serpentine timelines give way to milestone names, and icicle, sunburst and funnel pyramid labels stay inside their own segments. A label with no room is shortened with an ellipsis or left out, and the tooltip and accessible table still carry the full text.
- The last tick on every chart axis stays inside the chart. The final tick of a line, scatter, candlestick or bar chart’s axis, and the top y label, are no longer cut by the chart’s edge; a horizontal bar chart’s value ticks no longer fall outside it; a dual-axis chart’s right-axis title stays in view; and a short pie’s slice name no longer crosses into the next slice.
[1.86.3] - 2026-10-03
Breaking
grid.rows.matchCount()andgrid.rows.totalCount()returnnullwhile the total is still being counted. On a large source, a filter, sort or pivot change used to report the loaded window as if it were the total until the real figure arrived. They now returnnullfor that moment, so a host that does arithmetic on either must handlenull: checkgrid.rows.totalPending(), or wait for thesource:totalevent, which fires with{ total }when the number lands. The status bar shows “Counting rows…” instead of a figure, and the filter announcement, the KPI count tile and the AI view facts treatnullthe same way. A source that answers with its total, or that holds every row, never returnsnull.grid.rows.count()is unchanged.
Added
- A freehand pen on charts. The chart toolbar has a pen: press, drag and
release to draw a smoothed stroke. The stroke stays on the readings it was
drawn over when you zoom, pan, resize or the chart responds to a narrower
screen, and on a dual-axis chart it stays with the axis it started on. A
stroke can be selected, moved, deleted, saved with the chart’s state and
restored, and it is reported through the annotation events and listed in
the accessible table.
annotate: { pen: { colour, width } }sets its defaults.
Changed
- A grid works in one time zone. Every datetime column stores, displays,
sorts and filters in the grid-level
timeZone, or the viewer’s own when none is set. AtimeZoneon an individual column was never a documented setting; it is now ignored and warns once, naming the grid-level setting. - One measure button on the chart toolbar. The four overlapping measure
buttons are now a single
measurebutton, andmeasure: { axes: 'x' | 'y' | 'xy' }(default'xy') picks what it reads. The earliermeasure-x,measure-yandmeasure-xynames are still accepted and map to the new button, with a one-time developer warning. Measurements you saved earlier restore and draw unchanged.
Fixed
- The x-only measure on a time axis now reads out the number of points and the duration it spans instead of coming back empty.
- Moving a restored drawing on a time axis now moves it by the dragged
amount, whether it was saved with ISO date strings or
Datevalues. - A highlight that expires now fires
highlight:changed, so a host mirroringgrid.highlight.list()stops showing a flash that has ended. - A validation error cleared by a corrected value now fires
validation:clearedfor that cell, once, so a host counting errors sees it clear. - Chart decomposition panels no longer clip their left axis labels. Each panel reserves room for its widest label, shared across the panels so they stay aligned, in both themes and at narrow widths.
[1.86.2] - 2026-10-03
Breaking
- Keyboard and editing defaults changed in the grid. Tab and Shift+Tab
from a cell that is not being edited now leave the grid for the next or
previous element on the page; the arrow keys still move between cells, and
Tab while editing still commits and moves on. Delete and Backspace now
clear the focused cell or the selected range (
edit.clearOnDelete: falserestores the old behaviour). Double-clicking a column heading now sizes the column to its content (layout.autoSizeOnDoubleClick: falseturns it off). Pasting, the fill handle and Ctrl+D now refuse a value that breaks a column’s validation, with the grid’s own message, while the other cells still land. - A cartesian chart’s
viewportFilternow filters the grid. The option used to apply to maps only and was ignored on a line, bar, scatter or other cartesian chart. A chart that already setsviewportFiltertogether withzoomwill now narrow its grid to the zoomed range. - A filter on a
datetimecolumn now compares the time as well as the day. Abetween,gte,lte,eqorinfilter on adatetimecolumn used to admit every row on its bounding days. It now admits only the rows inside the times given, so a saved or programmatic filter written with times returns fewer rows than before. The column menu on adatetimecolumn now shows and writes times. - Chart brushes and clicks keep the grid’s other filters. A brush with
brush: 'filter'and a click withfilterOnClickused to replace the whole filter tree. They now add one condition of their own and leave every other condition in place, sogrid.filters.get()afterwards holds them as well. A second click on the same mark removes the click’s condition. A value-axis brush (axis: 'y'or'y2') now filters the measure behind the axis instead of the dimension column, and says so by name when the marks are totals and a range of them cannot select rows.
Added
- Pan and zoom on every cartesian chart, with the grid following the
visible range. Zoom with a two-finger pinch on a touch screen, with the
wheel, a drag, the plus and minus keys, the toolbar or a double-click, and
reset with the 0 key. A heatmap now zooms to a run of its columns, and a
horizontal bar chart pans its categories with a vertical drag. With
viewportFilter, the grid follows: once a pan or zoom settles, the table, the KPIs and every other chart over the grid show exactly the rows in view. The grid’s filter menu shows the condition and clears it, a filter you set on the same column is kept, and zooming back out removes it. Clearing the filter in the grid resets the chart’s zoom. - A measure tool on cartesian charts. Turn on
measureand drag between two points to read the change in x, the change in y, the percentage change and the slope. Ends snap to the nearest data point, a dual-axis chart measures against the axis under the start point, and a measurement can be pinned so it survives zoom and pan and is saved with the chart’s state. The keyboard drives the whole tool and each step is announced. - Voronoi treemap drill-down. With
drill: true, clicking a group redraws the chart as a Voronoi treemap of that group’s children alone, and deeper levels drill again. A breadcrumb names the path; click a crumb, press Escape or press Backspace to go up a level. Drilling never filters the grid. - Double-click a column heading, or its resize edge, to size the column to its content. The width respects the column’s minimum and maximum, goes on the undo timeline like any resize, and Alt+Enter on a focused heading does the same from the keyboard.
- Delete and Backspace clear cells. The focused cell, or every editable cell of the selected range, clears as one undo step. Read-only cells are skipped, a column that requires a value refuses the clear, and a screen reader hears how many cells were cleared.
- Ctrl or Cmd+Z undoes and Ctrl+Y or Ctrl or Cmd+Shift+Z redoes the grid’s
edits from the keyboard. A typed edit, a paste, a fill and a clear are one
step each, and an open cell editor keeps its own text undo.
edit.undoShortcuts: falseleaves the keys alone.
Fixed
- Time axes read oldest first. A chart whose x is a date or time column now orders its points oldest first whatever the grid’s sort, so a fan chart over a newest-first grid no longer runs backwards.
- The motion chart’s year and axis titles stay single. The large time label and both axis titles are redrawn in place instead of stacking over each other on every frame.
- The statistics panel works over a pivoted DuckDB grid. It profiles each column through the source, with the grid’s filters applied, and names any figure the source cannot compute.
- Maps no longer stop a page’s own timers. The Leaflet, deck.gl and Map View bindings could silently cancel an unrelated timer or polling interval on the page, which left a loading overlay on screen after the map was ready.
- A click and release on a column’s resize edge with no movement does nothing. It no longer emits a resize event or adds a history entry.
[1.86.1] - 2026-10-02
Fixed
-
Range areas, reference lines and dual axes now draw what you asked for. A range area fills the band between a low and a high measure, with an optional centre line, and says so by name if it is given a form it cannot draw. A reference line now appears on a candlestick or OHLC chart, dashed across the price pane and labelled at the edge, and on a horizontal bar chart it sits on the value axis instead of across the categories. A chart with a measure on the right axis now shows ticks and a title on both axes.
-
Tooltips behave in popouts and in groups. A chart inside a popout that blurs, transforms or clips its content now shows its tooltip in the right place, and a chart can name an element to host it. A tooltip no longer lingers if its chart is removed without being destroyed. Charts that share a cursor group show one tooltip, on the chart under the pointer, instead of stacking one on every member; set the group tooltips option to all to restore a tooltip on every chart.
-
ROC charts report the right numbers. The average precision on a precision and recall curve now matches the standard definition, and the area under the curve treats tied scores consistently. The AUC and AP label is readable on a dark page.
-
Fan and decomposition charts handle missing values. An empty forecast over the history no longer draws from zero: the forecast line and band begin at the first value, the axis uses present values only, and a decomposition panel breaks at a gap instead of dropping to zero. A fan chart also labels its time axis.
-
Charts are easier to use with a keyboard, a click and a screen reader. The arrow keys move point by point on line, area and step charts and read out each point. Sankey and network charts have a full accessible table of their links. Opt-in chart types have proper names in every shipped locale. A click on a pie, donut or sunburst slice, a treemap tile or a chord ribbon selects the rows behind it, a pointer over an image marker hits that point, and a per-row fill now paints pie slices and treemap tiles. Exporting a word cloud, Venn or scatter matrix to CSV no longer fails.
-
Chart options and types are more dependable. Annotation and cursor event handlers can be written in the usual inline form, strict TypeScript accepts extension chart types in range helpers, a host that silences motion is respected, and a chart redrawn with more ticks keeps its axis title intact. A string number format on an axis or data label now applies. A band, line or callout that cannot be placed, and rows passed to a chart that are not the grid’s own, are named once instead of vanishing. A hexmap over a geometry pack now zooms and pans.
-
Grid fixes. Grouping a grid and then pivoting it at runtime now gives the same result as declaring both in the column config. Header histograms that were still counting when the rows were replaced are counted again once the replacement settles. Over a pushdown source, the statistics panel now shows count, distinct count, range, mean, quartiles, median and standard deviation from the source’s own engine, and names any figure it could not compute. A column’s facet setting no longer logs a false warning, and a column group can set it for the columns under it. The group column can be resized. Deck.gl and Leaflet maps keep drawing every filtered row when a group is collapsed. Column header tooltips are drawn, a compact KPI format keeps its currency, and a data router keyed by a function keeps two feeds that share an id as two rows. Options that cannot take effect now warn once by name.
-
Adapters and the web component. Svelte KPI and Chart components bind by their kebab-case grid name and route options, and the custom element now applies quick filter, sort, filters and selected keys, at creation and live. The type declaration for registering the element matches what it does at runtime.
[1.86.0] - 2026-10-02
Added
-
Seventeen new chart types, for shapes a bar and a line cannot tell. Polar area (the Nightingale rose), radial bar and polar scatter put categories, rings and readings round a circle. Force-directed tree, packed circles, Voronoi treemap and a node-link tree or dendrogram draw a hierarchy four different ways, from a parent column, a path column or the grid’s own grouping, and clicking a branch folds it or selects the rows beneath it. A chord can now be directed, with ribbons that taper or end in an arrow head, and a funnel pyramid slices ordered stages out of one triangle. A pictorial chart tells a quantity with a picture, a serpentine timeline folds a long history into rows, and a spiral timeline winds it one turn per day, week or year so a rhythm shows as a streak. A word cloud sizes words by weight and clicking one filters the grid to the rows that hold it, a Venn chart counts the rows in each overlap of two or three sets, and a globe turns the built-in maps into a hemisphere you can spin.
-
Charts that play through time. A bar chart race ranks the top categories at each point in a time column and lets them trade places, and a motion chart glides one bubble per entity across two measures, with trails. Both have play, pause, speed and a scrubber, and the two can share one clock so they play as one.
-
Charts that move between pictures. Animation is on by default: bars grow from the baseline, lines draw across, slices sweep open, and every filter, sort or update glides from the old picture to the new instead of jumping. A feed of updates never queues, a very large chart draws at once, and anyone who prefers reduced motion gets the finished chart straight away. Set animation to false for no movement at all.
-
A crosshair that reads every series at once. Turn on the cursor and a hairline follows the pointer, snaps to the nearest reading, labels the axis and lists every series’ value at that point. Charts in the same cursor group share it by value, not by pixel, across different grids and ranges, and can share zoom and the grid’s hovered row too. The arrow keys, Home, End and Escape drive it from the keyboard, and your page can read and set it.
-
Charts that change shape with their container. Responsive rules lay options over a chart while its own container is inside a width or height band, so a chart in a narrow dashboard panel gets the narrow layout on a wide screen. A ready-made automatic set tidies the legend, titles and ticks as space runs out, and the options you set come back exactly when the space does.
-
Gradient and pattern fills, and pictures as markers. Bars, areas, pie and donut slices, treemap cells, bands and map land take linear or radial gradients and hatch, crosshatch, dot and grid patterns, per series or per row, and a patterns scheme tells series apart by texture as well as hue. Line, scatter, bubble and marker map points can use an icon or a picture in place of the dot.
-
Drag a chart mark to edit the row behind it. Drag a point, a bar end, both ends of a floating bar or a slice edge and the new value is written through the grid’s own edit path, so validation, read-only columns, vetoes, undo and change tracking apply as they do to a cell edit. A refused edit springs back with the grid’s own message. Every handle is a keyboard slider, and a mark that sums several rows says so rather than guessing.
Fixed
-
Every chart module’s type now compiles in strict TypeScript. Writing a waffle, word cloud, polar area or any other opt-in chart type in a chart spec was a compile error. Every type a shipped chart module registers, aliases included, is now accepted and autocompleted.
-
A chart keeps working after it is rebound to another grid. The tooltip, hover, click filtering, brush, keyboard arrows, crosshair and zoom all stopped after a rebind. A rebound chart now behaves exactly like a new one.
[1.85.0] - 2026-10-02
Added
-
Five new chart types for the comparisons dashboards are full of. A lollipop draws each category as a stem and a dot, a lighter alternative to a solid column. A floating bar spans exactly from a low value to a high one, for ranges, schedules and min to max bands, with a date axis if you want one. A population pyramid mirrors two halves about a central axis, with labels that always read as positive values and an option to show each bar as its share of the whole. A bullet chart now draws one row per KPI, with qualitative bands, the actual value and a target marker against a shared scale. An open, high, low, close chart draws price bars the way a candlestick does, with the volume pane, time axis and annotations.
-
Charts that read the way you mean them to. A bar, horizontal bar, area or combo chart can stack to 100%, so each column shows every series’ share of the whole and the tooltip shows the real value beside its share. Line and area charts can be smoothed or stepped, on the whole chart or one series at a time, while markers and the tooltip stay on the real readings. A pie or donut can be a half circle or any partial arc and fills its space properly, and its slices can vary in radius from a second measure, so one chart carries two numbers.
-
Network maps from ordinary rows. Map View now draws a route from the rows that share a route id, joined in sequence order, so the vertices of a cable route and the cabinets along it can live in one grid. Edit, add, delete or filter a row and only that route redraws; click a route to select all its rows, or select a row to highlight its route. Lines can be dashed, dotted or solid by value, so proposed, planned and live read at a glance, and a legend shows each pattern. Points can be icons, with cabinet, splice, exchange, pole, chamber, premises and fault glyphs built in, tinted by value or set on a coloured badge. In 3D, routes stay clear at street level and are hidden properly behind buildings in front of them, while icons stay on top.
-
The Columns panel shows header bands. A band formed by dragging one heading onto another used to be invisible in the panel. It now lists each band with its columns nested under it, with a collapse toggle, a checkbox that shows or hides the whole band, and rename and dissolve actions, so a band made by accident is easy to find and undo. Keyboard and screen reader support are included.
Fixed
-
The short form of the column histograms setting now works. Turning header histograms on with a plain true draws them, the same as the longer form.
-
Map View routes, areas, columns and points no longer show through a 3D building standing in front of them. A cable route behind a tall building now reads as hidden by it, instead of floating through it.
-
The Map View legend lists each value once when colour and dash are keyed on the same field, with a short line in that value’s colour and pattern.
-
A data router collect join now respects a list of fields to keep or a rename map, collecting rows with just those fields, or the bare values for a single field name. A field that the collected rows do not have is reported by name instead of collecting nothing silently.
[1.84.0] - 2026-10-02
Added
-
A 3D map beside your data grid. Map View draws the same rows a grid shows on a MapLibre and deck.gl map: points or extruded columns from a latitude and longitude pair, polygons from a geometry column, and routes from a path of coordinates, with an optional animated flow along them and OpenStreetMap buildings in 3D when you want them. The two stay in step both ways: filtering, searching or grouping the grid redraws the map; selecting rows flies the map to them; clicking a feature on the map selects its row and scrolls the grid to it. For a telecoms team, the build schedule, premises list or fault log already open in a grid becomes a 3D map of the network with no extra integration.
-
The data router can now build one row out of many feeds, several levels deep. A join can collect every matching row from another feed into a list column, or roll up a count, sum, average or other aggregate straight onto the parent row; it can also unnest a nested array in an incoming payload into its own set of related rows, or spread a custom-field feed into named columns as new fields arrive. Each join reads its source’s own joins first, so the graph chains to any depth: a country rename reaches every address and every company that collects from them, live. Deleting a parent row can now cascade the delete to its children, leave them marked as orphaned, or leave them untouched, your choice.
-
Incoming rows can now be given a type, not just a shape, at the data router’s front door. Declare a field as a number (with a decimal comma or thousands separators), a boolean (your own true/false markers), a date, or text with its own null markers, and every row is coerced to match before anything else runs. A feed sending
"1.234,56"or"Y"now sorts and filters as the typed column it is meant to be, instead of staying a string until you rewrote it yourself. -
The gauge chart can show threshold bands, a needle and ticks around the dial. Colour the range behind the value by band, draw a pointer from the centre instead of a filled arc, and place values round the edge; the centre reading now takes the measure column’s own format, so a percentage column reads as a percentage rather than a bare number.
Fixed
-
A combo chart now stacks its bar measures when you ask it to, and reads both of its axes. A combo with two bar measures and a line now stacks the bars when
stackis set, keeping the line on the same scale drawn over the stacked total, and honours a left and right axis exactly as a bar chart does. -
A plain list of colours, or a colour named per measure, now works on every chart type that takes one, not only some of them: a bar, pie, radar or combo chart all colour the same way from the same setting.
-
Hiding a kanban board, calendar or KPI panel with the standard HTML
hiddenattribute now actually hides it, the same way it already hides the grid itself, and each one re-measures correctly when shown again. -
A calendar’s all-day events and its scroll position are fixed. Overlapping all-day spans now stack into their own lanes and line up with the day columns instead of overlapping each other, and setting a start scroll time now actually scrolls the hour axis to it instead of leaving it at the top of the day.
-
A grid reading a paged data source now counts and lists exactly its current page, rather than every page cached behind it, when something on the page loops over its rows directly.
[1.83.1] - 2026-10-01
Fixed
-
A multi-day event in the calendar’s month and week views is now drawn over the days it spans, not as a band above the month. The bar sits inside the day grid itself, aligned to the columns it covers at any width, with one continuous bar per week and its continuing edges marked; overlapping events in the same week stack into their own lanes instead of covering each other.
-
A KPI panel or chart bound to a very large pushdown source, for example ten million rows in DuckDB, no longer freezes the page after the first rows appear. Checking whether the grid still holds a window now reads how many rows have actually loaded instead of walking the whole matching set, so a dashboard over a windowed source stays responsive as more rows stream in behind it.
[1.83.0] - 2026-09-30
Added
-
A calendar view of a data grid. The same rows a grid shows can now also show as a calendar: a month grid, a Monday-first week grid, and an hour-axis week and day view for anything with a time as well as a date. Point events stay a single day; give a row an end and it draws as a bar across every day it spans. Cards carry a title and subtitle out of the box, or a card template of your own; overlapping times split into their own columns, a working-hours band and a now line orient the day, and a built-in prev/next/today header keeps the range and its title in step. It reads and writes through the same row contract a grid does, so a Data Router feed can drive a calendar beside a grid, a board and a chart from one source.
-
Drag a card to another day or time slot and it reschedules for real. On a grid-bound calendar the move writes back through the grid’s own edit path, so parsing, validation, undo and history all run exactly as they would for a typed edit, and a refused move snaps the card back with the grid’s own validation message beside it. Dragging the edge of a timed block resizes it instead. Every move can be reviewed and vetoed before it commits, and the whole thing works from the keyboard as well as the pointer.
-
The calendar is available everywhere a grid is: React, Vue, Svelte, Angular and the plain web component, each with live row, view and date props and the same click and reschedule events. On an htmx page, a calendar element wires itself to its grid automatically, no setup code required, and keeps working after a page swap.
-
A sparkline can now hold its own scale. A line or column sparkline normally scales to the row it sits in; it can now instead take a fixed or per-row baseline and ceiling, so rows of very different size each read against their own range and the same relative move still looks the same size from row to row.
-
The free, non-commercial tier is now named on the page, not just in the licence. A grid running without a commercial key on a real domain now says so in one added line of its trial watermark and its one-time console notice, pointing to the free tier rather than just flagging itself as unlicensed.
Fixed
-
A large hydrated table no longer risks overflowing the browser’s history storage. Pressing Back on an htmx page used to save both the rendered grid and a second, serialised copy of its rows, so a sizeable table could roughly double in storage weight and occasionally hit the browser’s quota after a couple of Back presses. The saved snapshot is now the original table only, no larger than the page that was served, and a table too large to store even then degrades to a clean re-fetch instead of an error.
-
Dragging a calendar card to reschedule it now reaches every framework’s own move events, not just the plain API: React, Vue and Svelte fire their reschedule callbacks, Angular exposes them as outputs, and the web component re-dispatches them, matching how every other viewer already reports a move.
[1.82.0] - 2026-09-30
Fixed
-
On an htmx page, pressing Back now brings a hydrated grid back live, not a frozen snapshot. A grid built from a
[data-lattice-grid]config already survived the browser’s Back and Forward buttons with htmx’s history cache left on; a grid hydrated from a server-rendered<table>did not, because hydration had already consumed the table it read. Hydration now marks its host, and the grid’s own state, columns and rows are saved alongside it, so Back restores a live, re-hydrated grid with its sort, filter and tool panel exactly as they were, and Forward returns to wherever the page had swapped to. -
A
<th data-type="text">column now decides that column’s type for every cell, so a value like01keeps its leading zero instead of becoming the number1. A declared type on the header now wins over the automatic type-sniffing that runs on an undeclared column. Acolumnsentry passed to the hydrate configuration now refines the column it names while every other column still comes from the table, rather than silently dropping the rest; a page that wants the old behaviour, only the columns it lists, opts in withcolumns: { only: [...] }. -
A chart or KPI panel left behind by an htmx swap now stops cleanly and says why, instead of throwing. If the grid a chart or panel was reading from is destroyed, it stops drawing and warns once by name rather than failing on its next update.
chart.rebind(grid)andkpi.rebind(grid)point either one at a replacement grid, and on an htmx page a chart or KPI container markeddata-lattice-bind="<grid id>"is re-bound automatically after a swap, so a dashboard keeps its chart and its panel across a page update rather than needing to rebuild them.
[1.81.0] - 2026-09-30
Changed
- The four design-system presets now take on their system’s actual shape,
not just its colours. Material 3, Bootstrap 5, Ant Design 5 and Fluent 2
each now set their own row height, header weight and header background,
hover and selected tints, and draw horizontal dividers only, checked
directly against a real, installed MUI X Data Grid within a pixel. A new
theme token,
--lattice-cell-border-width, lets any theme turn off the vertical lines between cells; its default leaves existing grids unchanged, and an explicit grid-lines setting on a grid still wins over whatever a preset or theme requests.
[1.80.0] - 2026-09-30
Fixed
- Clicking Next, Previous, typing a page number or changing the page size on a grid over a paged or remote source now moves it. Before this release only an in-memory grid understood pages; over a server source the pager was drawn but every click on it was silently dropped. A page change now fetches exactly the rows that page needs, one request when the page size matches the source’s own, and the row range shown above the grid reads the current page while the total and the match count still read the whole result. A source that cannot return a window at all, a live stream among them, says so once by name and keeps scrolling with no pager rather than showing one that moves nothing.
- The custom adapter contract now ships in the package and is published here as a reference page. It states precisely what a custom pushdown adapter declares, the request and answer shapes it exchanges, how a refusal is named, and the write-back contract, the same shape every adapter this site documents already follows.
Added
- Four design-system presets, one stylesheet each: Material 3, Bootstrap 5, Ant Design 5 and Fluent 2. Load a preset as a second stylesheet after the grid’s own and it restyles the accent colour, surfaces, corner radius, type and elevation, in both light and dark, with no code change. The Bootstrap 5 preset reads a page’s own custom properties live, so a page already running Bootstrap inherits its exact palette rather than a copy of it.
themeFromMuireads a real MUI theme and hands back tokens ready to apply. Pass acreateTheme()object in and it returns the matching custom properties as one block of CSS, or a function that sets them on an element directly, so a grid dropped into a Material UI application picks up that application’s own palette, typography and corner radius instead of a separate one.<LatticeDataGrid>, a drop-in component for a page already written against the MUI X Data Grid. It takes the same props under MUI’s own names, rows, columns, sorting, filtering, selection and pagination among them, and maps every one onto the grid’s own configuration, an API call or an event. A prop it cannot honestly match is refused once by name rather than silently ignored, so a migrating page finds out immediately what still needs attention.- Every theme token is now written down. The reference lists all 171 public custom properties a theme or a preset can set, grouped by what each one affects, marked as required, optional or best left alone, and explains how an override at the grid, a container, the whole page or the operating system’s own light or dark preference wins.
- Motion now has its own duration and easing tokens, and turning on reduced motion turns it off everywhere at once. Every transition in the grid’s built-in themes reads the same two custom properties, so a reader who asks for reduced motion gets it applied once rather than depending on each part of the grid to opt out on its own.
Internal
- The capability registry now scores every public theme token the same way it scores every other capability, so one going undocumented, unstyled by a shipped preset or untested would be caught rather than going unnoticed.
- Every preset shipped in the package is now checked against the full token contract before it ships, and a required token it leaves unset is named rather than discovered as a visual mismatch later.
- A documentation page a changelog entry names by its file path is now confirmed to be inside the published package itself, not only committed to the source repository, before the entry counts as shipped.
[1.79.0] - 2026-09-29
Breaking
- A distinct-count KPI tile bound to a large, paged data source now reads the true count across every matching row, and shows nothing rather than a wrong number when that count cannot be answered. Before this, the tile counted only the rows already loaded, so a panel could read a few dozen distinct values against many hundred in the underlying data, with no warning. It now asks the same engine that already answers sum, average, minimum and maximum tiles, and a source that cannot answer shows no value rather than a partial one.
- A grid grouped over a source that cannot group now stays flat and says so by name, instead of showing subtotals built from whichever rows happened to be on screen. A county’s total, for example, could previously show a single sale’s price as if it were the whole group. Every row is now a plain row with the source’s own row count, and the grid names the reason grouping did not happen rather than presenting a wrong subtotal as real.
Added
- Rows still on their way in now say so. A cell waiting on its data shows a brief shimmer, or, for a longer wait, “Loading rows 200 to 350” in place of a blank stretch, and a banner appears while a grid’s very first page is still loading. Both wait a beat before showing, so a fast answer never flickers, and both can be turned off or reworded from the grid’s own settings.
- Grouping now runs where the data lives, on ClickHouse and Elasticsearch. Grouping a table of tens of millions of rows by county, or by any column, runs as one query per level, with totals computed by the database rather than the browser; opening a group fetches its own rows only once you open it.
- Elasticsearch and OpenSearch connections can now stream and export a whole result set, the way the DuckDB and Splunk connections already do, with a row limit you set yourself so a very large answer stops at a size you choose.
Fixed
- Selecting every row that matches a filter, rather than only the rows loaded, now warns when a feature needs the full list of keys and only has the loaded ones, instead of staying silent. Linking one grid’s selection to another now asks for a connection that can work from the filter itself in that case, and refuses by name rather than acting on a partial list.
- A KPI tile or the statistics panel over a pivoted table now computes the real figure through the underlying engine, or reports the figure as unavailable, instead of quietly showing a wrong one. Sum, average, minimum and maximum tiles compute the grand total the same way they already do over a plain grid; a count, a distinct count or a custom calculation that cannot be derived from a pivot reports as unavailable by name rather than a stale zero.
Internal
- The capability registry now accounts for every method and property a grid object exposes, not only the object itself, closing a gap in the completeness checks with no effect on shipped behaviour.
[1.78.0] - 2026-09-29
Breaking
- A pivot on a source that cannot answer it now refuses by name, instead of drawing grouped rows and calling them a pivot. REST, OData, GraphQL and DFQL sources take this path; DuckDB, ClickHouse, Elasticsearch and Splunk pivot in the engine instead, so the refusal only appears where a pivot genuinely cannot be answered.
- Select-all on a pushdown source now selects every row the filter matches, not only the rows already loaded. The header box shows checked, or indeterminate once a row is unticked, and the status bar reads the true count from the engine. A filter change afterwards keeps the selection to what was captured, and ticking a row outside it is refused by name.
- Exporting or deleting that kind of selection now works from the whole
matching set, not the rows on screen.
export.csv,export.excelandexport.clipboardwithrows: 'selected'stream the full selection and return a Promise; code awaiting a plain string return needs to await the export instead. A delete over the same selection carries the filter and a row count rather than a list of keys.
Fixed
- A column’s filter menu now opens at once on a pushdown source, in the header popup and the tool panel alike. The value list comes straight from the engine, the top 200 values by count, with a search box that asks again for anything past that. Opening the menu used to walk every matching row in the browser first, freezing the tab for tens of seconds on a large table.
- A pivot on a pushdown source now runs in the engine and shows real pivot columns, instead of plain grouped rows with nothing pivoted. DuckDB, ClickHouse, Elasticsearch and Splunk answer it from grouped aggregates, with the column set discovered from the engine’s own reply, and open a group’s children on demand rather than pulling every row into the browser first.
- Splunk exports now stop at the row cap you set, instead of reading a search to its end with no ceiling.
- A Splunk error that is not a permissions failure now carries Splunk’s own explanation, instead of a bare HTTP status with nothing to act on.
- A finished Splunk search job is now released the moment its last page is read, instead of sitting on a search-artifact slot until Splunk’s own timeout reclaims it.
- A map bound to the grid now agrees with it from the very first paint, instead of only after the first pan or zoom.
- A column setting both a style and a class now shows both, instead of one silently overwriting the other.
- ClickHouse now answers a filter or a second statistic over an aggregated column, instead of refusing the whole query.
Internal
- The capability registry now sees every option a column itself accepts, closing a gap in the completeness checks with no effect on shipped behaviour.
[1.77.0] - 2026-09-29
Breaking
- The frame diagnostic now reports real animation-frame figures. A page
reading
grid.diagnostics.snapshot().render.frameson a fast-updating feed could see a raiseddroppedcount and ameanMswell past 16.7 even while the screen painted smoothly, because the figure was timed against how often updates were flushed rather than how often the browser actually painted a frame. It is now sampled from real animation frames, so the numbers match what a reader sees. A threshold set against the old figures should be re-baselined against the corrected ones;grid.updates.stats().droppedis unrelated and unchanged, it counts change-log entries trimmed past a limit, never a lost update.
Added
- A dashboard is now a JSON spec, not a page of wiring. The new dashboard
module builds a whole dashboard from data: a layout of windows, each
holding a grid, chart, KPI tiles, a map or an html panel, with a source per
panel (rows already in the page, a pushdown adapter, a live route, or
another panel’s own grid, so a chart narrows when the table beside it is
filtered) and links that let one panel’s selection filter another.
dashboard.spec()hands the whole arrangement back, including anywhere a reader dragged a window, so an application can save and restore a dashboard as plain JSON. With the AI module and a hostask()callback,dashboard.propose('sales by region with a map', { source })drafts a spec from a source’s column names alone, and nothing is built until it is applied. See the dashboard guide. - Rows can now be dragged into an order of their own, even while a grid is sorted, or dragged into another group. Both are opt-in and off by default. A Manual order control keeps every row where the sort left it and lets a drag (or the keyboard) set an explicit order from there; choosing the sort again returns to it. A row dragged onto another group moves into it, as an edit of its grouping value, with every rule and event an edit already carries, including a refusal named in words.
- Lattice Grid can now read Elasticsearch and OpenSearch directly.
elasticsearchAdapter({ url, index })turns filtering, sorting, paging, counting and grouped aggregates into the cluster’s own Query DSL, with every value sent as JSON rather than built into a query string. A text field is read, sorted and grouped through its.keywordsub-field automatically, and a field with none is refused by name before anything is sent rather than failing on the cluster. See the Elasticsearch and OpenSearch guide.
Fixed
- A computed column read through audit mode no longer corrupts every
row’s diff status. Diffing now compares stored fields only by default; a
column can opt back in with
diff: trueor opt out withdiff: false. - A Leaflet or deck.gl map bound with the viewport filter on now agrees with the grid from the first paint, rather than only after the first pan or zoom.
- A configuration object that refers back to one of its own ancestors is now refused by name instead of exhausting the call stack.
Internal
- A duplicated console-warning prefix, a row-drag positioning edge case past an async handler, a capability-registry gap and test-hygiene work with no effect on shipped behaviour.
[1.76.0] - 2026-09-28
Breaking
- Erasing an annotation is now built in, and one return value changes shape.
An eraser tool and a Clear all button join the annotation rail, so a mark can
be removed on its own or the whole layer cleared with a confirm step, and
annotate.remove(id)deletes one mark by id from code.annotate.add()now returns{ id, count }instead of a plain number; ordinary arithmetic and comparisons against it keep working, but code doing a strict type check or equality test against a number should compare.countinstead.
Added
- The built-in row form can now add a row, not only edit one. Open it with
grid.form.openNew()for a blank form that saves a new record exactly the way an edit does, key included. - A dropdown column’s options can now follow a live source.
columns.refreshLookup()refreshes a column’s choices on demand, and a lookup can bind straight to a data router route so its options stay in step with a live feed instead of going stale. - Lattice Grid can now read ClickHouse directly. Point
clickhouseAdapterat a ClickHouse endpoint and filtering, sorting, paging, counts and grouped aggregates all run inside the database, with every filter value sent as a typed parameter rather than pasted into SQL. - Leaflet joins deck.gl as a map for your grid. The new Leaflet binding keeps a map in step with a grid’s rows and selection, and turning on its viewport filter narrows the grid to whatever is on screen as a reader pans or zooms the map.
- MotherDuck is now a supported source, through the DuckDB adapter you already use. The guide covers both the server and browser setup, with no change to how the adapter itself is called.
Fixed
- A currency, percent or unit format written on the wrong property now
warns instead of silently rendering a plain number. Use
stylefor a number’s format; the grid names the mistake with a warning once instead of dropping it quietly. - The row form now shows a control for every field it is told to display, including a column that is not editable inline, instead of leaving some fields blank.
[1.75.1] - 2026-09-28
Fixed
- Choosing a saved view on a wide grid could freeze the browser. Restoring a saved column order is now a single reorder followed by one relayout, and a column already in the right place is left alone instead of being moved and redrawn again.
- A saved view that only hid or showed columns no longer reorders the rest of them. Applying a view that changes just which columns are visible keeps every column where it already was, instead of pulling the columns it names to the front.
[1.75.0] - 2026-09-28
Breaking
- Ramp-coloured charts can change colour. Every chart that colours its
marks along a ramp (heatmap, geomap, the density cells on markermap and
bubblemap, calendar, hexbin, hexmap and choropleth) now takes a
ramp: { scale }oflinear,logorquantile. Left unset, a chart whose positive values span more than a hundred to one is now coloured on a log scale, so its smallest marks stay visible instead of fading into one pale blur under a single dark outlier; anything narrower is unaffected. Setramp: { scale: 'linear' }to keep the previous colouring. The legend’s tick labels follow whichever scale is drawn: round numbers for a log scale, four cut points for a quantile scale.
Fixed
- A deck.gl layer over a small set of visible rows could draw nothing
while the grid reported every row as drawn.
bindDecknow reads a row’s position however it is stored (a GeoJSON object, WKT, WKB, or GeoJSON written as plain text), so a location column that arrives as text from a remote source draws correctly on every path. A row whose position is blank or unreadable is now reported separately from the rows actually drawn, instead of being silently counted among them. - Every entry in the warnings reference now links to the exact section of the guide that explains it, with nothing published half-linked.
[1.74.0] - 2026-09-28
Breaking
- One dark theme switch now reaches the whole dashboard. Turn on
theme: 'auto'(or setdata-themeon any ancestor) and the grid, chart windows, KPI tiles, alarms panel, tabs, kanban board and gantt chart all draw dark together, not just the grid body. A grid given no theme of its own now reportsundefinedrather than'light'when you read it back, and a layout’s title bar only follows a dark desktop when you ask it to withtheme: 'auto'; leave it unset and it keeps its light look on any machine. Risk on upgrade. Code that reads a grid’s theme and compares it against'light'should treatundefinedas “following the page” instead. A dashboard with no theme set anywhere keeps its current light look even on a dark desktop, unless you opt it in withtheme: 'auto'.
Added
- Many values in one cell, each one filterable on its own. A new list column type draws chips with an overflow count, edits with a token editor, and answers “has any”, “has all”, “has none” and a count alongside every ordinary filter, matching any item in the list. Point it at a DuckDB
LISTcolumn and the same filtering runs inside the database. - deck.gl can now draw straight from your grid. Hand it a function that builds deck.gl layers and it renders exactly the rows the grid shows, redrawing as they change; turn on the viewport filter and panning or zooming the map narrows the grid back down to what is on screen.
- Price and volume in one pane. A candlestick chart takes a
volumecolumn and draws a synchronised volume pane underneath the price, sharing one x axis, one tooltip and one zoom gesture. - A layout window can now take square corners instead of the rounded default, for a flatter dashboard theme.
- A server-rendered table now reads its own column types and formats straight from the markup, through
data-typeanddata-formaton a<th>, so a page with no JavaScript configuration still gets the right sort, filter and editor for each column.
Fixed
- A dense, banded chart axis now shrinks its label size before it starts hiding labels, so a heatmap with many rows in a short band keeps more of them readable.
- A candlestick chart and a companion chart sharing the same time column now agree on the hour when that column declares its own time zone.
- A filter pushed into DuckDB now matches the same rows the same filter would return read entirely in the browser, including how upper and lower case are treated, and a condition DuckDB cannot translate is now refused outright rather than silently matching more or fewer rows than it should.
- Type declarations for saving and restoring grid state, and for reading selection statistics, are now complete for TypeScript projects.
[1.73.0] - 2026-09-27
Breaking
- A map over a paged data source now draws only the rows inside its current view, fetched through the engine rather than pulled from the grid’s loaded page.
markermap,bubblemapandchoroplethcount and fetch their own rows as you pan and zoom, with the grid’s filter and quick search carried along; a superseded request is dropped rather than raced. Setaggregate: 'client'to keep a map reading only the loaded page instead. Risk on upgrade. A map already bound to a paged or pushdown source now shows the true count of matching rows in view rather than only the loaded window, which can change what a map that previously looked complete now shows. - A marker or bubble map past 20,000 rows in view now draws shaded density cells instead of individual points, on paged and fully loaded grids alike; zooming in under that limit returns to points.
spec.viewportCapchanges the limit, andchart.provenance().viewportreports what was drawn, what matched, and whether the map was binned. - KPI sum, average, minimum and maximum tiles over a paged data source are now computed by the underlying engine and say so. The tile keeps its last figure while a new one is on the way, and a grid-bound tile now follows the page and total of a live source without a manual refresh.
Added
- A column can now hold a shape. Import the geometry module and declare a column
type: 'geometry'to read a point, line or polygon from WKT, WKB or GeoJSON: cells read as a plain-language summary (“Point 51.5074°N 0.1278°W”, “Polygon, 14 vertices, 2.31 km²”), sort by location, group by kind, and filter with four new spatial operators,withinBbox,withinPolygon,intersectsandwithinDistance. - Point the DuckDB adapter at a file with a geometry column, including GeoParquet, and its spatial filtering, sorting and grouping now run inside DuckDB rather than in the browser.
duckdbAdapter({ spatial: true })loads DuckDB’s spatial extension once, finds the geometry column, and answers the same four spatial operators with the same results the grid’s own evaluator gives. - Maps can draw straight from a geometry column, and a new
choroplethchart type fills each row’s own boundary.markermapandbubblemaptakegeometry: '<col>'in place of separate longitude and latitude columns;choroplethdraws each row’s polygon coloured by a measure, with an optional shape layer as backdrop. - A map’s own viewport is now a filter you can bind back to the grid. Turn on
viewportFilterand panning or zooming amarkermap,bubblemaporchoroplethnarrows the grid it reads from to what is on screen; setting a matching condition from elsewhere, a saved view or a sibling panel, pans the map to it in turn. - A chart over a paged data source can now ask the engine for its own totals, instead of only ever totalling the rows already loaded. Grouped chart types read
source.aggregate()when the source can answer it, withaggregate: 'auto' | 'engine' | 'client'to force a side andchart.provenance()to report which one answered. A chart in flight keeps its last drawing rather than going blank while a new answer is on the way.
Fixed
- A grid built and filtered in the same breath no longer sends its first, unfiltered query anyway. Filtering a
duckdbAdapter-backed grid immediately after creating it used to still dispatch and wait for the abandoned first query before the one you actually asked for could run; that abandoned query is now dropped rather than sent. - A marker or bubble map of a small area no longer opens as one blob bunched in the middle of the plot. The map now sizes its density cells and its initial zoom from the box it is about to draw, so a city or a street narrows to a readable spread instead of the world’s own scale.
- An aggregate reading is no longer reported as engine-computed when part of it was actually answered by an unpushed quick search. Every requested figure now falls back to the client together in that case, matching how a partly-unpushed filter was already handled.
- Filtering, sorting or paging a live Splunk, paged or streamed grid now reaches a handler listening for
rows:changed, so code that waits for that event to read the source’s latest query no longer waits forever. - Editing a nested grid inside a master-detail layout now reaches the master’s own handlers for the start and end of a cell or row edit, not only its toggle handler.
[1.72.0] - 2026-09-27
Breaking
- A filter naming a column the grid has never declared used to admit every row instead of excluding all of them. Filtering on a mistyped or missing column name now behaves the same way an unrecognised operator already did: the condition is refused and no row is let through by mistake, rather than every row showing because the condition could not be understood. Risk on upgrade. A view that looked complete only because a stale or misspelled column name let every row past this check will show fewer rows once you upgrade; that is the corrected result. A filter naming a column the grid does declare behaves exactly as before.
Added
- A grid can now read Splunk live, instead of waiting for someone to
export a CSV first. Point it at a Splunk Enterprise or Splunk Cloud search
endpoint and the grid’s own filter, sort and paging drive a real search: a
filter becomes an SPL search term, a sort orders the search itself, and a
filter on your time column narrows the search’s own time window rather than
widening it. An export mode streams a full result instead of paging it.
Your credential never sits in the grid: a fixed token goes in your own
request headers, or a
fetchyou supply refreshes one that expires. - Charts can emphasise one series over the rest, by weight rather than only by colour. Name the series that matters and every other one draws thinner and muted, or set a named series’ own line width and dash pattern outright. Works on line, area, and the line half of combo and pareto charts.
- A chart with drag-to-zoom can now be zoomed and panned properly, not only reset. Turn zoom on and a chart gains a scroll-wheel zoom, a click-and-drag pan, keyboard zoom and reset, and a small set of zoom and pan buttons that fade in on hover or keyboard focus.
- A chart can now be drawn on. Turn annotations on and a drawing rail appears beside the chart: a trend line, a horizontal or vertical level, a parallel channel, a Fibonacci retracement, an arrow, text, a callout and a note, each anchored to the data it was drawn across so it stays put through a resize, a rolling window or a filter.
- Three measuring tools join the drawing rail: one reads the elapsed time and point count between two readings, one reads the change and percentage move between two readings, and one reads both together in a single rectangle. A volume figure joins the readout wherever a volume column is bound.
- Candlestick and bar charts now track a live clock properly. Put either on a continuous time axis and it slides smoothly between marks as time passes, instead of jumping a whole step only when a new period closes.
Changed
- Tabs, kanban and Gantt boards now dispatch a cancellable event the same
way as every other module, wildcard listener included: a handler bound to
tabs.on('*', ...)orboard.on('*', ...)now sees every event by name, matching what a grid’s ownon('*', ...)already offers. Nothing changes for a handler already written againstpreventDefault, the legacyreturn falseveto, or an async veto.
Fixed
- A module’s own config and result types can now be imported by name from its module path in TypeScript, instead of only being worked out by hand from its factory function’s signature.
- The developer guide was missing Excel export’s
autoFilter,freezePanes,hiddenColumns,onProgressandvariantFillsoptions, one facet-band setting, and 34 module instance methods across the kanban, Gantt, KPI, tabs, charts, AI, Data Router and mock-socket modules, all of them already shipped and working. Every one is now written up. - A DuckDB-backed grid could fail a query with a misleading file error if a reload and a host query landed on the same connection at the same moment. Every query against one connection is now queued, so any number of calls, in any order, complete correctly.
- A candlestick chart now honours a rolling time window, showing only the candles inside it and sliding left as new data arrives, instead of always drawing every candle regardless of the window you set.
- A continuous time axis now chooses its default label by how much time it spans, time of day within a single day, day and month across a few, the wider unit across months or years, and a column’s own declared date format always wins outright, matching how a banded axis already behaved.
- A heatmap, calendar, hexbin or hexmap chart with its legend switched on now actually draws one: a gradient bar labelled with the low and high of the range, formatted the same way as the data, with an optional title. Previously the legend space was reserved but always drawn empty for these chart types.
- A grid-level time zone no longer turns number, currency, percent or text columns into dates. It now steers only date, time and timestamp columns, as documented, whether or not one is set.
- A candlestick chart’s category axis can now be thinned, rotated or hidden, with the same axis options a bar chart already honours over the same categories.
- A measure axis can now sit on the right, on any non-horizontal cartesian chart type and on candlestick, for the price-pane layout a trading screen expects; on a dual-axis chart, moving the first axis moves the second to the opposite side automatically.
- Fixed a redraw occasionally animating a bar to the wrong place on a live bar, waterfall or horizontal bar chart, when a rolling window, an inserted or removed category, or a category moving between series shifted the chart’s own drawing pool out of step with the data.
- An axis can now be pinned to an exact range. Setting a minimum and maximum on a time or linear x axis now fixes the visible range exactly, on line, area, step, scatter, combo, bar and candlestick charts, instead of being silently ignored while the axis kept sizing itself from the data.
- A chart replaying a recorded session can now supply its own clock, so a rolling time window measures staleness against the recording’s own time instead of the wall clock, letting a replay scroll exactly as a live feed would.
- Continuous time-axis labels now follow the page theme instead of always drawing in black, so they stay readable on a dark page.
[1.71.3] - 2026-09-25
Added
- Colour a pie, donut, sunburst, or single-series bar or column chart by
name. Set
schemeto an object mapping each category to a colour (or, on a chart with more than one series, mapping each series to a colour) and every mark takes the colour named for it wherever it appears, so a category missing from one view never shifts the colours drawn for the rest. A scheme can still be a named palette or a plain array of colours, exactly as before. - A legend that carries the numbers. Set
legend: { values: true }and every entry in a chart’s legend shows its own formatted total alongside its label and colour swatch. - A donut chart with a figure in the middle. Set
centreon a donut chart to show its overall total, a fixed number, or a string of your choosing, drawn in the ring’s own hole with an optional label beneath it.
[1.71.1] - 2026-09-24
Added
- Bar and horizontal bar charts now read colour from the same rules as
the grid. Give the value column conditional-formatting rules and a
single-series
barorhorizontalBarcolours each mark from them automatically, with the legend naming the rule that fired instead of one flat colour for every bar. Set aseriesand the chart still uses the colour scheme, exactly as before. horizontalBardraws its data labels.labels: truenow puts one label at the end of each bar, tucking inside the bar rather than running off the edge of the plot when it would otherwise overflow.
Fixed
- Every geometry pack now loads from a plain
<script>tag. The five built-in map outlines (world, US states, UK and European regions) used to need a bundler; each now also ships as a standalone script carrying its own global (LatticeGridGeoWorld110m,LatticeGridGeoWorld50m,LatticeGridGeoEuropeNuts,LatticeGridGeoUk,LatticeGridGeoUsStates), so a page with no build step can draw a map chart with real published boundaries.
[1.71.0] - 2026-09-23
Added
-
Every console warning the grid can print is now a searchable reference. Paste the exact message, or look up the code it prints under, and get the plain reason it appeared and the specific change that clears it, across the grid and every module, 474 entries in all. The same catalogue backs
grid.diagnostics.warnings()for a script that wants to look one up itself. -
Alarm events from a KPI tile, a grid’s own cells, or a Data Router route, wherever a threshold rule already grades a value as good, warning or critical. The new alarms module raises and clears
alarm:raised/alarm:clearedevents as a reading crosses a line, feeds an alarm grid with one row per raise and clear, and holds a level for a set number of milliseconds before it counts, so a value bouncing across a threshold for a few seconds in a row settles into a single alarm instead of a flood of them. A KPI tile now also announces its own crossing directly, and a Data Router route can watch a live measurement continuously rather than firing once on the way past. -
A filter row under the column headings. Turn on
filterRowand every column gets its own inline box, picker or chip to filter by, sitting directly under its heading. Type into the row or use the column menu, either one keeps the other in step, and a saved view remembers what was typed. -
A built-in row-number column. Turn on
rowNumbersand every visible row is numbered by its position on screen, kept correct through a sort, a filter or a scroll to any part of a very large grid, with the width, the starting number and the heading title all yours to set. -
Merge cells across rows or columns, for a reporting grid where a category repeats down a run of rows or a note needs to span the row’s width. A column’s
rowSpanandcolSpandecide the shape; the grid draws it once, arrow keys and selection treat it as one cell, and export writes it out correctly, including a real merged range in an Excel workbook.
Fixed
resizePreview,selectionColumnWidthandselectionColumnPinno longer warn that they are unrecognised, and setting the latter two on a live grid now moves and resizes the row-selection column immediately instead of waiting for the grid to be rebuilt some other way. The Kanban board’s flow-metric methods, the built-in chart colour scheme names, loading a module from a plain<script>tag, and theformatshorthand grammar are also now fully documented in the reference.
[1.70.0] - 2026-09-23
Breaking
LatticeGrid.defaultcan no longer be overwritten. A page reachingLatticeGrid.default.createGrid = ...used to have that silently take effect, a second, undocumented route around the same protection the top-levelLatticeGrid.createGridhas always had.LatticeGridis now frozen, sodefault’s members refuse a write exactly as the top-level export always did. Nothing changes for an ordinary integration; this only affects a page that was replacing the grid’s own constructor.
Added
- A fourth heading style,
headerControls: 'none', for a column that shows only its title, whatever the grid is doing underneath it. The existing'hidden'setting removes the interactive sort, filter and menu controls but still draws a read-only sort arrow when the column happens to be sorted;'none'removes that too, so a dashboard heading never changes its own appearance no matter how the grid beneath it is driven, sorted, filtered or grouped from code. Screen readers still hear the true state; only the visual badge is gone.
Changed
- A filtered query against a live pushdown source now shows its rows the moment they arrive, with the exact total following a moment later, instead of holding the whole page up until both were ready. This applies to DuckDB, OData, GraphQL, REST and a custom pushdown adapter; the status bar and pager say they are still counting while the total is on its way, and nothing is ever estimated, the count still reads every row.
- Every published bundle is now genuinely minified, about a quarter smaller to download and parse, and a source map now ships beside every one, so a stack trace from the live, minified grid resolves back to a real file, line and identifier name instead of a wall of single-letter names.
- The npm package now ships its own
CHANGELOG.md, so a project reading the installed package has the release history to hand without coming back to this page. - The Python wrappers (pandas, Jupyter and Dash) now track every grid
release automatically, so a fresh
pip installgets the grid this page describes rather than one many releases behind.
Fixed
- A KPI panel no longer leaks a listener and a row store every time it mounts and unmounts, which matters for a panel inside a routed page, a tab, or anywhere else that comes and goes rather than staying on screen for the life of the app.
- A KPI tile’s delta reading now reaches screen readers as words, not only as a colour. “Up”, “down” and “unchanged” are now part of the tile’s own accessible name, alongside the number and the arrow a sighted reader already sees.
- A KPI panel can now tell a feed that has gone quiet from a genuine
zero, with
maxAgeandageBysaying how fresh the data has to be and which clock to measure it on. Past that window a tile reads “No recent data” instead of quietly grading a stale zero as good news, or as an alarm nobody should act on. - A chart bound to a column, or a setting, it cannot honestly draw now shows its own empty state and names what’s wrong, instead of drawing a complete-looking axis and legend around no real data, which used to read as “this measures zero” rather than “this chart is misconfigured”.
- Charts keep every mark inside their own plot after a pan, a zoom, or a value outside a pinned axis, on the map, waffle, waterfall and horizontal bar chart types, instead of drawing past the chart’s own edge.
- Dozens more interface strings translate along with the rest of the grid instead of staying in English, among them the password field’s reveal toggle, the undo bar, the saved-views panel, and everything the AI panel says while it works.
- A repeated identical screen-reader announcement is now heard every time, not only the first. Trying the same refused header move, vetoed drop, rejected edit or empty undo/redo twice in a row used to announce the outcome only once, which read to a screen-reader user as the grid no longer responding at all.
columns.fit()’s “the columns don’t fit” warning no longer goes missing for a second grid on the same page, and no longer fires against a grid that has nothing on screen yet to measure.- Loading a chart extension, or the dhtmlx wrapper, before the module it shares code with no longer throws. Module load order no longer matters.
Documentation
- The API reference now links every type it names.
Density,Operator,SourceConfigand the rest of its 236 documented type aliases are each one click away from anywhere they’re mentioned, instead of being a word you had to already know. - The reference is also published one page per module (the grid, charts, the data router, KPI, Kanban, Gantt, tabs, layout, the AI layer, the mock socket, the framework adapters, and the type reference), alongside the full single-page version, so you can go straight to what you’re using instead of scrolling one long document.
- Configuration properties that used to spell out their allowed values
in place now name and link a documented type instead, so
directionreadsDirectionwith a link to what it means, the same way a property or a return value already did.
[1.69.0] - 2026-09-22
Fixed
- A right-pinned column’s heading now lines up exactly with its cells. It used to sit flush with the grid’s edge while the cells below stopped short to leave room for the vertical scrollbar, overhanging its own column; the header now reserves the same width the scrollbar takes, whether the scrollbar is visible, transparent, or not present at all.
- Presentation mode’s annotation tools now open every time, instead of only sometimes. The pen, shape, arrow and text tools are always in the presentation rail whenever a presentation is running, instead of occasionally being registered but not shown.
- Sixteen more pieces of text across the grid are ready for translation, among them the presence roster that shows who else is viewing a grid, the comment panel’s buttons and messages, gantt tooltips, and column heading totals. Set a locale and these translate along with everything else; a term with no translation supplied yet shows in English.
- Setting
messagesto translate the grid’s own text no longer raises a false configuration warning. The option always worked; the grid now recognises it too, so a localised app gets a clean console.
[1.68.2] - 2026-09-22
Changed
- Eleven configuration options now do what the reference says they do. Each had been read
nowhere in the grid, or did something other than what its name and description promised, with no
warning when you set it. A column’s
cell.flashnow flashes just that column on a change, with its own colour and duration. A colour scale’smidnow pins where the middle colour actually falls. A band’smarryChildrenkeeps its columns together through a drag, a keyboard move or a menu move. A custom editor’scancelOnCloseis asked before every commit, so an editor that returnstruehas its value discarded rather than written.rows.expand(key, true)opens only that row’s own subtree instead of every group in the grid.edit.start: 'key'binds no mouse gesture, for a grid read with the mouse and written with the keyboard. A correlogram’smethod: 'kendall'computes Kendall’s tau-b instead of drawing Pearson’s r under the Kendall name.display: 'switch'on a boolean column draws its own glyph, distinct from'checkbox'. Gantt export to MSPDI now maps each named resource to its stated capacity.csv({ hidden: true })and Excel’shiddenColumns: 'hidden'now carry the grid’s hidden columns into the exported file. A Data Router write now names the source feed an edited row arrived on, for a fan-in router to persist back to. If you build against the reference types:lockPositionis a plain boolean now, a few options and web-component attributes that never reached any code are gone (cell.autoHeight, usecell.wrapand the grid-levelautoHeightinstead), andChart.elementandChart.toPNG()are typed as what they always returned. Every other option keeps its existing default.
Fixed
- Pressing Enter or Space on a column heading with nothing to do now does nothing. A heading whose controls are hidden, or whose column cannot be sorted, used to open an editor on a body cell out of view; the key press is now consumed and ignored, while keys the heading declines still reach the page.
- A refused column resize or move now leaves the column exactly as it was. When a
beforeColumnResizeorbeforeColumnMovehandler vetoes a header drag or a keyboard move, the width and position are left unchanged end to end: no visual change, no follow-on event, and no screen-reader announcement for a change that never happened. - A chart’s
brushselection can now be refused withpreventDefault(), the same way aclickalready could.
Documentation
- Every one of the grid’s 162 events has a full entry in one table, naming when it fires, its payload, and whether it can be cancelled.
- Eight more surfaces get their own events table too: the Kanban board, KPI panel, dashboard
layout, tabs, charts, AI, Gantt and Data Router, each with every payload named instead of left as a
bare
any,unknownorobject. - The reference’s events section is now a short introduction to that table, rather than a second, separately maintained list that could say something different.
[1.68.1] - 2026-09-22
Fixed
- A horizontal bar chart’s axis titles now sit beside the axis they describe. The value axis title and the category axis title were swapped, so each one appeared next to the axis it did not name. Both now label the axis they belong to.
- Turning off a column’s filter control no longer removes it from quick search.
Setting
filter: { enabled: false }used to drop a column from quick search along with hiding its filter control. The two are independent now: the column stays searchable with its filter control hidden, and a new column option,quickFilter: false, is how you exclude a column from quick search on its own. If you were usingfilter.enabled: falseto keep a column out of quick search, setquickFilter: falseon it as well.
Documentation
- The API reference is rebuilt, organised by surface. The grid instance and each of its sub-APIs, the configuration options, every module, the framework adapters and a glossary each get their own entry in the reference’s navigation, with three separate tables per surface for its properties, methods and events.
- Every reference entry now carries a description. 2,826 of the reference’s 2,847 members are described, up from 1,452; the remaining 21 are index signatures and generic accessors with nothing more specific to say.
- Every grid event is documented in one table, naming the subsystem that raises each of the 162 events. Each entry lists when the event fires, its payload type, and whether the event can be cancelled. The Kanban board’s 33 events, the KPI panel’s 5 events and the dashboard layout’s 10 events each have their own table.
[1.68.0] - 2026-09-21
Added
- A KPI tile can show the size of a change, the percentage, or both.
A new tile option,
delta: 'absolute' | 'relative' | 'both', chooses what the movement line prints againstbaseline: the plain difference, the percentage alone, or both together (the previous behaviour, and still the default). Useful for a rate already expressed as a percentage, such as unemployment moving from 4.30 to 4.10, where a percentage-of-a-percentage adds nothing and a plain difference reads better on its own. The arrow and its colour are unchanged.
Fixed
- A
comboorparetochart over a date or number column now reads left to right, in order. Drawn over a temporal or numeric axis, these two chart types used to plot one bar per row in the order the rows arrived rather than one point per value along that axis. They now draw the same continuous time or numeric axis alinechart draws over the same column, sorted by value and spaced by date or number rather than row order.- A reference line or band placed on that axis now draws correctly instead of disappearing.
- An annotation the chart genuinely cannot place now warns once, naming what went wrong, instead of vanishing with no trace.
- A time axis spanning more than about fifty years now ticks on real years. An axis running from, say, 1970 to the present used to draw a single tick labelled with the earliest year. It now adds twenty, twenty-five and fifty-year steps, and every year-based tick lands on a real calendar year instead of drifting a day or two further out with each step.
- A
combochart’s measure title is now drawn. Settingtitleon acombochart’s measure used to be accepted and then ignored in favour of the column’s own title; it is read now. - Grouping KPI tiles into a hierarchy is now something you turn on, not an accident of naming.
A tile
idcontaining a dot used to build a collapsed tree automatically, with notreesetting and no warning. A hierarchy now only appears oncetreeis set on the panel; left unset, every tile renders flat as before, and a dotted id seen withouttreewarns once so the option is easy to find. A panel that already setstreeis unaffected. - The tabs strip no longer overrides your own height styling.
createTabsused to set its own height on the element you handed it, which could beat a page rule sizing that same element. Its sizing now applies inside the strip it builds, never to the host element itself, so a rule on your own host element always wins. - Pinned rows now redraw when you update them. Calling
setPinnedRows, or writingpinnedTopRows/pinnedBottomRowsthroughset(), used to leave the pinned strip showing whatever it was first given. Both now repaint the strip on every call. - A rollup route in the Data Router now drops a group correctly on a scrub. A
rolluproute grouped by a date, a missing value, an object, or several fields together could add and update groups correctly but never remove one, most visible scrubbing a screen backward in time. Removals now use the same key the grid itself derives for a group; a rollup grouped by a plain string, number or boolean was never affected. - A plain Data Router route keyed by a date or another non-plain value drops rows correctly too.
The same correction as above, for a route with no
rollup: a route keyed by a date, a missing value, or an array could add and update rows correctly but never remove one on a scrub. A route keyed by a plain string or number was never affected. - A grid removing rows no longer shows the old set to anything reading its rows at that moment. Reading the row count or the row list at the instant rows were removed, such as the status bar’s own count or a listener on the change event, could catch the row model mid-change and show rows that had just gone. The row model is now settled before the change is announced, so every reader sees the same rows the grid does.
- Rows now load correctly after a grid is pointed at a new source. Calling
grid.set('source', …)from an in-memory source to a paged one used to leave the old source’s values on screen wherever the two overlapped, with blank cells past the end of the old data. A source swap now behaves exactly as building the grid with that source from the start would. - A route now says so when a view refuses a removal it sends. If a view’s own row key does not match the key the router derives for that record, the route now warns once, naming the route and an example key, instead of leaving a stale row on screen with nothing said.
[1.67.0] - 2026-09-21
Fixed
-
A composite row key, or a row key function returning an object, is no longer silently merged onto one row. Passing an array of field names, or a
rowKeyfunction that returns an object, to a kanban board, a KPI panel, a Gantt view or a data router route used to key every card, tile, task or route entry the same way and quietly collapse them onto each other, instead of raising a mistake you could see. Each of these views now refuses the value at setup and names what it will accept: a single field name, or a function returning a string or a number. Composite keys remain a feature of the grid itself, since each of these views identifies its own cards, tiles, tasks and routes by a single value. If a board, panel, Gantt view or router route was relying on the old silent behaviour, it now raises an error naming the view and the row key it was given, instead of merging rows together with no warning. -
A page that sets its own left-to-right or right-to-left direction is now honoured over the grid’s locale. A page that declared
dir="ltr"on the grid’s mount, or on an element around it, could still render right to left when the grid’s locale was Arabic, Hebrew or Farsi. The grid now checks the mount and its ancestors for an explicitdir="ltr"ordir="rtl"first, and only reads the locale once neither the page nor an explicitdirectionsetting has already decided. A page that sets its own direction now gets it; setdirection: 'rtl'to keep a grid right to left inside a left-to-right page regardless of its locale.
Documentation
- The icon gallery in the API reference now shows every built-in icon, not just some of them.
The gallery is generated from the full set of built-in glyphs, so a name like
download,searchortrashthat was missing before now appears with its glyph alongside every other one.
[1.66.0] - 2026-09-20
Added
-
A Vue 3 application gets a component for every view, not just the grid. The Vue adapter used to hand you a grid component and nothing else, so the stat tiles, each chart, the board, the plan, the dashboard layout, the tab strip and the data router beside it each needed a mount and teardown pair written by hand. There is now one component per view, from the same import:
createLatticeKPI,createLatticeChart,createLatticeKanban,createLatticeGantt,createLatticeLayoutandcreateLatticeTabs, withcreateLatticeVueto build the set your application uses from a single call. Each view is built once and takes a changed prop into the instance already on screen, so scroll position, selection, expansion and any open editor survive a re-render, and unmounting destroys it and leaves nothing on a timer. Every event is an emit under its dashed name, so@cell-changedbinds the way a Vue template expects, and@grid-readyhands you the live grid. A panel finds its grid by name rather than by mount order, so a tile strip written above its grid still fills the moment that grid arrives. A tab’s content is a named slot, so a tab holds a real component with its own props, refs and injections. A live feed needs no ref of its own::row-updatesis a keyed diff applied in place and:predicatesare named filters that compose with whatever the reader has already set. Vue 3.3 and up, in the browser, with no template compiler needed in your build. The grid component you already use is unchanged. -
The Angular components are part of the grid package.
npm install @toclocoinc/lattice-gridis the whole install, andimport { LatticeGridComponent, provideLattice } from '@toclocoinc/lattice-grid/angular'resolves to the same ahead-of-time compiled library: one standalone component per view, no compiler in your production bundle, one version number and one licence for the product.@angular/coreand@angular/commonare optional peer dependencies, so a project that is not an Angular one installs nothing extra and sees no warning. Every component, input, output, provider and the data router service are exactly as described below; only the import specifier is shorter. The oldermodules/angularbundle still works where it always worked, is still deprecated, and now names this subpath when it meets an Angular build with no compiler in the page. -
Svelte 5 gets a component for every view. A Svelte application used to wrap the grid in
use:latticeand hand-write a mount and teardown pair for every chart, tile strip or board beside it: an action takes a node and a value, so it has no children, no snippets and no context, and there was no way to put Svelte-rendered content inside a tab.Grid.svelte,KPI.svelte,Chart.svelte,Kanban.svelte,Gantt.svelte,Layout.svelte,Tabs.svelte,GridProvider.svelteandRouter.sveltenow ship under@toclocoinc/lattice-grid/svelte/, as source your own toolchain compiles, the way Svelte packages ship. Each builds its view once and pushes changed props into it, so a prop change or a block toggled twice never throws away scroll position, selection, expansion or an open editor, andbind:thisplusgrid()orinstance()hands you the live instance. A tab’s content is a snippet named after the tab, rendered inside the strip with the parent’s context intact, so a grid in one tab is findable by a chart in another. Row updates arrive as a keyed diff, predicates as named filters that compose with the reader’s own, and grid events as callback props, because Svelte 5 removed component events. Svelte 5 and the browser only;svelteis an optional peer dependency, so nothing extra is installed for anyone else, and theuse:latticeaction is unchanged and still supported. -
A page with no build step gets a tag for every view, not only the grid. One
<script type="module">used to buy you<lattice-grid>and then leave you writing mount and teardown code by hand for a tile strip beside it or a chart under it. The same bundle now registers<lattice-kpi>,<lattice-chart>,<lattice-kanban>,<lattice-gantt>,<lattice-layout>,<lattice-tabs>and<lattice-router>alongside it, in a Rails, Django or Laravel page with no bundler in sight. You register the ones you use with the factories your page imported, so a page that draws no charts downloads no chart code. A view finds its grid by name, and a view written above its grid in the markup still fills the moment the grid arrives. Scalars work as attributes and structures as properties, a property assigned before the tag upgrades is honoured, and replacing the rows fifty times or moving the element in the DOM keeps the same instance with your scroll position and selection intact. A tab’s content is a<template>child, so a tab can hold a genuine<lattice-grid>with its own attributes. Events arrive asCustomEvents under alattice-name, and type declarations cover every element. -
The grid draws correctly inside your own shadow root. A grid or a chart placed inside a component that attaches a shadow root, a Lit or Stencil component, a micro-frontend container, or Angular with shadow DOM encapsulation, used to render structurally perfect and completely unstyled: a stylesheet does not cross a shadow boundary, while the theme’s custom properties do, which made a missing stylesheet look like a theming problem.
adoptLatticeStyles(root)now mirrors the theme, every module’s stylesheet and the grid’s own growing one into a shadow root and keeps them level, so a class compiled when you add a column later still arrives. Every Lattice element does this for its own root automatically,latticeStyleSheets()hands you the sheets if you manage adopted stylesheets yourself, andlattice-styles="off"opts an element out. A stylesheet that cannot be read as text, one served cross-origin or carrying an import rule, is cloned into the root as a link rather than dropped in silence.
Fixed
-
A chart’s tooltip is no longer cut off by the card or the window around it. Hover a tile near the edge of a dashboard card and the tooltip read “Departme…” instead of “Department”, because anything above the chart that clips its contents, a card, a layout window, a tabs panel, clipped the tooltip too, and near the edge of the screen it could run off entirely. The tooltip is now placed from the pointer’s own position on screen and flips to the other side of the pointer where it would otherwise run past the edge. Its content, its behaviour on leave, turning it off per chart, and its suppression while a map is being dragged are all unchanged.
-
A dense line or area chart answers the pointer anywhere in its plot. A line chart over several thousand daily readings in a panel a few hundred pixels wide showed no tooltip at all, while the same chart over a few hundred readings showed one. Past about one point per pixel the chart draws a reduced series, and the pointer was still being matched against the full set. The pointer now resolves to the nearest point actually drawn, whatever the density, and the hover and click events carry that reading. A chart small enough not to be reduced behaves exactly as before.
-
A histogram reads in the same units as the column it came from. Bin edges no longer carry floating-point residue, so a bar that should read
0reads0rather than a number in the seventeenth decimal place, and the labels go through the column’s own format, so a price histogram reads in the same currency and precision as the price column beside it. The tooltip, the hover reading and the accessible table now name a bar by the range it counts, as0 to 0.2, rather than by whatever the rows happened to be grouped by, and the reading carries that range so a page can act on it. A histogram also honours a declared ceiling or floor on its value axis, as the box plot beside it already did: readings outside the bound are left out of the bins rather than squeezed into the end bucket, and how many there were is reported. -
Chart axis labels no longer write over one another in a short panel. In a tile a hundred or so pixels tall, the value axis drew every one of its labels at a ten-pixel pitch. It now steps its labels to the room it has, and every gridline is still drawn: only the text thins. A turned axis title is kept inside the plot it runs along instead of running into the chart’s heading, and keeps its full text for a screen reader. Where you thin the category labels yourself, the labels that remain now take the room that frees, so an axis whose drawn labels fit upright keeps them upright and whole.
-
Changing a grid’s columns after it is built now changes what you see.
grid.set('columns', next), and a changed columns prop in the React, Vue, Svelte, Angular or web component adapter, which becomes the same call, left the header and the cells showing the columns the grid was built with. A grid built empty and filled in afterwards, which is how every adapter mounts, painted no header and no cells at all. A columns change now re-plans the widths, pins and order, rebuilds the header and repaints the cells: removing a column removes its cells, adding one adds them, reordering re-orders, and the selection and the scroll position survive. -
Conditional formatting rules set at runtime now reach the cells.
grid.set('formatting', rules)put the rules in the configuration and styled nothing, while the same rules applied throughgrid.formatting.replaceAll()worked. A write to the configuration key now reaches the rules and the cells restyle on the next frame. -
A KPI stat tile draws its own card again. Dropped onto a page on the default theme, a stat tile rendered as bare text with no border and no background behind it, and its title, footer, interval and “no data” lines lost their muted colour, so a dashboard had to wrap the tile in a box of its own to compensate. The tile now takes its edge, its fill and its muted text from the theme. Every colour a view asks the theme for is checked against the colours the theme publishes, so a view cannot quietly lose its styling this way again.
[1.65.0] - 2026-09-19
Added
- An Angular application gets a component for every view, not just the grid.
@toclocoinc/lattice-grid-angularis a package of standalone Angular components:<lattice-grid>and a[latticeGrid]directive for an element your template already owns, plus<lattice-kpi>,<lattice-chart>,<lattice-kanban>,<lattice-gantt>,<lattice-layout>and<lattice-tabs>. They are compiled ahead of time the way every Angular library you install is, so a production build carries no compiler in the page, and each factory is injected rather than imported, so an application that shows a grid downloads the grid and never the board, the plan or the chart types. Every grid event is an output under its kebab-case name. A tab’s content can be your ownng-template, with your injectors above it. A panel named to a grid finds that grid the moment it exists, so a dashboard no longer depends on the order its pieces mount in. The data router is a service, created and destroyed with the component that provides it. Change detection needs no setting up: zones and zoneless both work,OnPushis safe everywhere, and a changed input reaches the view already on screen with scroll position, selection, expansion and any open editor intact. Angular 17 and up, in the browser. The oldermodules/angularbundle still works where it always worked, and where it meets an Angular build with no compiler in the page it now names this package instead of leaving you with Angular’s own error. Since 1.66.0 these components ship inside the grid package itself, at@toclocoinc/lattice-grid/angular, sonpm install @toclocoinc/lattice-gridis the whole install: the separately named package described here was never published.
Fixed
- Registering your own icons is no longer reported as a mistake, and the types say so too.
createGrid(element, { icons: { … } })drew your glyphs and then told the console thaticonswas not a configuration key the grid recognised, sending you looking for a typo that was never there. The warning is gone, andiconsis now declared on the configuration type, so an editor completes it and a strict TypeScript project accepts it; a newIconDefinitiontype spells out all three shapes the registry takes. Two more things in the same declarations: a page that loads the grid from a script tag is now typed throughwindow.LatticeGrid, so it gets the same completion and checking as a page that imports the package, and the published types no longer carry our own tracker codes, which an editor used to show on hover over anything you touched.
[1.64.0] - 2026-09-19
Added
- A map that carries the figure and the colour at every location. A new
opt-in chart type,
markermap, draws one marker per row where that row is, over real published boundaries. The number beside each dot is the value column’s own formatted cell text, so a percentage, an amount of money or a unit reads on the map exactly as it reads in the table, and the dot takes its colour from that column’s conditional-formatting rules. An availability wall across every site you run is one rule set on one column plus one chart, with no colour column and no second set of thresholds to keep in step. The map reads the grid’s filtered rows like every other chart and follows a filter, a sort and a live feed; withselection: truea click on a marker selects that row, and the grid’s selection picks the marker out. A row whose coordinates are missing or out of range draws nothing and is counted and reported rather than quietly dropped, and the legend names only the rules that actually matched. - A network diagram that looks like your network. A
networkchart now takesnodes: [{ id, label, icon, x, y }]: a glyph per device from the grid’s own icon set, and a position you choose as fractions of the plot, so cores on top and sites below needs no hand-placed drawing. A device you name that appears in no row is still drawn, because a device with nothing plugged into it is worth seeing. Links take their colour from the value column’s conditional-formatting rules, and the legend names the rules that fired. Several rows between the same pair are now several lines drawn side by side, each with its own value, colour and tooltip, rather than added into one edge: three circuits at 40 per cent are three readings, not one at 120. Links carry no direction,linkWidthfixes the stroke width where a value-driven width is not wanted, and withselection: trueclicking a link selects its row and clicking a device selects every row it is an end of.- The icon set is readable from the grid.
grid.iconsoffersget(name)andnames()on the instance, so a viewer drawing its own glyph draws from the same set your cells paint from. Registering a glyph is unchanged.
- The icon set is readable from the grid.
- A KPI panel can show a live clock beside its figures. A tile written
{ kind: 'clock', label, timeZone, locale, seconds, date }renders the date on one line and the time on the next and ticks on the second, so a screen can carry the local time in every market it covers. Several clocks on one panel move together,timeZonetakes any IANA zone name, and whether the clock reads 24 hour or 12 hour follows from the locale rather than another option. It behaves like every other tile: it sits in the panel’s layout, answers the panel’s column count, fires the tile events, and reads its date and time to a screen reader.
Fixed
- An annotation drawn on a grid with a pinned column now stays with the cells it was drawn on. Circling a cell and scrolling sideways used to drag the mark across the pinned column, or leave a mark drawn on a pinned column behind. Marks saved before this read exactly as they did.
- Every optional module now weighs what the module weighs. Each optional bundle carried a large block of background-processing code it could never run, so a page that loaded the stat tiles, the tabs or a framework adapter downloaded and parsed weight that did nothing, and a page loading three modules downloaded it three times. It is gone, and nothing a page can observe has changed: the core and the two self-contained bundles that really do use that code are byte for byte what they were.
[1.63.3] - 2026-09-18
Documentation
- The Data Router’s TypeScript types now cover its full surface, and it
has its own place in the reference. Every router method and option now
compiles and autocompletes correctly, including
query,buffer,addSource,metrics, and each route’srollup,where,writableandbackpressuresettings. The reference page is reachable from the sidebar instead of sitting unlabelled inside another topic. No runtime change.
[1.63.2] - 2026-09-18
Fixed
- Wide grids with a grand total row scroll sideways more smoothly. Column headings and pinned rows now stay locked to their columns during a fast scroll in current browsers.
[1.63.1] - 2026-09-17
Fixed
- A grid with hundreds of columns no longer stalls while scrolling sideways. Its header and any pinned totals row now keep up with the body immediately, however many columns are on screen.
[1.63.0] - 2026-09-17
A geomap chart can now draw real published boundaries instead of a
schematic outline: world countries, US states, European regions and UK
regions, each a separate optional pack, with proper map projections and pan,
zoom and keyboard exploration. Grouping a large dataset queried through
DuckDB now runs inside the database rather than in the browser. The React
adapter now covers every viewer, not only the grid, so a KPI panel, a chart,
a board, a Gantt, a dashboard layout or a tab strip can each be a real React
component. A handful of chart types read the wrong numbers when bound to
grouped data, several charts could throw on a large dataset, and a live
stream re-sending a row under an active sort now moves it to its new place
immediately.
Added
- A statistic computed over only part of your data can now say so.
grid.rows.coverage()returns{ covered, total, windowed }: how many rows a figure was actually computed over, how many rows the source knows about in total, and whether a rolling window was involved. Call it beside any number you show a reader, on a live stream withmaxRowsormaxAge, or on a derived statistics grid built from one, so a correlation or an average computed over the 200 rows a window kept is never shown as if it covered the 2,000 rows that passed through it. A source with nothing to disclose, such as an ordinary grid over all its data, reads{ covered: n, total: n, windowed: false }and costs nothing to check. - Expand or collapse every group from any column’s menu, or from the row menu, not only the generated group column. Right-click the data area, or open any column’s header menu, and once the grid is grouped “Expand all” and “Collapse all” sit alongside the other grouping actions.
- The React adapter now covers every part of the product, not only the
grid.
createLatticeKPI,createLatticeChart,createLatticeKanban,createLatticeGantt,createLatticeLayoutandcreateLatticeTabsjoin the existing grid component, each mounted once, destroyed on unmount, and kept live: a changed prop is pushed into the running instance rather than rebuilding it, so scroll position, selection and expansion survive a re-render.createLatticeReactbuilds whichever of them your own code needs in one call.- A tab’s content can now be a real React element. The tabs module
still owns the tablist, the keyboard handling, lazy first mount and live
badges, and now renders a tab’s React
contentinto its own panel through a portal, so that tab is a genuine<LatticeGrid>(or any other component) with props and context, not something the module built for you. A tab with nocontentstill works the way it always has, and both kinds mix freely on one strip. - A grid-bound viewer can now find its grid through context instead of a
ref.
<LatticeGridProvider>anduseLatticeGrid()let a KPI panel or a chart appear the moment the grid it reads does, with no manual wiring. - The Data Router has a hook.
useLatticeRouter(config)owns a router for the life of your component, created and torn down in the same effect, and<LatticeGrid route="…">attaches to whichever router a<LatticeRouterProvider>published. - A live feed needs no ref. A
rowUpdatesprop hands a keyed diff straight to the grid whenever it changes, andpredicatesregisters a named filter that composes with whatever your reader already set, rather than replacing the whole filter tree. - Every prop and event is properly typed, generic over your row shape, rather than accepting anything and telling TypeScript nothing.
- A tab’s content can now be a real React element. The tabs module
still owns the tablist, the keyboard handling, lazy first mount and live
badges, and now renders a tab’s React
- Real maps: five ready-made geometry packs, proper map projections, and a
reader can explore one.
import { pack } from '@toclocoinc/lattice-grid/modules/geo-world-110m'andshapes: packon ageomapchart draws 177 countries on a proper world projection, fitted to the panel. Four more packs cover other scales and regions: world countries at a finer resolution, US states, European regions down to two levels, and UK regions down to local authority and constituency. Each is a separate, optional module a page loads only when it draws that map, built from its published source with the licence and any required attribution line drawn under the map automatically.- Six projections, chosen sensibly by default for a world, a country or a region, and overridable: an equal-area world view, a compromise view, Mercator, an equal-area conic with standard parallels, a transverse Mercator that stands the UK upright, and the plain equirectangular view kept for a map drawn before this release.
- A country whose outline crosses the date line, such as Russia or Fiji, now draws as the shape it is rather than as a band running the wrong way across the map.
- A map still joins your rows by ISO alpha-2, alpha-3 or numeric code, and now also by the codes each pack’s own source uses (USPS and FIPS for US states, NUTS ids, ONS codes); a code no pack recognises is still reported as unmatched rather than silently dropped.
- A region can label itself with its real name (“Wyoming”, “Saxony-Anhalt”) rather than the code your data joins on.
- Every region draws with a consistent, non-thickening stroke as you
zoom, and the colour scale used for real values now stays visibly
distinct from the “no data” tone.
labels: truedraws each region’s name at its centre, andgraticule: truedraws a longitude/latitude reference grid. - Pan, zoom and reset, by pointer or by keyboard: drag to pan, the
wheel to zoom, and Shift with the arrow keys,
+/-or0from the keyboard. A bubble map or hex-bin map can now take the sameshapesfor outlines to plot points or hexagons against.
- A grouped grid queried through DuckDB now groups inside the database
rather than pulling every row into the browser first. Each level of the
group tree runs as its own query, paged the same way any other window is,
with the group counts, the per-group subtotals, the matching row count
and the grand total all computed by the database. Expanding a group narrows
the next level to it; expanding the deepest level runs the ordinary,
already-paged row query.
- A grid grouped this way matches a grid grouped in the browser, row for row and order for order, including how absent or null keys sort.
- Grouping only ever pushes down whole or not at all. A filter, sort or search that cannot be pushed the same way refuses the grouped query outright rather than counting group rows over the wrong set, and says why through the source’s own plan reporting.
Fixed
- A number, currency or percent column configured with
format: { signed: true }now actually shows its sign. A positive value gets a leading+, a negative value is unaffected, and zero shows no sign either way. Previouslysignedonly did anything for a hex, binary or octal column; it is unchanged there. Any format option the grid doesn’t recognise, or that doesn’t apply to a column’s type, now warns once by name instead of doing nothing silently. - Hovering a treemap, a map, a correlation matrix, a network diagram or a
flow chart now shows a tooltip and fires the same
hoverevent every other chart type already does. Clicking to filter, and drilling in, now work on these types too. Each names what’s under the pointer in the way that makes sense for it: a tile with its branch and share of the whole, a region with its value, a correlation pair with its coefficient, a node with what flows through it, a ribbon with its source, target and share. - A Gantt chart’s date labels no longer overlap at week or day zoom. Labels now thin themselves to whatever spacing keeps them readable, on both the single-pane and split timeline views, while gridlines and week separators still fall on every tick.
- A KPI panel told to show a fixed number of columns no longer runs off the edge of a narrow container. Tiles shrink to hold the requested column count before the panel gives up a column, and their labels and values ellipsise instead of clipping mid-word.
- A violin, box plot, histogram, Q-Q plot, ECDF, Lorenz curve or ridgeline
chart split by a category now scales its axis to the actual readings,
not to the sum of each group. These types now reduce by median rather
than by sum by default,
axis.y.min/maxare honoured, and ameasuresbinding now works the same way it does on any other chart. - Two grids built from the same array of rows no longer collect each other’s data. The grid now keeps its own copy of the array you hand it, so adding, removing or updating rows on one grid never changes the array (or the other grid) you built from it. The row objects themselves are still shared, so an in-place cell edit is visible through your own array exactly as before.
- A
datetimeordatecell read throughrows.value()now answers consistently before and after an update, rather than switching from a formatted string to a raw number the moment the cell changes.rows.text()was always correct; this only affected code readingrows.value()directly. - A row that a live stream re-sends with new values now moves to its new place immediately under an active sort, the same way a memory grid always has, instead of keeping its old position until something else happened to rebuild the order.
- A memory grid whose rows are declared inside
source: { mode: 'memory', rows }rather than at the top level no longer opens empty. Both forms now behave identically; declaring rows both ways keeps the top-level ones and warns once, naming both places. - A histogram, and several other charts that bin or take an extreme value
over a very large result, no longer throws past around 120,000 rows.
Affects
histogram, a single-categoryviolin,capability, abubblechart sized by a column, and any chart reduced byminormaxover a large group; bin edges and counts are unchanged for anything that already worked. - Eight statistical chart types now have a proper name in every supported language, instead of showing their internal key.
- A chart no longer cuts off long column or axis labels. The margin a correlogram, horizontal bar, forest plot, Gantt or heatmap chart reserves for its labels is now measured from the labels it’s actually drawing, up to a limit, past which a label ellipsises rather than running outside the chart; a rotated label’s tail no longer paints below the plot, and a horizontal bar chart with hundreds of rows thins its side labels instead of stacking them on top of each other.
- Charts choose the right x-axis automatically, whatever shape your data is, and say so when a choice was made for you. A date, timestamp or similar temporal column now always binds to a time axis and always draws, even over thousands of points; a numeric column with a handful of distinct values no longer gets wrongly treated as a set of categories; and a chart that falls back to treating a column as categories now names the column and the option that overrides it, once, instead of doing so silently.
- A grouping data source that returns an empty or missing value for its outermost group no longer locks up the grid. Such a group now renders, expands and collapses like any other, at any depth, with no change to your saved views, selection or any group key you already store.
- The Augmented Dickey-Fuller stationarity test now runs in a fraction of the time on a real time series, with no change to the lag chosen, the statistic, the p-value or the verdict it returns.
Documentation
- The DuckDB source guide now states plainly when a Parquet file is
actually read in byte ranges rather than in full. Whether row-group
pruning and range reads apply depends on your DuckDB-Wasm version, loading
the
httpfsextension before your first query, and your file host answering byte-range requests with the right headers over CORS; none of it is automatic, and the guide now says so rather than implying it is.
[1.62.1] - 2026-09-16
Added
- Every opt-in chart type now loads on a page with no build step, not only
as an ES module. Alluvial, arc, bubble map, bump, calendar, decomposition,
diverging, dumbbell, fan, hexbin, hex map, icicle, parallel coordinates,
ridgeline, ROC, slope, scatterplot matrix and waffle now ship the same way
every other chart type already does: a script-tag build for a plain HTML
page and a
require()build for a CommonJS project, alongside the ES module. Load the grid, then the charts module, then the chart type wanted, and call it the way you already call any built-in type; each adds roughly 1.5 to 2.5 KB gzipped on top of the charts module already on the page.
Fixed
- Adding a row to a live, still-open stream with
rows.apply({ add })could show another row’s values until that row’s next update. The row you add now reads back its own values immediately, in its cell, a bound KPI tile and any chart reading it. - Grouping a live stream that holds a rolling time window could bring back
rows the window had already dropped, and count the rest twice. A grouped
view of an open stream now matches the ungrouped view row for row.
rows.leavesOf()now answers correctly on a grouped stream, so a KPI tile’s drill-down into a group’s rows works on a live stream the same way it already does on a static grid. A row whoseageByvalue changes between updates now ages out from its current value rather than the one it first arrived with. - A row a live stream re-sends with new values now updates everywhere at once. Its cell, a bound KPI tile and any chart reading it all reflect the newest values immediately, instead of one of them holding the previous reading until something else refreshed it.
[1.62.0] - 2026-09-16
A grid can now draw its own scrollbars, the same size and in the same place in every browser, instead of leaning on whatever the platform gives it. A live, still-open stream stays filterable, sortable and groupable while rows keep arriving, and changing a filter no longer interrupts the connection. A KPI or stat tile bound to a grid now follows a live stream as rows arrive and age out, and reads the rows sitting inside a collapsed group rather than skipping them. Hovering a horizontal bar or waterfall chart now names the right bar, and dragging across one now selects what you dragged over. Every configuration option across the kanban, layout, AI, KPI and data router modules now ships with a runnable example backed by a test.
Added
- The grid can now draw its own scrollbars. Set
scrollbars: 'custom'and the grid paints its own bar on each axis: always visible, the same size and hit area in every browser, a 12px track with a 32px minimum thumb by default. Drag the thumb, click the track to page, or reach it from the keyboard; it is named for a screen reader and mirrors correctly in a right-to-left grid. Seven theme tokens control its size, track, thumb and radius.'auto'stays the default and'always'is unchanged; one thing to know is that turning'custom'on for one axis hides the native bar on both, because no browser lets a page show one native scrollbar and hide the other.
Changed
- Filtering and sorting keep working on a live stream while it stays open. A rolling time window and a filter or a sort can now run together on the same still-open feed, and rows keep arriving as usual.
- A filter change on a live stream no longer interrupts it. Narrowing what you are watching used to restart the connection underneath you; now it does not.
- The quick filter and grouping now also apply to a live stream while it stays open, the same as an ordinary filter or sort.
- A grid-bound KPI or stat tile now follows a live stream’s arrivals and drop-offs, and reads the rows sitting inside a collapsed group instead of leaving them out of the count.
- A horizontal bar chart’s tooltip now names the bar under the pointer.
- A waterfall chart’s tooltip now names the segment under the pointer.
- Dragging across a horizontal bar or waterfall chart now selects the bars under the drag, the same as it already does on a vertical one.
Documentation
- Every configuration option across the kanban, layout, AI, KPI and data router modules now has a runnable example backed by a test, closing out the reference for those modules.
- The AI
enableoption is now documented as gating only the panels and bars it mounts. Reading the figures, answering a question and proposing a change all keep working through your own code whateverenablesays; it only decides which of the built-in panel and bar controls are allowed to appear.
[1.61.0] - 2026-09-15
The split gantt view catches up with the joined single-pane one: the timeline can now be dragged, resized and driven from the keyboard, typing a start, finish or duration into its table moves the same bar, and a new workload band shows and lets you edit each resource’s hours across the plan. Scrolling is steadier too, with the column header and any pinned rows staying locked to the data during a fast horizontal scroll, and a trackpad or wheel gesture over the header now scrolls the grid instead of doing nothing. A grid or chart placed inside a tab draws at the right size the first time, a chart no longer paints itself twice, and a diff hover’s previous value now reads the way the cell itself does.
Added
- The split gantt view’s timeline is now editable. Drag a bar to move it, drag its right edge to resize it, or move and resize it from the keyboard. The table beside it updates in the same instant, and every edit can still be reviewed and rejected by your own approval logic before it lands, the same as on the single-pane view.
- Typing a start, finish or duration into the split view’s table now moves the bar to match. It is the same edit a drag on the timeline makes; type either date, or a new length, and the bar reflows around the change.
- The split view can now show a workload band beneath the plan. It lists each resource on its own row with the hours it is booked for in each day, week or month, matched to the timeline’s own columns, so a spike shows up directly under the day it falls on. Expand a resource to see its hours by task, and type into a cell to change them: the bar, the table and the band all move together.
Changed
- The split view’s table now shows Start, Finish and Duration by default, alongside the task name, assignee and percent complete it already showed. Passing your own set of columns is unaffected.
Fixed
- A diff hover’s previous value is now formatted the same way the cell itself is. A currency column now reads a properly formatted previous amount instead of a bare number, and a date column reads a real date rather than a raw one.
- A masked column no longer reveals its previous value in a diff hover. The cell itself already kept it hidden; the hover now matches.
- The column header no longer falls behind the data during a fast horizontal scroll. It now moves in step with it on every scroll, in either writing direction.
- Pinned rows, including a grand total, no longer fall behind the data during a fast horizontal scroll. They now move in step with it too.
- A trackpad or mouse wheel gesture made over the column header now scrolls the grid, the same as the same gesture over the data already did.
- A grid or chart placed inside a tab now draws at its correct size the first time you open that tab, instead of drawing small or empty and resizing a moment later.
- A chart no longer draws itself twice. A chart with a legend used to visibly resize just after its first paint; it now settles in one pass.
Documentation
- The developer guide’s two-pane gantt reference now covers both ways to build a joined table-and-timeline view, states exactly when a drag on the timeline writes back to your own data, and lists the switches that control editing, keyboard input and the resize handle, alongside the new workload band.
[1.60.0] - 2026-09-15
Filtering gets more trustworthy: a custom filter function on a database or API backed grid now actually filters instead of silently doing nothing, and three places that used to guess at an operator they did not fully support now refuse and say so. A column group can draw its own header, a column can control its own visibility without a wrapper group, and a hidden or collapsed band no longer leaves a blank header row behind. Context menus scroll instead of cropping and keep keyboard focus while the grid updates behind them.
Added
- A column group can now draw its own header. Give a
ColumnGroupaheaderblock the same way you would a column’s, with your own markup, styling and class, in place of the plain title it drew before. Renaming the band in place, dragging it and collapsing it all keep working. showWhencan now be set directly on a column. Tie a column’s visibility to whether the group around it is open, closed or always shown, without wrapping it in a group whose only job was to carry that setting.- The grid now reports a pointer pressing down and releasing on a cell as two separate events. Each names the row and cell involved, so you can tell a deliberate click apart from the start of a drag, or start your own gesture on the initial press.
Changed
- A custom filter function on a database or API backed grid now
actually filters. It used to silently match every row while warning
that it could not. This applies to a source built with
createPushdownSource(DuckDB, OData, GraphQL, REST or a custom adapter). It filters up to 50,000 matching rows by default; past that, the grid refuses and tells you to push the condition down to the source instead. - An operator a filter layer cannot fully evaluate is now refused instead of guessed at. This covers the data router, an AI query, and a manually built filter condition. Previously an unsupported operator could silently include rows it should have excluded, or return an empty result with no warning; it now refuses the condition and names the operator it does not support there.
- A relative date filter with a mistyped token is no longer accepted as
though it matched everything. A token such as
lst7Days(a typo forlast7Days) is refused, and any filter you already had applied stays in place rather than being replaced. If your grid depended on a bad token being silently ignored, upgrading changes that: rows the correct filter would exclude no longer show. The documented{ relative: 'lastNDays', n }form now also resolves correctly.
Fixed
- Setting a filter with a mistyped operator no longer wipes out the filter you already had applied. The previous filter is left exactly as it was, and the grid still tells you which operator was invalid.
- Sorting or filtering by a computed column on a fully-loaded database backed grid now reflects the latest computed value after a forced refresh. It used to keep sorting or matching by the value from before the refresh.
- A hidden or collapsed band no longer leaves a blank header row
behind. Whether its columns are hidden through
showWhen, throughcolumns.hide(), or because it was an untitled group used only to control visibility, the header now shrinks to fit only the bands actually showing something, and grows back when they reappear. Your existing column and group definitions do not need to change for this. (An untitled, empty spacer band that carried no title or renderer of its own is no longer draggable or renamable once it is fully hidden, since there is no header cell left for it.) - A context menu taller than the space available to it now scrolls instead of being cut off. Using the arrow keys to reach an item below the fold scrolls it into view automatically.
- An open context menu no longer loses keyboard focus when the grid redraws behind it. If live data, a totals update or a resize repaints the grid while a right-click menu is open, the arrow keys, Home, End and Escape keep controlling the menu instead of jumping back to the grid. This is an accessibility fix for anyone navigating the grid by keyboard.
- A custom filter control could go blank if the filter panel repainted itself while already mid-update. The filter itself was always applied correctly; only its own display could go empty. It now stays populated.
- Importing a module directly no longer raises a false TypeScript
error.
@toclocoinc/lattice-grid/modules/kanban,/modules/htmxand the other module subpaths now type the names they genuinely export.
Documentation
- Forty-three configuration options across the tabs, layout, KPI, kanban, gantt, AI and data router modules now have a guide entry. These were already part of the modules; they simply had no documentation entry until now.
- The developer guide now shows, with a full worked example, how to feed a live WebSocket connection into the data router yourself. This includes handling out-of-order messages and reconnecting after a dropped connection. The router has never opened a connection on its own; that boundary was previously unclear from the module’s own comments.
[1.59.0] - 2026-09-14
Tabs get richer: a live count badge, and a body that can be a board, a KPI strip or a project timeline instead of only a grid. A dashboard layout can now close a gap sideways as well as upwards, a column can keep itself sized to its content with no repeated call, and cell-level hover events and richer tooltips join the interaction surface. Several fixes tighten auto-sizing, right-to-left menus, presentation mode and drawn annotations.
Added
- A tab can now carry a live count of its own rows.
badge: trueshows the row count for that tab, updating as data changes; a number, a string or a function controls exactly what appears, and a derived tab’s badge follows its parent’s filter. An optional leading icon and a colour cue for good, warning or critical states are also available. - A tab can now be a board, a KPI strip or a project timeline, not only a grid.
Give a tab a
viewand its body derives from the parent tab exactly as a grid tab does, narrowing and following it the same way, so one strip can offer a queue as a grid, a kanban board and a schedule at once. - A dashboard layout can now close a gap sideways as well as upwards.
compact: 'horizontal'floats windows left into the space a dragged-out window leaves behind, alongside the existingcompact: 'vertical'. - A column can size itself to its content and keep doing it.
layout: { fit: 'content' }measures a column on first paint and again whenever the rows, columns or grid size change, so it stays sized correctly without a re-issuedcolumns.autoSize()call after every update. - The grid now reports the pointer entering and leaving a cell.
cell:mouseoverandcell:mouseoutfire once each as the pointer crosses a cell boundary, so a hover card, a row highlight or a linked panel can follow the pointer reliably even as rows scroll past underneath it. - A cell tooltip can now show more than a line of text.
cell.tooltipaccepts an object withrender,mountandunmount, for a tooltip the grid draws itself: a related record, a small chart, a list of validation errors or an edit history, reachable and dismissible by keyboard.
Fixed
- Sizing columns to their content right after loading rows is now reliable.
Calling
columns.autoSize()immediately aftercreateGridorrows.load()used to size to the column headings alone; it now waits for the pending frame before measuring, so an immediate call sizes to the loaded content correctly the first time. - A right-click menu in a right-to-left grid now opens on the correct side. A pointer-opened context menu used to grow rightward from the click, as it does in a left-to-right grid; it now grows leftward from the pointer, matching the column menu’s existing mirroring.
- Turning the annotation tools off now keeps them off during a presentation.
toolPanel: { annotate: false }used to be overridden the moment a presentation started; it is now a firm opt-out, honoured whether or not a presentation is running. - A drawn annotation can no longer spill onto the grid’s own chrome. Ink is now clipped to the data body, so a pen or highlighter stroke stays over the rows it marks rather than crossing the column headers, status bar or tool rail. Every annotation saved before this change keeps its stored position and simply renders clipped.
Documentation
- Several configuration hooks are now declared the same way as the rest of the API.
groupRenderer,fullWidth.when,fullWidth.renderandai.asknow match the declared style every comparable option uses, so an editor’s search finds them and TypeScript’s strict function-type checking treats them consistently.
[1.56.0] - 2026-09-12
Selection, filtering and saved views get sharper edges: a click that opens a record no longer selects it, an application-level filter can carry its own name, and one event now reports a saved view’s every change instead of a handful of them. Timestamps keep their time on the way in as well as on screen, and several quiet failure modes in row identity, computed columns and column configuration are closed.
Added
- A row click that opens a record no longer also selects it. Turn on
selection: { checkboxOnly: true }and only the checkbox column changes which rows are selected; a click anywhere else in the row still opens the record, or runs whatever else your handler does, without adding it to a bulk action. Off by default, so a plain click keeps selecting a row exactly as it always has. - An application-level filter can now register itself by name. Several can be active at once, each named, composed with the grid’s own sort and filter controls, and removing one leaves the rest untouched. Two things to know: it narrows what a paged or remote data source returns only if you also give it a matching declarative condition, and switching a named filter on or off does not raise the single change event described below, so a view-persistence feature built on that event will not notice it.
Changed
- A column of ISO timestamps keeps its time instead of losing it to a bare date.
A timestamp typed directly into the grid used to be read as a
date-only value and shown with the clock stripped out; it now keeps the
time. A genuinely date-only value still reads as a date. If your app relied
on the old behaviour, declare the column as
type: 'date'explicitly. - Importing a CSV or TSV file keeps a timestamp column’s time of day too. The same fix applies to the import dialog, so a column of timestamps brought in from a spreadsheet or export keeps its clock rather than being previewed and mapped as a bare date.
- A filter set with an operator the grid doesn’t recognise is refused, not silently accepted. It used to warn and then let every row through while the grid still looked filtered. The grid now stays exactly as filtered as it was, and the warning names the operator you gave and the operators valid for that column.
- One event now reports every change to a grid’s saved state. Sorting, filtering, moving or resizing a column, grouping, paging and applying or resetting a view all raise the same event, so a view-persistence feature can watch one thing instead of several. Selection, scroll position and expanded groups keep their own separate events, and a named filter added through the new application-level filter above does not raise this one.
- “Restore default view” now returns to the view your configuration declares, not one you seeded the grid with. If a grid is constructed with a starting view already applied, resetting used to bring that starting view back rather than clearing it. Reset now returns to the columns, sort and filters the configuration’s own settings describe; apply the starting view again yourself if you want reset to restore it.
Fixed
- A stale-data warning on one chart no longer silences the same warning on another chart reading the same column. Each chart now warns for itself when its data goes stale, rather than only the first one to draw.
- Refreshing a derived panel, or a polling data source, no longer leaves listeners and timers running behind it. Repeated refreshes used to add to a growing pile of background work that kept running even after the panel was gone, so a long-lived dashboard slowed down and used more memory the longer it stayed open. A refresh now cleans up fully after itself.
- Drilling into a treemap or sunburst now works at every level, not only the first. Clicking a nested treemap tile, or a segment on a sunburst’s second ring, used to do nothing; a click now drills to whatever you clicked, however deep it sits.
- Fitting columns to a fluid-width container no longer flickers a scrollbar.
columns.fit()in a panel whose width isn’t a whole pixel, a percentage width or a flexible layout share, could add a stray horizontal scrollbar that appeared and disappeared on every frame. It’s gone. - TypeScript types for the htmx, devtools and web component helpers now match what they actually do, so autocomplete and type-checking stop pointing you at a signature the code doesn’t have.
- A row missing the field its row key expects is now flagged, instead of being merged onto whichever other row happens to share the same blank key. The grid warns once, naming the field and how many rows were affected.
- A misspelled or unsupported column setting is now flagged, the same way a mistyped grid-level setting already was. The warning names the setting and the column so a typo doesn’t just get silently ignored.
- A column heading given more than one CSS class no longer blanks the whole grid. Multiple space-separated classes on a heading now apply as expected.
- A computed column now recalculates when the values it depends on change, and again when its rows are refreshed, instead of staying blank after an edit or holding onto a placeholder value once the real one has arrived.
- A row key made of more than one field no longer triggers a false configuration warning. A composite row key across several fields is a documented way to identify a row and is now recognised as one.
- A toolbar button given an icon name the grid doesn’t recognise now says so, naming the icon it couldn’t find, instead of showing a blank, clickable tile.
- Presentation mode keeps the presenter’s drawing tools within reach. Starting a presentation now keeps the rail on screen for the pen, arrow, rectangle and highlighter, with the grid’s everyday rail controls tucked away for the length of the presentation and back exactly where they were on the way out. A grid with no drawing tools on its rail still hides it on entry, as before.
Documentation
- How to trigger a manual refresh on a panel derived from another grid is now documented:
call
rows.load()on the derived grid, with no arguments, and it re-reads its source and replaces its rows. - Reference corrections for composite row keys, headings with more than one CSS class, and how a saved view interacts with “restore default” now match what the grid actually does.
[1.55.0] - 2026-09-12
A grid that holds still and panels that keep up: fitted columns that add up exactly, a scrollbar that stops twitching, a percentage column that keeps its share of a narrowing window, and KPI tiles and derived views that follow every filter and edit on their own.
Fixed
- A KPI panel beside a grid follows it. Filter, sort or edit the grid and the tiles beside it update on their own, with no refresh call from your page. One change to the grid is one re-read, however many events announce it. To show a snapshot instead, hand the panel the rows rather than the grid.
- A derived grid follows an edit. A tab or panel derived from a grid now
updates after an inline edit, a paste, a fill, an undo, a redo or an edit made
from code, once per batch rather than once per cell, and a chain of
derivations updates all the way down. For a derived child of a large editable
grid,
refresh: 'idle'keeps the editor snappy. - A routed board or tile panel keeps its rows across a refresh. A kanban board or KPI panel fed by the Data Router now regroups, or recomputes every tile, from the rows it holds when refreshed, instead of falling back to the rows it started with.
- Fitted columns fill the grid exactly.
columns.fit()measures the space the cells actually have, counts every column the grid draws, including checkboxes, expanders and fixed-width columns, and rounds the widths so they meet the edge: no stray horizontal scrollbar and no clipped last column. When there is too little room, each column stops at its minimum and the grid scrolls rather than squeezing one to nothing.fit()sizes once; give a columnflexif it should keep following the width by itself. - A percentage-width column keeps its share when the window narrows. A column declared as a share of the grid now follows the grid’s width in the body as well as the header, so a responsive panel or a dashboard window shows its columns side by side rather than scrolling sideways, and grows back when the container does.
- A grid at rest holds still. A scrollbar no longer flickers on and off at compact density or in a container of fractional width, and anything that measures the grid, a chart aligned to it or a grid synchronised with it, holds still too.
- Tab into a grid and you can see where you are. A grid draws a focus ring around its own edge the moment keyboard focus lands on it, in the theme’s focus colour, and in the system colour under Windows High Contrast, before any arrow key is pressed.
- Keyboard focus stays in the grid. Leaving a maximised grid with Escape or the restore button, and Delete on a cell, now return focus to the cell you were on, or to the grid if that cell has scrolled away, never to the page.
- A column heading answers a key once. Enter on a heading sorts the column and nothing more, and ArrowRight on a heading moves to the next heading rather than dropping into the data.
- Right-click anywhere on a row opens the grid’s menu. The empty surface to the right of the last column now opens the grid’s context menu for that row, with the row and grid items and without the cell items that would have nothing to act on.
- A column menu written as a list keeps the built-in items. A column’s
contextMenugiven as an array now adds to the built-in and grid-level items, the same as the function form. To replace a column’s menu outright, use the function form and ignore the defaults. - Left means left in a right-to-left grid.
align: 'left'and'right'stay on those edges whichever way the text runs, in the cell, its editor and the header, whilestart,centerandendfollow the writing direction.direction: 'rtl'is recognised without a warning. - Every announcement speaks the reader’s language. A refused edit, the
presence roster, a comment’s history note, a facet filter’s name, a heading’s
total tooltip, the audit view’s “was” note, a kanban list’s card count and a
gantt lag label now come from the message catalogue, so a screen-reader user
in any locale hears their own language. A kanban board or gantt view takes a
messagesoption, and borrows its grid’s catalogue when composed with one. - A gantt anchored on a
Datestarts on the right day everywhere. A project start or today line given asnew Date(y, m, d)is read as the calendar date it shows, so a plan opens on the same day in Paris, Tokyo and New York. If you passed a UTC midnight to work around this, pass the local date or an ISO string instead. - A kanban board keeps your styling on its mount element. Mounting a board adds its own class beside yours rather than replacing them, and removing the board removes only its own.
- A trend forecast stays inside its chart. The fitted line, the forecast and its band are clipped at the chart’s edge, so a forecast longer than the chart no longer paints over the panel beside it.
- A treemap nests as deep as the data goes. Over a grid grouped two or more levels deep, the treemap draws children inside their parent’s tile, each branch named in a header band, to the depth you allow. A tile too small to read is folded into its parent, and the tooltip and click report the deepest tile under the pointer.
- A sunburst names every ring. Labels are drawn on every ring where the name fits, and left off where one would cross a neighbour.
- A chart given a list of columns for
xsays so. A sunburst or treemap with more than one column onxnow warns once thatxtakes one column, and says to group the grid to nest the chart. - A dashboard’s handles stay visible on a dark window. Under
data-theme="dark"the resize handle, and the drag grip of a window without a title bar, take an ink that meets the contrast a control needs on a dark body. - A clean console. A default grid no longer logs four warnings of its own about events it never raises, so the warning that catches a misspelt event name in your code is one worth reading again.
Documentation
- Escape gets you out of a maximised dashboard window. The layout reference now says what the grid does: Escape restores a maximised window from anywhere inside it, a focused grid cell or column heading included, unless an open editor or menu has already used the key, and focus then lands on the window’s maximise control.
- The install snippet always installs the current release. Every install
line in the reference now pins the major line,
@toclocoinc/lattice-grid@1, which resolves to the latest release, and every version label in the reference is stamped from the package itself so none can fall behind.
[1.54.0] - 2026-09-11
Blow one dashboard window up to work in it, fold the others out of the way, and see at a glance where every window begins.
Added
- Fill the dashboard with one window, or fold a window down to its title bar. A button in the title bar gives one window the whole dashboard to work in, and another folds a window down to a single row so the windows beneath it can close up the space. Restore puts everything back exactly where it was, in whatever order you restore them. Both work from the keyboard, Escape takes a filled window back, and they work on a locked dashboard too, so someone can read one window at full size without being able to rearrange anything. Turn them on for the whole dashboard or window by window. A saved arrangement never captures either, so a dashboard always reopens the way it was laid out.
- A KPI tile can count by any column of the grid it reads. A tile such as “P1 jobs” that counts the rows matching a condition now counts them correctly, whichever column the condition tests. Name those columns once on the panel. Forget one and the tile still counts right, and the console names the tile and the column to add. A tile pointed at a column the grid does not have shows no reading at all rather than a confident zero.
Changed
- You can see where each dashboard window begins. Title bars are now a strong navy with white titles, so every window stands clear of the one beside it and the bar you drag it by is obvious. On a dark page they lift to a brighter blue so they still stand out. The colours are theme settings, so a dashboard already styled to match its app keeps its look, and a single rule sets them to your own brand.
Fixed
- A live chart keeps its picture when one device’s clock runs fast. A rolling time chart fed by several devices no longer collapses to a single point because one of them stamps its readings ahead of your clock. Readings from a device too far ahead are left off the chart, everyone else’s stay on it, and the console names the column and how far ahead they were, so the faulty clock can be found.
- Escape gets you out again. With a cell or a column heading focused, Escape leaves a maximised grid, a presentation and the annotation tool, and reaches your own dialogs and drawers that hold a grid. Keys the grid does not use now reach your page too, so your own shortcuts keep working while someone is in the grid, and Enter on a cell that cannot be edited is yours to bind, for example to open the record.
[1.53.0] - 2026-09-10
A dashboard your users arrange themselves, locked or unlocked at the press of a button, with every panel beside it telling the truth.
Added
- A dashboard the person using it can arrange. Grids, charts, boards and tiles sit in windows that move and resize on a grid, by mouse or by keyboard, and the arrangement they leave behind saves and restores as ordinary data. Put a wall display back exactly as it was, or ship one arrangement to everyone.
- An “Edit layout” button you can actually build. A dashboard unlocks for rearranging and locks again while it stays on screen, so a live chart keeps drawing and a half-typed cell keeps its text right through the switch. A panel the dashboard says must stay put stays put when everything else is unlocked.
- Decide once what a whole dashboard allows. Whether windows can be moved, resized or closed is stated for the dashboard rather than for each window, so a twelve-panel screen is one decision instead of twelve.
Fixed
- A figure beside a sorted grid recovers when you clear a filter. A derived panel or a KPI tile reading from a sorted grid could go on reporting the narrowed set after the filter had been cleared, and never correct itself. It now follows the grid back.
- A chart stays inside its own frame. A series no longer draws past the edge of its plot and over whatever sits next to it on the page.
Changed
- A KPI tree can be read at a glance rather than added up. Every reading at the foot of the tree draws a small bar showing where it sits against the bands it was given, and the headings that open say so, to a screen reader as well as on screen.
- A KPI panel dresses to match the grid it reads. Bound to a grid, the panel takes that grid’s type and row rhythm, so tiles beside a compact grid come out compact instead of towering over it.
[1.52.0] - 2026-09-10
Timestamps keep their time of day, a plan draws itself on a real calendar, and a chart with a hole in the data shows the hole.
Breaking
- A column of dates that carry a time of day now keeps the time. If you hand
the grid JavaScript
Datevalues and do not declare that column’s type, the grid used to read them as calendar days and drop the clock, so every reading inside a day became the same stored value: sorting inside the day did nothing, and a chart of one day’s readings had a single point. Those columns now keep the time, so they sort chronologically, display it, and export it. Date filters return exactly the same rows either way. The one thing to check before you upgrade is grouping: a group on such a column used to make one bucket per day and now makes one per row. Declaring the column as a date keeps day buckets and restores the previous behaviour exactly.
Added
- A plan fits the space you give it, and keeps fitting. A project timeline now sizes itself to its container and follows it as the window or the panel changes, so a plan in a resizable pane stays whole instead of being cut off.
- Say which calendar date your plan’s day zero is. A plan written as day offsets can name the real date it starts on, so it draws on this year’s calendar rather than in 1970.
- Dependencies in the shorthand you already type. A task link accepts the
Microsoft Project string form, so
FS+2andSS-1can be written as they are said. - A grid that is the statistics of another grid. A derived grid can hold the relational figures, the ones that need two columns or a second grid, as ordinary rows you can sort, filter, chart and export.
- A KPI rail that answers “is anything wrong under here”. Top-level items expand to the indicators beneath them, and a parent reports the worst status below it, so a wall display says where to look before it says what the number is.
Changed
- A category that reported nothing draws as a gap, not as a zero. A feed with a quiet period leaves a hole in the line where there was no reading, instead of drawing a floor at zero that reads as a real measurement.
- A measure with no stated aggregation follows its column. A chart bound to a column that declares its own total, or to a rate or ratio type, is now reduced the way that column says rather than always summed.
- A plan given both a zoom level and a width no longer discards one in silence. The zoom still decides the geometry, and now says so.
- A timeline’s today marker accepts a calendar date. A date used to be discarded without a word and no today line was drawn.
- A derivation says out loud which settings it ignores. A sort, a limit or a filter written onto a profile or a statistics derivation was silently passed over; it now warns rather than leaving you to find out.
Fixed
- A live chart whose data is older than its window no longer draws itself outside its own plot. A rolling window over readings that have all scrolled past used to paint the series across the axis and off the page, in silence.
- Dependency links take a sensible route between tasks. Every link used to be drawn the same way whatever the geometry, so an arrow left the right of the finished task and came back across to the left of the next one.
- The tab strip matches the density of the grid below it. A grid given its own density left the strip at its compact size above it.
- A time axis shorter than two hours labels its ticks distinctly. A one-minute rolling window drew four ticks fifteen seconds apart and labelled all four the same.
- A single reading draws. A live series that has just started, or one collapsed into a single bucket, used to leave an empty plot under fully drawn axes.
- A chart of a single instant labels its axis in minutes, not years. One reading at half past five produced an axis spanning half a decade.
- Sub-day timestamps bound to a calendar-day column now say so rather than collapsing into one point without a word.
[1.51.0] - 2026-09-09
A grid over a large Parquet file reads a fraction of the bytes it used to, and a column can carry its own right-click menu.
Added
- Stack several grids into one. A derived grid’s source can be a list of grids rather than a single one, each with its own label and its own mapping, so several row sets read as one and stay live as any of them changes.
- A right-click menu declared on the column. A context menu can be set on a column definition, including as a plain list of items for the common case, so the menu over a cell can differ by the column it sits in.
Changed
- A grid over a remote Parquet file reads far fewer bytes. Measured in Chrome against a ten million row Parquet file of 162.4 MB, served from a range-capable origin counting the bytes it actually sent: first paint used to pull 162.5 MB, the whole file, to show a hundred rows, and now pulls 5.71 MB. A selective filter on two columns used to pull 191.1 MB and now pulls 45.2 MB, or 3.2 MB with the total turned off. Nothing you call changes, and a grid that showed a total shows the same total.
- The one case where this costs more rather than less: a session that ends up reading the whole table anyway. Every individual query is cheaper than or equal to what it replaced, but a whole session need not be. Reading the file eagerly pulled all of it on first paint and was warm from then on; reading it lazily means range reads over a large Parquet overlap where one eager read did not, so bytes already paid for can be paid for again. Over one session doing a first paint, a selective filter, a full-table sort, a deep page and two more filters, the old path transferred 162.5 MB in total and the new one transferred 209.8 MB. A user who never sorts the whole table pays 46.7 MB rather than 162.5 MB, and gets a first paint in 566 ms rather than 5808 ms; a user who does sort the whole table has to read the whole file either way and now pays part of it twice. A full sort on an unindexed column is unchanged at 162.5 MB before and after.
- Turn the total off for a grid nobody jumps in. A DuckDB or GraphQL source can be told not to count at all, so the engine is never asked for a number nobody reads. It is a trade rather than a free win: with no total the scrollbar is open-ended, a “showing X of Y” readout has no Y, and you cannot jump past the end the grid has discovered so far. Turn it off for a grid whose users scroll, leave it on for one whose users jump.
- A source that reports no total no longer has one invented for it. An OData or GraphQL endpoint that returns no count used to have the length of the current page reported as the total, which is right only for a single-page result and wrong everywhere else. It now reports no total, which is the truth.
- A KPI tile that measured nothing reports unknown instead of grading as healthy. A count over no rows is zero, and zero against a lower-is-better threshold graded as good, so an empty tile looked like the best result on the wall. It now says it does not know.
- The tab strip takes its type and rhythm from the grid, not from the host page. It used to render at the page’s font size above a grid rendering at its own.
Fixed
- Paging a GraphQL source that exposes no count no longer stops after the first page. A read of the whole result returned a partial one as though it were complete.
- A page past the end of a DuckDB result no longer reports the whole set as empty. A grid scrolled or paged past the last row read as though it had matched nothing.
- A menu opened from the keyboard knows which cell it opened on. The keyboard route handed the menu builder only the row and the column id, so the column, the key, the index and the value were all missing.
[1.50.0] - 2026-09-09
Tabs across the top of a grid, where each tab is a full grid of its own.
Added
- Tabbed grids. Tabs above a grid, switched by click or by keyboard, where each tab is its own fully configured grid instance rather than a filtered view of one, and a tab can be derived from another.
- Save and restore a timeline’s arrangement. A project timeline gains the same state snapshot the board and the KPI panel already carry, so how a user has arranged a plan can be saved, restored and shared.
- A sparkline that keeps moving on a row that does not change. A row-history column can be bounded by time rather than by number of changes, so a static row’s sparkline advances with the clock instead of freezing while its label still says “last five minutes”.
Fixed
- A date-range filter is answered where the data lives. DuckDB and OData sources now push a range condition down to the engine as two typed, bound comparisons, so the range typed into the grid’s own date filter is applied at the source rather than after the fact.
Changed
- An OData date and time filter must name an unambiguous instant. A date-time with no zone on it used to be read as local time and written into the query as whatever instant that happened to resolve to. It is now refused rather than guessed at.
[1.49.0] - 2026-09-09
A live chart keeps scrolling when the feed goes quiet.
Added
- A live feed can be bounded by time rather than by row count. Bounding a stream by number of rows drifts: a burst quietly shrinks “the last five minutes” to two, a quiet spell stretches it to twenty, and when nothing arrives at all the chart freezes with the last reading pinned to the right edge. A feed can now be given a maximum age and a chart a time span, so the window means what it says and keeps scrolling through a silence.
[1.48.0] - 2026-09-08
Find in the grid with Ctrl+F, and date filters that reach DuckDB intact.
Added
- Find in the grid. Ctrl+F, or Cmd+F on a Mac, opens a find bar; typing highlights every cell whose displayed text matches, in place. No row is reordered, removed or edited. The bar counts the matches, and Enter, Shift and the arrows step through them.
Fixed
- A filter on a DuckDB timestamp, date or time column works. It used to fail outright with a type-comparison error from the engine.
- The “nothing to chart” message no longer shows beneath a drawn chart, and no empty frame is painted behind it when there really is nothing to draw.
- A chart under a title or a legend is drawn at full scale. Titled and legended charts were shrunk to fit, so rings came out undersized and axis lines fell below a whole pixel.
- Scrolling to a row lands it where you can see it, clear of pinned rows, sticky group headings and a bottom total.
- Keys typed in the find bar or the ask bar reach the bar. They behave as they do in any form control, and your page’s own shortcuts still see them.
[1.47.0] - 2026-09-08
A plain-language risk summary for a board or a plan, and an uncertainty band on a forecast.
Added
- A risk summary for a board or a plan, in plain language, built from figures the grid itself computed.
- An uncertainty band around a forecast. A chart forecast can shade the range around the projected line, choosing the band for a future observation or the narrower one for the mean, at a confidence level you set.
- A legend on the parallel-coordinates chart.
- A stable identifier for a column band you create, chosen by you rather than generated.
- The board’s service-level thresholds are in the published types, global and per column, so your editor completes them.
Fixed
- A board write that is reverted leaves no entry in the flow log, and a card write that is confirmed now announces itself.
Documentation
- Router persistence and backpressure are documented in the reference, including how a route persists and restores, and the README covers every package entry point.
[1.46.1] - 2026-09-07
Fixed
- Auto-sizing a column measures its heading too, so a column with a long title over short values no longer collapses and clips its own name.
[1.46.0] - 2026-09-07
Vertical alignment in a cell, and scrollbars that stay where you can see them.
Added
- Vertical alignment in a cell. Top, middle or bottom, as a grid default with a per-column override. It is unset by default, so a grid that does not ask for it renders exactly as before.
- Scrollbars that stay visible. Keep the horizontal and vertical scrollbars on screen, on either axis or both, rather than letting them fade out.
Changed
- A grid inside a scrollable page feels anchored. The body no longer rubber-bands past its own bounds.
- The sort badge sits at the trailing edge of a sorted column when header controls are hidden, and shifts inboard on hover.
[1.45.0] - 2026-09-07
Header controls on your terms, and a column drag that shows where it will land.
Added
- Decide when header controls appear. On hover, always, or never, as a grid default with a per-column override.
- A column drag that shows where it will land. An insertion line for a reorder, a highlight on the target for a grouping, and a grab affordance on the band, so dragging a column into a group is legible rather than guessed at.
- The column-group toggle shows the right glyph, a plus when collapsed and a minus when open.
[1.44.2] - 2026-09-07
Fixed
- A pivot shows aggregated rows and nothing else. A terminal pivot group row no longer expands into blank children that have no values in the generated columns, and the scroll area is sized to what is actually there. Ordinary grouping is unchanged.
[1.44.1] - 2026-09-06
Fixed
- A plain-language reading no longer mangles the identifiers it quotes, and never shows a placeholder where a figure it could not verify would have gone; it drops the clause instead. The counts and ranges it cites are grounded in what the grid computed.
[1.44.0] - 2026-09-06
Ask for a change in plain language, see exactly what it would do, and approve it before anything is written.
Added
- Propose an edit in plain language. “Set the Network Upgrade project to In Progress”, or a card move on a board, becomes a validated set of proposed edits over the rows you are currently looking at. An unknown column, a column that is not editable, an unknown label, a wrong type and an out-of-range value are all rejected before anything is shown; a label is resolved to the value stored behind it; and where more than one row matches a name, the candidates are surfaced rather than one being silently picked.
- Nothing is written until a person approves it. An opt-in bar previews the before and after of a proposal and the scope it covers, and applying it goes through the grid’s own edit gate, so the rules you already wrote still run and can still refuse it. Every change is reversible.
- A change made this way is marked as one, so your rules can treat it differently from a person typing in a cell.
[1.43.0] - 2026-09-06
Ask a question in plain language and the grid’s own engine answers it.
Added
- Ask your data a question. A plain-language question becomes a validated, read-only query that the grid’s own engine runs, and the answer fans out across any mix of views. The model returns a query, never data; a query that would change anything is rejected and never runs; and the query it resolved to is shown for you to confirm or edit before it applies. Bring your own model and your own key: only the shape of your data leaves, never a row value.
[1.42.0] - 2026-09-06
The grid explains its own figures in plain language, and shows nothing it cannot verify.
Added
- A plain-language reading of what is on screen. A narrative and an insights panel built from the grid’s own computed figures: its schema, its profile, its statistics, its forecast and the counts in the current view. Every number is checked back against the value the engine produced for that render, and a figure that cannot be grounded is removed before you see it. Bring your own model and your own key: the grid ships no model, holds no key and makes no call of its own. What reaches the model is bounded by you, and a model that errors leaves the grid fully usable.
[1.40.0] - 2026-09-06
A stream that survives a reload, a board that flags a card left too long, and forecasting from the grid.
Added
- A routed stream survives a reload. A route can keep its buffered events durably and resume from where it left off, so a view that goes away and comes back does not lose the stream.
- A fast producer cannot swamp a slow view. Each route can throttle to a rate, coalesce a burst into the latest value, or take a fraction as a sample, set per route.
- A board flags a card that has sat too long. Warn and breach thresholds, set globally, per column or per lane, measuring either time in the current column or age since arrival. A card past a threshold carries a subtle indicator and an age chip, and the breach is in what a screen reader announces. Crossing a threshold calls a handler with the same signature a router alert uses, so board breaches feed the alerting you already have with one handler. An optional tick re-checks on an interval, so a card that breaches by simply sitting still still lights up.
- Forecasting from the grid. Moving average, exponential smoothing and linear regression, each with prediction and confidence bands, reachable from a live grid over the rows the filters currently show.
- Read an Excel file in. The importer reads
.xlsx, completing the round trip the export began: preview a file, or bring it straight in as coerced records, and a dropped file routes to the Excel reader on its own.
[1.39.0] - 2026-09-06
Data bars and icon sets computed in the grid, and rules that refuse a bad edit before it lands.
Added
- Conditional formatting the way a spreadsheet does it. Data bars and icon sets from a built-in catalogue, computed in the grid itself rather than painted on top, so what you see is what exports.
- Validation rules on a column. Per-column rules evaluated as an edit is committed: an invalid write is refused and surfaced in place, with events to listen for and translated messages.
- Join and enrich rows as they flow. Rows can be joined across sources on the way through the router, so a view downstream receives one enriched stream rather than several partial ones.
- Earned value on a plan. Planned value, earned value and actual cost at a status date, with the schedule and cost performance indices, off the same plan you already drew.
- A column’s profile from its own menu, now including its most common values.
Fixed
- A trend line and an annotation can share a chart. They were drawn onto one layer and erased each other.
[1.38.0] - 2026-09-06
Resources on a plan, rows in from a file, and flow metrics off a board.
Added
- Resources on a plan. Assign resources to tasks, see where they are over-allocated against capacity, and level the plan to resolve it, with the overload highlighted on the timeline and a load report beside it.
- Round-trip a plan with Microsoft Project. Import and export the MSPDI XML form, carrying tasks, dependencies, calendars and resource assignments.
- Bring rows in from a file or the clipboard. The mirror of export: a built-in importer with a preview, column mapping and translated strings.
- Delete a row by gesture. An opt-in Delete key and menu item, routed through the cancellable delete gate, so you can confirm or block it.
- Flow metrics off a board. Cycle time, lead time, throughput, work in progress and a cumulative-flow diagram, computed from the board’s own history.
- Trend lines and forecasts on a chart. Linear, moving-average and exponential fits on line, bar and scatter charts, with an optional forecast horizon and an R² label.
- Bands and event markers on a chart, positioned against the x axis, a category or a number, rather than only against a measure range.
- Anomaly detection over a live stream, windowed and scored as it arrives, with a helper that turns a detector into a router alert condition.
[1.37.0] - 2026-09-06
Accessibility work across popups, control names and the tool panel.
Changed
- Popup triggers announce whether they are open. The filter, the menu and the submenu triggers carry their expanded state, and the filter dialog declares its modality.
- Built-in control names and the tool panel are translated, and a custom renderer gets a typed hook so it can translate its own strings the same way.
[1.36.0] - 2026-09-06
Say no to a change before it happens, and load a file straight from a URL.
Added
- Refuse a change before it happens. A cancellable gate sits in front of every change a user makes: an edit, a sort, a filter, a column move, resize or hide, a selection, an added row, a delete, a row move and a grouping, plus the board’s moves and the timeline’s task edits. A handler refuses with a reason, and it may be asynchronous, so the change waits on an answer from your server; a throw counts as a refusal. Live data arriving from a feed never reaches a gate. Guarded editing and confirm-before-delete become one line each.
- Load a file straight from a URL. Point a grid at a JSON file and it reads it whole, or at an NDJSON file and it streams in, so the first rows render while the rest is still on the wire. The format is worked out from the URL, the content type or the bytes themselves; an error surfaces as an event rather than an uncaught throw; and an optional poll re-reads on an interval.
- Hover a bar to see what depends on it, and edit a task’s fields inline in the split panel, routed through the new gate.
Changed
- Two views attaching to the same slice of a stream now say so. The second one used to be left silently empty.
[1.35.0] - 2026-09-05
KPI tiles as a view of their own, and the board and the timeline complete.
Added
- KPI tiles as a view of their own. A fourth routed view alongside the grid, the board and the timeline: value, delta, sparkline and target, computed by incremental aggregation over a row source and updated live from the same feed, so tiles keep step with the data rather than being recomputed from scratch. It loads from a plain script tag with no bundler.
- The Data Router’s full method surface is documented, time travel and replay, cross-tab sync, pushdown query, write-back, fan-in and observability, with runnable examples, so what the router can do is discoverable from the reference.
Changed
- The board, complete. A custom card renderer, card covers and per-card progress, a sprint and backlog shape, lane reordering and enforced work-in-progress limits. A card is resolved by id or by field on write-back, and a guard stops a dropped card being applied twice. Large boards are windowed so they keep scrolling smoothly under sustained updates.
- The timeline, complete. A row-aligned split view keeps a grid and the timeline scrolled together line for line. Scheduling gains a working-time calendar with non-working days, baseline bars for planned against actual, task constraints, and a project start that accepts a date.
[1.34.1] - 2026-09-05
Fixed
- Weekend shading on a plan, and the grid’s date filter, work in the shipped files again. A defect in the build step dropped part of an import from the bundles it emitted. The source was always correct and nothing you call changed, so the upgrade is a drop-in.
[1.34.0] - 2026-09-05
A project timeline and a kanban board, each a view of the same data as the grid.
Added
- A project timeline. A dependency-free planning view on a critical-path engine: bars, dependency arrows, the critical path, milestones and summary tasks, drawn from a task and dependency model. Drag to edit with write-back and validation, zoom the time scale, scroll to today, flag what is overdue or at risk, group into swimlanes, export the tasks or the chart, and drive all of it from the keyboard with live announcements. It syncs line for line with a grid beside it and updates live from a feed. The scheduling engine is reachable on its own, with early and late start and finish, total float, the critical flag, and all four link types with lag and lead.
- A kanban board over any row source. Rows group into columns by a property, with work-in-progress limits per column, drag and drop with write-back and a veto hook, swimlanes that collapse, column reordering, search, a sprint and backlog view with epic rollup, a card that pops out into a nested grid, and inline card editing through the grid’s own field editors. Large boards are virtualised, the keyboard crosses lanes, and the board’s arrangement can be saved and restored.
- Both new views load from a plain script tag, with no bundler and no import.
- An edit made in a routed view travels back to the source, so a change made in one view is persisted and reflected across the rest rather than staying where it was made.
- Many feeds into one router. Several upstream streams merge into the single ordered, de-duplicated path the routes already read, the inverse of the fan-out it already did.
- See what the router is doing. Live counters, streamed as they change, and a devtools panel that renders them in place.
[1.33.0] - 2026-09-05
Anything can ride a routed stream, not just a grid, and eighteen new chart types.
Added
- Route to anything, not just a grid. A tile, a detail pane, a map or a form receives the same keyed diff a grid receives, with the same reshaping options, so a routed view need not be a grid at all.
- A route that watches instead of rendering. Give it a condition over its slice and it fires once when the condition is crossed and re-arms when it releases, debounced, watching the live stream independently of anything you are scrubbing through. It owns no interface of its own.
- The whole routing graph as one spec. Routes, subscribers, alerts, the default, cross-grid links, the relationship graph and the time-travel buffer, expressed as data, which composes with the calls you would otherwise make and behaves identically either way.
- The same feed in a popped-out window. Peer tabs and windows mirror the router’s ordered deltas over a broadcast channel, so a popped-out grid joins the feed with no second connection. A tab joining midway announces itself and a peer answers with a snapshot, so it catches up. Off by default.
- Source the router from a pushdown query. The result is partitioned across the routes, and where a route declares a filter of its own it is pushed down to the engine as far as the source allows, with the rest finished locally. It reports which half went where.
- The stand-in socket loads from a plain script tag, so a realtime demo can be built with no bundler at all.
- Eighteen new chart types: ridgeline, calendar, SPLOM, hexbin, icicle, waffle, alluvial, arc, bubble map, hexbin map, slope, dumbbell, bump, diverging, parallel-coordinates, ROC/PR/calibration, fan/forecast and decomposition. Each is its own opt-in module carrying only its own geometry, so you pay for the ones you draw and the seam does not grow as types are added.
[1.32.0] - 2026-09-05
The router becomes a small streaming layer, gains time travel, and ships with a stand-in socket so you can build a realtime interface with no backend.
Added
- Reshape a stream per view. A route can map, rename or derive on each row before its grid sees it, filter down to the rows it admits, and sort what it receives. A rollup route emits one aggregated summary row per group instead of the raw rows.
- Related rows across grids. A declarative relationship graph links grids across the router, multi-hop, multi-source and mutual, so a selection or a key in one grid resolves the related rows in the others.
- A live feed you can safely drink from. Changes apply in order and are de-duplicated, rapid updates to the same key are coalesced into a single applied change, and a dropped connection resumes from the last change applied, with a snapshot and replay rather than a full reload.
- Move a live view through time. The router can buffer its own stream, opt-in and bounded, and reconstruct the world at any point, pushing it to the grids by the same keyed diff the live path uses, so scroll and selection survive and only changed rows repaint. Replay a range change by change at a speed you set, pause and resume, and return to live. Live data keeps extending the head while you are scrubbed back; the grids simply do not repaint until you come back.
- A stand-in for a live socket. An opt-in, dependency-free stand-in with the same surface as a real WebSocket, so the code reading it does not change when you swap the real one in. It opens after a short delay, sends a snapshot, then pumps changes, and it ships with ready-made feeds and a seeded generator, so a realtime, routed interface can be built and tested with no backend at all.
Fixed
- A derived panel repaints when its source changes. The rows underneath were re-derived correctly when the parent was filtered, sorted or fed new data, but the panel on screen was never repainted, so a connected summary showed stale values over a model that was right. Ordinary grids were never stale.
- The column filter popup opens over the grid’s own rows, not over whatever sits above it. On a short grid low in the viewport under taller content, it could flip above the header and land on top of that content.
Changed
- Row separators, striping, hover and selection span the full width when the columns do not fill it, instead of stopping at the last column. The area past the last column is inert filler: never a data cell, and never counted, sorted or exported.
[1.31.0] - 2026-09-04
One arriving stream, split across many grids by what each record is.
Added
- The Data Router. An optional, zero-dependency module that takes one arriving stream or dataset and splits it across many grids by what each record is. A snapshot fans out as a keyed diff per grid, so unchanged rows never repaint, and changes apply in place by key. A row whose partition changed moves between grids rather than duplicating, and a record that matches nothing is counted and kept rather than dropped. A selection in one grid can filter what another receives, by a key map or by a predicate you supply, a multi-select as a set, debounced.
- Text annotations. A label anchored to a point in the content, with its own colour, size and background. It seeds from saved state, is added through the annotation API, and round-trips through a saved view, tracking scroll and resize like every other mark.
Changed
- An unknown aggregation on a chart says so. A name that matches no known aggregation used to be quietly treated as a sum, so a typo produced a plausible, wrong chart. It now warns once, naming what it saw and what it supports, and still draws.
Fixed
- Annotation changes are a declared event, so a subscriber can listen for them by name without a wildcard, and the React, Vue, Svelte and Angular adapters expose them.
[1.30.0] - 2026-09-04
Annotations you can save and re-load, and four more time-series kernels.
Added
- Annotations that persist. Marks can be pre-loaded and saved, not only drawn live: they seed on first paint, are added through the API without synthesising pointer input, and round-trip through a saved view. They are stored against the content, so they track scroll and resize rather than hanging over the viewport, and a saved mark survives a presentation ending.
- Time-series analytics. Seasonal decomposition into trend, seasonal and residual columns over a period you declare, with a coverage stamp for the partial edges, additive or multiplicative. Exponential smoothing, single or with a trend, the factor either set by you or fitted and reported. A stationarity test with its statistic, the lag it used, critical values and a plain-language verdict. And autocorrelation beyond lag one with its approximate band, ready for a correlogram.
[1.29.0] - 2026-09-04
A full regression toolkit, rolling time-series columns and an Angular adapter.
Added
- A regression toolkit. Fit a multi-predictor linear model, ordinary, weighted or robust, and get its coefficients, standard errors, t and p, R² and adjusted R², per-row fitted values, residuals, leverage and Cook’s distance, the variance inflation factor per predictor, a heteroscedasticity test and a pointwise confidence band. A diagnostics panel shows the fit, and model-backed columns put the per-row diagnostics into the data itself, sortable, filterable and exportable, following the grid’s filters. The charts module turns a fitted model into ready diagnostic charts: fit with band, residuals against fitted, QQ, scale-location, residuals against leverage, a multicollinearity correlogram and a coefficient forest plot.
- Rolling time-series columns. Rolling sum, average, minimum, maximum and quantile over an order you state, plus window coverage, cumulative to date and period over period, in one cached ordered pass. Count, time and session windows, per group or across the whole set.
- An Angular adapter, a standalone component and a directive from one shared controller, at parity with React, Vue and Svelte. Nothing is bundled and construction is guarded for server-side rendering.
- A timestamp column type, with time-zone-aware parts, bucketing and Excel serialisation.
- Drag a column group. Banded column groups can be formed, renamed, dissolved and reordered by keyboard chord and by pointer drag, each step announced for assistive technology.
Changed
- A header renderer accepts a component, matching the contract the cell renderer already offered.
[1.28.0] - 2026-09-04
The annotation tools are built into the tool panel, ready to use.
Added
- The annotation tools are built in. Pen, arrow, rectangle and highlighter are toggle buttons on the tool panel’s action rail rather than something you wire up. Turn them on, or let them appear for the duration of a presentation. The active tool shows pressed, and pressing it again exits it. Off by default. English labels ship now and the other locales fall back to English.
Changed
- The web component reaches the headless and data-source factories. The headless grid, the DuckDB adapter and the pushdown source are re-exported from the web component bundle, matching the htmx entry, so a page that adds the element reaches them without pulling a second copy of the engine.
- Serving is free and watermarked, and the licence now says so. Serving from any non-loopback host is permitted without a key and renders the full grid with a trial watermark; a key removes the watermark, and that is the whole of what a key does. Loopback hosts remain entirely free and unwatermarked.
Fixed
- The chart events reference documents the events that exist. It described an event and a nested payload that were never emitted; it now documents the real events, their flat payload and the click-to-filter option.
[1.27.0] - 2026-09-04
One grid with the same full feature set, whichever way you reach it: from your framework, from a GraphQL endpoint, or straight on the page.
Added
- The same grid in every framework. Use Lattice Grid from React, Vue, Svelte, htmx or the web component, each with the same way to reach the live grid and the same complete feature set behind it, charts and currency included. Nothing is held back for one framework and missing in another.
- Read from a GraphQL endpoint. Point the grid at a GraphQL API and it lists, filters, sorts and pages against it, so a GraphQL back end joins the remote sources the grid already reads from.
- Rows that do not belong, flagged for you. The grid can pick out the rows that sit apart from the rest and mark them, so an outlier surfaces on screen instead of hiding in the middle of the data.
- Tell whether a difference is real. Compare two groups on any column and get the answer as data you can read: the named test, its confidence interval and its effect size together, so you always know both how big the difference is and how sure the result is. No verdict, no badge, just the figures.
- Fill down and fill a series. Edit many cells at once: copy a value down a column, or extend a series so the next cells continue the pattern, so filling a block is one action rather than a cell at a time.
- Add and remove rows on a remote source. A new row or a deleted row now travels back to the source and is confirmed, alongside the cell edits that already did, so a grid over a remote source is fully editable.
- Analytics on screen without wiring. A KPI strip, an insights panel and an anomaly summary come built in, so the headline numbers, what stands out in the filtered view, and the rows that look wrong are all on screen without extra work.
Fixed
- Editing an annotation stays put while you work: you can scroll the grid without dropping out of edit mode, and Escape leaves edit mode cleanly.
[1.26.0] - 2026-09-03
A grid over a remote source is now fully editable, and every change makes the round trip back to where the data lives.
Added
- Edit a cell and the change persists back to the source. Point the grid at a DuckDB, OData, REST or DemandFlow query language source and a committed edit travels to the server, then reconciles against what the server returns, so the grid shows the saved value rather than the typed guess. If the server refuses the write, the cell returns to its last saved value and says so out loud, so a change is never left looking saved when it is not.
- Add and delete rows over a remote source. A new row appears at once and a deleted row disappears at once, each applied straight away and then settled on the server’s answer. Selection, focus and any edit in progress follow a new row from the moment you add it to the key the server hands back, and a delete the server declines is restored exactly as it was.
- The same write path across every remote source. Cell edits, add row and delete work the same way over DuckDB, OData, REST and the DemandFlow query language. Each source opts in to writing explicitly and declares which changes it accepts, so a write to something a source does not support is refused clearly rather than dropped in silence, and a source you have not opened for writing stays read only.
- A typed, documented write-back surface. Every write-back option is declared in the TypeScript types and carries a reference entry and a runnable example, so your editor completes the write path the same way it completes the read path.
[1.25.0] - 2026-09-03
Every optional module now loads from a plain script tag, with no bundler and no import.
Added
- Load any module from a script tag. UMD (global) builds now ship for every optional module, so charts, devtools, the web component, the dhtmlx compatibility layer, and the React, Svelte and Vue adapters can each be added with a single script tag beside the core script. No build step, no import line, nothing to configure.
- Modules attach to the LatticeGrid global. Charts and the other grid modules hang off the same global the core script defines, so a charts page reaches them as LatticeGrid.createChart once the module script has loaded. The framework adapters expose their own globals instead, ready to pick up from the page.
[1.24.0] - 2026-09-03
Analysis you can act on, and a few new ways to read your data.
Added
- Ask what a number is doing lately. A new windowed statistic reports a figure over just the last N rows, the last few minutes, or the current session, each answer labelled with the window it covers, so a moving average or a recent count is one call rather than a manual slice.
- Two scales on one chart. Bar and line charts can carry a second, independent vertical axis, so you can read a value and a rate together (revenue and error rate, volume and price) without splitting the chart in two. Both axes are labelled so the picture cannot mislead.
- Reference lines, targets and bands. Draw a target line, a benchmark, or a shaded in-spec band over any chart, fixed or following the data (the mean, a percentile), and it is described for screen readers too.
- Currency as a real type. Store an amount with its currency, show it in the currency you choose using your own rates, and the grid refuses to add unlike currencies rather than quietly summing dollars and euros into a meaningless total.
- Pivot you can browse. Turn the grid into a pivot with row and column dimensions and a measure, expand or collapse either axis, and every subtotal matches the grid’s own totals because they come from the same figures.
Fixed
- Editing over a writable remote source now activates correctly when edit is set to true, instead of quietly doing nothing.
[1.23.0] - 2026-09-03
Editable grids over remote sources. Until now the grid read from a remote source and left saving to your own code; now a cell edit travels back to the source and is confirmed, through the grid.
Added
- Edit a cell and it saves itself back. Point the grid at a writable DuckDB or OData source and an edit applies at once, goes to the source, and is reconciled against what the source confirms, so the grid shows the saved value rather than the typed guess. If a save is refused, the cell returns to its last saved value and says so. A change is never left looking saved when it is not.
- Read-only unless you opt in. A source stays read-only by default; you make a DuckDB or OData source editable explicitly, and a source that cannot save refuses out loud rather than quietly dropping the edit.
[1.22.0] - 2026-09-03
Three additions that turn analysis you could see into values you can work with.
Added
- Pass or fail becomes a column, not just a colour. Set limits on a number and every row carries a pass, warn or fail status you can sort, filter and group by, with a pass rate and a failure count that roll up in the totals row and in every group subtotal. “94.4% within spec” is a figure in the footer, not something you read off the shading.
- Compare two datasets and see what moved. Put two sets side by side, this month and last, one batch and another, and the grid ranks the columns by how much they differ, using effect size so the columns that genuinely changed come first rather than the ones with the largest raw numbers.
- Build conditional-formatting rules without writing JSON. A visual rule builder in the formatting panel: pick a column, a condition or a colour scale, and watch it apply live before you save. Every operator and gradient the engine supports is now reachable by clicking, and a rule you build is the same rule you could always write by hand.
[1.21.0] - 2026-09-03
A hardening release. It changes nothing in how you build a grid; existing grids run leaner in long-lived and multi-grid pages and stand up better to malformed input.
Fixed
- Disposed grids release their memory. In an app that creates and destroys grids over its lifetime, a page that swaps views or a dashboard that rebuilds, a disposed grid now frees everything it held instead of leaving a background timer alive that pinned the whole grid in memory. Long sessions stay flat rather than climbing.
- Several grids on one page no longer interfere when one is disposed. Tearing down one grid used to be able to disturb modules the other live grids on the page still needed. Each grid now tears down only what it owns.
- Further teardown tidy-ups, so view-rendering references, an internal warning cache and header-drag listeners are all released when a grid is disposed.
Security
- Source adapters tightened. The OData adapter validates column identifiers the same strict way the DuckDB adapter does, rather than trusting them. The dhtmlx compatibility layer runs the same HTML safety pass as the core. Copy to clipboard can opt into the formula-injection guard already applied to CSV and Excel export. Configuration merging ignores keys that could reach the object prototype.
[1.20.0] - 2026-09-02
The release that turns the grid into an analytical instrument as much as a table. Select any set of rows and the grid tells you which columns most set that selection apart from the rest of the data, ranked so the columns that genuinely differ come first. Heavy statistics gain a fast approximate path that keeps a running estimate as rows arrive, each answer carrying its own error bound, so a distinct count, a quantile or a top-N stays instant on a very large set, with rolling figures over an ordered window when you want a moving view. A large cold sort can move off the main thread so the grid stays responsive while it runs. And a grid driven through the dhtmlx compatibility layer can now hide and show rows and set cell spans in code, so more existing dhtmlxGrid work carries across without a rewrite.
Added
- See what makes a selection different. Pick a subset of rows and the grid
ranks every column by how strongly it distinguishes that selection from the
full data set, scored by standardized effect size so the columns that truly
differ rise to the top rather than the ones with the largest raw numbers. Reach
for it through
grid.subsetVsPopulation(). - A fast approximate tier for heavy statistics, with honest error bounds.
Distinct counts, quantiles, heavy hitters and moments can now be answered from
a running sketch that updates in constant time as each row arrives, so they
stay instant on a set far too large to rescan for every figure. Every
approximate answer reports its own error bound, and
grid.statistics.approximateandgrid.statistics.maintenanceTiertell you which figures are kept live, which need a rescan, and what the approximate route costs, so you choose exact or approximate with the trade-off in front of you. - Rolling statistics over an ordered window. Windowed aggregates compute a moving figure across an ordered window of rows, for a running average or a trailing total that follows the data rather than summarizing all of it at once.
- More dhtmlxGrid code runs unchanged. The dhtmlx compatibility layer adds
imperative row visibility and cell spans, with
hideRow,showRow,isRowHidden,addSpan,removeSpanandgetSpan, so a grid built against dhtmlxGrid’s row-hiding and cell-span calls works against Lattice without being rewritten.
Changed
- A large sort can run off the main thread. When you enable the worker and a data set is over the threshold, a cold sort of a big table is handed to a background worker while the grid stays interactive, and a worker that fails mid-sort recovers on the main thread so a result is never lost. Filtering and grouping continue to run inline.
[1.19.0] - 2026-09-02
The release that lets a grid stop looking like a grid when the data reads better another way. The same rows you already load can now show as full-width record cards, as a gallery of tiles, or as a kanban board of grouped lanes, and on a narrow screen the table folds down to stacked cards on its own so a grid stays usable on a phone. An exported workbook comes closer to what is on screen, with cell borders, conditional fills and fonts, a grand-total band, hidden-column handling and body merges. A screen reader now hears which cells are read-only before a user tries to edit them, decorations like data bars survive a reload and undo, and a chart can be pointed at a chosen window of the grid rather than only the current selection.
Added
- Show rows as cards, a gallery or a board, not only a table. A grid can now
render each row as a full-width record card or as a responsive gallery of
tiles, for detail-heavy and media-heavy data where a row of columns reads
badly. Turn either on with
recordCardorgallery, astruefor the defaults or as an options object to tune it. - A grid folds to stacked cards on a narrow screen by itself. Small viewports collapse the tabular layout to a stacked card presentation automatically, so a grid stays readable on a phone without a second configuration for mobile.
- A kanban board view of grouped lanes. Rows can render as a board of grouped
lanes of cards through the
boardconfig key, astrueor an options object. Every card is still a live row, so it clicks, selects and drags through the same grid, and long lanes are virtualized so a board renders only what is on screen. - Excel exports that look like the grid. The Excel exporter gains cell
borders, per-cell fills and fonts driven by your conditional formatting, a
grand-total band, hidden-column handling (
hiddenColumns: 'omit'or'hidden') and body cell merges (merges), so a workbook you hand to someone else reflects what they saw on screen. - Read-only cells announce themselves to assistive technology. Cells in a
read-only column now carry
aria-readonly, set from the permission layer, so a screen reader tells a user a cell cannot be edited instead of leaving them to discover it from an edit that does nothing. - A portable sort that can run off the main thread. A sort can now be described in a portable form that does not depend on the runtime, and the first cold string sort of a large table can be handed to the worker, so the main thread stays responsive while the ranks are collated.
Changed
- Column decorations survive a reload and undo like everything else.
Decorations applied through
columns.decorate, such as data bars, are now saved with your views and can be undone and redone with other column changes, rather than disappearing on reload or sitting outside the history. - A degraded worker falls back more cleanly on ingest. The worker host now tells a lost worker apart from compute it can no longer use, so a grid whose worker has gone drops back to the main thread cleanly, with a keyed-stream fallback that avoids duplicate rows on ingest.
Fixed
- A chart can be pointed at a chosen window of the grid.
chartRangenow accepts an explicit range or host-filter binding, so a chart can draw from a window you name rather than only the current selection.
[1.18.1] - 2026-09-02
The release that pushes the last of the heavy statistics down to DuckDB, lets a column show a different total at the group level than at the bottom of the grid, reads and writes a value as a mixed compound unit, and stops a partial result from ever passing for a complete one. Edited group subtotals now update the way the grand total already did, so a cell edit on a large group is much cheaper. This release also fixes a crash on grids that total a running column, so a finance grid with a running balance no longer falls over when it is grouped.
Added
- Every composed statistic now computes in DuckDB. The long tail of derived statistics, concentration and inequality measures, top-k shares, trimmed and winsorized means, robust outliers and the rest, now runs inside the database rather than pulling rows to the browser to work them out, both across the table and with a group by. Each was checked to return the same number as the browser did before it was allowed to run in the engine. If you use weighted quantiles, those still compute in the browser, so a grid that relies on them is unchanged.
- A column can total one way per group and another at the grand total. Set a group subtotal and a grand total on a column independently, on the column or at runtime, so a column can average within each group while summing at the bottom. A column that does not split either is unchanged, the split is saved with your views, and the aggregate chooser gains a submenu for picking each scope separately when it is turned on.
- Compound units read and write in mixed form. A unit type can now show and
accept a value as a mixed compound, so a length reads as
5 ft 11 inrather than a single decimal, and typing that mixed form back parses to the same number.
Changed
- Editing a cell now moves the group subtotal by the difference. On an in-memory grid, editing a cell no longer re-totals every row in the affected group, the subtotal shifts by the change alone, the way the grand total already did. Moving a row between groups adjusts only the two groups involved. Sums, averages, counts, minimums and maximums over numbers take the fast path, and custom or statistical group totals still do the full pass. The number you see is identical, this is a speed change that pays off most on grids with large groups.
- A partial pushdown result now fails rather than pretends to be complete. When a source returns fewer rows than it declared for a whole-set request and there is still filtering or sorting to do, the grid now stops rather than treating the fraction it got as the whole. Turn on the partial-results option to go back to the previous behaviour where you know the subset is acceptable.
Fixed
- A grouped grid that totals a running column no longer crashes. When a grid grouped its rows and totalled a running or computed column, such as a running balance on a finance grid, the new incremental subtotals could recurse until the browser ran out of stack and the grid fell over. A running column now contributes no subtotal, the way it always has at the grand total, so the grid totals its plain columns and stays up. Plain and pure computed columns are unaffected.
- A statistic’s maintenance label was corrected. A weighted average was labelled as maintained incrementally when it is recomputed on read. The number reported was always right, only the label was wrong, and it now reads correctly.
[1.17.0] - 2026-09-02
The release that lets your totals, grouping and statistics run over a whole dataset held in DuckDB or a REST source rather than only the rows on screen, frees the memory your source rows were holding once the grid has read them, and gives every other row a subtle stripe if you want one. One default changes: a grid that leaned on group headers staying pinned to the top now has to ask for that behaviour, so read the note below before you upgrade.
Added
- Totals and statistics over the whole dataset, not just the visible rows. Turn on the full-dataset option and grouping, totals and statistics for a DuckDB or REST source compute across every row that matches, not only the window the grid has loaded. A grid backed by a large remote table can now show a true sum or a true group breakdown for the whole set while still paging the rows themselves.
- Push statistics down to DuckDB. Aggregation and statistics, both across the table and with a group by, can now run inside DuckDB and come back computed, with per-statistic control over whether the database or the browser does the work, and a record of which one did it. The heavy counting happens where the data already lives.
- Let the grid drop your source rows once it has read them. A new ingest option releases the original row objects after the grid has taken them in, which is a large memory saving for a grid fed from an in-memory source, since the data no longer sits in two places at once.
- A warning when a number is measured over only what is loaded. In development, the grid now tells you when a statistic or total for a windowed source was computed over just the rows on screen rather than the whole set, and exposes that coverage on its profile, so a partial figure cannot pass for a complete one unnoticed.
- Striped rows. An opt-in setting gives every other row a subtle background, which makes a wide, dense grid easier to read across.
Changed
- Group headers no longer stay pinned unless you ask. Sticky group headers are now off by default. A grid that relied on group headers staying at the top of the view as you scroll must now set the sticky option on, either as a plain on switch, which stacks up to two levels, or as a number to cap the depth. If you already set it explicitly, on, off, a number or a depth, nothing changes for you. This is the one behaviour in this release that an existing grid may need to update for.
- Faster repeat sorts on text columns. Sorting a text column a second time is quicker, because the grid keeps a ranking it can reuse. Sort order is identical to before.
Fixed
- Dragging the scrollbar on a remote source no longer misbehaves. Dragging the scrollbar on a paged or remote grid could set off a burst of fetches or leave the grid blank for a moment. It now settles on where you let go and fetches once.
- No gap under the header at comfortable density. Comfortable and other non-standard densities no longer leave a thin gap between the header and the first row.
[1.16.0] - 2026-09-02
The release that lets you see a bulk change before you commit it, group by dragging a column into place, and chart a range you have selected without leaving the grid. Everything new here is opt in and off until you turn it on, so a grid already in production behaves exactly as it did before you upgrade.
Added
- See a bulk paste before it commits. Paste into more than one cell and the grid can show you exactly what the paste will do first: every cell that will change, from its old value to its new one, and every cell the grid would refuse and why. The preview is run through the same checks as the real thing, so what you approve is what you get. A single-cell paste goes straight in, as it always did.
- Group by dragging a column into place. A drop zone above the header lets you group by dragging a column heading into it; each active group shows as a chip you can reorder or remove, and the order of the chips is the order of the nesting. The whole strip works from the keyboard, so you can add a group, reorder it and clear it without reaching for the mouse. Grouping this way, from the column menu, or in code all stay in step, because they are the same thing underneath.
- Chart a range you have selected. Select a block of cells and turn it into a chart in one step, from the cell menu or a keyboard shortcut. A leading text column becomes the labels and the number columns become the series, so a quick visual of a corner of your data is a selection and a click away. It draws with the same chart engine the rest of the grid uses.
- Choose how a column totals from its own menu. A column’s footer total now offers an Aggregate menu listing only the summaries that make sense for that column’s type, with the current one marked and an option to stop totalling altogether. Pick sum, average, count or whatever the column supports, and the footer updates to match.
- Threshold icons that read at a glance. A column can now show a small icon set driven by thresholds, so a number turns into a traffic light, an up or down arrow, or a rating as its value crosses the bands you set, with the value still shown beside it. The built-in sets are drawn from the grid’s own icons, so there is nothing extra to load and nothing fetched at runtime, and each step carries a label a screen reader can announce. You can also set, change or clear a column’s decoration while the grid is running.
- Loading a large stream keeps the interface responsive. When you feed the grid from a stream, it can now do the heavy work of packing a large chunk off the main thread, so the interface stays responsive while the data arrives rather than freezing until it has finished. This applies to streamed loads; a ready-made array in memory still loads as before. If a browser cannot support it the grid quietly falls back to the existing path, so nothing breaks.
Fixed
- Exported spreadsheets are safe to open. A cell whose text began with a character a spreadsheet reads as the start of a formula could, once, run as a live formula when the exported file was opened in Excel, Google Sheets or LibreOffice. Exports now neutralise that text so it opens as the text it is. Numbers, dates and true or false values are untouched and still sort and total exactly as before.
- Faster sorting and loading. Sorting text columns and loading large datasets are both meaningfully faster in this release, and a new check runs on every build so those gains cannot quietly slip back. Sort order is identical to before, down to the byte.
[1.15.0] - 2026-09-01
The release that lets a stat card and a menu item carry an icon, and fixes a licence path that some compatibility grids could not reach. Give a KPI card a leading icon or image and it reads at a glance without any extra layout work; give a context-menu item an icon and the action beside it is easier to scan. Nothing in an existing setup has to change to take the fix or the icons.
Added
- A leading icon or image on stat and KPI cards. A card takes an icon through its config and places it beside the title and value on its own, without touching the change indicator, the threshold bands or the confidence interval. Leave the icon off and the tile lays out exactly as it did before, so adopting this is opt in, one card at a time.
- Icons on context-menu items. A menu item accepts an icon that can be a built-in name, a single character or emoji, or your own trusted markup for an icon font or an image. Cards and menus share the one icon helper, so the same value renders the same way in both places, and an icon the grid cannot render is flagged while you are building rather than shipped blank.
Fixed
- A licence now reaches every compatibility grid. A grid built through the dhtmlx compatibility layer used to run on a separate copy of the engine, so a licence set on the page did not always apply to it and it could fall back to a trial watermark. Those grids now share the same licensed engine as the rest of the page, so one licence covers every grid, however it was created. The way you construct a grid is unchanged.
- The reported version now matches the one you installed. The version the grid reports about itself is read straight from the package, so it can no longer drift from the release you are running, and a build check keeps the two in step from here on.
[1.14.0] - 2026-09-01
The release that tells you when a setting was not understood. A value the grid cannot use no longer disappears without a word: the grid names it, says what it expected, and carries on with the safe default, so a wrong colour, a mistyped option or a setting that never took effect is something you find while you are building rather than a thing you go looking for later.
Added
- Value diagnostics. Every configuration value is checked as the grid reads
it. A setting the grid cannot use is reported by name, with what it expected
and the value it saw, in the console and on
grid.diagnostics, so a typo or a wrong type surfaces at once. A correct grid stays silent: the check is deliberately conservative and never warns about a setting it understands. - A support bundle you can send as is.
grid.diagnostics.bundle()gathers configuration, query state, timings and warnings into one object that carries no row, cell or column values and says so, so a report can go to support without anyone reading it for confidential content first.
Changed
- No breaking changes. A grid already in production behaves exactly as before; the same setting is used or the same default applies. The only difference is that a value the grid could not use is now named instead of ignored.
[1.13.0] - 2026-08-31
The release that lets the grid drive a real query engine. A source can push the grid’s filter, sort and paging down to whatever holds the data, take back only what it asked for, and finish anything the engine could not do itself. The grid stays a client library: an adapter takes a connection, a URL or a token you already have and imports nothing, so the bundle is identical whether a page uses this or not.
Added
- Pushdown sources.
createPushdownSource({ adapter })turns the grid’s query into a portable request the adapter translates for the engine, with the split reported:source.lastPlan()says what reached the engine and what the grid finished locally. An adapter declares what it can do, filter (a term, a flat conjunction or a full condition tree), which operators, sort, quick search, windowing and totals, and everything it does not claim the grid does itself, so an adapter that declares nothing still works. - Four adapters.
duckdbAdapterfor DuckDB, including DuckDB-Wasm in the browser over Parquet with no server;odataAdapterfor an OData service;restAdapterfor the REST or GraphQL endpoint you already have; anddfqlAdapter. None of them ships an engine: each takes a connection, a URL or a token you provide. - Process-control charts.
control,movingRangeandcapabilitychart types draw an SPC study from a column’s declared tolerance, andgrid.statistics.capabilitygainsrules: 'nelson' | 'westernElectric'for the numbered rule breaks. Thirty-seven chart types now.
Changed
- No breaking changes. Existing grids and sources run unchanged.
[1.12.2] - 2026-08-31
A patch over 1.12.1. Chart error-bar whiskers now draw. The whisker was computed
and placed correctly but painted with no stroke, so it was there in the markup
and invisible on screen; it now carries the grid’s foreground colour, so
error: true shows the interval on the chart, not only in the DOM.
[1.12.1] - 2026-08-31
A patch over 1.12.0. A chart asked for error-bar whiskers it cannot compute, one bound to a summary or a derived panel rather than to the rows a figure was measured from, now says so once rather than drawing nothing in silence. The interval is computed from the readings the chart can see behind each mark.
[1.12.0] - 2026-08-31
A minor release with a new statistics figure, a complete type reference, and a web component that stands on its own. Existing grids are unaffected.
Added
- Confidence intervals.
grid.statistics.interval(col)returns the range an estimate pins a figure down to, rather than a single number: a mean with its margin of error, or a proportion withkind: 'proportion'and a rule for what counts as a success. Process capability can report one too. Each reads the filtered rows, so the interval narrows as the grid does and describes the population on screen rather than the whole table. - A complete type reference. The API reference gains a Type reference page: every interface the library declares, with the type of each member, generated from the type declarations so it always matches the release. It sits beside the hand-written namespace pages, which stay the place to start.
Changed
- The web component stands on its own. The
<lattice-grid>element now carries the grid’s own functions, so using the element and callingcreateGridare two separate choices rather than one setup shared between them. Use one or the other in a page, not both. - No breaking changes. A grid written for an earlier 1.x release runs unchanged.
[1.11.0] - 2026-08-31
A minor release that deepens derived grids in three ways: they can join a second grid, filter the grid they came from, and update in place when only a few rows change. Existing grids are unaffected, and a derived grid that uses none of it behaves exactly as before.
Added
- Joins in a derived grid. A derived source can match each row against a
second grid on a shared key and bring some of its fields across:
join: { with, on, select }. It runs before grouping, so a total or a condition can read a field the join produced, and it is an inner join by default, keeping the rows that matched, or a left join when the unmatched rows are the finding. - Cross-filtering, the path back up. Derivation runs one way, which is what
makes a chain of grids safe to reason about; cross-filtering is the single
deliberate way back. Set
crossFilter: trueon a derived grid and clicking a summary row filters the grid it summarises, through that grid’s own filter model, so it undoes, rides in a saved view, and appears in the filter UI already there, with no second filter model beside the real one. Thegrid.crossFilterhandle offersset,toggle,clearandget. - Row-level change identity. The
rows:changedevent now names exactly which rows were added, updated or removed. Anything that maintains state from a grid, a derived grid, a chart or a cache of your own, can patch just those rows rather than rebuild from scratch, so a live grid stays responsive as it streams.
Changed
- No breaking changes. A grid written for 1.10 or 1.9 runs unchanged; the additions are new configuration and a richer event payload, not a new contract.
[1.10.0] - 2026-08-30
The release that turns a grid into a dashboard. A grid can now take its rows from another grid and follow its filter, so a table and the panels beside it move together from a single control, and a matching tile sits above them. Existing grids are unaffected: every addition is opt-in, and a grid that uses none of it loads none of it.
Added
- Derived and chained grids. A grid can source its rows from another grid
rather than from data of its own:
source: { mode: 'derived', from }. Group and total the source, expand an array column into one row per element, round a date into a weekly or monthly series, keep the top few overall or the top few in every category, or transpose a column into a statistical profile. The result is a real grid - sortable, filterable, exportable, saveable into a view - that re-derives whenever the source is filtered, so the panels of a dashboard cannot disagree with the table they came from. A derived grid can itself be the source of another, so the chain composes to any depth and a single filter at its root moves every level at once. createStat, the dashboard tile. A label, a value, its movement against a baseline, and a line saying what the comparison was. It reads the grid rather than a copy of the data, so it cannot drift from the table beneath it, and it formats through the column’s own type, so a figure in hours reads in hours and money reads on its own ladder with nothing declared.showturns a maximum into the name of the row that holds it - the best rep rather than the best figure - andscope: 'all'holds a whole-set total steady while the filtered figures beside it move.
Changed
- No breaking changes. A grid written for 1.9 runs unchanged; the additions are new configuration, not a new contract.
[1.9.1] - 2026-08-30
A patch over 1.9.0, same API and same stylesheet, worth taking for anyone drawing charts.
Fixed
- Small multiples drew a single panel. A chart configured with
multiplesrendered one chart rather than one per distinct value of the column. It now draws the panel per group its own caption describes. - A numeric axis could order its ticks by first appearance rather than by
value, so an axis of bandwidths could read
100, 200, 500, 2000, 10000, 1000. Ticks now sort numerically. - A control-chart edge case on a constant series no longer divides by a zero moving range.
Changed
- Fourteen registered data types that shipped working but undeclared are now in
the TypeScript
TypeNameunion, so an editor completes and type-checks them.
[1.9.0] - 2026-08-29
The release that lets the grid answer back. Three things arrive together: a charts module, a statistics layer, and shadow columns. All three read the grid’s own filtered rows, so they say the same thing the grid says, and none of them is held back for a larger licence. Existing grids are unaffected: every addition is opt-in, and a grid that uses none of it loads none of it.
Added
- A charts module, thirty-five chart types drawn from the grid’s data.
import { createChart } from '@toclocoinc/lattice-grid/modules/charts', hand it a grid and a container, and it draws from the grid’s filtered rows and redraws when the grid moves, with nothing to subscribe to. The families run from the everyday (line, area, bar, scatter, pie, donut, histogram) through flow (sankey, chord, network), specialist (gauge, funnel, radar, candlestick) and statistical (Q-Q, ECDF, Lorenz, correlogram, control) to a geomap that joins ISO codes to continents or countries. The module imports nothing from the grid core, so it is a separate file a page loads only when it charts, and it takes no third-party charting dependency. - A statistics layer on
grid.statistics. Reductions in the totals row (median, percentiles, deviation and more than twenty others), a docked statistics panel that profiles a column and follows the filters, and the two-column and whole-series figures directly: correlation, regression, weighted averages and quantiles, volatility and drawdown. Every figure is computed over the filtered rows through the same handles the totals row uses, so a median in the panel and a median in the footer agree by definition. - Shadow columns. A column declared against another and maintained by the
grid, whose value depends on what happened before: delta and percent change
against the value at page load, a move count and a signed streak, rate of
change and a recent-history sparkline, and a positional family of rank,
percentile, quartile and places moved. They are real columns, sortable and
filterable, so “the top movers, most-changed first” is one sort rather than a
report.
grid.statistics.rebase(col)marks a new baseline. - Process capability. Declare a tolerance on a column with
spec: { lower, upper, target }and the capability figures, a control chart and any out-of-tolerance rule all read the same limits. - Data-driven conditional formatting. An
{ op: 'outlier' }rule that finds the extremes in the data rather than against a threshold you typed, and colour scales withfrom: 'quantile', which span a percentile range and are not flattened by a single large value. - Custom unit systems. Twenty-six unit families ship, and
registerUnitSystemadds your own from a set of symbols and their factors.
[1.7.1] - 2026-08-27
A scrolling fix. The distribution is otherwise identical to 1.7.0 (same API, same types, same stylesheet), so it is a drop-in upgrade, worth taking for any grid tall enough to reach the scaled part of the scroll range.
Fixed
- Dragging the scrollbar thumb could settle on the wrong rows in a very tall grid. Once a grid holds enough rows that its true pixel height runs past what a browser will lay out (on the order of a million), the scroll range is scaled to fit and a single thumb pixel then stands for many rows. In that range, dragging the thumb landed the view slightly adrift from where it was dropped, and the gap widened the further down the grid you went. The body now rides on its own explicitly sized surface, and scroll position is measured against that surface directly, so the thumb and the rows it brings into view stay in step at any height. Wheel and keyboard scrolling, and every grid below the scaling threshold, were never affected.
[1.7.0] - 2026-08-25
Added
-
Licence keys are perpetual by default. A key carries no expiry unless one is deliberately issued - a trial, a time-boxed pilot - so the ordinary key stays valid until the domains it names change, rather than lapsing on a date.
grid.licence.info().expiresisundefinedfor a perpetual key and an ISO date only for one issued with a term. -
Saved views can now persist with no backend.
views: { local: true }stores a user’s saved views in this browser’s ownlocalStorage, under a default key shared by every grid on the origin, or a key of your own viaviews: { local: { key: '…' } }to keep two grids’ views apart. This sits alongside the existingviews.storageoption, for a developer plugging in a real server - an explicitstoragealways wins if both are given, with a console warning, rather than the two silently competing. The adapter itself,createLocalViewStorage(opts), is exported directly too, for a custom key without thelocalshorthand or a differentStorage-shaped backing such assessionStorage. -
An htmx integration.
modules/htmxlets a grid survive htmx’s own DOM swaps, hydrate from a server-rendered<table>, and drive sort, filter and infinite scroll over plain htmx requests. It is a complete package rather than an add-on:createGrid,autoInit,hydrateTable,readTable,serialiseStateandrestoreStateare all re-exported alongside its own functions, so a page using htmx integration needs only this one import - never the base package as well. Importing it is enough for the lifecycle half on its own - it registers againstdocumentautomatically, building grids from[data-lattice-grid]elements onhtmx:loadand tearing them down onhtmx:beforeCleanupElementbefore htmx detaches the subtree they live in.driveServerMode(grid, trigger, opts)fires a request carryingoffset,limit,sortandfilterswhenever sort or filter changes, and replaces the grid’s rows with the response.driveInfiniteScroll(grid, sentinel, opts)appends rows as the grid’s own visible row window nears the end of what’s loaded - the sentinel’s ownhx-triggernames bothrevealed, for the first chunk, andlattice:scroll-near-end, for every chunk after, since a fixed-height virtualised grid’s own scroll area is what changes, not the page’s.driveOobUpdates(grid, opts)applies an out-of-band swap landing on[data-lattice-row="<key>"]to that row in place, leaving scroll position, selection and filter state untouched. A failed request never touches the grid’s existing rows and shows a recoverable message instead of leaving it blank.Browser back and forward restore the prior sort, filter and scroll position: on
htmx:beforeHistorySave, every live grid’s state is written onto its element for htmx’s own history snapshot to capture; onhtmx:historyRestore, it’s read back and applied, except on a cache miss, where a fresh server response is already the truth. Ships as ESM and as a plain<script src>build with no bundler required. See the developer guide’s “Using with htmx” section for the full wiring and the query convention a server needs to support. -
Declarative init, table hydration and compact state URLs, independent of any framework adapter.
autoInit(root)builds a grid on every[data-lattice-grid]element underroot, reading a sibling<script type="application/json" data-lattice-config">for its configuration, or hydrating a<table>element directly - reading its header row for columns and body rows for data, then replacing itself with the grid - when no config script is present.serialiseState(grid)/restoreState(grid, encoded)encode everythinggrid.statecovers as a compact, URL-safe string, diffed against the grid’s own defaults first, so an untouched grid encodes to a handful of characters. A<meta name="lattice-license" content="…">tag is read automatically when nolicenceis passed tocreateGrid. Every elementcreateGridbuilds on is now discoverable from itself:element.__latticeholds the live instance, cleared ondestroy(). -
A compatibility wrapper for dhtmlx Grid.
modules/dhtmlx-compatexposes aGridclass shaped like dhtmlx’s owndhx.Grid(Suite 5+) - the same constructor call, the same.data,.selection,.history,.exportand.eventsnamespaces - backed by a real Lattice grid underneath. For the covered surface, existing calling code does not change.Covers column definitions;
.data’sadd/update/remove/removeAll/parse/load/find/findAll/exists/getItem/getId/getIndex/getLength/forEach/serialize/sort/filter/resetFilter;.selection’ssetCell/getCell/getCells/isSelectedCell/removeCell;.history’sundo/redo/canUndo/canRedo/clear/getHistory;.export.csv/.xlsx; and a name-mapped subset of.events, several of which -cellClick,cellDblClick,cellRightClick,afterEditStart,afterEditEnd,afterSort- call your handler with dhtmlx’s own positional arguments rather than Lattice’s event object, matching dhtmlx’s documented signatures.afterRowDropis included, firing(data, event)from either a same-grid reorder settling or a row landing from another grid - dhtmlx has one event name for what Lattice models as two. Grid-leveldragItem: 'row'becomesrowReorder: true.Every
before*/can*/cancel*event is deliberately unmapped: dhtmlx lets a handler returnfalseto cancel the action, and Lattice has no cancelable-event model to honour that with. Row and column drag negotiation - a handler refusing or steering a drop mid-gesture - is unmapped for the same reason. Cross-grid dragging needs an explicitrowTransfer, passed straight through: dhtmlx lets any twodragItem: 'row'grids on a page exchange rows by default, and Lattice’srowTransferis deliberately opt-in per pair, with nothing to derive it from.export.pdf()andexport.png()throw - there is no raster export to translate to..rangeSelectionis offered on a best-effort basis, with a range shape of this wrapper’s own design rather than dhtmlx’s ownRangeSelectionmodule. The classic, pre-Suite-5dhtmlXGridObjectis not covered. See the developer guide’s “Coming from dhtmlx Grid” section for the full list.
Fixed
- A grid that had offloaded work to a background Worker left it running
after
destroy(). Every other listener, observer and buffer was released; the Worker itself was the one thing nothing reached. Only grids past the row-count threshold that triggers Worker offload were affected - a small or mid-sized grid was never at risk.destroy()now terminates it.
[1.6.2] - 2026-08-25
Fixed
- Dragging a row by its handle also started a range selection underneath it. The handle sits inside a cell, so a press on it was picked up by both gestures at once - the row began moving, and a one-cell range was left selected and stayed that way even after the row had already been dropped elsewhere. A press on the handle is now read as a row drag only.
Added
- Picking up a row now shows it, wherever the pointer goes. The row being dragged dims in its own grid, and a small label naming it follows the pointer for as long as the drag is held. A plain mouse drag holds no cursor across whatever it crosses, so previously nothing on screen showed a drag was under way until the pointer physically reached another grid.
[1.6.1] - 2026-08-23
Documentation only. The distribution is unchanged from 1.6.0 - same bundles, same types, same stylesheet - so an upgrade is worth taking only for the reference that ships beside them.
Fixed
state.applywas documented as returning nothing. It returns a report naming everything in a saved view it could not apply and why, which is how a host tells a user that a view saved against an older layout has aged. The behaviour has always been this; the reference was wrong.
Added
-
The reference now says what a saved view does when the columns change underneath it. A view is user data written against a column set that has since moved on, and nothing stated the rules.
A column added since the view was saved appears, in whatever state its definition declares, after the columns the view names - a view is not a whitelist, and silence about a column nobody had heard of is not an instruction to hide it. Ship a column
hiddenif it should not arrive announced. A removed column is skipped and reported, and a sort or grouping that pointed at it is dropped rather than left dangling.
[1.6.0] - 2026-08-22
Added
-
The grid’s own text can be translated. Every string the grid renders or announces now comes from a message catalogue rather than from the source, and a catalogue can be replaced or partially overridden through the new
messagesoption. British English (EN_GB) is the default, and catalogues ship for twenty other locales, each exported under the code shown: American English (EN_US), French (FR_FR,FR_CA), Italian (IT_IT), Spanish (ES_ES), Brazilian Portuguese (PT_BR), German (DE_DE), Dutch (NL_NL), Swedish (SV_SE), Danish (DA_DK), Norwegian (NB_NO), Finnish (FI_FI), Polish (PL_PL), Czech (CS_CZ), Hungarian (HU_HU), Romanian (RO_RO), Ukrainian (UK_UA), Greek (EL_GR), Japanese (JA_JP) and Arabic (AR).A catalogue carries a region only where two variants of the language ship, which is why the Englishes and the two Frenches are qualified and nothing else is. Arabic is therefore
AR- butAR_SAis exported as an alias for it, because that is the name people reach for first. Both are the same catalogue. For any other tag,resolveCatalogue('ar-EG')finds it.Each is an export of the package, so importing one does not reduce what is bundled.
import { createGrid, FR_FR } from '@toclocoinc/lattice-grid'; createGrid(element, { locale: 'fr-FR', messages: FR_FR });Where
localeis not given, the grid takes the language the page declares inlang. Overrides merge over the default, so translating part of the interface leaves the remainder in English rather than showing raw keys.MESSAGE_KEYSlists every key andauditCatalogue()reports what a catalogue is missing. -
Eleven strings that were never translatable now are. The comment panel’s labels and its Comment and Delete buttons, the timeline bar’s scrubber and Go live control, the fill handle’s tooltip, the icon picker’s list and the placeholder shown when a value is too long to encode as a QR code all read from the catalogue like everything else. They previously rendered in English whatever the locale.
-
grid.messagesexposes the resolved message set -t(),list(),number()and the activelocale- so a host-supplied renderer or panel can draw its text from the same source the grid does. -
Rows can be pinned above and below the scrolling body.
pinnedTopRowsandpinnedBottomRowshold rows against the header or the status bar, andgrid.setPinnedRows(rows, { edge })moves them at runtime. Use one for a column-units line, a target to compare against, a precomputed summary, or a note that must stay in view.createGrid(element, { columns, rows, pinnedTopRows: [{ product: 'Units', capacity: 'MW', margin: '%' }], });The rows render through the ordinary column pipeline - value getters, formatters and cell renderers all run - but they are not part of the data: not counted, sorted, filtered, grouped, selectable, totalled or exported. A filter that matches nothing leaves them visible.
-
The grid lays out right to left.
direction: 'rtl'renders the grid mirrored; omitted, the direction follows the element’s owndirand then the locale, solocale: 'ar'is right to left without further configuration. Pinned columns, the header, horizontal scrolling, column resize and reorder, the fill handle, annotations, the facet band and menu placement all follow the writing direction.createGrid(element, { locale: 'ar' }); // direction follows the locale createGrid(element, { direction: 'rtl' }); // or say so outright -
Fifty new data types for physical, engineering and scientific quantities. Each stores a plain number in a named base unit, so the column still backs onto a typed array and sorting, filtering, grouping and totalling stay ordinary arithmetic - only display and input are unit-aware.
{ field: 'span', type: 'metres' }, // 1500 → "1.5 km" { field: 'inlet', type: 'pressure' }, // 200000 → "200 kPa" { field: 'cap', type: 'capacitance' }, // 4.7e-11 → "47 pF" { field: 'intake', type: 'celsius' }, // accepts "72 F", stores 22.2Twelve new unit systems - speed, acceleration, area, volume, energy, power, force, pressure, torque, density, flow and angle - join the length, mass and elapsed-time ladders, which existed but had no declared type and needed a hand-rolled
dataTypesentry to reach. Fifteen SI-prefixed electrical and scientific quantities are generated from one prefix table: voltage, current, resistance, capacitance, inductance, charge, conductance, flux density, luminous flux, illuminance, substance, three radiation measures and frequency.registerUnitSystemadds a system of your own. The elapsed-time ladder now reaches down to nanoseconds.Ambiguous units are refused rather than guessed. A US gallon and an imperial gallon differ by about a fifth and “ton” means three different masses, so each carries its own symbol and the bare word is rejected on input. The same rule covers case:
mVandMVare a billion apart, so both work andmvdoes not.display: 'auto'walks the coherent SI ladder only. Customary units are accepted on input and available as an explicit display, but never chosen - otherwise 4,000 J renders as3.79 BTU, since auto picks the largest unit that fits. -
significantFiguresrenders to a fixed precision rather than a fixed number of decimals, so a column claims the same accuracy on every rung of its ladder. Rounding is applied before the unit is chosen: 999,999 bytes to three figures is1.00 MB, not1,000 kB. -
Angles average by direction. The mean of 359° and 1° is 0°; the arithmetic answer of 180° points the opposite way.
degreesandradianscolumns replace the mean and report nothing where the angles cancel. The sum stays arithmetic, since a total rotation of 720° is two turns. -
A temperature editor. A temperature column now gets its own editor rather than the plain number one, so the field carries its scale and
72 Fcan be typed straight into a Celsius column - which the type already accepted from a feed and from the clipboard, but not from the keyboard. -
Temperature columns refuse to be summed. Twenty degrees plus twenty degrees is not forty degrees.
celsius,fahrenheitandkelvinoffer average, minimum and maximum, convert on input, and are a separate type from the unit ladders because a temperature scale carries an offset that no single factor can express. -
Rows can be drawn as cards instead of columns.
rowTemplategives each row a layout of your own - a card list, a feed, a search-result list - over the same pipeline, so sorting, filtering, grouping, selection, permissions, redaction, saved views, undo, export and the remote source all keep working and only the drawing changes.createGrid(element, { columns, rows, rowKey: 'id', rowHeight: 64, rowTemplate: '<p class="title">{{data.name}}</p><p class="sub">{{data.owner}}</p>', });The template compiles once, the way a cell template does, and binds with
{{data.field}}. There is deliberately no per-row callback: that shape would be used to allocate DOM per row, and the virtualisation would stop paying for itself. Scrolling ten thousand records through a hundred pooled cards allocates nothing.{{cell.column}}shows what the table shows - the column’s own formatter, data type and lookup label - while{{data.field}}reads the raw value for a template that wants the number rather than the rendering. A card showing2where the table showsHeldis a bug, not a preference. Binding a raw field on a secret or redacted column reads its masked text instead, so a card cannot become the hole a redaction closes.cardsPerRowormaxCardWidthputs several cards on a line. The first is a count for a layout that must not reflow; the second is a ceiling, and the number across follows the container - 900px at a 260px ceiling is three cards that share the width, and a narrower container fits fewer. The scroll height counts lines rather than records, so the scrollbar matches the content.A card is still a row.
row:clickedandrow:dblclickedfire with the same payload as a table row, clicking selects, the context menu opens, androwReorderworks with the card itself as the drag handle. The presentation is announced as a list rather than a grid, since a card has no columns, and the column header is not drawn. -
A grid can collapse to cards when its container is narrow.
responsive: { maxWidth: 640, template }presents rows as cards below the threshold and as a table above it - the mobile answer, since a table on a phone is a compromise however it is styled.Measured on the container, not the viewport, so a grid in a narrow panel on a wide screen collapses and a grid filling a small tablet does not. The sort, the filters, the selection and the scroll position all survive the switch in both directions; an open cell editor is closed, since the cell it belonged to stops existing.
presentation:changedfires with'cards'or'table', and the change is announced.Sorting and filtering need a home when there are no column headings to click:
toolPanel: truekeeps its rail available in card presentation. Export is unaffected - the columns remain the data model, so a file from a collapsed grid holds every column, including ones the card does not show. -
A row can be edited on a form.
rowForm: trueand double-clicking a row opens it in a right-hand drawer - or a centred dialog withmode: 'dialog'- with one control per field, a Save and a Cancel. The fields are the grid’s own columns, edited with the same editors, types, formats and lookups the cells use.createGrid(element, { columns, rows, rowKey: 'id', editable: true, rowForm: true });Where the record holds more than the grid shows, a
loadfunction fetches the fuller version andfieldssays what to show and in what order. The panel opens immediately and fills in when the record arrives, rather than waiting with nothing on screen. A load that fails - or that has not answered withintimeoutmilliseconds, two seconds by default - leaves the panel open with a retry instead of closing and discarding the intent.rowForm: { mode: 'dialog', load: ({ key }) => fetch(`/api/orders/${key}`).then((r) => r.json()), fields: [{ field: 'name', label: 'Name' }, { field: 'ref', label: 'Reference' }], }Every editor is available on a form, including your own: a field named after a column borrows that column’s editor, and any field may name one with
editor, configured bytype,propsandlookupexactly as on a column. A picker - a date, a dropdown, a tree, a colour - shows its value on the field and opens its panel when clicked, rather than arriving open.Save writes the changed fields that map to columns and emits
form:savedwith all of them, including any that came fromloadand have no column; where the record is persisted is yours.grid.formopens, closes and saves from code, andtrigger: falseleaves opening entirely to it.containerbuilds the form in an element of your own - a sidebar, a panel below the grid, a column in a layout you already have - instead of over the grid. It takes an element, a selector or a function, and a selector is resolved when the form opens rather than when the grid is configured, since a grid is usually built before the layout around it exists. A form in your own container is announced as a region rather than a modal dialog and does not trap Tab, because it sits beside the grid rather than over it.On an editable grid the form takes the double click, so double-clicking a row opens the form rather than a cell editor. Cell editing stays on Enter and on typing into the cell.
-
Columns can be tagged, and filtered by tag. Give columns a
tagslist and setcolumnTagFilter: true, and a bar above the headings lets a user show only the columns carrying a chosen tag - sixty months of figures across five years become twelve by picking a year.{ field: 'jan24', title: '01/24', tags: ['2024', 'Q1'] }Only tagged columns are ever hidden. An account name or a total belongs to no year, so it stays visible whatever is selected - and you do not have to tag every column merely to keep it on screen.
A column may carry several tags, which gives a second axis for free: tag each month with its year and its quarter, and either can be picked.
columns.tags(),columns.showTagged()andcolumns.activeTags()are the same thing from code. -
showColumnFunctions: falseleaves each column heading as its label, with no sort, filter or menu control. They are not drawn rather than hidden, so the label has the whole cell - which is what a dense grid of narrow columns wants. Sorting, filtering and the menu stay reachable through the API, the keyboard and the tool panel. -
Grid lines, rounded corners, and percentage column widths.
gridLineschooses which rules are drawn between cells:'horizontal'(the default, and what the grid has always drawn),'vertical','both'or'none'. Vertical rules between body cells are new - the grid never drew them - so the default is unchanged and nothing moves on upgrade.cornerRadiusrounds the grid’s outer corners:truefor the theme’s own radius, a number for pixels, or a string used as written.A column width may now be a percentage -
layout: { width: '25%' }- which is a share of the whole grid. That is not the same asflex, which divides only the space left after the fixed columns. -
Group headings stay pinned while you scroll inside a group. Scroll a few hundred rows in and the rows on screen still say which group they belong to, instead of leaving you to scroll back up and check.
On by default, stacking at most two - each heading costs a row of viewport.
stickyGroupHeaders: falseturns it off and a number sets the cap.grid.rows.groupHeadings(index)returns the same answer directly, for a breadcrumb of your own. -
A data type can refuse a total that means nothing for it. Values that do not add up the way plain numbers do now say so, and the grid fails when the column is configured rather than rendering a confident wrong figure.
Four types use it.
decibelanddecibelAmplitudesum and average in the linear domain and convert back - 90 dB and 90 dB make 93 dB, not 180.ratioandpercentRateaverage by weight, using the column named intypeOptions.weight: a 100% conversion on two visits and a 1% conversion on ten thousand average to 1.02%, not 50.5%.{ field: 'conversion', type: 'percentRate', total: 'avg', typeOptions: { weight: 'visits' } }A type that declares nothing supports every aggregate, so no existing column changes. Custom types can declare
totals.supportedandtotals.implementto do the same. -
Grids can be kept column-aligned.
alignedGridslinks two or more grids so they read as one table split into sections - a summary band above a detail grid, or two datasets side by side under identical columns.const summary = createGrid(top, { columns, rows: totals }); const detail = createGrid(bottom, { columns, rows, alignedGrids: [summary] });Column widths, order, visibility, pinning and horizontal scroll are shared. Sort, filters, selection, grouping and the rows stay independent - sharing those would make one grid with extra steps rather than two aligned ones.
Also adds
grid.scroll.to({ top, left }). -
grid.scrollreaches a cell, and takes a row key.scroll.toCell(row, colId)moves both axes in one call, andtoRownow accepts a row key as well as a display index - a key is what a caller usually holds, and it follows its row through a sort where an index does not. -
Rows can be dragged between grids.
rowTransferlets one grid send rows to another - a catalogue beside a basket, an available list beside an assigned one.createGrid(left, { columns, rows, rowReorder: true, rowTransfer: { receive: false, group: 'order' } }); createGrid(right, { columns, rows, rowTransfer: { send: false, group: 'order' } });Off by default.
sendandreceiveare both on when the option is present, so a one-way drag is expressed by turning off the direction you do not want.mode: 'copy'leaves the row where it was, andgroupstops unrelated grids on a page accepting each other’s rows.The target adds before the source removes, so a refused transfer - a duplicate key, most likely - loses nothing.
row:sent,row:copiedandrow:receivedreport what happened. -
Rows can be reordered by dragging.
rowReorder: trueadds a drag handle to the first visible column, andAlt+Shift+arrows does the same without a pointer.grid.rows.move(key, to)is the same operation from code.createGrid(element, { columns, rows, rowReorder: true }); grid.on('row:moved', () => api.saveOrder(grid.rows.data()));The move reorders your data and tells you it happened; writing the new order somewhere permanent is yours, since only you know where it lives.
It is refused while a sort, filter or grouping is active, and says why rather than springing the row back: dropping between two visible rows says nothing about where it belongs among rows that are hidden or reordered.
-
Press
?for the keyboard shortcuts. The grid ships twenty-odd bindings - arrows to navigate, Ctrl with arrows for the ends, Enter to sort a heading, Alt with arrows to resize it, letters to group from the tool panel - and nothing in the product told anyone they existed.The overlay is generated from the bindings themselves rather than written out, so it cannot drift from what the grid actually does, and a build check fails if a binding is added with nowhere to show it. Every description is translated in every catalogue. Modifiers follow the platform: the grid reads Ctrl or Cmd, so a Mac is shown Cmd.
Escape closes it and focus returns exactly where it was.
shortcuts: falsesuppresses it for a host that wants?for itself. -
Rows can span every column.
fullWidthdraws matching rows as a single band across the whole grid instead of dividing them into columns - a section banner, an explanatory note, an empty-group message, a “load more” affordance.createGrid(element, { fullWidth: { when: (row) => row.data.kind === 'section', render: ({ data }) => data.title, }, });The band is drawn over the pinned regions as well as the centre, so it spans every column, and it holds still while the columns scroll underneath. A full-width row is still one of your data rows - counted, sorted, filtered and exported like any other; only its presentation changes. For a row that should not be part of the data, use
pinnedTopRows. -
The column menu takes your own items.
columnMenuacceptedtrueorfalse; it now also accepts(params, defaults) => items, the same formcontextMenuhas always taken. It covers both routes into a column’s menu - the header’s 3-dot button and a right-click on the heading - andparamscarries{ colId, column, grid }.columnMenu: (params, defaults) => { const month = params.column.def.context?.month; if (!month) return defaults; return [...defaults, { name: 'Select quarter', action: () => selectQuarter(month) }]; }As with
contextMenu, an empty array suppresses the menu and returning nothing leaves the defaults alone.
Fixed
-
Text a numeric cell cannot read is refused rather than accepted. A refused value keeps what was in the cell, marks it with the reason, leaves the editor open holding the typed text, and stays in the cell rather than moving on. Clearing a cell deliberately still works: a blank field and an unreadable one are different answers.
-
Counts are pluralised by the rules of the language, not by an English suffix. Where the grid said “1 row” or “12 rows” it applied an
n === 1test, which is wrong in most of the languages now shipping. French makes zero singular - “0 ligne”, not “0 lignes”. Polish, Czech and Ukrainian need four forms, so five rows and two rows take different words. Romanian inserts a word from twenty upwards: “2 rânduri” but “20 de rânduri”. Arabic has six categories including a dual for exactly two. Japanese and Hungarian have effectively one. Plural selection now uses the platform’s own rules. -
Numbers inside messages are formatted for the locale. Several counts were interpolated directly and rendered ungrouped - in announcements, and in the
jsontype’s summary of a large value, which now reads[5,000 items]rather than[5000 items]. -
Lists inside announcements are joined the way the language joins them, rather than always with a comma.
-
Numbers are read in the grid’s locale on every write path. Typing
1.234,56into a number filter or editor already worked; pasting it into a cell, or writing it through the value pipeline, now reads the same way.Group and decimal separators come from the locale, including Switzerland’s apostrophe and the narrow no-break space French uses. A grid with no
localebehaves exactly as before. The guards that refuse bare arithmetic and stray formulas are unchanged, so2*3is still refused rather than stored as 23.
[1.5.4] - 2026-08-20
Fixed
getVersion()is exported from the module. The reference has always said it is available there, “for when you have no grid to hand”, and it was not: it was imported into the entry point and never re-exported, so the unused import made it look as though it were.grid.getVersion()on an instance was unaffected.
[1.5.3] - 2026-08-20
Fixed
- The
lattice-grid.cssspecifier resolves. It pointed at the unminified stylesheet, which is not part of the published distribution, so importing it failed. Both it and/cssnow resolve to the stylesheet that ships.
Changed
-
Bundle sizes are no longer quoted in the documentation. A figure in prose is wrong from the next release onwards, and several were: the adapters were described as far smaller than they are.
-
The web component’s constraint is documented. It carries the grid inside it, which is what makes it a drop-in custom element. Loading it alongside
createGridin the same page therefore gives you two independent copies of the grid, each with its own registries - a renderer, editor or data type registered through one is invisible to the other, and nothing errors to say so. Use one route or the other; the reference and the README now say this.
[1.5.2] - 2026-08-20
Fixed
-
require()returns the library rather than an empty object. The package declares"type": "module", which makes every.jsfile in it an ES module - including the UMD build thatrequireresolved to. Node parsed the wrapper as ESM, wheremoduleis not defined, so the branch that assignsmodule.exportsnever ran and the call handed back an empty namespace.It did not throw, which is why it survived:
require('@toclocoinc/lattice-grid')succeeded and returned{}. The CommonJS entry now ships as.cjs, which opts out of the package type whatever it is, and the build refuses to produce a manifest whoserequireentry is a.jsfile.The
<script>tag path is unaffected and still loadslattice-grid.min.js; no manifest is involved there. -
requireresolves to a file that is published. It pointed at the unminified UMD build, which is not part of the public distribution.
[1.5.1] - 2026-08-20
Fixed
-
The framework adapters can be imported. They shipped in
modules/and were unreachable: a package’sexportsmap blocks every subpath it does not list, and the adapters were not listed.@toclocoinc/lattice-grid/modules/reactand its siblings now resolve, verified by installing the built package and importing each one. -
The package name matches what you install. The manifest was published under the repository’s unscoped name while the package itself is scoped, so every import example in the documentation named something that does not exist.
-
The README describes what actually ships. It opened by claiming no framework wrapper, in a distribution containing four of them, and never mentioned the adapters at all. It also listed unminified builds that are not published, and carried a trademark notice for marks that do not exist. The repository README and the published one are now generated from one source, so they cannot disagree again.
[1.5.0] - 2026-08-20
Added
-
targetSize: 'large'raises hit areas for touch. The grid meets the minimum target size on its own, but that minimum is a conformance floor rather than a comfortable size for a finger; both mobile platforms recommend nearer 44 pixels. This raises the targets and leaves the type alone, which is the distinction that matters - a touch user wants a bigger target, a low-vision user wants bigger text, and density is already the control for the second. The two combine.It applies by itself under a coarse pointer, because the person holding one is both who the criterion is for and the least likely to go hunting for a setting.
targetSize: 'default'opts out.Density alone does not do this: measured at every preset, it scales the header, the rows and the type while the affordances inside them stay exactly as they were - the menu button 24 pixels, the filter 16, the resize grip 10. A grid with nothing configured measures exactly as it did.
-
Grouping, values and pivot no longer need a pointer. They were expressed by dragging a column into a zone in the tool panel, so on a grid without that panel there was no route to them at all, and for a keyboard user there was none anywhere. With a column focused in the panel’s list,
G,VandPput it into row groups, values or pivot columns - and take it out again, because a binding that only adds leaves no way back. Each change is announced.The column menu now carries the same three, alongside the grouping item it already had, so the functions are discoverable rather than only memorable.
-
rows.forEachAll(fn)walks the data rather than the view.rows.forEachvisits what is on screen - filtered, sorted, grouped, collapsed rows left out - which is the right default and the wrong answer for a caller totalling a column, exporting, or reconciling against another system. There was no way past the filter short of reading your own source array back.grid.rows.forEachAll(row => { total += row.data.amount });Leaf rows only, in the order they arrived: group rows are a product of the current grouping and are not in the data, and the sort belongs to the filtered view. A remote or paged source holds the page it has fetched rather than the whole set, so it says so instead of quietly handing back the filtered rows.
-
The grid says what changed, not only that something did. The live region announced three things - sort, filter and selection - and everything else happened in silence. Collapsing a group moved a hundred rows out of view without a word, which is worst for the user who cannot see them go.
Now announced as well: grouping applied or cleared, a group expanded or collapsed with what remains visible, page changes, undo and redo naming the action they reversed, a paste with the number of cells written, and rows arriving or leaving on a live feed.
The feed is summarised on a two-second interval rather than announced per batch. A screen reader queues what it is given, so narrating a fast feed leaves the user listening to counts that stopped being true several seconds ago - worse than saying nothing. Changed values are deliberately not announced: they already show themselves, and “500 updated” every two seconds is the noise that makes someone switch the grid off. Holding the feed silences it entirely, which is the point of holding it.
-
An accessibility section, with the keyboard map published in full. The bindings existed in the build and were documented nowhere, so the ones the grid already had were undiscoverable. The reference now carries the complete map, what a screen reader is told, the colour and contrast position, and the known limits stated plainly rather than omitted.
The map is generated from the bindings the build ships and checked on every build, so it cannot drift. A keyboard reference that is wrong is worse than none: a user who tries a listed binding and gets nothing concludes the grid is broken rather than the page.
-
The accessibility checks run on every build, across every shape of grid. They existed and ran only when a developer opened the devtools panel, so nothing stopped a refactor undoing them. They now run in the test suite against the configurations that differ structurally - flat, grouped, tree, pinned at both ends, editing, paginated and with a tool panel - because a flat grid is the easy case and the one that never breaks.
The rules were extended to cover what a grid gets wrong: the role matching the data, hierarchy rows carrying their position, headings being focusable, dialogs declaring their modality, the live region existing, target sizes, and chrome staying inside the grid. Colour rules are skipped where a document has no computed styles rather than bringing the checker down with them.
-
Popups behave the way they describe themselves. The comment panel keeps Tab inside itself and returns focus to the cell that opened it - behaving modally - while declaring
aria-modal="false", which tells a screen reader the grid behind is still available when it is not. It now declares what it does.The filter popup had the opposite problem: a dialog with no way to dismiss it from the keyboard and no focus return, so a user who opened it lost their place.
Escapenow closes it and focus goes back to whatever opened it. It stays non-modal, which is correct for it - the trap and the declaration now agree in both directions. -
The status strip no longer pushes its controls off the grid. It was a flex row that could not wrap and had nothing to scroll it, so on a narrow grid its last children simply ended up outside: at 320 pixels the next-page and last-page buttons sat 8 and 36 pixels beyond the right edge and could not be reached by any means. That is loss of functionality under WCAG 1.4.10 Reflow, not an untidy strip. The strip and the pager now wrap. Nothing changes above about 480 pixels, where they never overflowed.
-
Every control meets the minimum target size. WCAG 2.5.8 asks for 24 × 24, or 24 pixels of clearance, or the same function on a control that qualifies. Measured across all three densities, four controls met none of the three.
The page buttons were 19 × 19 with 22 pixels between centres, failing on size and clearance together; they now carry a 24-pixel floor that holds even when a host’s font size would otherwise shrink them.
In a column heading, the menu button is now 24 × 24 and stops clear of the resize grip, which is absolutely positioned over the cell’s edge and had been taking two pixels of it. That one change settles the heading: the grip gains the clearance it needed and passes on spacing, and the filter icon passes on equivalence, because filtering is a menu item and the menu button now conforms. Only the hit area grew - the glyph is unchanged until you hover it.
The cost is about eighteen pixels of heading furniture per resizable column, which is noticeable on a narrow column at spacious density, where the title has least room to give.
-
The tool panel’s column list is operable from the keyboard. Its rows reordered by dragging a grip, and the panel had no key handling at all - so the place a user goes to rearrange columns in bulk could be read and not changed without a pointer. The rows are now focusable, arrows walk the list, and
Shiftwith an arrow moves a column, matching the header’s binding so there is one gesture to learn rather than one per surface. Each move is announced, and a column already at an end says so rather than going quiet. -
The column menu can move and size a column. Reordering and resizing were expressible only as a pointer drag, so neither was discoverable and neither worked on a touch device, where there is no hover to reveal the resize grip. The menu now carries Move left, Move right, Move to start, Move to end, and a Width submenu of Narrower, Wider and Fit to content - the same steps the key bindings use, so the two routes agree rather than disagreeing by pixels.
Actions that cannot apply are disabled rather than hidden, so the menu keeps its shape from column to column and the greying is itself the explanation.
-
A refused edit says so, not just shows so. An optimistic write the provider rejects is rolled back and the cell marked for a couple of seconds. That marker was purely visual, so a screen reader user committed an edit, heard nothing, and had no way to learn the value had gone back - the highest consequence silence in the product, because the user believes they saved something they did not.
The rollback is now announced with the column, the value restored and the reason the provider gave. A batch that is refused wholesale produces one message with a count rather than one per cell, and an accepted edit stays silent, which is correct for the ordinary case.
-
history:appliedreports an undo or a redo.history:changedfires whenever the undo and redo stacks move, including when a new action is pushed onto them, so it cannot tell a host that something was actually reversed. The new event carries the direction and the step. -
Rows in a hierarchy report their position among their siblings. A tree or grouped row now carries
aria-posinsetandaria-setsize. Virtualising means most of a branch is not in the document, so a screen reader could not count the set and said nothing about position at all - “expanded, level 2” with no indication of whether that was the first of three or the last of four hundred. A flat grid writes neither, since the row index already answers it. -
A read-only grid says so.
aria-readonlyis set when editing is off, so the grid is distinguishable from an editable one before a user tries to edit it and nothing happens. -
Forced colours: the opt-out no longer leaks into a whole row.
forced-color-adjustis an inherited property, and it was set on selected cells - so everything inside a selected row silently opted out of forced colours and kept its own palette. A status pill in a selected row held its own pale background and its own text colour against the system’s selection ground, and the row’s high-contrast text colour never reached the text at all.Selection now opts out at the cell, so the system’s selection colours reach its text, and opts the decorations inside it back in, so a pill is forced like anything else. Both halves are needed: without the opt-out the browser paints the cell’s children on a plain background and the text disappears; with it alone, everything inside keeps its own palette.
Elsewhere the opt-out is gone. It is only ever needed to preserve an author colour, and it now appears on two leaf elements: a colour swatch, where the colour is the value, and the dot identifying a collaborator. A peer’s cursor and range are marked with a border instead, because those are cells with text inside them.
Progress and histogram fills were losing their forced-colours treatment to more specific variant rules while still carrying the opt-out - the worst of both, a bar keeping an author colour with the browser told not to correct it. They now match at the specificity of the rules they override.
-
Windows High Contrast Mode is supported. The grid had no
forced-colorshandling at all, so in that mode every piece of meaning it carries in a background tint was simply lost: which row was selected, which cell had changed, which rows were added or removed, where a pinned region ended, what a status pill signified.State is now translated rather than recoloured. Selection takes the system’s own selection colours. Pinned regions swap their shadow, which the mode does not render, for a rule. Pills, fill decorations, progress tracks and histogram bars each gain a border, since a fill with no edge disappears once its colour is discarded. Diff states stop relying on hue and are told apart by border style - solid for added, dashed for removed, doubled for changed - because the mode offers no way to hold four colours apart.
A colour swatch and a collaborator’s presence colour keep their own colour, declared explicitly: there the colour is the value, and translating it would destroy the meaning rather than carry it.
-
The column header is operable from the keyboard.
Ctrl+Alt+Hmoved focus onto a heading and nothing there responded to a key: sorting, the column menu, resizing and reordering were all bound to pointer events only, so a keyboard user who reached the header was stranded. With a heading focused:Keys ←→Move between headings Ctrl+←→First / last heading EnterorSpaceSort by the column, Shiftto add to the sortAlt+←→Resize the column, Ctrlfor a coarse stepShift+←→Move the column Alt+↓Open the column menu ↓orEscapeReturn focus to the data Resizing and moving announce what they did, and a resize that has reached the column’s
minormaxsays so rather than appearing to do nothing. The modifiers follow the convention already established elsewhere in the category, so the bindings are the ones most users will already have.
Fixed
-
A rejected value is associated with the reason for it. Editors set
aria-invalid, which says a value was refused and not why, and the message was written only as atitle- a tooltip, not a reliable accessible description.aria-errormessagenow points at the text in every case, so a screen reader reads what is wrong rather than only that something is. Where an editor already displayed the fault, that element is referenced rather than a second one being built. -
The grid’s role follows its configuration. The root role was read once, when the grid mounted. A grid given tree data afterwards went on describing itself as a flat
gridwhile rendering a hierarchy, leaving its rows carryingaria-expandedandaria-levelwith no role for them to belong to. It is now re-read whenever the configuration changes. -
A moved column reorders the headings, not only the data. Moving a column reordered the leaf order the body is built from and left the retained column group tree, which the header is built from, exactly as it was. The headings were then repositioned individually, so the header looked correct while the document still held the old order.
That gap is invisible on screen and decisive for a screen reader, which reads the document rather than the pixels: the heading order no longer matched the data beneath it. It also affected the order headings are reached in when moving between them by keyboard. Both descriptions of the order are now kept in step, on a plain grid and with grouped, generated and pinned columns present.
-
A moved column lands where it was dropped on a grouped grid. The drop target was resolved to a position counted across the visible columns and then applied to the full column order, which are the same list only when nothing is grouped, hidden or generated. With a row-grouped column or a selection checkbox present, a dragged column landed one or more places away from where it was released, and further away the more columns were grouped.
[1.4.0] - 2026-08-17
Added
-
grid.diff.swap()shows the snapshot as the data. A snapshot is held as plain objects and never enters the columnar store, which is why a removed row can be displayed but not sorted or filtered among the live ones. Swapping is the answer to that rather than teaching the pipeline to work across two data sets: the old rows become the real rows, with the whole pipeline behind them, and what was live becomes the comparison.grid.diff.swap(); // now looking at yesterday, compared against today grid.diff.swapped; // true grid.diff.swap(); // and backBoth sets are already in memory, so this costs one ingest of each - real work on a large grid, and a deliberate action rather than something to put behind a toggle that fires per keystroke. The comparison reverses: what was an addition is now a removal. That is what looking at the change from the other end means, so
swappedreports which way round the grid is anddiff:swappedannounces it. -
diff.removedRowsshows what was deleted. An audit view that reports additions and edits and silently omits deletions is telling half the story, and a removed row still exists in the snapshot even though it is gone from the data. One option decides both questions it raises:diff: { snapshot: yesterday, removedRows: 'pinned' }'pinned'shows it beneath the rows, struck through and dimmed, outside the row set - not counted byrows.count(), not exported, not selectable.'data'appends it to the set instead, so it is counted and exported. Omitted, nothing changes: it stays out, as it always has.Neither mode sorts or filters a removed row among the live ones - its values are the snapshot’s, and ordering yesterday’s numbers among today’s would present two data sets as one. Neither lets it be edited, since there is nothing left to write to; a write aimed at one is refused rather than reported as applied.
-
pivot.groupTotalsadds a total beside the pivoted columns. Pivoting by Country turns one Sales column into one per country; this adds the column that was there before the pivot took it apart - sales across all of them.'before'places the group at the near edge,'after'at the far edge, andtotalsLabelheads it. Omitted, a pivot has exactly the columns it always had.pivot: { groupTotals: 'after' }The option was declared and documented in 1.4.0 and did nothing; it is now implemented. It reads the reduction the group row already carries rather than computing a second one, and its columns count towards
maxColumns. -
The status bar reports unresolved comments on hidden rows.
comments.hiddenUnresolved()has always been able to answer the question and had nowhere to say it, so the only sign of an outstanding thread was a marker on a cell - and a filter that hides the row hides the marker with it. Acommentspanel now carries the count:1,204 of 20,000 rows 3 unresolved comments on hidden rowsIt is in the default panel set and silent whenever the count is zero, so a grid with nothing outstanding is unchanged. It stays silent while the comment index is partial, matching the count itself, which reports zero rather than a total it cannot stand behind.
Fixed
-
A pinned-end column no longer splits in two when the columns do not fill the grid. Its header sat against the right edge of the viewport while its cells stopped just after the last centre column, so one pinned column appeared as two: a stranded strip of values, and a heading far to the right of them. The regions now share an edge at every width.
A pinned region holds the viewport edge only while there is something to scroll. Where the columns do not fill the grid, nothing does, so the pinned columns sit directly after the centre ones and the spare width falls beyond them all - space at the end of the table rather than a band closed on both sides, which read as an empty column.
It showed up wherever the columns are narrower than the grid - fixed widths, or a
flexcolumn that has reached itsmax- and grew worse as the window widened. Grids whose columns already fill the width were never affected and are unchanged. -
layout.minWidthcorrected tolayout.minin the reference. The option has always beenmin; the sizing example named a key the grid does not read, so a flex column copied from it had no minimum and said nothing about it. -
An attribute selector no longer throws where
CSSis absent. Opening a comment thread read theCSSglobal through a guard that could not guard it: testing an undeclared name throws before the test is reached. Anywhere the global is missing, opening a thread raised aReferenceErrorfrom the event handler. The check is nowtypeof-based, and the fallback escapes the row key rather than passing it through, so a key containing a quote resolves to its cell instead of a broken selector. -
A column with no
typenow takes one from the data. Type inference was built and reachable through the column model, and nothing in the grid ever called it, so an undeclared column resolved totextwhatever it held. A column of numbers arrived left-aligned with a text filter and a text editor, and exported as text. Sampling now runs before the store is laid out, so the inferred type reaches storage as well as display:columns: [{ field: 'quantity' }] // number: right-aligned, numeric filterOnly undeclared columns are touched - an explicit
type, apreset,columnDefaultsand alookupall win, andtype: falsekeeps sampling off. Inference runs once, when rows first arrive, so a grid built empty and filled after a fetch infers on that first load and a later load cannot re-type a column underneath a formatter configured around it.Check columns you left undeclared. A column that was text by accident and suited you that way now becomes what its data says; declare
type: 'text', ortype: false, to keep it as it was. Sorting is unaffected either way: the default comparator is value-aware, so an undeclared numeric column already sorted 2.5 before 10. -
sampleSizereaches inference. The option was accepted and never passed on, so every grid sampled the default hundred values per column. A grid whose columns change character further down the data can now say so:sampleSize: 500 -
A template binding that resolves to nothing says so. A bare field name -
{{ amount }}rather than{{ data.amount }}- compiled without complaint and rendered empty for every row. The compiler had always collected these and nothing reported them. It now names the binding and the column, and stays quiet for a realcell.propsbinding, which lands in the same set. -
A rail action with a function title shows its text. On a text rail the button was built with neither text nor icon - an empty box that still worked when pressed - because a function title was skipped when the label was written and only ever re-read into the tooltip.
-
capture({ download: true })saves without a file name. It required afileNameas well, so asking for a download and not naming the file returned the blob and wrote nothing, which reads as the capture having failed. A name is generated when none is given, as the other exporters do. -
The trial watermark honours
prefers-reduced-motion. Its move between corners is a timer rather than a CSS animation, so the stylesheet’s media query could not reach it. It now stays in one corner for a reader who has asked for less movement. -
setSpotlightsays when nothing will happen. The spotlight is only painted while a presentation is running, so arming one on a stopped grid changed nothing and reported nothing. It now says so, and points out that stepping to a view clears the spotlight - so a deck should be started before one is armed, not after. -
config.formulaFunctionsreaches the evaluator. A registered=VAT(price)was refused exactly as an unknown name is, because neither the edit path’s parameters nor the value pipeline’s carried the registered functions through to the formula. The closed list still closes: registering one name does not open anything else. -
A formula can be typed into a number column. The number editor read its box as a number before the column’s own parser saw it, so
=1+1emptied the cell and=quantity*2silently stored2. Formula entry needed a text editor to be reachable at all. Text that starts as a formula now passes through untouched; anything else parses as a number exactly as before. -
Naming an editor without enabling editing says so.
edit: { editor: 'text' }leaves a column read-only - editing is opt-in and stays that way, since inferring it would make one line ofcolumnDefaultsturn every column editable - but the combination now warns instead of quietly doing nothing. -
The formula helpers are in the bundles.
evaluateFormula,parseFormula,referencesOf,looksLikeFormula,FormulaErrorandFUNCTIONSwere declared as top-level exports and absent from every build, because the package barrel the bundles are made from did not carry them. -
Type declarations corrected.
toolPanel.actionsdeclared four rail action names where nine ship, and theRailActiontype for supplying your own was declared and never referenced - both are now in the signature.grid.elementis documented as what it is: the element you passed tocreateGrid, not the grid’s own.latticeroot, which it builds inside - soclosest('.lattice')never matches it and a theme attribute set on it does nothing.toolPanel.panelslistsformattingalongside the other four. -
Every kind of body row says what it is. A group heading, a group footer and an inline grand total were all bare
lat-rowwith identical cell classes, so none could be themed or told apart - the only discriminator was the shape of the row key. The pinned grand total was the one exception, reachable through the sticky container, which meant the same logical row was styleable pinned and unstyleable inline. Rows now carrylat-row--group,lat-row--group-footer,lat-row--grand-totalandlat-row--detail, with a light default treatment you can override. -
Pinned columns that leave no room say so. Pinning more width than the grid has left the unpinned columns rendering at their minimum widths beneath the pinned regions - present in the DOM, invisible at every scroll position, and silent. The grid now names the widths involved and how many columns are affected.
-
Setting both
autoHeightand arowHeightfunction says which wins.autoHeightdid, silently, while every row still carried the height the function returned - so the function appeared to work while the geometry ignored it. -
autoHeightmeasures rows again. Two faults, and either alone was enough to make it do nothing. Cells werewhite-space: nowrapwith an ellipsis, so text never wrapped and no row could ever want more than one line. And the measurement read the row’s own rectangle - a height written from the height model moments earlier - so it returned the number it had just written, the batch was always empty, and no row was ever patched. Rows now wrap in auto mode, the content is measured from the cells, and the pass runs after the cells exist rather than before. Verified in a browser: wrapping rows resolve to 54px against 24px for single-line rows, tiling contiguously.autoHeightas “the grid takes the height of its rows rather than its container” was never affected and is unchanged. -
A formula cannot read a column the permissions model withholds. Typing
=salaryinto another cell returned the value even wheresalarywaswriteOnly- documented as never shown, exported, copied or searched - orhidden. Formula entry was a read channel those levels did not cover, and it needed no developer tools to use. A reference to such a column is now refused and the formula reports it, rather than resolving to a blank and computing a plausible number from it.A column you have merely hidden from view is unaffected: hiding a column is not a permission, and formulas may still reference it.
-
A pinned row no longer hides the last row of data. With
grandTotalRow: 'bottom'- and now withdiff.removedRows: 'pinned'- the strip is drawn over the body rather than beside it, and the body reserved no room for it. At full scroll the final row sat underneath: visible for a moment in an overscroll bounce, and unreachable otherwise, because there was nowhere further to scroll. The body now stops short by exactly the strip’s height. -
diff.strictNullgives one answer. With it set, the row-level and cell-level questions could disagree about the same cell:statusOfandchangedColumnssaid changed whilecellStatusandisChangedsaid unchanged, in three of the eight ways a value can move betweennull,undefinedand an absent field. The row comparison read the data as you supplied it on both sides; the cell comparison read your snapshot against a value fetched through the store, which normalises an absent value tonull- so the distinction the option exists to make survived on one side only. Both now read the same way, computed columns included. Grids withoutstrictNullwere never affected, since both spellings of absence are equal there by definition. -
The grid announces what it does. It carried no live region, so a screen reader was told nothing when the grid sorted, filtered or changed its selection - the three changes a sighted user sees immediately.
aria-sortandaria-rowcountdescribe the grid to someone who goes looking and say nothing at the moment it changes. A polite live region now announces “Sorted by Amount descending”, “Filtered to 3 rows of 12 rows”, “2 rows selected” and their cleared counterparts. Repeating an identical message is suppressed, since a reader hearing the same count twice has been told it changed. -
columnMenu: falseremoves the menu button. The menu itself was correctly suppressed, but each heading still drew the button that opens it, so every column kept a control that did nothing when pressed. -
A live feed no longer slows down as the grid grows. Applying an update scanned the whole row array to find the row it named - twice, when the row arrived as a patch object rather than the stored one, which is what a feed sends. The cost of one update therefore rose with the size of the grid: 1.1ms on a hundred thousand rows, 4.7ms on four hundred thousand. It is now a key lookup and flat at about 0.01ms whatever the grid holds.
Queuing had a second, compounding fault: each queued message rebuilt the entire pending batch, so a window holding nineteen thousand rows made every further message a nineteen-thousand-row copy and the window as a whole cost time proportional to its size squared. Messages now fold into the batch in place.
Measured on a hundred-thousand-row grid, forty thousand single-row updates: 49 seconds before, 0.18 seconds after - from roughly 800 rows a second to around 230,000. Nothing about the API changed.
-
Tree parents show their totals. A totalled column rolled up under grouping but not under
tree, so a parent row and a synthesised level both showed nothing where the reference says the total covers the tree node. Both now aggregate over their descendants. -
Type-specific format options are reachable. Two of the three documented ways to give a type its options -
typeOptions, and a bag named after the type - were dropped when the column was resolved, and a plainformatobject on a self-formatting type was compiled as a number format. So{ maxUnits: 3 }on a duration printed90,061,500, and{ style: 'clock' }threw out ofIntl.NumberFormatwhile the grid was still being constructed. All three spellings now reach the type, and a genuine number format still compiles as one. -
totalFilteredOnlycan be changed after construction. It was not part of the total stage’s memo key, so switching it left the previous reduction in place - the setting changed and the number did not, and nothing short of a change that moved the grouping would dislodge it. The same applied tototalOnlyChangedColumns. -
A paste with no extent lands at its own size. The target was everything below and to the right of the anchor, and the tiling rule then filled it - a two-row block pasted into a 20,000-row grid was written 10,000 times, as a single undo entry. Passing an explicit
extentstill fills that range, which is what a selection means. -
highlightOnChangeflashes changes that arrive as data. It followedcell:changed, which only an edit emits, so a value replaced throughrows.apply({ update })- a live feed, the case the option exists for - never flashed. A cell whose value has not actually moved still does not flash. -
A refused validation no longer misdirects the next write. When a value failed validation the edit session stayed open, correctly, so it could be corrected where it was typed - but the next
setCellsborrowed it: the write landed on the refused cell instead of the one it named, and was reported as applied. It is now refused, returning0like any other write that cannot land, until the session is closed withedit.stop(true). -
A paste anchored on a non-editable column is refused. The anchor was checked against column permissions but not against
edit, so a column declarededit: falsestill accepted one. The payload was then laid out from a cell that could not be written: the first value was dropped and the rest landed one column to the left of where they were aimed. -
columns.autoSize()measures content. It read back the width the column already had, so every call grew each column by a fixed 24px and a two-character value ended up as wide as a seventy-character one. Columns now fit their contents and can shrink; calling it twice changes nothing the second time. -
A group footer is distinguishable from its group. The footer row carried no class of its own, so it was identical to the heading in the DOM and could not be styled or targeted, and it displayed a row count of
(0). It now carrieslat-row--group-footer, is markedfooter: trueon the row, and leaves the count to the heading. -
Compute kernels load in the shipped build. In
distthe worker never started: its bootstrap fetched akernel.jsthat is not part of the package, and a dynamic import inside the bundle resolved against the bundle’s own location rather than the module beside it. Together they meant the kernels failed to load in every released build, so anything crossingworkerThresholdfell back to the main thread or produced nothing - a header histogram above the threshold rendered no chart at all and logged “compute kernels could not be loaded”. The worker source now travels inside the bundle, so the package is self-contained with no file to serve alongside it. Running from source was unaffected, which is why it went unnoticed.This adds the compiled kernel that should always have been in it.
-
A duplicate column id keeps the first column, not a mangled pair. Two columns resolving to the same id - easily done, since an id defaults to the field - overwrote the first definition while still taking a second slot in the order. The result was that the first column’s settings were lost and the survivor appeared twice in
columns.all()andcolumns.visible(), while the only warning said the second had been ignored. The first now wins, the second is refused, and the warning says which column and why. -
grid.destroy()releases presence. The provider subscription, its throttle timer and the peer map outlived the grid, so a destroyed grid went on receiving peer updates and never announced that its own user had left. -
A fetched page repaints when it lands. With a paged or remote source, the exact row total arriving changed the grid’s height but triggered no repaint - the scrollbar kept its estimate and the new rows stayed unpainted until the next frame from somewhere else, usually the user’s next scroll.
-
An
objectcolumn shows its value. Cells rendered[object Object]unless the column suppliedvalue.format. They now show the same JSON form the clipboard already produced, so what you see and what you copy agree. -
An unsupported date pattern token says so. Tokens the pattern compiler does not implement -
zzzfor the zone,QQfor the quarter - were emitted as literal text, so the letters appeared in the cell. The compiler now names the token once and lists the supported set. Quote it to keep it as a literal.
Changed
-
grandTotalRowacceptstruein the type declarations. The runtime has always honoured it as an inline total row and the reference documented it; only the declaration disagreed, and it excluded the value outright. The stream source’smaxRowswas likewise declared as a property of theopen()request rather than of the source configuration, which is where it is read. -
The default density is documented as
compact. The reference saidstandard; the grid has always started atcompact(23.8px rows). No behaviour has changed. -
A cell menu offers a writing action only where it can write. Paste, Clear, Fill down and Edit cell were enabled whenever any visible column accepted edits, so on a grid with one editable column among several they were offered over read-only cells and then did nothing. Each is now resolved against the column the menu opened on - or, for the range actions, the columns the range covers. Copying is unaffected: reading is not writing.
-
Tree headings count their rows. A level named by a row’s path but with no row of its own reported
(0)however many rows sat beneath it. -
Tree rows caught in a parent cycle go to the orphans bucket. With
orphansnaming a heading, rows with a missing parent were placed under it and rows in a cycle were scattered among the real roots instead. -
A lazily loaded branch always reports how it finished. Collapsing a branch before its rows arrived aborted the fetch silently:
tree:loadingwas emitted and nothing followed it, so anything tracking in-flight branches kept a spinner turning for a fetch that had been abandoned. It now emitstree:loadAborted. -
column.headerdraws the heading. A column could declare a header renderer, props and classes; all of it was accepted and onlyalignwas ever read, so every column produced the stock heading.header.rendernow takes a function, a component, or a name registered inconfig.components, withheader.propspassed through andheader.classapplied to the header cell. -
quickFilterTextandquickModework from configuration. Both were accepted and read by nothing, so a grid built with a quick filter showed every row.quickModealso travels in saved state now: a view saved while searching inwords,fuzzyorregexcame back ascontainsand matched a different set of rows than the one it was saved showing. -
Date filters match again. A condition on a date column carrying an ISO instant -
2026-01-15T09:30:00Z- returned no rows. The column stores the wall-clock day asYYYY-MM-DDwhile the condition was converted to epoch milliseconds, so the two sides were never comparable. Both are now brought to the same form, and a filter value may be an ISO instant, a plainYYYY-MM-DD, aDateor an epoch number. Where a condition does not declare a type, the column’s is used. -
Relative date filters work against in-memory data.
op: 'relative'-last7Days,thisMonth,yearToDateand the rest - was rewritten into an absolute range only on the way to a server. Against a memory source the operator reached the evaluator unrecognised and passed every row, so the filter appeared to apply and changed nothing. It now resolves the same way in both places, per pass, so “today” still means today after midnight. -
config.totalFnsis consulted. Custom total functions registered by name were accepted and then ignored: the reducer knew only its built-ins, so a column totalled with a registered name showed no total and the console advised registering it inconfig.totalFns- which is where it already was. Passing a function inline always worked and is unchanged. -
Clicking a row selects it. Row selection was reachable from the keyboard and from the checkbox column, but a mouse click only announced
row:clicked- nothing selected. Two faults met here: the click handler never asked for a selection, and the keyboard path that did asked for a method the selection model does not have, so it was silently discarded. Clicking now selects, Ctrl/Cmd adds and removes, and Shift extends from the anchor. A click on a link, button, input or expander inside a cell still belongs to that control, and the checkbox and detail columns keep their own behaviour. -
export({ rows: 'all' })now reaches past the filters.'all'and'visible'produced identical output, so a caller exporting everything silently got only the rows the filters had left.'all'now covers every loaded row in the source;'visible'is unchanged. -
export.print()honoursunpinand its row ceiling. Neither worked from the grid: the ceiling compared against a row count it could not read, so a print of any size was allowed through, and the unpin step looked for the column API in a place the grid does not put it, so pinned columns stayed pinned and were clipped at the page edge. -
Type declarations corrected.
HistoryEntrydescribed akindfield that no entry has ever carried, and omitted the six that every entry does -seq,type,label,target,atanddelegated.toolPanel.panelslisted four built-in panels; there are five, the fifth beingformatting.EditorNamenamed seven of the twenty-two editors that ship, and the twenty-four built-in cell renderers had no declared names at all - both are now full unions that still accept your own registered names. -
WARNING_IDS.NO_ROW_KEYmatches the warning it names. The constant held an id nothing emits, so filtering diagnostics on it matched nothing. The source layer’s separate warning is now named too, asWARNING_IDS.SOURCE_NO_ROW_KEY. -
grid.diff.isChanged(key)answers the row-level question. The column argument is optional and omitting it asks whether the row changed, but the call was routed to the per-cell comparison regardless - which resolved no column and returnedfalsefor every row, including rowsstatusOf()reported aschanged. Calls passing a column id were correct throughout. -
Diff marks survive the first paint. Row and cell diff classes were written before the cell layer filled the rows, and filling a row rewrites its classes - so the marks were erased a moment after being applied and an audit grid showed no highlighting at all. They are now written after each render completes, as the other overlays already were.
-
allowUnsafeTemplatesno longer permits script. The flag turns off HTML escaping so a template can render markup, and the value interpolated into a{{{ }}}segment reachedinnerHTMLuntouched - so a row value containing<img src=x onerror=…>executed, and ajavascript:URL in a value survived into the rendered link. The compiler’s existing refusals covered dangerous tags and URL schemes written in the template, which you author and can audit, and not the value, which usually arrives from your data.Interpolated values now go through the same rules: executable tags (
<script>,<iframe>,<style>and the rest),on*handler attributes, andjavascript:/data:URLs are removed, including entity-encoded spellings. Presentational markup - emphasis, links, spans - is unaffected, as is any value with no markup in it. A string returned fromcell.renderis the same gate and gets the same treatment.Grids that do not set
allowUnsafeTemplateswere never exposed and are unchanged. This is a narrow allowance rather than a general sanitiser: to render arbitrary third-party HTML, sanitise it yourself and return an element fromcell.render. -
Esc now ends a presentation instead of only leaving full screen. A presentation runs full screen with its chrome hidden, and Escape exited full screen while leaving the presentation running - so the grid returned to the page still enlarged, still dimmed by any spotlight, with the tool panel that carries the stop control still hidden and no way to turn it off from inside the grid. An open editor or menu still answers the key first. A grid configured with
maximise: falsenow binds Escape directly. -
grid.presentation.reset()now exists. The documented method - and theRkey it is bound to, which puts the current view back as it was saved - was absent from the model, so pressingRduring a presentation raised aTypeError. -
Presentation no longer leaves an empty bar across the bottom. The status bar and the pager share one strip. Hiding both left the strip itself in place, so a presentation with no chrome still showed a blank band. The strip now goes when everything in it has gone, and stays when
chrome: ['statusBar']orchrome: ['pagination']asks for it. -
Annotations now land under the pointer on high-DPI displays. The drawing layer was sized in device pixels without a CSS size, and a canvas is a replaced element - so it laid out at its backing-store size rather than filling the grid. At a device pixel ratio of 2 a mark made 100px down painted 200px down, and the lower-right of the grid could not be drawn on at all. Displays at ratio 1 were unaffected, which is why it showed on laptops and not on external monitors.
-
presentation:capturedreports the image’s dimensions. The payload’swidthcarried the file’s size in bytes - a 1800×600 capture reportedwidth: 41030. It now carrieswidthandheightin pixels (afterscale), with the byte count asbytes. The image’s format moved fromtypetomimeType, because the event bus writes the event name ontotype, so the format never reached subscribers. -
rows.apply({ update })no longer replaces the whole row. An update is a patch: fields absent from it are left alone. Previously the patch was assigned over the row, so a delta destroyed every field it did not mention - and the loss was written through to the columnar store, so the cells read null rather than merely the row object being wrong.// before: b and c were lost, in the row and in the store grid.rows.apply({ update: [{ id: 'R1', a: 'A1' }] });Coalescing had the same fault: two partial updates to different fields inside one window kept only the last, so a feed sending
{price}and{volume}as separate messages lost whichever arrived first.The same fault also defeated the optimisation it should most help. Changed columns were computed against the raw patch, so a field the patch omitted looked like it had become undefined - marking columns dirty that had not changed and re-running the sort and filter stages that selective invalidation exists to skip.
Callers passing whole rows are unaffected.
-
Editing a row hidden by a filter no longer moves the totals. With a filter active, an update to a row outside it changed the grand total by the difference, even though that row contributes nothing to a total over the filtered set - and since the row is not on screen, nothing about the grid explained the change. Totals over the filtered set now ignore rows the filter removed, and
totalFilteredOnly: falsecounts them deliberately. -
An open editor now commits when you click away.
commit()was reachable only from the Enter and Tab key paths, so starting an edit and clicking another cell left the editor open on the first while the selection moved to the second - two cells looking active at once, and a value that was never written. Clicking outside an editor commits it, as Enter does. Clicking inside it - including a popup editor’s calendar or dropdown, which renders in the overlay layer rather than in the cell - leaves it open. -
The generated control columns no longer offer a cell context menu. The selection checkbox and detail expander columns hold controls, not data - right-clicking one offered copy, filter and edit actions with no value, column or cell to apply them to.
-
A nested detail grid keeps its own pointer and key events. A detail region hosts a whole grid inside one of the outer grid’s row elements, and both grids delegate their listeners from their own root - so clicks, right-clicks and keystrokes inside a detail reached the outer grid as well. A right-click on a nested cell opened two context menus; a double-click walked up to the inner row’s key and started an edit on whichever outer row shared it, which two grids over the same id scheme routinely do; and arrow keys moved a focus ring in the outer grid while the user typed in the inner one.
Added
-
Incremental grand totals. On a memory source the grand total row is now maintained across cell updates instead of re-reduced. A single-cell update on a million rows with four totalled columns went from 9.3ms to 0.16ms - the same cost as having no totals row at all.
{ field: 'amount', type: 'number', total: 'sum' } // nothing to configuresum,avg,countValues,minandmaxon numeric columns are maintained by difference. The reported number is unchanged: where a running value cannot be trusted the column falls back to a full pass rather than reporting a value it is unsure of. That happens when a value moves off the currentminormax, when rows are added or removed, when the filter, sort or grouping changes, and when a total grows past the magnitude at which a 64-bit float stops registering small changes. Running sums are compensated, so a long editing session does not accumulate error.A custom
totalfunction is re-reduced on every change, as before - a reduction supplied as a function has no inverse. Group totals are also re-reduced; only the grand total is incremental.Placing the grand total inline (
grandTotalRow: true) no longer rebuilds the display array on every change, which is where the remaining cost sat for large grids using the default placement. -
Two more themes, and
themenow does something.high-contrastandterminaljoin light and dark.createGrid(el, { theme: 'high-contrast' }); grid.set('theme', 'terminal'); grid.set('theme', null); // back to following the viewerconfig.themenever reached the DOM. The token sets hang off.lattice[data-theme]and nothing wrote the attribute, sotheme: 'dark'styled nothing - a grid only went dark when the viewer’sprefers-color-schemehappened to say so, and a page that set the attribute on<html>instead was not selecting the grid at all.high-contrastis 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 3:1 for 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.terminalis a phosphor console: one hue on near-black, monospaced, with status carried by brightness rather than colour.The variant-contrast test now covers all four themes, holding high contrast to AAA where the others are held to AA.
-
Tree data. Rows form a hierarchy, by parent reference or by path. The row model implemented this and nothing ever loaded it - the memory source builds the display array itself, so a configured
treereturned a flat list.tree: { parentKey: 'parentId', label: 'name' } // the row names its parent tree: { path: (row) => row.hierarchy } // the row carries its ancestryParent-reference is what a join or a document store produces; every node is a real row. A row whose parent is missing is an orphan - it goes to the root, or into a bucket named by
orphans, and is never dropped. Path-based rows describe their own place, so levels no row occupies are synthesised and render as group rows; a real row arriving later for a synthesised level fills it rather than appearing beside it. A parent cycle is reported once and cut, with the rows shown at the root.Branches can load on demand.
tree.hasChildrenlets a row declare children it does not hold, so the expander exists before anything is fetched, andtree.loadChildren(row, signal)supplies them when it is opened:tree: { parentKey: 'parentId', hasChildren: (data) => data.childCount > 0, loadChildren: (row, signal) => api.children(row.data.id, { signal }), }Such a node reads as closed until its rows arrive, because an open branch with nothing under it leaves no gesture to load it. The rows are added to the data set, so they sort, filter and export like any other. A branch is fetched once however often it is toggled; closing it before the rows arrive aborts the request; a rejection is reported and leaves the branch retryable rather than permanently empty.
tree:loading,tree:loadedandtree:loadFailedare on the event bus.The grid generates a tree column for the expander and indent, on the same terms as the auto-group, selection and detail columns. Its text comes from
tree.label, falling back to your first visible column. Expansion is the same state group expansion uses, sorows.expand,collapseAlland saved views all work on it, and a collapsed branch is skipped rather than hidden. Grouping andtreetogether is not a combination: the grouping wins and says so once. -
Master-detail. A master row expands into a detail region - by default a nested grid over whatever
detail.rows(row)returns. The row model implemented this and nothing constructed it with a detail factory, so thedetailconfig block was read nowhere and no row could ever expand.detail: { rows: (row) => api.lines(row.data.id), // array or promise config: { columns: [{ field: 'port' }, { field: 'vlan' }] }, isMaster: (data) => data.lineCount > 0, // default: every data row height: 240, cacheLimit: 10, } grid.detail.toggle(key);The grid generates an expander column while the feature is on, on the same terms as the auto-group and selection columns: pinned to the start, and out of
columns.visible(), saved views, exports and the tool panel. The detail is a real display row - virtualised, height-managed, pushing the rows below it down - and any number of masters can be open at once.Regions are cached by row key so collapse and re-expand does not refetch.
cacheLimitbounds what is retained after closing, not how many may be open at once; an open region is never evicted.cacheLimit: 0destroys on collapse. -
An editable detail reports its edits on the master. The detail is a whole grid, so
detail.config.editmakes its cells editable - but that grid is created by the grid, not by you, so its events were out of reach.grid.on('detail:cell:changed', (e) => { e.masterKey; // 'C1' - the row the detail belongs to e.path; // 'ports.1.vlan' - where it lands on the master's record e.value; });detail:edit:started,detail:edit:stoppedanddetail:cell:changedare re-emitted on the master, tagged with the master they came from.pathis the dot notation from the master’s record to the value that changed, worked out by identity -rows(row)usually returns an array already on the record, and that property is the prefix. A detail fetched from a server is not part of the master’s record and reportspath: null; setdetail.pathto name one anyway.detail.onCreate(grid, masterRow)hands over the nested grid itself for anything else. -
detail.target- a detail pane instead of a detail row. New. Point it at an element and the detail renders there rather than into the grid.detail: { target: '#detail-pane', rows: (row) => api.lines(row.data.id) }This is the list-and-pane layout: no detail row is created, so the grid’s row count does not change when a master opens and nothing about the list’s geometry moves. Exactly one master is open at a time - one element cannot show two details, and stacking them turns a fixed-height pane into a scrolling list of grids with no rule for how tall each should be. Expanding a second master closes the first.
grid.detail.active()names the open one.A
targetselector matching no element is reported once rather than failing silently.The expander reflects the placement. Inline it is a chevron that turns down when the row expands, with
aria-expanded- the ordinary disclosure. With a target nothing expands, so it becomes the “opens elsewhere” glyph and a toggle (aria-pressed); a chevron there would promise an expansion that never comes. The chosen row carrieslat-row--detail-activeandaria-current, because with the detail off to the side nothing else in the grid says which record the pane belongs to. -
selection.checkboxandselection.headerCheckboxnow render. Both were documented, and the second was already switched on in the demo, but no checkbox column was ever generated - setting either did nothing.selection: { mode: 'multiple', checkbox: true, headerCheckbox: true }checkbox: trueadds a narrow pinned column of row checkboxes.headerCheckbox: trueputs a tri-state select-all in its heading: unchecked, checked, or the native indeterminate mark when some rows are selected. Clicking it selects every row the filter currently shows, or clears when everything already is - including from the indeterminate state, where the intent is “select the rest”.grid.selection.headerState()returns the same tri-state, for building your own control.The column is generated, not declared: it stays out of
columns.visible(), saved views, exports and the tool panel’s visibility list, and disappears when the option is turned off. -
Selection now reaches the rows.
Row.selectedwas written by a pass over the display array, and any row rebuilt afterwards - by scrolling, by cache eviction, by any repaint outside that window - came back unselected. The keys were correct in the model and nothing on screen ever showed it: no row highlight, andaria-selected="false"reported to screen readers on every row however many were selected. Selected rows now carryaria-selectedand the classlat-row--selected, which the theme has styled all along. -
grid.selection.all()no longer throws. It called a method the selection model does not have. Every call raised aTypeError. -
workerUrlandsharedMemorynow reach the worker. Both were documented and read inside the worker package, but the grid built its worker host without them, so neither had any effect.workerUrl: '/assets/lattice.worker.js', // for a CSP that forbids blob: sharedMemory: true, // where the page is cross-origin isolatedworkerUrlmatters most: under a Content-Security-Policy that forbidsblob:workers, it is the only way a worker can be constructed at all, and without it compute stayed on the main thread with no way to change that.sharedMemoryis off by default - it avoids re-copying a column on every message, at the cost of retaining a shared copy of each column that crosses. Both are settled when the worker is constructed, so changing either discards the running worker and the next offload builds a new one. -
workerThresholdbelow 50,000 now takes effect. The grid passed the value to the worker host under the wrong name, so the host kept its own 50,000 default. A grid configured to offload above, say, 10,000 rows let the call past its own gate and the host handed it straight back to the main thread. Values above 50,000 were unaffected. -
Distributions actually cross the worker boundary. The facet call carried the column’s resolved data type, which holds functions -
parse,compare,matches- and a function anywhere in the arguments makes a call unstructured-cloneable. The one kernel wired to a worker therefore spawned one and then ran everything on the main thread. Only the type’sbaseis needed, and only that is sent now.grid.diagnostics.renders().workerreports the threshold the host is actually using, rather than the configured one, along with thesharedMemoryandworkerUrlit was built with. -
showTotalInHeader. Under grouping or pivot, a totalled column’s heading now names its reduction - a smallSUMline aboveCapacity,AVERAGEaboveMargin. The heading returns to the column’s own title when grouping and pivot are both off. On by default; setshowTotalInHeader: falseto leave headings alone.The reduction sits on its own line rather than reading
Sum of Capacityacross one, because a header cell reserves width for its sort, filter and menu buttons whether or not they are showing - on a default column the label gets 54px of 129px, and a one-line version truncated toSum o…, trading the column’s identity for its reduction. Stacked, it costs no width at all: no heading truncates that did not already.Such a heading carries
data-totalon its header cell, naming the reduction, and the two lines are.lat-header-total-fnand.lat-header-total-name, so a theme can style them;--lattice-header-total-sizeand--lattice-header-total-colorset the reduction line’s size and colour. The full phrase is on the label’stitlefor the pointer. Where a pivot has a single value column, its leaf is titled with the pivot value rather than the column’s name, and that heading is left alone.The option was previously in the defaults but read nowhere, and no heading ever named its reduction.
The tool panel’s aggregation picker now takes its names from the same table the headings use, and gained
Count of values- the reduction that counts present values rather than rows - which it had been missing. -
totalFilteredOnly: false. Totals now reduce the whole dataset when the option is off, instead of always reducing the filtered set.totalFilteredOnly: false // the filter is a lens; totals report everythingBoth the grand total and each group total follow the setting, so a group row shows the total for every row belonging to that group rather than only the visible ones - including groups the filter emptied entirely, which have no row of their own but still count toward the totals above them. The count on the grand total row follows the total, so it never reports fewer rows than the total covers.
The unfiltered row set and its grouping are computed once and reused, so the cost lands on adds and removes rather than on every edit or filter change. On 500,000 rows grouped and half-filtered, a single-cell update measured 2.1ms with the default and 3.2ms with the option off.
The option was previously in the defaults but read nowhere, so setting it to
falsehad no effect. -
totalOnlyChangedColumns. Reduce only the totalled columns an update actually changed, instead of every totalled column on every change. An update that rewrites a field with the value it already held reduces nothing.totalOnlyChangedColumns: trueOn a million rows in seven groups with four totalled columns, a single-cell update went from 15.4ms to 7.3ms when it changed one of them, and from 15.6ms to 7.3ms when it changed none. There is nothing to gain when an update changes every totalled column - the saving is proportional to the columns it leaves alone.
Off by default, because it asserts that each total depends on nothing but its own column. That holds for every built-in reduction. A
totalsupplied as a function also receives the row, the grid andconfig.context, and is only recomputed when its own column changes - so a function reading state outside its column reports the value from the last time that column moved. Adding or removing rows, filtering, sorting, grouping, and changing which columns are totalled all reduce everything again regardless of the option.The option was previously documented and declared in the type definitions but had no effect.
-
Collaborative presence. See who else is on the grid and what they are doing: cursor, selection, active edit, an optional advisory lock, and a peer roster you can click to jump to someone.
presence: { provider, // your transport: subscribe + publish me: { id: 'u_17', name: 'Tony' }, lock: true // advisory - see below }The grid never opens a connection. You supply the transport and the identity; a WebSocket, MQTT, a CRDT library or a polling endpoint all satisfy the interface, and without a provider the feature is inert.
Presence carries intent, never values. A peer’s committed edit must reach the grid as data, through whatever channel you already use. Presence is throttled and lossy by design, so a value carried on it is a value that can be dropped.
Positions travel as row key plus field, never index, so peers who sort and filter differently still see each other on the right records. A peer whose row is not in your view is held, counted, and shown in the roster as “not in view” - not dropped, which would read as a disconnection.
Idle and removal are inferred from local receipt time rather than the timestamp in the message, because clocks between clients disagree. Publishing is throttled rather than debounced, and stops while the tab is hidden.
A peer’s cursor is drawn dashed against your own solid focus ring, so the two can never be confused; their name shows briefly when they move, then fades to the bare border. Nothing is inserted into the grid, so presence cannot shift layout, cover an in-cell chart, or intercept a click.
Locking is advisory. It reduces collisions and does not eliminate them: two clients can enter an edit in the same instant. The authoritative resolution is the conditional write in
edit.commit, which returns a conflict and rolls back. A refused edit announces who holds the cell, because one that silently will not open is indistinguishable from a broken grid.grid.edit.start()now returns whether a session began, which it was previously discarding. -
Flush strategies, a queue ceiling and a frame budget. Queued changes now flush on a frame by default rather than a 50ms timer, which is what makes one repaint per batch reliable - a timer can fire twice between two paints.
updates: { flush: 'frame', // or 'microtask', 'interval', 'manual' maxQueued: 20000, // force an early flush, whatever the strategy budgetMs: 10, // defer the rest of a long flush to the next frame }A frame and a timer are armed together and the first wins. In a foreground tab the frame always wins; in a backgrounded tab, where the browser stops firing animation frames entirely, the timer keeps the feed applying rather than the grid stalling with every queued promise unresolved.
Over budget, a flush returns the remainder to the queue and it lands next frame. The promise a caller holds resolves when their rows land, not when the first slice does.
updates.stats()gainsstrategy,deferrals,maxQueuedandbudgetMs. -
Per-row rejection reporting.
apply()andqueue()results carry arejectedlist of{ operation, id, reason }. A batch of a thousand rows containing three bad ones applies the other 997 and names the three.unknown-idcovers an update or remove for a row that is not there;duplicate-idrefuses a second row under an existing key, which would otherwise corrupt selection, expansion, comments and the key index at once. Rejections are reported, never thrown. -
Diagnostics, and a devtools panel.
grid.diagnosticsreports what the grid is doing: render counts and their causes, memory layout, operation and provider timing, listener counts, effective configuration, and a list of conditions that look like mistakes.const before = grid.diagnostics.renders().dom.cellWrites; await doTheThing(); expect(grid.diagnostics.renders().dom.cellWrites - before).toBeLessThan(200);The API is the interface and the panel consumes it, so the figures above can be asserted in your own tests rather than only looked at.
grid.diagnostics.bundle()produces a support bundle - configuration, query state, timing, warnings, provider statistics, version and environment. It contains no row data, cell values or column values, so it can be attached to a ticket without being read first.Warnings each carry a stable id, a plain description and the values involved: duplicate row keys, a filter naming a column that does not exist, an options object changing identity without changing content, listener counts growing without bound, a large operation running on the main thread, and a slow provider. All of them produce no error on their own, which is why they are worth detecting.
The panel is an optional module that imports nothing - the grid is handed to it - so deployments that never load it pay nothing.
import { createDevtools } from '@toclocoinc/lattice-grid/modules/devtools'; createDevtools({ grid }); // Ctrl+Shift+D collapses itNine tabs, a compact vitals strip, and a render heat overlay that tints cells as they are written: blue for a new value, red for a cell rewritten with the value it already had. The second is the one worth chasing.
The Accessibility tab runs live checks against the rendered grid - ARIA roles and counts, cells with no accessible name, and WCAG AA contrast against the active theme. It checks what was drawn rather than what was configured, because a custom cell renderer can pass every configuration check and still leave a cell unnamed.
The panel observes and never mutates, and nothing leaves the browser.
-
Cell comments. Threaded discussion attached to individual cells, for reviewing data with other people without leaving the grid. A commented cell carries a small triangle in its upper-right corner; clicking the corner opens the thread.
comments: { provider, // without one the feature is inert rowLabel: (row) => row.data.name // so the panel says what is being discussed }The grid owns presentation and interaction only. Storage, identity and permissions stay yours, reached through an asynchronous provider - load the index, load a thread, add, edit, delete, resolve. Rejections roll back an optimistic write rather than leaving the grid showing something your server refused.
A stable
rowKeyis required, and comments are disabled without one rather than falling back to row index. They are keyed on row identity plus field and outlive the values they annotate, so index identity would reattach every thread on the next sort. The requirement is stricter than it first looks: identity must be stable across sessions and across data reloads, not only within one session.The grid authorises nothing. A comment may carry
can: { edit, delete, resolve }and the affordances follow it, but hiding a button is a convenience for the reader and never a control - your provider rejects what it must. Author names and avatars are rendered exactly as supplied; the grid does not know who the user is.Comment bodies are text. Optional
markdown: trueadds emphasis, code and links only, built as elements rather than parsed as markup, with any link scheme other thanhttp,httpsandmailtorefused.A comment records the value it was written against and shows it whenever the cell has since moved, so a note does not appear to contradict what is on screen. Changing a value never removes a comment.
Comments follow their row through sorting and grouping. When a row is filtered out its comments are hidden rather than lost, and
comments.hiddenUnresolved()reports what is still outstanding out of sight.comments.filterToCommented()restricts the grid to rows carrying comments, and refuses rather than half-applying untilloadAll()has covered the whole row set.Comments remain available while streaming. A thread whose row is evicted by a bounded window closes with an explanation.
Alt+Mopens the thread on the focused cell. The panel traps focus while open and restores it to the cell on close, and commented cells announce their count and unresolved count to screen readers. -
Column header histograms, and filtering by clicking them. Each column heading can carry a distribution chart that is also a filter control. Click a bar to filter to that bucket, drag across bars on a numeric or date column to filter to the range. As filters are applied the other columns recount, so a dataset can be explored by clicking through headings rather than opening a dialog.
facets: { enabled: true } // per column, layered over the grid's settings { field: 'price', type: 'number', facet: { strategy: 'quantile' } } { field: 'notes', facet: false }Off by default, because the band roughly doubles the header’s height.
A column is never counted against its own filter. Every other active filter applies; that column’s own conditions are removed before counting, so clicking a bucket dims the others rather than collapsing the chart to one bar. Without that there is no way to see what you excluded or to widen the selection.
The filters this produces are ordinary filters. They undo, serialise into saved views, and appear in the existing filter UI - nothing downstream can tell them from a filter typed into the filter panel. A drag emits a
betweenrange rather than a set of bucket indices, so it still means the same thing after the data is replaced.Numeric columns take
equal,quantileorlogbucketing; date columns pick a granularity from their span; text columns get one bar per value, ordered by count. Text columns abovecardinalityLimitdistinct values are suppressed - a name column has no readable histogram - or show a top-N with an aggregated remainder underaboveLimit: 'topN'. Nulls and NaN land in a terminal bucket rather than being dropped, so the counts always sum to the row count.Each bar shows two readings: its full height is the bucket’s share of the unfiltered column, and the solid fill inside is how much survives the current filters.
Charts are keyboard operable - arrows move between buckets,
Entertoggles,Shiftwith arrows extends a range,Escapeclears - and carry a sentence describing the distribution’s shape for screen readers.Live streams suppress the charts, because buckets moving under the pointer make the control lie about what clicking it will do; pausing the stream brings them back. Paged and remote sources need a
facets.providerto supply counts, and without one the charts are silently absent. -
Compute now runs in a Worker. Distribution counting is offloaded above
workerThresholdrows. This is the first kernel to use the Worker path; sort, filter and grouping continue to run on the main thread. -
Time scrubber.
grid.timelinemoves the grid back through recent data changes - what a row held a minute ago, before the number moved.grid.timeline.attach(); // start recording grid.timeline.seek(5); // five changes back grid.timeline.toLive(); // returnAttaching puts a scrubber on the grid: a slider with how long ago and the clock time beside it, updating as you drag rather than on release. It turns accent-coloured whenever you are off live, and removes itself on
detach().The window it reads is bounded two ways:
updates.logLimitchanges (2000) andupdates.logRowsrows between them (100,000), dropping oldest-first on whichever it reaches. Both are configurable. A cap on changes alone does not bound memory, because one change may carry a single cell and the next fifty thousand rows.grid.updates.stats()reportsheldagainstheldLimit.Cells whose value moved during a seek are marked, and stay marked until the next seek - on a wide row the change you are hunting for is easy to scroll past, and a flash you can miss while reading the other end of the row helps nobody. The colour is
--lattice-timeline-changed. Marking compares rendered column values rather than raw fields, so a computed column that moved because its inputs moved is marked too. Chart columns redraw as you scrub, like any other cell.It reads the change log rather than the undo history: history records what you did, and the question on a live grid is what the data did. Seeking backwards writes back what each change replaced; seeking forwards re-applies the changes themselves.
Recording is off until
attach(), because what a value used to be is not recoverable afterwards, and reading a row per key on every change is real cost on a busy feed. Value changes reverse; row additions and removals do not, and a window containing them scrubs over the value changes and leaves the row set alone. Thedeltarenderer is the one cell type to keep off a scrubbed grid: it samples on a wall-clock timer, so it reads a seek as a real movement and draws an arrow for it. -
Bounded windows for streaming sources.
source.maxRowsturns an endless stream into a sliding window: the oldest rows are dropped as new ones arrive, so a grid left up overnight holds a fixed number of rows rather than every row it was ever sent.source: { mode: 'stream', maxRows: 50000, open: … }Omit it for the previous behaviour, which is no limit. The progress report gains
liveandevictedso a status bar can show the window. Evicted rows are tombstoned rather than spliced out, because the physical index is row identity for the life of the source and shifting it would silently move every cached sort, selection and expansion. The viewport is compensated when rows are trimmed above it, so a user reading history does not have the floor drop out from under them. -
Hold live updates, and see what coalescing saves. A pause button in the action rail, and
grid.updatesbehind it.grid.updates.pause(); grid.updates.resume(); // apply everything held grid.updates.stats(); // { pending, queued, coalesced, ... }Changes keep merging while paused, so forty updates to one row are one row of work when play is pressed.
coalescedis the difference - the number that made the throughput claim true and that nothing previously reported. The status bar shows it, and shows the backlog while paused.grid.updates.log()returns what arrived, in order, with timestamps. It keeps the raw sequence rather than the merged one, survives the flush, and is capped so a grid paused indefinitely holds the recent past rather than all of it.Pausing is deliberately a control rather than something inferred from whether the user seems busy: that heuristic is wrong in both directions, and a grid that stops updating for reasons nobody can see is worse than one that keeps moving.
-
Jump to a page by typing it. The page number in the pagination bar is an input rather than a label: type a number, press Enter. On a thousand-page grid the last-page button and a run of clicks were the only ways to reach page 400.
Out of range clamps to the last page. A blank or non-numeric entry restores the page you were on rather than jumping to the first - a mistype is not a request to go home. Escape abandons the edit.
-
Presentation mode.
grid.presentation.start()renders the grid for a room: full-screen, application chrome hidden, everything enlarged. The data stays live and interactive throughout.grid.presentation.start(); // 1.5x by default grid.presentation.start({ scale: 2, chrome: ['statusBar'] }); grid.presentation.nudge(1); // live, or Ctrl/Cmd + grid.presentation.stop(); // or EscapeThe scale multiplies the configured density rather than replacing it, so a
spaciousgrid presented at 1.5x is still that grid, half as big again. Virtualisation follows the enlargement. Separators soften and selection strengthens for reading at distance, and numeric columns take tabular figures at heavier weight.Full-screen goes through the existing maximiser: a grid already maximised stays maximised when the presentation ends. Chrome hidden on entry is recorded and restored, so anything the host had already hidden stays hidden.
Saved views are the slides. Pass a sequence and step through it:
grid.presentation.start({ views: ['escalations', 'at-risk', 'margin-watch'] });Arrow keys, space and Page Up/Down move between them; Home and End jump to the ends; R puts the current view back as saved, discarding whatever the presenter sorted or filtered after arriving at it. Each view applies through the ordinary
views.apply, so a view means exactly what it meant before.The stepping keys bind only when there is a sequence, and never while something is being typed into - a quick filter answering a question from the room does not advance the deck on the space bar. Stepping past either end sits there rather than wrapping. A position indicator names the view and its place in the sequence.
Transitions are a cross-fade and honour
prefers-reduced-motion.Spotlight.
grid.presentation.setSpotlight({ keys, colIds })dims everything it is not on - rows, columns, or the cells where they intersect. Dimming stops short of illegible so the audience can still see there is more data; it is opacity alone, so a dimmed sparkline keeps its colours. A spotlight is transient and does not survive a view change.Redaction travels in views and undo. It is part of grid state now, so a saved view carries its own masking and toggling it undoes like any other change.
Auto-advance cycles a sequence for an unattended wall display:
start({ views: [...], autoAdvance: 15000 }). It wraps, unlike a keypress.chrome: ['statusBar']keeps named chrome visible.Annotation layer.
grid.annotate.use('pen')puts a transparent canvas over the grid to draw on - freehand, straight arrows, rectangles and a highlighter.grid.annotate.use('arrow', { colour: '#e0245e' }); grid.annotate.undo(); grid.annotate.clear(); grid.annotate.use(null); // hand the grid backIt never reads or writes data. The canvas is not created until a tool is first chosen and is
pointer-events: nonewhenever none is active, so scrolling, selection and editing pass straight through. Marks are stored in content coordinates, so a circle drawn round a cell travels with that cell as the grid scrolls. They are cleared when the presentation ends, and a capture taken while they are on screen includes them.Still capture.
grid.capture({ scale: 2 })renders the grid as it stands to a PNG, for the cases where a static image genuinely is what is wanted.const blob = await grid.capture({ scale: 2 }); await grid.capture({ scale: 3, fileName: 'q3-margins.png' });It photographs the browser’s own rendering rather than redrawing the grid, so every decoration, sparkline and pill comes out as it appears. Cross-origin images are refused before the work starts, naming the offending URL, because they taint the canvas and would otherwise fail at the last step with an error that names nothing. Web fonts need embedding to appear; the default
system-uistack is unaffected. Firespresentation:captured.Events:
presentation:started,presentation:ended,presentation:scale,presentation:changed,presentation:view. -
Density is one number, and it now takes effect. Four presets, each setting a single scale that every geometry token derives from - row height, spacing, decoration sizes, and type at a damped rate.
createGrid(el, { density: 'spacious' }); createGrid(el, { density: 1.4 }); // between the presets grid.set('density', 'compact'); // livepreset row font compact23.8px 12.7px standard28px 13px comfortable42px 14px spacious56px 15px Type is damped rather than scaled: the row doubles across that range while the font moves about 18%.
Virtualisation follows the resolved
--lattice-row-height, so overriding that token by hand now moves the rows as well as the padding. An explicitrowHeightin config still overrides both.standardreproduces the geometry the grid rendered before, so a grid that never asked for a density is unchanged. -
twolinecell renderer - a bold primary line over a quieter secondary one, taken from a second property of the same row.{ field: 'name', cell: { render: 'twoline', props: { secondary: 'email' } } }secondarytakes a dot path into the row’s data, or a function;formatdecorates the result. It reads the row rather than another column, so the second line needs no column of its own.Both lines truncate rather than wrapping, and the accessible name carries both as one string. A row with no second line collapses to a single centred line. Two lines need the room - pair it with
density: 'comfortable'or taller. -
type: 'image'treats a column’s value as a URL and draws it - avatars, logos, thumbnails.{ field: 'avatar', type: 'image', cell: { props: { shape: 'circle' } } }Props:
shape(rounded,circle,square),size,fit,alt,loading. Omitsizeand the box follows the density scale.Only image URLs load:
http,https,blob:anddata:image/. Anything else is refused, including adata:URL claiming another type. A missing or broken image leaves the box empty rather than shifting the column, and every export carries the URL rather than markup. -
Redact a column while presenting. Right-click a column heading and choose Redact column to obscure its values for a screen share. The row count, the sort, the filters and the layout all stay readable; only the values go.
grid.redaction.toggle('salary'); grid.redaction.set(['salary', 'bonus']); grid.redaction.clear();Column headings stay legible so a redacted column can still be identified and turned back on. The pinned totals row is redacted along with the values, because a filtered total is the value rather than a hint at it.
Redaction is presentational. The values remain in the model, the DOM, the clipboard and every export, and anyone with access to the page can read them. For a value that must not reach the browser at all, use
permissionswithwriteOnly.--lattice-redaction-filtersets the treatment and defaults toblur(5px) contrast(0.85). It accepts anything the CSSfilterproperty does, including an SVG filter for a mosaic. -
Column headings have a context menu. Right-clicking a heading previously did nothing. It now offers Redact column and Hide column; the header menu button still carries the full set of sorting, pinning, sizing and grouping actions. New event:
header:contextmenu. -
Optimistic writes with rollback. Supply
edit.commitand the grid tracks whether each edit reached your server, rolling back the ones that did not.edit: { enabled: true, commit: async ({ key, colId, value }) => { const res = await fetch(`/api/rows/${key}`, { method: 'PATCH', body: JSON.stringify({ [colId]: value }), }); if (!res.ok) throw new Error(await res.text()); // rolled back }, }Resolving confirms the write; throwing rolls the cell back and fires
cell:revertedwith the error asreasonand the refused value asrejected, so you can offer a retry. Three events carry the lifecycle:cell:pending,cell:confirmedandcell:reverted. Pending cells are marked with--lattice-pending-background, rolled-back ones flash--lattice-reverted-background.For a backend that acknowledges on a different channel - a websocket, an event-sourced projection - set
confirm: 'manual'and callgrid.edit.settle(id, ok, reason)when the answer arrives. The id comes fromcell:pending. The mode is always declared rather than inferred from whatcommitreturns.A rollback restores the last value a confirmation vouched for, which is not always the value immediately before the failed write: if later edits have replaced it, they win and nothing is written back. Undo of an edit still in flight sends a compensating write rather than cancelling the request.
Without
edit.commitnothing changes - writes are durable the moment they are made, exactly as before, and none of the new events fire.New:
grid.edit.settle(),grid.edit.pending(),grid.edit.status(), andedit.pendingTimeoutfor the stale-write warning.grid.edit.setCells()andgrid.edit.pasteInto()are now declared in the published types; both already existed at runtime. -
Framework adapters for React, Vue 3 and Svelte. One optional bundle each, shipped as
dist/modules/react.esm.js,dist/modules/vue.esm.jsanddist/modules/svelte.esm.js.// React const LatticeGrid = createLatticeGrid({ React, createGrid }); <LatticeGrid columns={columns} rows={rows} rowKey="id" onCellChanged={fn} /> // Vue 3 const LatticeGrid = createLatticeGrid({ vue, createGrid }); <LatticeGrid :columns="columns" :rows="rows" @cell-changed="fn" /> // Svelte - an action const lattice = createLatticeAction({ createGrid }); <div use:lattice={{ columns, rows, rowKey: 'id' }} on:cell-changed={fn}></div>Props are the configuration keys you already know, plus
sort,filters,quickFilterandselectedKeys, plus one callback per grid event -cell:changedisonCellChangedin React,@cell-changedin Vue,on:cell-changedin Svelte.A changed prop is pushed into the live grid through the ordinary public API. The grid is never rebuilt on a re-render, so scroll position, selection, expansion state and any open editor survive. Change detection is reference equality, so hold
columnsandrowssteady between renders and hand back a new array only when the data has actually changed.The framework and
createGridare passed to a factory rather than imported. Lattice still ships zero dependencies, and each adapter bundle contains only glue - no second copy of the grid, and no version of it to disagree with the one you already loaded. The grid instance stays reachable: a forwarded ref in React,exposein Vue, your own reference in Svelte.Svelte is an action rather than a component because
{ update, destroy }over a node is already the shape of the work, and it needs no framework runtime at all. -
<lattice-grid>web component. A self-contained module bundle that registers a custom element on import - one script, one tag, no build step.<link rel="stylesheet" href="dist/lattice-grid.min.css"> <script type="module" src="dist/modules/webcomponent.esm.min.js"></script> <lattice-grid row-key="id" columns='[{"field":"id"},{"field":"city"}]' rows='[{"id":"A","city":"Leeds"}]'></lattice-grid>Scalars are attributes -
theme,density,locale,row-key,row-height,header-height,auto-height,selection. Structures are properties:rows,columns,config.rowsandcolumnsalso accept JSON attributes, for a page whose markup comes from a server template. Attribute changes update the grid in place rather than rebuilding it, so scroll position, selection and an open editor survive.The underlying grid is on
.grid, giving the full imperative API.Grid events are re-dispatched as
CustomEvents namedlattice-plus the grid name with colons hyphenated, socell:changedbecomeslattice-cell-changedand the payload is onevent.detail.The element renders in the light DOM, so the
--lattice-*tokens and any CSS written against the grid’s classes work exactly as documented.The bundle carries the grid with it: load it or
lattice-grid.esm.js, not both. Optional module bundles are now minified as well, sodist/modules/*.esm.min.jsexists alongside the readable build. -
The fill handle continues a series. Dragging it now extends what it can recognise instead of repeating the selected block.
1, 2, 3 // → 4, 5, 6 5, 10, 15 // → 20, 25 15 Jan, 15 Feb // → 15 Mar, 15 Apr whole months, holding the day 31 Jan, 28 Feb // → 31 Mar, 30 Apr month ends stay on the month end 'x', 'y' // → x, y, x, y unrecognised, so it repeatsDetection runs per column, so dragging several columns down continues several independent series. A single source value copies rather than incrementing, and anything unrecognised repeats as before. Month steps hold the day of month and clamp where the month is short.
Where every source value is the last day of its own month, the fill stays on the month end rather than continuing in days - including 29 February in a leap year. Values that share a day of month keep the day-preserving answer, so
30 Apr, 30 Junstill gives 30 August.Filling upwards is not supported; the handle extends downwards only.
selection.filltakes precedence when supplied, for domain series the grid cannot infer. It must return one value per target row. -
selection.addRange(range)adds a cell range without discarding the others - the API form of ctrl-clicking a second block. The added range becomes the anchor thatextendRangegrows.startRange,extendRange,cornerandinRangeare now declared in the published types alongside it; all four already existed at runtime. -
Ctrl/Cmd + Shift + arrow selects a second range from the keyboard. The first press opens a block at the focused cell without discarding what is already selected; the presses after it extend that block, so holding the chord draws one rectangle rather than one per key repeat. A plain Shift + arrow goes back to extending a single block.
-
Conditional formatting an end user can change.
grid.formattingholds rules as runtime state rather than compiling them at configuration time, so a user can add and reorder them while the grid is running.grid.formatting.add('margin', { when: { op: 'lt', value: 0 }, style: { background: '#fbeceb' } }); grid.formatting.add('*', { when: { op: 'blank' }, style: { background: '#f1f3f5' } }); grid.formatting.move('margin', ruleId, 0);A scope is a column id, or
'*'for every column. The two are evaluated as one ordered list - grid-wide rules first, then the column’s own - so a column rule can override a grid-wide one andstopIfTruebehaves the same across the join as within either half.Rules travel in saved views and undo like any other change. They must be JSON, so
stylecannot be a function here; config-timecell.stylestill accepts one and is unchanged. Where both apply they are merged, and the runtime rule wins only for the properties it names. Group rows are not formatted.A
formattingtool panel edits them: add, reorder, enable, disable and remove, per column or across the grid. Add it withtoolPanel: { panels: ['columns', 'filters', 'formatting'] }.Icon sets and data bars are not in the panel yet. Both are column decorations rather than cell styles, and driving the existing
barandicondecorations from the panel needs a runtime column-decoration API.New:
grid.formatting, theformattingconfig option, and aformatting:changedevent. -
Conditional formatting rules.
compileRules()turns a rule list into the functioncell.stylealready accepts.{ field: 'margin', cell: { style: compileRules([ { when: { op: 'lt', value: 0 }, style: { background: '#fdecea' } }, { scale: { min: 0, max: 100, colours: ['#f8f9fa', '#1a6bc7'] } }, ]) } }Conditions use the same operators as filters. Rules evaluate in order and the first match wins unless
stopIfTrue: false. Colour scales take two or more stops between a givenminandmax. A blank cell satisfies no comparison, and values typed as text still compare numerically.Exported:
compileRules,testRule,mixColour,RULE_OPS. -
Quick filter matching modes.
grid.filters.quick(text, { mode })acceptscontains(the default and unchanged),words,fuzzyandregex.grid.filters.quick('acme london', { mode: 'words' }); // every term, any column grid.filters.quick('crc', { mode: 'fuzzy' }); // characters in order grid.filters.quick('^CIR-[12]', { mode: 'regex' });The mode persists until changed, so text alone can be passed on each keystroke.
grid.filters.quickState()returns{ text, mode }.An invalid regular expression falls back to a literal search, so a pattern being typed does not empty the grid.
fuzzydecides what stays and never reorders. Matching remains restricted to columns the viewer may see. -
In-cell charts. Seven renderers:
line,area,column,winloss,pie,donutandbullet.{ id: 'trend', field: 'readings', cell: 'line' } { id: 'mix', field: 'split', cell: { render: 'donut', props: { hole: 0.55 } } } { id: 'sla', field: 'uptime', cell: { render: 'bullet', props: { target: 80, bands: [60, 85], max: 120 } } }The sparklines read an array from the cell’s value, or from another property named by
series.bulletreads a number and compares it totargetover optionalbands.Props:
series,min,max,label,marker,hole,target,bands. Pinminandmaxto give several columns a shared scale.Values that are not numbers are treated as gaps: a line breaks across them and a bar is omitted. Each chart is a single SVG whose path data is all a repaint writes, and the chart is
aria-hiddenwith a text summary on the cell. -
delta- a direction arrow and the movement over a sampling interval.{ id: 'px', field: 'price', cell: { render: 'delta', props: { interval: 1000, show: 'both' } } }Every
intervalmilliseconds the column is sampled and each row compared with its value at the previous sample. One timer serves the whole column, and history is keyed by row key so it stays correct as rows recycle, sort and filter.mode: 'against'compares with another property - an opening price, a target - and runs no timer. Direction is carried by the arrow glyph as well as by colour. -
stacked,rangeandgauge.stackedshows how one row’s total divides, comparable down a column in a way a pie is not.rangeshows the span a set of values covers with the middle marked, for aggregates where an average hides the spread.gaugeshows one value as a dial, for a level rather than progress towards completion.
Fixed
-
The time scrubber can be dragged. The overlay layer it mounts into never intercepts pointer events, and each overlay opts back in; the scrubber did not. It rendered correctly and could not be dragged, clicked or focused.
-
The scrubber’s return button reads “Go live”. It said “Live”, the same word the state label beside it uses, so the two read as one thing said twice rather than as a state and the way back to it.
-
rows.apply()androws.queue()now reach the columnar store. They updated the row objects and not the store the kernels read, so on any grid large enough to be columnar the documented API for live data feeds silently did nothing: the value changed in your object and the cell, sorts, filters and totals all went on reporting the old one. Inline editing was unaffected and always wrote through correctly. -
The formatting panel’s reorder and delete controls now respond to a click. The buttons carry a class that sets
pointer-events: none- correct where it dresses an icon inside a button, wrong where the element carrying it is the button. A pointer aimed at one landed on the list row behind it, so the controls worked when driven from a script and never when clicked. -
The Formatting tab in the tool panel draws its icon. It asked for an icon the set did not contain, so the tab rendered as an invisible button and the panel could only be reached by knowing where to click.
-
The action rail groups the export buttons last, behind a divider. Export, Excel, Copy and Print sat among Undo, Redo, Restore and Maximise; they are the only actions whose effect leaves the page, and mixing them in invited clicking Print while meaning Restore. A bare
'-'intoolPanel.actionsrenders a divider, so a host reordering the rail can keep its own grouping. -
Copying several cell ranges no longer produces misaligned rows. Blocks stacked over the same columns, or joined over the same rows, copy as before. A diagonal pair has no rectangular form, so
grid.export.rangeText()now returns''andclipboard:copyreportsreason: 'discontiguous'instead of emitting rows whose columns mean different things on different lines. -
The fill handle is hidden while several ranges are selected, and
fillToandfillDowndecline, rather than silently filling from the first block.
1.3.0 - 2026-08-17
Added
-
Formulas in cells. A leading
=in a numeric cell is evaluated and the result stored:=5 + 5 =quantity * unitPrice =[Unit Price] * 1.2 =ROUND(quantity * unitPrice, 2) =IF(quantity > 10, "bulk", "single") =SUM(readings)References name columns of the same row rather than cells, matched on field or title and ignoring case and spacing. Bracket a name containing spaces. A row property with no column of its own is also reachable, so an array such as
readingscan be summed or averaged.Functions:
SUM,AVERAGE/AVG,MIN,MAX,COUNT,PRODUCT,ROUND,ROUNDUP,ROUNDDOWN,ABS,FLOOR,CEILING,SQRT,POWER,MOD,IF,AND,OR,NOT,COALESCE,CONCAT,LEN,UPPER,LOWER,TRIM,LEFT,RIGHT. Add your own withformulaFunctions.Operators
+ - * / ^with parentheses, comparison operators forIF, and postfix%.^is right-associative and unary minus binds tighter than it, matching Excel.Formulas are evaluated with a purpose-built parser. Neither
evalnornew Functionis used, so a formula can perform arithmetic and nothing else.The result is stored rather than the expression, so a formula commits as a single undo step and passes through the column’s validation like any other edit.
evaluateFormula,parseFormula,referencesOf,looksLikeFormulaandFUNCTIONSare exported for use outside a cell.
Fixed
-
Numeric cells reject unparsed arithmetic. Text such as
2*3or10/2is refused rather than stored, so the cell keeps its existing value. Enter=2*3to calculate.Values the numeric reader accepts are unchanged:
1,234.5,(50),12%,£1,000and1e3.
1.2.0 - 2026-08-17
Added
-
Custom items in the cell menu.
contextMenuaccepts a function(params, defaults) => items, receiving the cell that was clicked and the built-in items:contextMenu: (params, defaults) => [ ...defaults, { separator: true }, { name: `Open ${params.value}`, action: (ctx) => open(ctx.data.id) }, ]paramsand each item’saction(ctx)receive{ key, colId, value, row, data, column, index, grid }, wheredatais your own row object. Return the items to show; an empty array suppresses the menu, and returning nothing leaves the defaults in place. -
Custom buttons on the left rail.
toolPanel.actionsaccepts objects as well as built-in names, positioned where they appear in the list:actions: ['undo', 'redo', { name: 'sync', title: 'Sync to the server', icon: 'restore', run: ({ grid, keys, cells }) => api.sync(keys), enabled: () => grid.state.modified(), }]titleandiconmay each be a function, re-read on every repaint. -
Excel, clipboard and print on the rail and in the cell menu, alongside CSV. Rail actions are now
undo,redo,export,excel,clipboard,print,restore,maximise. The cell menu adds Excel for visible and selected rows, “Copy visible rows”, and Print.An explicit
toolPanel.actionsarray replaces the default rather than extending it. Add the new names to include them, or omit the key to take the current default. -
rows.matchCount()- data rows passing the filters, across every page, excluding group headers, footers and totals.
Fixed
-
Custom items supplied through
contextMenunow appear in the menu. -
The status bar counts data rows on a grouped grid, excluding group headers from the total.
-
Paste,Clear,Fill downandEdit cellare disabled on a grid that accepts no edits.Copy,Copy with headers, the exports andClear selectionare unchanged.
1.1.0 - 2026-08-16
Added
-
Maximise. The rail’s last button fills the browser window with the grid; clicking it again, or pressing Esc, returns it to the page.
grid.maximiseexposesenter(),exit(),toggle()andactive()for binding your own control, andmaximise: falseremoves both.The host element is moved to
<body>and pinned to the viewport, then returned to its original position. A placeholder holds its space, so the page behind keeps its layout and scroll position. Inline styles are restored exactly. While maximised the element carries.lat-maximisedand<body>carries.lat-maximised-host. -
The grid is insulated from the host page’s CSS. Elements the grid creates are given a baseline for margin, padding, border, radius, background, shadow, text transform and letter spacing, plus type and colour on form controls. A page-level rule such as
section { padding: 5.5rem 0 }no longer affects the grid.No
!importantis used. Any rule of yours aimed at a Lattice class continues to take precedence, so deliberate overrides work as before. The reset covers box model and decoration only, neverdisplay,positionor dimensions, and applies only inside.lattice. -
Complete type declarations.
lattice-grid.d.tsnow covershistory,views,diff,permissions,ai,licence,pagination,highlight,maximise,getVersion,readyandconfig. -
dist/package.jsonnamingtypes,exports,main,moduleandstyle, withLICENSEandREADME.mdalongside, so the distribution is self-contained and editors find the declarations without configuration.
Changed
-
Licensing is one product. Every copy is feature-identical; a licence removes the trial watermark and unlocks nothing. The watermark depends only on where the grid is running: loopback hosts never show one, and any other host without a valid key always does.
-
The trial watermark is larger and moves between the four corners. It takes no pointer events except on its own link, so clicks, drags and keystrokes reach the grid beneath it. Licensed grids and loopback hosts are unaffected.
.lat-watermarkcarries a--br/--bl/--tl/--trmodifier for the corner in use.
Removed
-
GridModule.tieris no longer part of the module type. Modules that set it install exactly as before; only a TypeScript build naming the field is affected. -
Public documentation no longer describes how licence keys are constructed or issued. It covers what a licence does, where to obtain one and how to install it.
Fixed
-
calc()expressions are preserved in the minified stylesheet. Spacing around operators inside math functions is required by CSS and is now retained, so padding and spacing inlattice-grid.min.cssmatch the unminified build. Recommended for anyone using the minified stylesheet. -
Column widths account for the scrollbar gutter. Columns are laid out against the space actually available, so a grid with a pinned end column can scroll its rightmost centre column clear of it.
-
Reference corrections: the methods are
grid.diff.setSnapshot()andgrid.ai.schema().
1.0.1 - 2026-08-15
Added
grid.getVersion(), and a matchinggetVersion()on the module. Theversion()export continues to work.
Fixed
-
Demand-driven sources load their data.
remoteandpagedsources fetch the blocks covering the viewport as it moves.The request shape is
{ range: { start, end }, sort, filters, quick, groupBy, … }.
Documentation
-
docs/api-detail.html- a developer guide covering what each part of the API does, with worked examples, alongside the reference tables indocs/API.html. -
This changelog.
1.0.0 - 2026-08-15
First release.
The grid
- Columnar store with typed arrays, dictionary encoding, presence bitsets and index permutations. A six-stage memoised pipeline - filter, sort, group, total, pivot, flatten - where a change re-runs only the stages it affects.
- Virtualised DOM renderer. Rows and cells come from pools and are reassigned rather than destroyed; the only vertical write is a transform. A 1,000-row change against a 20-row viewport touches 20 rows of DOM.
- Headless core with no DOM dependency, for tests and server-side export.
- Zero dependencies, at runtime and at build time. No bundler, no framework wrapper, no icon font, no date library.
Columns and data
- 20 built-in data types beyond text, number, boolean and date: durations, units and bitrates, IP addresses and CIDR, hex, binary and octal at several widths, colours, ratings, JSON and more. Each is a bundle of format, parse, compare, storage, Excel and clipboard behaviour.
- Custom types through
dataTypes, withcreateRadixTypeandcreateUnitTypeexported for building them. - Formatting for numbers, dates, booleans and text, including currency, percentages, compact notation and pattern-based dates.
- Lookup columns that store an id and display a label, with async option sources and search.
- Computed columns with a dependency graph, so a change to one field invalidates only what reads it.
Interaction
- Inline editing with a catalogue of editors: text, number, date, time, select, multi-select, tree select, object picker, colour, slider, rating, segmented, password, code and more.
- Range selection with drag, Shift+arrow extension and a fill handle. Copy in the tab-separated form a spreadsheet reads, paste with Excel’s tiling rules, fill down, and clear.
- Keyboard navigation across cells, rows and pages, with the grid keeping its hands off keys typed into an input.
- Sorting, filtering, grouping, pivoting and column reorder, resize and pin, driven from the header, the tool panel or the API.
Product features
- Saved views - named states, both developer-defined and user-saved, with a picker. Applying one is a destination rather than a patch: the same view gives the same grid whatever preceded it.
- Undo across the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied view and a restore all record a labelled entry, and a multi-cell paste is one entry rather than one per cell.
- Column permissions over four levels -
hidden,read,writeOnly,write- enforced consistently across editing, clipboard, export, sort, group, filter and saved state.writeOnlyis for secrets a user may set and never read. - Audit mode, showing a row’s before and after against a supplied snapshot with changed cells marked.
- An AI intent layer that describes the grid to a model of your choosing, validates the reply against the real columns and operators, and applies an approved plan as one undoable step. The grid makes no network call.
- Highlighting, on change or on demand, for a cell, a row or a column.
- CSV, Excel and clipboard export, and print.
Chrome
- A left action rail carrying undo, redo, export and restore, with the columns, filters, views and quick-filter panels as icons. (Extended in later releases - see 1.1.0 and 1.2.0.)
- Status bar, pagination, context menus, tooltips and an overlay layer.
Licensing
- One product, feature-identical everywhere. A licence removes the trial watermark; it does not unlock anything.
- Free on loopback hosts -
localhost,*.localhost,127.0.0.0/8,::1- so development needs no key. - Any other domain without a valid key renders in full and carries a trial watermark. Nothing ever refuses to render.
- Keys are checked locally with no network call, and name the domains they cover including wildcards.