Architecture
Status: partly implemented. The math primitives, ids, frames, the box drawables (rectangle, ellipse, diamond) with geometry and hit testing, and frame resize are implemented, along with the line, freehand, text and arrow drawables. See Math primitives, Ids and frames, Drawables and geometry, Resize, Points drawables, Text and Arrows. The
Shaperecord,Result, fractional z-order with its reorder operations, selection bounds, picking and arrow bindings are implemented too. See The Shape record, Results, Z-order, Selection and picking and Bindings. Parsing of untrusted input is implemented as well. See Parsing. Everything else below is a proposal, and its type sketches may change.
- One interaction model. Every shape is a rectangle with corner and edge handles. Move, resize and rotate are written once.
- Pluggable content. Rectangle, ellipse, diamond, line, freehand, text and arrow are adapters behind one interface.
- Pure domain. No DOM, no framework, no rendering backend, no mutable state. The package builds against
lib: es2023only, so DOM types are not even available. - Serializable. Every value is plain JSON-compatible data, so undo/redo, persistence and sync are straightforward.
Non-goals (for this package)
Section titled “Non-goals (for this package)”Rendering (Canvas, SVG, roughjs), input handling, the camera and its UI, and storage are out of scope. They will live in separate packages or in the app and consume the geometry this package produces.
Ubiquitous language
Section titled “Ubiquitous language”| Term | Meaning |
|---|---|
| Shape | An entity on the canvas: identity + frame + drawable + style + z-index. |
| Frame | The rotated rectangle that contains the drawable. It is what the user selects and drags. |
| Drawable | The content inside a frame, tagged by kind. |
| Adapter | Stateless behaviour for one drawable kind. It maps frame operations to drawable data and data to geometry. |
| Geometry | Derived, read-only description used for rendering and hit testing: outline, path segments, bounds. |
| Handle | A grab point. Every frame has 8 resize handles and 1 rotate handle. An adapter may add its own handles, such as line vertices or arrow ends. |
| Binding | A record that attaches an arrow terminal to another shape. |
| Page / Local / Screen space | The three coordinate systems. Each one has its own branded point type. |
Shape├── id: ShapeId branded string├── frame: Frame { x, y, width, height, rotation } page space├── drawable: Drawable discriminated union on `kind`├── index: ZIndex fractional index string└── style: Style stroke, fill, roughness, opacity, seedShapes carry no sync or persistence metadata (versions, nonces, timestamps). Layers built on top of this package wrap the records if they need that.
Ids are branded strings created with crypto.randomUUID(), which is native in browsers and Node. It adds 0 bytes to the bundle, compared with about 130 bytes for nanoid. Generating an id is the package’s only impure operation. Every other function receives ids as input, which keeps the operations pure and the tests deterministic.
type Frame = { readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly rotation: Radians;};
type LinePoints = readonly [LocalPoint, LocalPoint, ...LocalPoint[]];
type FreehandSample = { readonly point: LocalPoint; readonly pressure: number };
type FreehandSamples = readonly [FreehandSample, ...FreehandSample[]];
type Drawable = | { readonly kind: "rectangle"; readonly cornerRadius: number } | { readonly kind: "ellipse" } | { readonly kind: "diamond" } | { readonly kind: "line"; readonly points: LinePoints } | { readonly kind: "freehand"; readonly samples: FreehandSamples } | { readonly kind: "text"; readonly text: string; readonly sizing: "auto" | "fixed-width"; readonly fontSize: number; readonly fontFamily: "hand-drawn" | "sans" | "mono"; readonly align: "left" | "center" | "right"; } | { readonly kind: "arrow"; readonly start: LocalPoint; readonly end: LocalPoint; readonly bend: number; readonly startHead: Arrowhead; readonly endHead: Arrowhead; };- Freehand samples: each sample holds a point and its pressure. The first sketch kept
pointsandpressuresas parallel arrays, which could disagree in length. One list of samples makes that state unrepresentable. - Minimum points:
LinePointsneeds at least two points andFreehandSamplesat least one, so an empty drawable cannot be built. - Frame conventions:
(x, y)is the top-left corner before rotation, and rotation is about the frame’s centre. Width and height are always ≥ 0. A flip is applied to the content and never stored.
The adapter
Section titled “The adapter”type DrawableAdapter<K extends Drawable["kind"]> = { readonly kind: K; create(frame: Frame, env: AdapterEnv): DrawableOf<K>; geometry(frame: Frame, drawable: DrawableOf<K>): Geometry; resize(drawable: DrawableOf<K>, change: FrameChange, env: AdapterEnv): Fitted<K>; handles(frame: Frame, drawable: DrawableOf<K>): readonly Handle[]; dragHandle(drawable: DrawableOf<K>, drag: HandleDrag, env: AdapterEnv): Fitted<K>;};
type AdapterEnv = { readonly measureText: (line: string, font: Font) => number };
type Fitted<K> = { readonly frame: Frame; readonly drawable: DrawableOf<K> };- Registry: adapters are collected in a registry keyed by
kind. The rest of the system callsadapterFor(shape.drawable.kind)and never switches on kind. - Exhaustiveness: the registry is typed
{ [K in Kind]: DrawableAdapter<K> }, so adding a kind without its adapter is a compile error.
How each family behaves
Section titled “How each family behaves”| Family | Kinds | Source of truth | On resize |
|---|---|---|---|
| Box | rectangle, ellipse, diamond | the frame | nothing to do; geometry follows the frame |
| Points | line, freehand | local points | scale points by (sx, sy); mirror on flip |
| Text | text | text + font + sizing | side handle → fixed width, re-wrap; corner → scale font |
| Connector | arrow | endpoints + bend | scale ends and curve midpoint; resolveArrow re-attaches bound ends |
For points and connectors, any edit to the points re-fits the frame to their bounds. This is why resize and dragHandle return a Fitted pair instead of only the drawable.
Why points are local, not normalized 0–1
Section titled “Why points are local, not normalized 0–1”We considered normalized coordinates (0–1 inside the frame), which would make resizing free. They break for degenerate frames: a horizontal line has height 0, so the division is undefined, and a flip would also have to be stored somewhere. Both tldraw and Excalidraw store local units and scale on resize, and we follow them.
Coordinate spaces
Section titled “Coordinate spaces”| Space | Origin | Type |
|---|---|---|
| Screen | viewport top-left, CSS pixels | ScreenPoint |
| Page | infinite canvas origin | PagePoint |
| Local | frame top-left, unrotated | LocalPoint |
The three point types are branded, so passing a ScreenPoint where a PagePoint is expected fails to compile. Conversions go through Mat (2D affine):
frameToPage(frame): Mathandles local → page.- The camera
{ x, y, zoom }handles page ↔ screen.
Bindings (arrows)
Section titled “Bindings (arrows)”type Binding = { readonly id: BindingId; readonly arrowId: ShapeId; readonly terminal: "start" | "end"; readonly targetId: ShapeId; readonly anchor: NormalizedPoint;};- Storage: a binding is its own record, not a field on the arrow, so deleting either side is a simple filter (
removeBindingsFor). - Anchor: a branded
NormalizedPointin(0..1, 0..1)inside the target’s frame, so it survives the target being moved, resized or rotated. - Targeting:
bindingTargetpicks the smallest non-arrow shape whose outline is near the point or whose inside holds it. - Resolution:
resolveArrowmoves each bound end to its anchor’s page point, then clips it back along the curve to the target’s outline.arrowsToResolvelists the arrows a change affects. Resolution keepsbendand is idempotent. - Left to the app: unbinding when the arrow itself is dragged, and snapping anchors to the centre or edges.
See Bindings.
Z-order
Section titled “Z-order”index is a fractional index string (e.g. "a0", "a0V"), so moving a shape forward or back rewrites one record only. Implemented with the fractional-indexing package, wrapped in one module so nothing else imports it. It is the package’s only runtime dependency. See Z-order.
Operations
Section titled “Operations”All operations are pure functions: (state, intent) → state. There are no setters and no classes holding state.
translate(shape, delta)resize(shape, handle, pointer, options)— options: aspect lock, from-centrerotate(shape, angle, pivot)editHandle(shape, handleId, pointer)hitTest(frame, geometry, point, options)— one shape’s outline against a pointpickShape(shapes, point, options)— the shape a click selects. See Selection and picking.bounds(shapes)— the axis-aligned box around a selectionbringToFront,sendToBack,bringForward,sendBackward— reorder a selection, returning patches
Errors and parsing
Section titled “Errors and parsing”- At the boundary: loading a file or pasting goes through
parseShapeDocument,parseShapeorparseBinding, each takingunknownand returning aResult<_, ParseError>. - Inside the domain: types guarantee validity, so no defensive checks are needed.
- No throwing: failures are values. A
ParseErrorholds thepathof the first bad value and a closedreason. - Shape of the checks: the first error wins, unknown fields are dropped, and a document with one bad record is rejected whole. A document also has to agree with itself: no duplicate ids, and every binding joins an arrow to a non-arrow shape in the document.
- Left to the app:
JSON.parsefailures and file versioning.
See Parsing.
Testing strategy
Section titled “Testing strategy”- Unit tests per adapter, mainly on resize: negative scale (flip), rotated frames, zero-size frames, aspect lock.
- Property tests for round-trips, such as
pageToLocal(localToPage(p)) ≈ p, and for z-index ordering. - Geometry snapshots for each kind.
Decisions (2026-10-14)
Section titled “Decisions (2026-10-14)”- Runtime dependencies: keep the bundle small.
- The only runtime dependency allowed is
fractional-indexing. It landed with z-order. - Ids come from
crypto.randomUUID. - Parsing is hand-written; there is no schema library.
- The only runtime dependency allowed is
- Rendering: not part of this package. Drawing is the next package, built on the geometry this one produces. Style fields such as
roughnessandseedwere first placed outsideShape. ADR 0005 (docs/decisions/0005-hand-drawn-canvas-renderer.md) moved them ontoShape, asstyle, so they persist and copy with the shape. - Groups and containers: later. No
parentIdfor now. - Sync and persistence: out of scope. The package models shapes and nothing built on top of them.
- Text measuring: the package has no DOM, so measuring is injected.
- A
MeasureTextport returns the advance width of one unbroken line. Callers bundle it as anAdapterEnvand pass it as a last argument tocreate,resizeanddragHandle. - Every adapter call takes the env, and callers always pass it. Adapters that do not measure declare shorter functions, which TypeScript accepts, so there is one adapter shape.
createreturns a drawable only, so it cannot fit the frame to a text.editTextdoes that.- Layout is a pure function over the measurer. Line height is a constant,
1.25times the font size.
- A