Skip to content

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.

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.

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.

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.

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.

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.

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.

External packages, then @suite/* workspace packages, then relative imports, separated by blank lines. Biome’s organizeImports enforces it; run pnpm lint:fix.

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.

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.

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.

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.

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.

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.

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.

Conventional commits: feat:, fix:, docs:, refactor:, test:, chore:. The subject is imperative and at most 72 characters; commitlint enforces it.