Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# AGENTS.md — canvas-harness

Canvas-rendered node-graph library (React Flow's API, Excalidraw's perf ceiling, TipTap's
extensibility). Headless, styleless. pnpm monorepo. Pitch + usage: `README.md`.

This is the single source of truth for working in this repo. Package-specific rules live in
additive `packages/*/AGENTS.md` files (loaded when you open that package). Design docs are linked,
never restated here — read them on demand.

## Commands

Package manager **pnpm@9.15.0** (pinned via `packageManager`), Node **>=20**. Run from repo root:

| Task | Command |
|---|---|
| Build all packages | `pnpm build` (tsup → ESM+CJS+d.ts) |
| Unit tests | `pnpm test` (vitest, per package) |
| Browser tests | `pnpm test:browser` (vitest + playwright/chromium; `core` & `react` only) |
| Typecheck | `pnpm typecheck` |
| Lint | `pnpm lint` (`biome check .`) |
| Format | `pnpm format` (`biome format --write .`) |
| Dev / playground | `pnpm dev` (runs `examples/playground`, Vite) |

- **Build before typecheck/test in a clean tree** — cross-package types resolve through built
`dist/*.d.ts` (this is why CI builds first).
- Browser tests need chromium once: `pnpm -F @canvas-harness/react exec playwright install --with-deps chromium`.
- Single package: add `-F @canvas-harness/core` (or `react` / `sync-broadcast`).

## Conventions

Enforced by `biome.json` + `tsconfig.base.json` — run `pnpm format` and `pnpm lint` before finishing.
What differs from language defaults (the rest, just write idiomatic TS):

- **No semicolons** (`asNeeded`). **Single quotes** in TS, **double quotes** in JSX. **Trailing
commas everywhere.** Arrow parens omitted for a single arg (`x => x`). 2-space indent, width 100, LF.
- **`import type`** is mandatory for type-only imports (`verbatimModuleSyntax`).
- **No `any`** (`noExplicitAny: error`), no unused imports/locals/params. `console.log` warns.
Non-null `!` is allowed. Imports are auto-organized.
- TS is `strict` **plus** `noUncheckedIndexedAccess`, `noImplicitOverride`, `noImplicitReturns`,
`noFallthroughCasesInSwitch` — indexing an array gives `T | undefined`, handle it.
- Biome only touches `packages/*/{src,tests}` and `examples/*/src`. `dim0/`, `dist/` are out of scope.

## Repo map

```
packages/
core/ @canvas-harness/core — framework-agnostic engine (store, renderer, edges,
hit-test, ops/history, text, ai). ~13.7k LOC. See packages/core/AGENTS.md.
react/ @canvas-harness/react — <Canvas>/<CanvasProvider> + data/interaction/presence/
history hooks. ~2.9k LOC.
sync-broadcast/ @canvas-harness/sync-broadcast — BroadcastChannel SyncAdapter (multi-tab demos).
examples/playground/ the dev app `pnpm dev` launches (Vite + React 19). NOT dim0.
docs/ design docs — see below. perf/ perf baselines + fixtures. scripts/ bump-version.mjs.
dim0/ SEPARATE product ("Dim0 - The Thinking Canvas"). NOT in the pnpm workspace, NOT this
library. It *consumes* the published packages. Don't edit it when working on the lib.
```

Workspaces = `packages/*` + `examples/*` (`pnpm-workspace.yaml`). All 3 packages at v0.1.25.

### `@canvas-harness/react` public surface

`<Canvas tool=...>` (`'select'` / `'arrow'` handled internally; any other string falls through to
`onClick`/`onCreateDrag`), `<CanvasProvider store>`, `<Minimap>`, `useCanvasStore`, and selector
hooks: `useNode(s)`, `useEdge(s)`, `useSelection`, `useCamera`, `useInteractionState`/`Mode`/`useCursor`/
`useIsMoving`/`useDraggedIds`, `useLocalPresence`/`usePresence`, `useCanUndo`/`useCanRedo`. Hooks
subscribe narrowly (a `useNode(id)` re-renders only when that node changes) — keep that granularity.

## Design docs (link, don't duplicate)

- `docs/ARCHITECTURE.md` — the WHAT: data model, rendering model, edges, interaction, extensibility.
- `docs/IMPLEMENTATION.md` — the HOW: tool choices (tsup/vite/vitest/biome/signia) + phased build.
- `docs/IMPROVEMENTS.md` — deferred perf/polish backlog (sized XS–L).

Architecture facts belong in those files. If you learn a durable *why*, add it there (or an ADR),
not here.

## Gotchas (cross-cutting — the ones that bite)

These are behavioral traps not obvious from the code. Rendering/store internals are in
`packages/core/AGENTS.md`.

- **View state ≠ document state.** Camera, selection, hover, interaction mode are *view* state.
Never wire them into the document save/sync/op bus — it tanks FPS and is semantically wrong.
- **Pan/zoom must set `interaction.mode`.** `use-pan-zoom` (react/internal) MUST write the interaction
mode on every motion; every downstream motion-LOD decision reads it. Silent to miss, breaks LOD.
- **Cross-event gesture flags live in component-scope `useRef`**, not inside a `useEffect`. Flags like
`justCommittedRef` reset on prop-driven remounts if scoped to the effect.
- **Image/icon resize does NOT lock aspect by default.** Free resize; shift constrains (standard
editor behavior). Don't add a default aspect lock for image/icon node types.
- **The renderer's sorted-(z,id) paint cache invalidates only on document `'change'`** — never on
camera/selection/interaction. If you add a field that affects paint order, it must flow through a
document op or the cache goes stale. (Details in `packages/core/AGENTS.md`.)

## PRs & commits

- CI (`.github/workflows/ci.yml`) runs on PRs to `main` and gates merge on **lint + typecheck + unit
tests + browser tests + dist build**. Make all of `pnpm lint`, `pnpm typecheck`, `pnpm test` pass.
- Release is **manual only** (`workflow_dispatch`, `release.yml` → OIDC npm publish + tag). Don't bump
versions or tag as part of feature work.
- Don't commit unless asked; when you do and you're on `main`, branch first.
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@AGENTS.md

## Claude-specific notes

- The hot rendering path (`packages/core/src/render/*`) is perf-load-bearing and full of
non-obvious invariants — prefer plan mode there, and read `packages/core/AGENTS.md` first.
- Do broad code search/exploration with read-only sub-agents so the investigation doesn't pollute
the main thread; keep a single writer.
80 changes: 80 additions & 0 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# AGENTS.md — @canvas-harness/core

Additive to the root `AGENTS.md` (commands, conventions, cross-cutting gotchas). This file is the
map + invariants for the engine. Framework-agnostic; no React. Single barrel entry `src/index.ts`.

## Subsystem map

| Dir | Owns | Entry |
|---|---|---|
| `store/` | The document: reactive scene (signia atoms per node/edge/group), typed-Op mutations, spatial-index sync, undo/redo, selection, camera, interaction, presence, conflict | `store/store.ts` (`createCanvasStore`) |
| `render/` | Dual-surface frame loop, scene cache, culling, LOD dispatch | `render/renderer.ts` (`createRenderer`), `render/frame-loop.ts` |
| `edges/` | Edge geometry: project, auto-route, sample, clip, arrowhead, versioned geometry cache, draw | `edges/cache.ts`, `edges/draw.ts` |
| `text/` | Markdown tokenize → layout → measure → offscreen bitmap cache; font/math epoch invalidation | `text/bitmap-cache.ts`, `text/layout.ts`, `text/font-epoch.ts` |
| `node-types/` | Custom-node registration + LOD/lifecycle contract | `node-types/define-node.ts` (`defineNode`) |
| `ai/` | LLM scene context (md/JSON) + Op schemas as Anthropic tools | `ai/context.ts` (`getContext`), `ai/op-schemas.ts` |
| `hit-test/` | Pointer→entity resolution | `hit-test/` |
| `spatial/` | `UniformGrid` broad-phase + AABBs | `spatial/` |
| `camera/` | World↔screen math; zoom clamps (`MIN_ZOOM 0.05`, `MAX_ZOOM 16`) | `camera/index.ts` |
| `codec/` | Scene ↔ wire serialization + schema migrators | `codec/index.ts` (`toSerialized`/`fromSerialized`, `registerMigrator`) |
| `clipboard/` `export/` `assets/` | copy/paste · PNG/SVG export · image/svg decode+sanitize | resp. dirs |
| `extension/` `ids/` `types/` | plugin escape-hatch · id gen (`${clientId}-${counter}`) · all domain types | resp. dirs |

**Store (`store/store.ts`)** owns per-entity + id-list atoms, camera/selection/frameOrder/interaction/
presence atoms, two `UniformGrid` indexes, `EdgeGeometryCache`, `incidentEdges`, `edgeVersions`,
`topZ`/`bottomZ` watermarks, the node-type registry, the batch buffer, and `undo/redoStack` (cap 50).
Siblings: `interaction.ts`, `presence.ts`, `conflict.ts`, `inverse-op.ts`, `sync.ts`, `palm-rejection.ts`.

**Public API:** `createCanvasStore` (`store/store.ts:117`); `addNode`/`updateNode`/`removeNode`/`addEdge`/
`addImage`/`addSvg`/`batch`/`undo`/`redo` (store methods, `store.ts:494+`); branded ids
`asNodeId`/`asEdgeId`/… (`types/primitives.ts:10`); `getContext` (`ai/context.ts:47`); `defineNode`
(`node-types/define-node.ts:177`).

## Rendering hot path (`src/render/`)

- `frame-loop.ts` — rAF-coalesced; schedules only when dirty.
- `renderer.ts` — **static surface** (committed scene, `paintStatic` :881, 6 cache tiers,
`SCENE_CACHE_MARGIN_PX 256`) vs **interactive surface** (`paintInteractive` :1111 — drag/resize/
selection chrome/marquee/draft edge). Cache math in `scene-cache-math.ts`.
- Culling: `visibleNodes` :1345 / `visibleEdges` :1097 — `store.querySpatial` broad-phase → sorted-id
walk → exact AABB.
- LOD dispatch :493–538 (sub-pixel skip → placeholder → canvas fallback → React overlay); thresholds
`define-node.ts:73`. **DOM overlay itself lives in the React layer** — core only maintains
`overlaySet` and fires `onOverlayChange(mountedIds)` (:209, :557). `render/overlay.ts` is selection
*chrome*, not the overlay.

## Invariants — read before editing render/ or store/

- **Sorted-(z,id) paint cache** (`renderer.ts:262`) invalidates ONLY on `'change'` (:1374), never on
camera/selection/interaction (:1370). Paint order = `a.z - b.z || id asc` (:1340, :1092) — keep the
tie-break stable or z-order flickers.
- **Save/restore elision.** Built-in drawers must set every ctx state they depend on and assume no
defaults (`define-node.ts:44`) — NO per-node save/restore. Only *custom* `renderCanvas`/
`drawPlaceholder` are wrapped (:530, :964). Frame paint saves only when opacity≠1 (`paint-frame.ts:33`).
The rough-misregister translate (:436, mirror :1165) is unpaired — must translate back manually.
- **Integer edge cache versions** (`edges/cache.ts:107`) — pure int compare; the old `toFixed(2)`
string version cost ~5–8ms at 2k edges (:112). Store bumps via `bumpEdgeVersion` on edge add/update
AND on incident `node.update` (`store.ts:158,321`). Drag bypasses the cache (:1188) — version
doesn't bump mid-gesture.
- **Font/math epoch** (`text/font-epoch.ts`) bumps an int on `document.fonts` settle → `clearMeasureCache()`
+ repaint (`renderer.ts:1431`); the epoch is folded into the bitmap-cache key. Add any font-affecting
field to that key or you get stale glyphs.
- **Sub-pixel / readability skips**: `MIN_ON_SCREEN_SIZE_PX 1.5` (:85), `MIN_READABLE_FONT_PX 3` (:93) —
skipping bypasses path build + the bitmap FNV walk. Don't remove without measuring.
- **`undefined` → `null` normalization** (`store.ts:429`, `slicePrev` :445): `undefined` is dropped by
`JSON.stringify`, so a field clear would silently no-op over sync/undo. Both patch and prev slices are
normalized. Clearing a field = set it to `null`, not `undefined`.
- **z watermarks monotonic + central** (`store.ts:169`): `topZ` only ++, `bottomZ` only --, maintained in
`applyOpInternal` so remote/explicit z stay consistent; negative z is first-class. `bringForward`/
`sendBackward` binary-search non-target z (:91).
- **Op log / undo** (`types/op.ts`, 9 variants): `node.update`/`edge.update` carry `prev` slices so the
inverse needs no diff. Only `origin:'local'` batches enter the undo stack (`store.ts:244`); undo/redo
replay with `origin:'history'` and bypass `emitChange` (:774,:788). Conflict detection runs BEFORE
apply, LWW wins (:745).
- **`removeNode` cascades incident edges first, same batch** (`store.ts:539`). Skipping the cascade
orphans edges in the spatial index.

## Tests

`vitest run` (unit) and `vitest run -c vitest.browser.config.ts` (browser, chromium/playwright,
`tests/**/*.browser.test.{ts,tsx}`). Rendering/geometry that needs a real canvas goes in browser tests.
Loading