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.
1. Model it in @suite/graph
Section titled “1. Model it in @suite/graph”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.enumbuilt from anas consttuple, 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.tswith the parse and reject cases. The loop ispnpm exec vitest run packages/graph/src/schema.test.ts.
2. Put the write on WorkspaceDocument
Section titled “2. Put the write on WorkspaceDocument”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 asetCard(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/peekTitlefor fragments, and only creation inserts. - Commit once per gesture, so
Cmd-Zundoes 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 tomerge.property.test.ts.
3. Expose it to the UI
Section titled “3. Expose it to the UI”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.
4. Build the surface
Section titled “4. Build the surface”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.
5. Story and test
Section titled “5. Story and test”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 keepapps/canvas/src/editor/editorSchemaContract.test.tspassing; 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 areaccount,appearance,boards,boot,cards,collab,input,library,links,selection,sharing,viewsandvisual. Tag the happy path@smokeif that area already has a smoke test, sincepnpm e2e:smokeruns 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.
7. Verify
Section titled “7. Verify”pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm docs:symbols && pnpm shapepnpm check # biome, tsc, vitest run --changed, docs symbols, shapepnpm exec vitest run apps/canvas/src/cards # one area, while iteratingThat 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.