Coding conventions
The rules as rules. The model they serve is architecture.md. The procedure that applies them is ../how-to/add-a-feature.md, and the gate is ../how-to/test-and-verify.md.
Shape: pure core, imperative shell
Section titled “Shape: pure core, imperative shell”Pure modules take data and return data: schema, records, planning (layoutPlan), projection, publish
planning, link geometry, name derivation. No DOM, no Loro, no fetch, no timers, and a test that needs
none of them.
Shell modules own state or I/O: WorkspaceDocument and its domain modules, LoroIndexeddbPersistence,
the collab provider, the projection worker, HTTP handlers, React host components.
Dependencies point inward. A pure module that imports loro/WorkspaceDocument.ts is a bug; the graph
layer passes plain values down and interprets results up.
One owner per piece of state. Document truth lives in Loro through WorkspaceDocument, UI state lives in
Zustand, and transient view state may live in React. Never keep a second copy.
Files and folders
Section titled “Files and folders”Folder by feature, not by file type: apps/canvas/src/{canvas,cards,library,panels,command,field,shell,sharing,session,store,collab,editor,theme},
apps/collab-server/src/{http,store,projection,config,sync},
services/auth/src/{identity,organizations,invitations,licensing,http,store}, and
packages/graph/src/loro/<domain>.
Each folder exposes one index.ts and nothing else as public surface. No barrel files that re-export
everything. The facade (WorkspaceDocument) and the folder index are the API.
Co-locate *.test.ts(x) and *.stories.tsx with the code they cover.
One primary export per file; small helpers stay private. Named exports only, with App at the entry as
the one default. Size is a signal: around 400 lines, look for a seam. pnpm shape fails a source file
over 900 lines. The 400 number is a prompt, the 900 number is the gate.
Components are PascalCase.tsx, hooks are useCamelCase.ts, other modules are camelCase.ts.
Naming
Section titled “Naming”Functions read verbNoun: placeCard, moveSectionsBatch, applyProjection. Predicates read isX,
hasX or canX: isSelfLink, canManageSchema. Conversions read toX and fromX; parseX returns a
Result and assertX throws.
Hooks are useX, component props are XProps, tagged error types are XxxError.
A type pluralises by derivation: read typePluralName(type) where a type labels a set, and type.name
where it labels one card.
Tests describe the unit under test and use it("should …") for the behaviour.
Data and errors
Section titled “Data and errors”Prefer plain readonly objects, discriminated unions and exhaustive switch.
No boolean-flag arguments. Take a small options object, or write two named functions.
Result<T, E> from neverthrow belongs at fallible boundaries: parsing, validation, HTTP handlers,
licence and admission checks. Use ok and err, then map, andThen or match, and convert once at
the seam; services/auth/src/http/response.ts holds the single error to status mapping.
Throw for programmer errors and invariants only, and never a bare Error("…") across a module boundary.
Define a tagged error type.
remeda is for transforms where it removes code or a loop. Do not wrap a one-liner.
zod is the source of truth for persisted shapes: derive types with z.infer, never duplicate an
interface, and validate on every write.
No new dependency without a stated job and the correct package.
Refactor on sight
Section titled “Refactor on sight”Fix these when the file is already open. Each one has a failure mode.
| Smell | Smallest fix |
|---|---|
| A whole-record write where two fields can change independently | Write the leaf field, or use a map per record |
| A derivation stored back into the document | Recompute at read time |
| An effect that exists to run one user action | Run it in the handler |
| A second copy of geometry, formatting, or presence rules | One helper, one call site |
| A read share that can write | Check the capability at the mutation |
| A log line with a token, email, or document body | Redact at the logger |
pnpm shape enforces the mechanical half: file length, parameter count, banned dynamic-code APIs, one
WorkspaceDocument.observe call site, and no raw Loro writes to graph roots outside @suite/graph.
pnpm docs:symbols enforces that every backticked path, root script, and ADR reference in the docs
resolves.
TypeScript
Section titled “TypeScript”Strict mode stays on, including noUncheckedIndexedAccess, exactOptionalPropertyTypes,
verbatimModuleSyntax and isolatedModules. Do not weaken it.
No any without a // biome-ignore lint/suspicious/noExplicitAny: <reason> and a real reason. Prefer
unknown with narrowing, and discriminated unions.
Type-only imports use import type. Write .js extensions in @suite/graph relative imports, because
the package emits ESM.
Imports
Section titled “Imports”External packages, then @suite/* workspace packages, then relative imports, separated by blank lines.
Biome’s organizeImports enforces it; run pnpm lint:fix.
Domain language
Section titled “Domain language”A workspace is one LoroDoc, one sync room and one IndexedDB entry. A board is a view inside it. A card
is the object, with content, primary type, tags, properties and connectors, and exists with or without a
board. A placement is where a card sits on one board, so say “place” and “remove from board” and never
“delete”. Trash is the soft delete through deletedAt; “delete forever” belongs to purging.
Never write “node”, “document” or “note” for these. A node is a React Flow node, a note is the built-in card type, and a document is the whole workspace document. The full list is glossary.md.
Libraries first
Section titled “Libraries first”Use the library’s own primitive before writing UI or state plumbing: Tiptap’s BubbleMenu and
useEditorState, React Flow’s useNodesState, useEdgesState and ViewportPortal, Zustand selectors.
Hand-roll only what the library leaves out, such as the buttons inside BubbleMenu or the canvas context
menu.
Function components with hooks, no classes.
Derive document data through the workspace snapshot store (useWorkspaceSnapshot, useCard,
useCards) and never a useState copy. Never call WorkspaceDocument.observe from a component: the
store owns the single subscription and selectors stay atomic. Keep components small and split
presentational pieces into the feature folder.
Zustand holds UI state only. Document data is owned by the Loro doc.
One editable surface per card per client. A card’s title and body may be mounted on several surfaces, and
only one is editable, decided by editingSurface in the UI store. Never add a second cursor store or
peer id to work around it.
Navigation
Section titled “Navigation”Reveal requests go through the UI store’s focusTarget (focusCard, focusSection): a surface asks and
the canvas moves. Never call setCenter or fitBounds from a panel.
A reveal changes the view only. It writes nothing and does not change selection unless the surface also selects.
Presence and coordinates
Section titled “Presence and coordinates”Broadcast presence in flow coordinates (screenToFlowPosition), never raw screen pixels, and render the
overlay inside <ViewportPortal> so it follows pan and zoom.
Never render a shared cursor store across editors without filtering by Loro container, and publish carets only from the focused editor.
Persist positions to the CRDT on drag stop only; live positions travel through EphemeralStore. Dragging
a connector dot means “connect”, while repositioning uses the selected grab handle.
Do not publish a pointer position while a local editor or canvas gesture is active. Peers show the name on the interacted element and the pointer returns on the next move.
References and rich text
Section titled “References and rich text”A property block in a body is a view. It carries a fieldId and writes through setProperty and
clearProperty; values live in the card index so facets and dashboards never parse text.
Blocks are inserted from the body, not the panel. /property and @ hand over to the one picker, and
removing a block removes the view only.
One stored style, many views. A value renders through displayTemplate(field) by ValueTemplate; never
format a value by hand, never write a template into a value, and never prepend a marker the template does
not name.
A @card.field chip is a link. It stores cardId and fieldId and renders the live value.
References are derived and rebuildable. references.<cardId> is maintained from mention nodes; never
hand-write it, never store a backlink on a card, and fix the index or let ensureReferences do it when
content disagrees.
A semantic link never creates a placement. Only an explicit place action puts a card on a board.
No schema writes from the editor. Field creation stays in type settings, because body undo excludes structural origins.
Arrow descriptions are free text. Arrows stay untyped, and meaning belongs to a property name.
A label drag is a gesture. Publish labelDrag and persist once on release.
The @ list has three row kinds: field for a block on this card, card for a mention, reference for
another card’s field. Own fields come first, then cards.
A suggestion is previewed. The completion is a widget decoration, so arrowing cannot write into the card or land in the undo stack.
Data layer
Section titled “Data layer”Never mutate WorkspaceDocument internals from the UI. Add typed methods to packages/graph instead, and
let every set* method validate with its Zod schema.
Undo is split by scope: never add structural changes to an editor’s history, and never put text in the
document’s UndoManager scope.
Write fields, not records. Prefer mutateCard, mutateEdge, updatePlacementPosition,
updatePlacementSize, moveConnector and reconnectEdge over setCard and setEdge.
Reads never create shared state: use peekContent and peekTitle for fragments, and let creation paths
insert containers. Rely on record identity to skip re-renders, and never rely on it flipping.
Membership is a collection write: setCardTypes, addCardType, removeCardType, addCardTag,
removeCardTag, setProperty, clearProperty. Never replace a whole map.
Removing a field, switching a type or removing a tag leaves values dormant on purpose. Do not clean up other records; a later explicit command owns that. Required fields are advisory and never block a write.
Section membership is derived. Read with sectionMembership and sectionForCard, write a drag with
moveSectionsBatch, and write geometry on release rather than during a gesture.
One commit per gesture. Never loop a single-record method over a selection, because that produces N undo steps.
Testing
Section titled “Testing”Unit-test pure logic in packages/graph: schema, records and the WorkspaceDocument domain modules.
Multi-client behaviour gets a two-document test. Drive both documents through conflicting edits, sync
with doc.export({ mode: "update" }) and doc.import(...) in both directions, then assert the merge.
workspace.convergence.test.ts fuzzes this across seeds, so add a seeded case for a new kind of write.
React Testing Library covers non-trivial UI in apps/canvas, and Playwright covers journeys in e2e.
Tests run under Node’s runner in services/billing and Vitest elsewhere; the tiers are in
../how-to/test-and-verify.md.
Commits
Section titled “Commits”Conventional commits: feat:, fix:, docs:, refactor:, test:, chore:. The subject is imperative
and at most 72 characters; commitlint enforces it.