api reference
Presence, comments and redaction
grid.presence, grid.comments, grid.redaction, and grid.diff.
API reference › Presence, comments and redaction
grid.presence
Who else is on this grid and what they are doing: cursor, selection, active edit, and an optional advisory lock. It prevents the two failure modes of multi-user data work: two people editing the same cell unaware of each other, and one person unable to tell whether anyone else is there at all.
The grid never opens a connection. You supply the transport and the identity; the grid renders what arrives and publishes what changes. A WebSocket, MQTT, a CRDT library or a polling endpoint all satisfy the interface. Without a provider the feature is inert.
presence: {
provider, // subscribe + publish
me: { id: 'u_17', name: 'Tony' },
throttleMs: 60,
lock: true // advisory; see below
}
| Method | Returns | Description |
|---|---|---|
| enabled | boolean | False without a provider. |
| peers() | Peer[] | Everyone else, most recently active first, each with idle, hidden and cursorFresh. |
| hiddenCount() | number | Peers none of whose positions are in this view. |
| editorOf(rowId, colId) | Peer | null | Who is editing a cell, if the claim is fresh. |
| lockedBy(rowId, colId) | Peer | null | Null unless lock is on. Advisory. |
| jumpTo(peerId) | boolean | Scroll to a peer's cursor. False when their row is not in this view. |
| publish() | void | Publish now. The grid already does this on cursor, selection and edit changes. |
| setPublishing(on) | void | Receive without appearing, for observer and supervisor roles. |
| setPaused(paused) | void | Suspend publishing. Done for you while the tab is hidden. |
| connect(provider) | void | Attach or detach after construction. |
| stats() | object | Published, received, throttle drops, provider errors, peer count. |
The provider
{
subscribe(onMessage) { /* call onMessage(peer) or onMessage(peer[]) */ return unsubscribe; },
publish(state) { /* send it however you like */ }
}
A message is one peer state or an array of them, so a transport that sends a full roster on connect and deltas afterwards needs no unwrapping. { id, left: true } removes a peer.
Presence carries intent, never values. A peer's committed edit must reach the grid as data, through whatever channel you already use, as a transaction, so it gets the flash-on-change treatment. Presence is throttled, lossy and ephemeral by design, so a value carried on it is a value that can be dropped. That is the kind of bug that surfaces once a month in production and cannot be reproduced.
Positions travel as row keys
Peers sort and filter independently, so a row index addresses a different record on every screen. Presence is positional in data terms: a peer's cursor renders wherever that row currently sits in your view, and is held but not drawn when the row is filtered out, on another page, or evicted from a bounded window. Those peers are counted by hiddenCount() and shown in the roster as “not in view”, so their absence does not read as a disconnection.
Idle and removal
Derived from local receipt time, never the timestamp in the payload. Clocks between clients disagree by seconds routinely, so a peer with a fast clock would look permanently fresh and one with a slow clock permanently idle. Silence past idleMs desaturates them; past removeMs they go. An explicit left signal is used when your transport provides one.
Locking is advisory
Locking reduces collisions. It does not eliminate them. Presence is throttled and can arrive out of order, so two clients can enter an edit at the same moment. The authoritative resolution is the conditional write in edit.commit, which returns a conflict and rolls the optimistic edit back. If you treat locking as a guarantee and skip that write, you will lose data.
With lock: true, starting an edit on a cell a peer holds returns false from edit.start, emits presence:lockRefused, and announces the holder through a live region, a cell that silently refuses to enter edit mode is indistinguishable from a broken grid.
What is drawn
A peer's cursor is a dashed border in their colour; your own focus ring is solid, and the difference is in the kind of line rather than only the hue so the two can never be confused. Their name shows for a moment after their cursor moves and on hover, then fades to the bare border. A selection is a low-opacity tint, with the most recent peer winning a contested cell outright rather than blending. An active edit is solid and tinted, the loudest treatment, because it is the state that matters most.
Nothing is inserted into the grid: every treatment is written onto cells that already exist, so presence cannot shift layout, cover an in-cell chart, or intercept a click. The roster is the exception, because it is a control.
Colours are assigned by hashing the peer id against --lattice-peer-1 … --lattice-peer-8, so one person is the same colour on every screen and across reloads.
grid.comments
Threaded comments attached to individual cells, for collaborative data review: flagging an anomaly, asking why a figure changed, recording the reason behind a manual correction. A commented cell carries a small triangle in its upper-right corner; clicking the corner opens the thread.
The grid owns presentation and interaction only. Storage, identity and permissions are yours. Comment data lives wherever you put it and is reached through a provider.
A stable rowKey is required. Comments are keyed on row identity plus field, never row index, and they outlive the values they annotate. Configure the grid without a rowKey and comments are disabled: named in the same console warning as the other identity-dependent features: rather than silently filing threads against positions that move on the next sort.
Identity must be stable across sessions and across data reloads, not merely within one session. A key derived from load order is not enough: reload the data in a different order and every comment reattaches to the wrong row.
comments: {
provider, // required; without it the feature is inert
mode: 'anchored', // or 'docked' for a side panel
markdown: false, // restricted: emphasis, code, links
rowLabel: (row) => row.data.name // so the panel says what is being discussed
}
| Method | Returns | Description |
|---|---|---|
| enabled | boolean | False without a provider or without stable row identity. |
| unavailable() | string | null | 'no-provider', 'no-row-identity', or null. |
| at(rowId, colId) | object | null | { count, unresolved, updated } for one cell. Counts only: this is read on every repaint. |
| open(rowId, colId) | Promise | Open a thread and load its bodies. |
| close(opts?) | void | Close and discard the bodies. |
| add(body, opts?) | Promise | Add to the open thread. opts.parentId replies within it. |
| edit(commentId, body) | Promise | |
| remove(commentId) | Promise | |
| resolve() / unresolve() | Promise | Mark the open thread. |
| request(rowIds, fields?) | void | Ask for index entries. Debounced; the viewport does this for you. |
| refresh() | void | Reload the index for known rows, after your application learns of a change elsewhere. |
| loadAll() | Promise<boolean> | Load the index for every row, which the comments-only filter needs first. |
| complete | boolean | Whether the index covers the whole row set. |
| hiddenUnresolved() | number | Unresolved threads on rows the current filter hides. Zero when the index is partial. |
| filterToCommented(opts?) | boolean | Restrict to rows carrying comments. unresolvedOnly narrows further. False when the index is incomplete. |
| thread / openKey / loading | , | The open thread, its cell key, and whether it is still loading. |
The provider
Every method returns a promise. A rejection surfaces in the panel without disturbing grid state, and an optimistic write is rolled back.
| Method | Description |
|---|---|
| loadIndex(rowIds, fields) | Counts and timestamps for the requested cells. Never bodies. Called for the viewport and on scroll, debounced. |
| loadThread(cellKey) | The ordered comments for one cell. |
| addComment(cellKey, body, parentId, ctx) | ctx.value is the cell's value at the time of writing. Store it. |
| editComment(id, body) | |
| deleteComment(id) | |
| resolveThread(cellKey) / unresolveThread(cellKey) |
The grid performs no authorisation. A comment may carry can: { edit, delete, resolve } and the grid draws affordances accordingly, but that is a convenience for the user and never a security control. Absent flags mean every affordance is shown. Your provider must reject what it must reject.
Author information is rendered exactly as the provider supplies it: author: { name, avatarUrl, initials }. The grid does not know who the user is and does not guess.
Bodies are text. The default path never produces markup. With markdown: true the panel handles emphasis, code and links only, builds elements rather than assigning HTML, and refuses any link scheme other than http, https and mailto.
Comments follow their row through sorting and grouping. When a commented row is filtered out its comments are not lost and not shown; hiddenUnresolved() reports what is outstanding on hidden rows so their absence does not mislead, and the status bar’s comments panel puts that count on screen whenever it is not zero. Comments remain available while streaming, and a thread whose row is evicted by a bounded window closes with an explanation. Comments do not appear in exports and do not serialise into saved views, a view captures display configuration, not data.
Keyboard: Alt+M opens the thread on the focused cell. The panel traps focus while open and returns it to the originating cell on close. Cells carrying comments announce the fact, and the unresolved count, through their accessible description.
grid.redaction
Obscures a column's values on screen while leaving the shape of the data (row count, sort, filters, layout) perfectly readable. Built for presenting and screen sharing. Right-click a column heading for Redact column.
This is not a security control. The values stay in the model, the DOM, the clipboard and every export; anyone with the page can read them from devtools or by turning off one CSS rule. It defeats a camera, which is the whole claim. For a value that must not reach the browser at all, use permissions with writeOnly.
grid.redaction.toggle('salary'); // returns the state it is now in
grid.redaction.add('salary');
grid.redaction.set(['salary', 'bonus']);
grid.redaction.list(); // ['salary', 'bonus']
grid.redaction.clear(); // back to normal when the call ends
| Method | Returns | Description |
|---|---|---|
| has(colId) | boolean | Is this column redacted? |
| list() | string[] | Every redacted column id. |
| toggle(colId) | boolean | Redact, or stop. Returns the state it is now in. |
| add(colId) | void | |
| remove(colId) | void | |
| set(ids) | void | Replace the whole set. |
| clear() | void | Stop redacting everything. |
| active | boolean | True when at least one column is redacted. |
The treatment is a CSS token, so a host can swap it: --lattice-redaction-filter defaults to blur(5px) contrast(0.85) and accepts anything the filter property does, including url(#your-svg-filter) for a mosaic.
grid.diff
Audit mode. Give it a prior snapshot and every row reports whether it was added, removed or changed, and which cells moved.
| Method | Returns | Description |
|---|---|---|
| setSnapshot(rows) / clear() | void | Also settable as config.diff.snapshot. |
| summary() | object | { added, removed, changed, unchanged }. |
| statusOf(key) | string | 'added', 'removed', 'changed' or 'unchanged'. |
| changedColumns(key) | string[] | |
| before(key, colId) | unknown | The prior value. Also on the cell as data-before. |
| enabled | boolean | |
| swap() | boolean | Show the snapshot as the grid's data, and compare it against what was live until now. The snapshot is held as plain objects and never enters the columnar store, so a removed row cannot be sorted or filtered among live ones; swapping is the answer to that, the old rows become real rows with the whole pipeline behind them. Costs one ingest of each set, so it is a deliberate action rather than a toggle. The comparison reverses: what was an addition is now a removal. swapped reports which way round the grid is, and it is worth saying so in your interface. |
| swapped | boolean | True while the snapshot is the data. |
| removedRows | false | 'pinned' | 'data' | Whether a row in the snapshot but gone from the data is shown, and whether it counts as data. false (the default) leaves it out. 'pinned' shows it beneath the rows, struck through, outside the row set, not counted, not exported, not selectable. 'data' appends it to the set, so it is counted and exported. Neither is sorted or filtered among the live rows, because its values are the snapshot's; neither can be edited, because there is nothing left to write to. |
| strictNull | boolean | Off by default, so null, undefined and an absent field all count as the same absence. Set it to tell them apart, for an audit where a field being cleared and a field never being sent are different events. It compares the data as supplied, not as stored, so it is a statement about your snapshot rather than about the grid. |