Lattice Grid Buy a licence

developer guide

Collaboration, comments and permissions

Live presence and cursors, threaded cell comments, column permissions against a user context, audit and diff mode, and redacting a column for a screen share.

Developer guide › Collaboration, comments and permissions

Collaborative presence

Who else is looking at this grid, and what they are doing.

An in-memory provider, which is all the interface requires

const handlers = new Map();
const providerFor = (id) => ({
  subscribe(fn) { handlers.set(id, fn); return () => handlers.delete(id); },
  publish(state) {
    for (const [peer, fn] of handlers) if (peer !== id) fn(state);
  },
});

Presence carries intent, never values. This is the line that matters most. A peer's committed edit reaches the grid as data, through the channel you already use for data. Presence is throttled, lossy and ephemeral by design, so a value carried on it is a value that can be dropped, and that is the class of bug that appears once a month in production and cannot be reproduced on demand.

Positions are row keys, resolved against your view at render time. Peers sort and filter independently, so index 12 is a different record on every screen. Publishing an index would put a colleague's cursor on an unrelated row the moment either of you sorted. The cost of this is real: the grid resolves a key to a position rather than reading one, and it is the difference between the feature working and the feature lying.

Idle is measured from when a message arrived, not from what it says. Clocks between clients disagree by seconds routinely and by minutes occasionally. Keying idle detection on the sender's timestamp means a peer with a fast clock never goes idle and one with a slow clock is idle immediately. The sender's timestamp is kept for inspection and decides nothing.

Publishing throttles rather than debounces. A debounce sends nothing until the user stops moving, so every peer sees a cursor that teleports on pause instead of moving. The leading edge goes out immediately and the trailing edge carries wherever it settled.

Publishing stops while the tab is hidden. Nobody is moving that cursor. Receiving continues, so coming back to the tab shows the current state rather than an empty roster filling in slowly.

A peer's cursor is dashed; your focus ring is solid. The distinction has to be in the kind of line, not only its colour. Colour alone fails for anyone who cannot separate two hues and fails for everyone at a glance, and the palette originally contained the exact value of --lattice-focus-color, so the first peer assigned drew a cursor identical to the local user's own selection. An active edit is solid and tinted, because an edit is not a cursor and those two must not be confused with each other either.

Nothing is inserted into the grid. Every treatment is an attribute and a custom property written onto a cell that already exists, drawn with an outline and a pseudo-element. That is what keeps presence from shifting layout, covering an in-cell chart or swallowing a click: none of which survives an implementation that appends overlay elements. The overlay layer is pointer-transparent and presence deliberately does not opt back in; only the roster does, because it is a control.

The roster is the part people use. More than the cursors, in practice. It carries the name as well as the colour, because colour alone is not a signal everyone can read, and it reports peers whose rows are not in your view rather than omitting them, an absent peer reads as a disconnection that has not happened.

A parked cursor does not fade. The label does, after a couple of seconds, because permanent labels over a dense grid are unreadable. The border stays, dims at idle, and goes only on removal: a cursor that vanished while its owner was still connected would report exactly the thing this feature exists to prevent.

Locking is advisory, and the documentation says so because a developer who believes otherwise will skip the conditional write. Presence is throttled and can arrive out of order; two clients can enter an edit in the same instant. What actually resolves the conflict is the conditional write in edit.commit, which returns a conflict and rolls the optimistic edit back. Locking narrows the window; the conditional write at commit closes it. A stale claim is disregarded after a much shorter window than peer removal, because a lock held by someone who shut their laptop blocks a cell nobody is editing.

Outside its scope: transport, reconnection or authentication; operational transform or CRDT merging; presence history; text-level cursors inside a cell editor; follow mode; chat.

Cell comments

Threaded discussion attached to individual cells, for reviewing data with other people without leaving the grid.

Wiring a provider

comments: {
  provider: {
    loadIndex:  (rowIds, fields) => api.counts(rowIds, fields),
    loadThread: (cellKey)        => api.thread(cellKey),
    addComment: (cellKey, body, parentId, ctx) => api.add(cellKey, body, parentId, ctx.value),
    editComment: api.edit, deleteComment: api.remove,
    resolveThread: api.resolve, unresolveThread: api.unresolve
  },
  rowLabel: (row) => row.data.name
}

Comments are not row data, and never enter the column store. They have a different lifecycle: append-mostly, sparse against the row count, carrying their own identity and timestamps, subject to their own permissions, and outliving the values they annotate. Putting them in the store would mean columnarising a field that is empty for almost every row and rebuilding it on every write.

Two levels of data, and only one of them is cheap. The index maps a cell to { count, unresolved, updated } and nothing else; it is consulted on every repaint, so it must never carry bodies. A thread is the bodies for one cell, loaded when the thread opens and dropped when it closes. Holding every thread a user opened would accumulate the whole comment dataset over a long session to render a few triangles.

Stable row identity is a hard requirement, enforced rather than documented. A comment is keyed on row identity plus field. Row index changes under sort, filter and grouping, so a comment keyed on it reattaches to whichever row later occupies that position, and a comment on the wrong row is worse than no comment at all. A grid with no rowKey disables comments and names them in the same single warning as the other identity-dependent features.

Identity has to survive more than the session. Comments outlive the page that wrote them, so a key that is stable only within one load (anything derived from arrival order, for instance) is not enough. Reload the data in a different order and every thread points somewhere else. If you have only used the grid client-side you may never have needed a durable identity before; you do now.

Writes are optimistic, and rejections are taken back. The author sees their own comment at once, faded until the provider confirms. A rejection removes it rather than leaving the grid displaying something the server refused. The same pattern as cell editing, for the same reason.

The grid authorises nothing. A comment may carry can: { edit, delete, resolve } and the affordances follow it, but that is a convenience for the person looking at the screen. It is not a control, and the documentation says so here rather than leaving it to be assumed: hiding a button stops nobody who opens the console. Reject in the provider.

A body is user input that has round-tripped through your storage. That is the exact shape of a stored cross-site script, so the default path sets text and nothing else. Turning on markdown buys emphasis, code and links: three constructs, built as elements rather than parsed as markup, with any scheme other than http, https and mailto refused. A refused link still shows its label, so nothing the author wrote vanishes without trace.

The marker cannot move the cell's contents. It is a corner triangle drawn with a border on a pseudo-element, so it occupies no space in the layout: no shifted text, no rewrapped number, no displaced sparkline. That constraint is why it is a corner rather than a badge: every other position in a cell is already spoken for. Only the corner opens a thread; a click elsewhere belongs to selection, and taking it would make commented cells behave unlike every other cell. The hit region is larger than the drawn mark, because at compact density the triangle is about seven pixels across.

A comment shows the value it was written against whenever that no longer matches the cell. Without it, a note reading "this looks too high" sits beside a number it never described and the reader concludes the comment is wrong. Changing a value never deletes or invalidates a comment.

Filtered-out comments are hidden, not lost, and the grid says so. hiddenUnresolved() reports what is still outstanding on rows the filter is hiding, because a user who filters and sees no markers should not conclude there is nothing left to deal with. The status bar carries this: its comments panel reads “3 unresolved comments on hidden rows” and is silent whenever the count is zero, so a grid with nothing outstanding gains no permanent furniture. The panel is in the default set, and can be placed explicitly like any other:

statusBar: { panels: ['rowCount', 'comments'] }

The count returns zero rather than a number it cannot stand behind: it is only meaningful once the index covers every row, so it stays at zero, and the panel stays silent, until loadAll() has resolved.

The comments-only filter is refused rather than approximated. Restricting the grid to rows carrying comments needs the index to cover the whole row set, not just what has been scrolled past, a partial answer would hide precisely the rows the user opened it to find. Call loadAll() first; until complete is true, filterToCommented() returns false and does nothing.

Comments stay available while streaming, unlike header histograms: a comment does not move when new rows arrive. Index loads for new rows follow the same debounced viewport path. A thread whose row is evicted by a bounded window closes with a short explanation rather than hovering over a row that has gone.

Keyboard and screen reader. Alt+M opens the thread on the focused cell: Alt because the grid binds nearly every unmodified key to navigation and editing. The panel traps Tab, which it has to: the grid behind it is still there and still focusable, so without the trap a keyboard user would be moving through cells with a dialog open over them. Focus returns to the originating cell on close rather than being dropped at the top of the document. Cells announce their comment count and unresolved count through aria-description rather than their label, because the label is the cell's value and burying a count inside it would make every commented cell read as something other than what it holds.

Outside its scope: mentions, notifications, rich text, attachments, reactions, row-level and column-level comments, and export of comments. The grid opens no transport of its own: if your application pushes updates, call refresh() and the index reloads.

Column permissions

Four levels, resolved per column from configuration or a callback. They are not a ladder, reading and writing are independent, so they are the four corners of a 2×2.

LevelVisibleReadableEditableFor
hidden, , , Absent from the grid, the tool panel, exports, the clipboard, saved state and the filter model.
readyesyes, No editor opens; paste, fill and clear skip it.
writeOnlyyes, yesA secret. The cell shows a mask, the editor opens empty.
writeyesyesyesThe default, so the feature is opt-in.

Every accepted form

permissions: 'read'                                   // blanket
permissions: { salary: 'read', ssn: 'hidden' }        // '*' sets the default
permissions: (column, ctx) =>
  ctx.context.role === 'admin' ? 'write' : 'read'

permissions: {
  default: 'read',
  columns: { name: 'write' },
  resolve: (column, ctx) => ctx.context.role === 'admin' ? 'write' : undefined,
}

grid.permissions.setContext({ role: 'clerk' });        // re-resolves everything

For three of the four this is a usability control, not a security boundary. Anything the grid can render it has already loaded, and devtools reaches it. Hiding a column removes it from the interface, not from the process, which is worth a great deal for the way data actually leaks, which is an export mailed onward or a shared view carrying a column a colleague should not see.

writeOnly is the exception, and the reason it exists. Nothing in the grid needs the value, so your server can send null for that field and the column still works: at which point the secret is genuinely not on the page. Enforce everything else server-side; permittedColumns and permittedExport are pure and dependency-free so the same policy object runs in Node against a request that arrived over the wire.

Audit mode

Give the grid a prior snapshot and every row reports whether it was added, removed or changed, and which cells moved.

Before and after

diff: { snapshot: lastApprovedVersion },

grid.diff.summary();              // { added, removed, changed, unchanged }
grid.diff.statusOf('CIR-100042'); // 'changed'
grid.diff.changedColumns('CIR-100042');
grid.diff.before('CIR-100042', 'capacity');

Changed rows get a band down the leading edge and changed cells a tint with the prior value on the cell as data-before. The row band and the cell tint are deliberately different devices: "which rows moved" and "what changed in this row" are different questions and one highlight cannot serve both.

Showing what was deleted

A deletion is a change too

diff: { snapshot: lastApprovedVersion, removedRows: 'pinned' }   // or 'data'

The row is gone from the data and still in the snapshot. That is the only place it exists, and it is what these rows are built from, so they carry the values they had when the snapshot was taken, not any current ones.

One option answers both questions: whether to show it, and whether it counts. 'pinned' puts it beneath the rows, struck through and dimmed, outside the row set, so rows.count(), exports and selection all pass over it. 'data' appends it to the set instead, and all three include it. Omitted, nothing is shown and the grid is the one you already had.

Neither sorts or filters it among the live rows. A removed row's values are yesterday's; ordering them among today's presents two data sets as one, and lets a filter written for current values decide the fate of historical ones. Neither permits an edit either, a write aimed at a removed row is refused and returns 0, rather than being counted as applied against a record that is not there.

Redacting a column

For presenting and screen sharing: obscure the values in a column while everything that makes the grid readable (the row count, the sort, the filters, the column layout) stays exactly as it was. Right-click a column heading and choose Redact column.

From the API

grid.redaction.toggle('salary');
grid.redaction.set(['salary', 'bonus']);
grid.redaction.clear();          // when the call ends

This is not a security control, and the difference matters. The values are still in the model, still in the DOM, still on the clipboard and still in every export. Anyone looking at the page can read them from devtools, or by turning off a single CSS rule. What redaction defeats is a camera and a screen recorder, which is the threat a presenter actually has, and the only one it claims to answer. For a value that must never reach the browser, use permissions with writeOnly: there the value is not sent, so there is nothing to reveal.

Headings stay readable, totals do not. The column heading is left alone deliberately, a redacted column still has to be identifiable, by the presenter who wants to turn it back on and by the audience who need to know what they are not being shown. The pinned totals row is redacted, because the sum of a column is not a hint at its values: filter to one row and the total is the value.

Swapping the treatment. --lattice-redaction-filter defaults to blur(5px) contrast(0.85) and takes anything the CSS filter property does. A blur is the default because it is GPU-composited and stays cheap across a scrolling viewport; point the token at an SVG mosaic filter if you prefer classic pixellation and can afford it per cell.