Skip to content

Add a feature

The data flow fixes the order: the document package owns the shape, the workspace document owns the write, the snapshot store owns the read, and the component renders. Skip a step and the feature works until two clients disagree.

The example throughout is a dueAt date on a card. The same steps cover a new field kind, an edge attribute or a board setting.

Schema first. Everything persisted is a Zod schema in packages/graph/src/schema.ts, and the TypeScript type is derived from it rather than written twice.

  • Add or extend the schema (cardSchema, edgeSchema, boardSchema, …) and export the inferred type beside it.
  • A new constrained value is a z.enum built from an as const tuple, so the UI can iterate it.
  • Shape changes are additive and optional, so an old document still parses. Bumping a stored format is an explicit decision, not a side effect of adding a field.
  • Update packages/graph/src/schema.test.ts with the parse and reject cases. The loop is pnpm exec vitest run packages/graph/src/schema.test.ts.

Graph writes go through the typed wrapper in packages/graph/src/loro/. The method lives in the module that owns the record (cards/, placements.ts, edges.ts, sections.ts, boards.ts) and is re-exported from WorkspaceDocument.ts.

  • Prefer a field write over a whole-record write: mutateCard-style methods keep concurrent edits to other fields from resolving last-write-wins. Do not add a setCard(wholeRecord) path for one field.
  • Validate with the Zod schema at the boundary. Every set* method does, and a new one must too.
  • Keep reads side-effect free: peekContent/peekTitle for fragments, and only creation inserts.
  • Commit once per gesture, so Cmd-Z undoes the whole drop rather than N single-record steps.
  • Add the method’s test beside the module (packages/graph/src/loro/cards/*.test.ts). If the write can conflict with another client, add a seeded case to merge.property.test.ts.

Read through the snapshot. The store holds one subscription and hands components plain objects.

Read a collection with useWorkspaceSnapshot() from apps/canvas/src/store/workspaceStore.ts, and a single record with useCard(cardId) or useCards(ids). workspaceSnapshotStore.ts projects the document into the snapshot; widen it there when the new value must reach a component, and keep selectors atomic (useCanvasStore((s) => s.graph)).

Never call WorkspaceDocument.observe from a component, and never copy document data into useState or Zustand. Zustand holds selection, active card and menus only. UI state the feature adds (an open panel, a pending gesture) belongs in useUiStore.ts.

Components live in the folder that owns the surface, under apps/canvas/src/:

Folder Surface
canvas/ The board, cards on it, sections, connectors, placement and the outline rail
cards/ The card face, the panel, the body and title editors, field controls
editor/ The Tiptap nodes, marks, suggestion menus and collaboration plugin
field/ Field kind registration and the composing kinds
library/ The drawer, its filters, rows and drag-out
chrome/ Title bar, board tabs, views bar, dock
panels/ Container panel, link inspector, type settings
dialogs/ Modal shell, context menu, confirmations
command/ The command registry, palette and ranking
styles/, theme/ Style rules, the appearance dialog, theme tokens and resolution
presence/, cursor/ Peer cursors, name tags, avatars, the status cursor

One component per file, named export, PascalCase.tsx. The two reference pages are the map for this work: reference/editing.md for text, fields, chips and undo, and reference/ui.md for the shell, focus and theme rules.

Use the library primitive before writing plumbing: Tiptap BubbleMenu, useEditorState and useNodesState/useEdgesState on the board, Zustand selectors. A card may be mounted on several surfaces, so consult editingSurface in the UI store rather than starting a second editor. A reveal goes through focusTarget (focusCard, focusSection); a panel never calls setCenter or fitBounds. A modal panel gets useFocusTrap from apps/canvas/src/dialogs/useFocusTrap.ts, a menu gets menuKeyDown from apps/canvas/src/dialogs/menuKeys.ts, and Escape is registered with useEscapeLayer rather than a listener of its own. Format values through displayTemplate(field) and ValueTemplate instead of formatting by hand; that rule and its history are in reference/editing.md.

Tests sit beside the thing they test: apps/canvas/src/cards/NodeCard.test.tsx covers the card face, apps/canvas/src/field/compositeKinds.test.tsx covers the composing field kinds, and packages/graph/src/schema.test.ts covers the schema.

  • A story (*.stories.tsx) when the surface is visual, with the states a reviewer would ask about: default, empty, long content, error. Stories are what the visual baselines render.
  • A React Testing Library test (*.test.tsx) for behaviour that is not trivially presentational. A new node, mark or field renderer also has to keep apps/canvas/src/editor/editorSchemaContract.test.ts passing; the procedure is in change-the-editor.md.
  • A Playwright spec under the matching e2e/tests/<area>/ folder when the behaviour only exists across the server and the browser. The areas today are account, appearance, boards, boot, cards, collab, input, library, links, selection, sharing, views and visual. Tag the happy path @smoke if that area already has a smoke test, since pnpm e2e:smoke runs exactly those.

6. Touch the server only if the feature reaches it

Section titled “6. Touch the server only if the feature reaches it”

The collab server does not know about card fields; it stores documents. A feature reaches it when it adds a route, a stored format, a quota or a metric, and each has one home:

Concern Home under apps/collab-server/src
Route, request parsing, CORS, rate limit http/
Persisted file, migration, store schema store/
Share projection, room, sweep projection/
Socket, seat gate, room save sync/
Env parsing, limits resolution config/
Admin API, metrics, log line ops/

The account control plane has the same shape in services/auth/src/ (http/, identity/, invitations/, licensing/, organizations/, store/), and billing in services/billing/src/ (handler/, stripe/, store/, ops/).

A new quota is a constant in config/limits.ts plus a regenerated reference/limits.md, never a hand-edited number. A new stored format needs an N−1 fixture under apps/collab-server/src/fixtures/n-1/ and a test that opens it.

Terminal window
pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm docs:symbols && pnpm shape
pnpm check # biome, tsc, vitest run --changed, docs symbols, shape
pnpm exec vitest run apps/canvas/src/cards # one area, while iterating

That is the whole per-change gate. pnpm verify runs it plus the billing tests, the story suite and the build. Playwright runs when the owner asks for a run, and test-and-verify.md has the area scripts.

Then update the docs that describe the new fact. A user-visible capability is worth a line in the internal roadmap when it closes a gate, and a decision that changes ownership gets an ADR. The command list is in test-and-verify.md; the standards are in reference/conventions.md.