@factory/whiteboard
Canvas v1 core for the AI Software Factory whiteboard (T-167 / A-001): the
document schema + validator, pure geometry, immutable canvas ops, and a generic
bounded undo/redo stack. Zero runtime dependencies, plain TypeScript — the
canvas document must be readable everywhere (API, worker, web) without a shared
build pipeline. No UI, no I/O, no React: the web renderer
(apps/web/src/components/whiteboard/) is a thin hand-rolled SVG layer over this
package; every coordinate/decision in it comes from here.
Source-first (like @factory/shared): main/types point at src/index.ts, so
consumers import without a build step.
import { validateCanvas, elementBounds, addElement, OpStack } from "@factory/whiteboard";
The document (recorded layout)
- Versioned:
CANVAS_VERSION = 1. A canvas is{ version, elements: Record<id, CanvasElement> }— an id→element object map (not an array); order is byz(depth), not key order. - 7 element types (
ELEMENT_TYPES):sticky,text,rect,ellipse,connector,freehand,group. Connectors reference their endpoints by element id + sideAnchor(top|right|bottom|left), so they re-route when an endpoint moves. - Sticky colors are a closed set (
STICKY_COLORS); unknown values are rejected at validation.
Modules
| File | What |
|---|---|
schema.ts |
The types + the hand-rolled validateCanvas (no runtime deps — a JSON-Schema lib would violate the zero-dep constraint). Returns { ok: true, canvas } or { ok: false, errors: string[] }; isCanvas is the type-guard wrapper; emptyCanvas() seeds a valid v1. |
geometry.ts |
Pure, dep-free geometry: unionRect/unionAll/elementBounds (connectors = endpoint union), hitTest (tolerance, connector-aware), anchorPoint/nearestAnchor, connectorPath (straight/orthogonal), arrowHead, decimatePoints (freehand), and the viewport math worldToScreen/screenToWorld/zoomAt/viewportToWorldRect/rectsIntersect/displayBounds (the viewport-culling primitives the renderer uses for the 2,000-object perf gate). |
ops.ts |
Immutable canvas ops — each returns a NEW canvas, never mutating: cloneCanvas, nextZ, addElement, updateElement (patch), removeElement, moveElement/moveMany, groupElements/ungroupElement/rebindGroup, bringToFront, setSelection. Invariants (group integrity, connector rebinding, z-order) are enforced here; CanvasOpError is the failure type. |
history.ts |
OpStack<T> — a generic bounded undo/redo stack (bounded depth, so a long session can't grow unbounded). The renderer pushes op results, not raw events. |
Invariants the package owns (the renderer must not re-derive them)
- Bounds/hit-testing/connector routing are pure functions of the document — the
SVG layer only positions elements from
elementBounds/anchorPointand culls viaviewportToWorldRect/rectsIntersect(the 2,000-object mount < 100 ms gate,confluence-whiteboard-perf.spec.ts). - Every mutation goes through an
ops.tsfunction (immutable) so undo/redo is a singleOpStack.pushof the next canvas state — there is no second, divergent mutation path in the UI. - Validation is at the trust boundary (load from DB / realtime) via
validateCanvas; the ops assume a validated input.
Tests & gates
- Unit:
vitest runfrom this directory — schema validation, geometry (incl. the viewport-culling primitives), ops invariants, and theOpStack. 43/43 (thegeometry/ops/schema/historysuites). - Perf: the web package's 2,000-object render gate (
tests/e2e/specs/ confluence-whiteboard-perf.spec.ts) relies onviewportToWorldRect/rectsIntersectfrom here for off-screen culling. tsc --noEmitclean (0 errors).