From a65e13ae9fab111f2ba2dfc827bb653db7badb00 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Mon, 3 Aug 2026 10:45:20 +0200 Subject: [PATCH 1/2] docs: add AGENTS.md agent-maintenance docs system Root AGENTS.md as single source of truth (commands, conventions, repo map, cross-cutting gotchas, PR/commit rules), a thin CLAUDE.md that @-imports it (no duplicated facts), and an additive packages/core/AGENTS.md mapping the engine subsystems and its load-bearing render/store invariants with file:line references. Grounded in the real code and existing docs; links to docs/ARCHITECTURE.md et al. rather than restating them. --- AGENTS.md | 101 ++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 8 ++++ packages/core/AGENTS.md | 80 +++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 packages/core/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d77e5a4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# 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 — / + 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 + +`` (`'select'` / `'arrow'` handled internally; any other string falls through to +`onClick`/`onCreateDrag`), ``, ``, `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. +- **Do not add `Co-Authored-By: Claude` (or any AI attribution) trailers to commits in this repo.** +- Don't commit unless asked; when you do and you're on `main`, branch first. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e536a90 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md new file mode 100644 index 0000000..c1e7c34 --- /dev/null +++ b/packages/core/AGENTS.md @@ -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. From 42b781499646f5929eccd005cc7cf982945a0b66 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Mon, 3 Aug 2026 11:15:06 +0200 Subject: [PATCH 2/2] docs: drop Claude-specific commit-trailer rule from AGENTS.md AGENTS.md is tool-agnostic; the no-AI-attribution trailer rule is a Claude-only preference already held in global memory, so it doesn't belong in the repo doc. --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d77e5a4..ebe6958 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,5 +97,4 @@ These are behavioral traps not obvious from the code. Rendering/store internals 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. -- **Do not add `Co-Authored-By: Claude` (or any AI attribution) trailers to commits in this repo.** - Don't commit unless asked; when you do and you're on `main`, branch first.