This is system-canvas — a library for rendering interactive, zoomable SVG diagrams from JSON Canvas spec documents. It is the high-level UI for an LLM code generation platform, providing a spatial canvas where users can visualize an entire organization, zoom into areas, and kick off code generation or research agents.
The library is domain-agnostic. It knows about canvases, nodes, edges, colors, themes, and categories. It does not know about teams, repositories, agents, or any application-specific concept. The consuming application maps its domain onto generic canvas primitives.
This is an npm workspaces monorepo with three publishable packages and a demo app:
packages/core/ → system-canvas (pure TypeScript, zero dependencies)
packages/react/ → system-canvas-react (React components, depends on system-canvas + d3-zoom)
packages/standalone/ → system-canvas-standalone (Self-contained IIFE bundle for <script> tag / CDN use)
demo/ → system-canvas-demo (Vite + React demo app, not published)
Pure TypeScript. No React, no DOM, no dependencies. Any framework adapter imports from here.
src/types.ts— All TypeScript interfaces. This is the source of truth for the data model.src/canvas.ts— Canvas data helpers: resolve nodes, build lookup maps, validate, get labels, find group children, and editing helpers (addNode,updateNode,removeNode,addEdge,updateEdge,removeEdge,generateNodeId,generateEdgeId,getNodeMenuOptions,createNodeFromOption).createNodeFromOptionaccepts an optionalthemeargument so it can deep-clonecategory.defaultCustomDataonto the new node viastructuredClone.src/actions.ts— Pure helpers for the node-toolbar action system:getNodeActions(theme)(theme-level),getNodeActionsForNode(node, theme)(per-node —category.toolbarwins when present, else falls through togetNodeActions),buildDefaultToolbar(theme)(returns the default groups so categories can spread them into their own toolbars),buildDefaultColorActions(theme),filterActionsForNode(group, node),resolveActionPatch(action, node).src/slots.ts— Pure helpers for category slots (§Category slots below):computeCategorySlotRegions(node, theme)(canvas-spaceRectperSlotPosition),resolveAccessor/resolveAccessorOr(evaluateNodeAccessor<T>against aSlotContext),getCategorySlots(node, theme),pickRefIndicatorCorner(defaultCorner, slots)(collision resolver that moves the ref indicator to a free corner when the default is occupied),slotEntries(deterministic iteration order),computeReflowReservations(node, theme, slots)(px to reserve on each side for header/footer/edge-strip slots).src/rollup.ts—rollupNodes(canvas, predicate)androllupNodesDeep(canvas, predicate, getSubCanvas). Both return{ total, matched, fraction }. Used inside slot accessors —SlotContext.rollup(pred)is a zero-arg convenience wrapper that rolls up the node's own sub-canvas.src/paths.ts—getAtPath/setAtPathutilities for dot-paths likecustomData.status. Used by the form editor to read/write nested fields without a dependency on lodash.src/text.ts—measureTextWidth(text, fontSize)andwrapText/wrapTextWithBreaks(text, maxWidth, fontSize, maxLines?)pure helpers for word-wrapping SVG text without a DOM. Used internally bykind: 'text'slots and re-exported so consumers writingkind: 'custom'body renderers can wrap consistently.src/themes/— Six pre-made themes (dark, midnight, light, blueprint, warm, roadmap) plus the resolver that merges partial themes and resolves colors/categories.src/rendering/— Pure math: anchor point computation, edge path routing (bezier/straight/orthogonal), viewport transforms, bounding box calculation.src/lanes.ts— Pure helpers for the lane primitive (columns/rows):findLaneAt,snapToLane,evenLanes,lanesExtent. Zero opinion about what lanes represent (dates, ordinal buckets, teams, phases — it's all the same to the library).
React bindings. Depends on system-canvas for all types and math.
src/components/SystemCanvas.tsx— Main orchestrator component. This is what consumers import.src/components/Viewport.tsx— SVG container with d3-zoom pan/zoom and grid background. Forwards thecanvasesmap down toNodeRendererso category-slot accessors can look up sub-canvases for rollups.src/components/NodeRenderer.tsx— Dispatches to type-specific node components. For each node it looks up the category slots, computesReflowReservations(reservedTop/Bottom/Left/Right) so text doesn't collide with header/footer/edge slots, and picks the ref-indicator corner viapickRefIndicatorCorner. Acceptsonly?: 'groups' | 'non-groups'so the caller can interleave edges between group and non-group layers.src/components/TextNode.tsx,FileNode.tsx,LinkNode.tsx,GroupNode.tsx— One component per JSON Canvas node type. Each accepts optionalslots,canvases,reservedTop/Bottom/Left/Right, andrefCorner— the renderer plumbs these through so every node type honors category slots and reflow uniformly. Each rendersCategorySlotsLayeron top of the body so slot primitives paint above the fill/stroke but below the ref indicator and resize handles. Default label text wraps by default ontext/file/linknodes (nobodyslot required) — the node-type components render their label throughNodeTextwithwrap={true}andverticalAlign='center'(or'top'when a header slot is present), so a long title in a plaintextnode reflows to fit the node's width instead of overflowing as a single line. This replaces the legacy\n-splits-into-title/sublabel rendering inTextNode; multi-line input is preserved as paragraph breaks within the wrap path. Thebody-slot-owned path is unchanged.src/components/CategorySlotsLayer.tsx— Renders the category slots for a single node. Computes regions, builds aSlotContext(withrollup,getSubCanvas, etc.), and dispatches to the appropriate primitive perkind(color/progress/count/pill/text/dot/icon) or calls the consumer'srenderfunction forkind: 'custom'. All slot primitives setpointerEvents="none"so they never intercept node-body clicks.src/primitives/(secondary entrysystem-canvas-react/primitives) — Low-level building blocks used internally byCategorySlotsLayerand re-exported forkind: 'custom'slot implementations:NodeColorFill,NodeProgressBar,NodeCountBadge,NodeStatusPill,NodeDot,NodeText,NodeIcon.src/components/EdgeRenderer.tsx— Renders all edges with arrowhead markers, labels, and click targets.src/components/ConnectionHandles.tsx— Four small circular handles (one per side) shown on the hovered node in editable mode; pressing one begins an edge-creation drag.src/components/PendingEdgeRenderer.tsx— Ghost edge drawn during an edge-creation drag. UsescomputeEdgePathwith a synthetic zero-sized target at the cursor when no drop target is hovered.src/components/RefIndicator.tsx— Clickable "enter sub-canvas" corner drawn on navigable nodes.src/components/NodeEditor.tsx— Inline editor rendered via<foreignObject>. When the node's category declareseditableFields, renders a multi-field form (text/textarea/number/select/boolean) and commits a single merged patch on blur /Cmd+Enter. Otherwise renders the historical single-field variant (<textarea>for text,<input>for file/link/group). Uses core'sgetAtPath/setAtPathto read and write dot-paths likecustomData.status.src/components/EdgeLabelEditor.tsx— Inline edge label editor rendered via<foreignObject>centered on the edge midpoint.src/components/AddNodeButton.tsx— Default floating "+" FAB and add-node popover menu.src/components/NodeContextMenuOverlay.tsx— Floating right-click menu rendered when the consumer passesnodeContextMenuto<SystemCanvas>. HTML overlay (position: fixed) anchored to the right-click'sscreenPosition. Owns its own dismissal lifecycle (outsidemousedown/ Escape / scroll / blur). Usestheme.contextMenufor styling; renders nothing if that theme block is missing (graceful fallback for hand-rolled themes that haven't opted in).src/components/NodeToolbar.tsx— Floating toolbar rendered as an HTML overlay above the selected node in editable mode. Tracks the viewport viarequestAnimationFramefor fixed-pixel sizing at any zoom; flips below the node when near the viewport top. Horizontal alignment relative to the node is driven bytheme.toolbarAlign('center'default,'left', or'right') and clamped to the viewport in all modes. Readstheme.nodeActions(or a generated color-swatch default) and renders three group kinds:swatches(colored dots),buttons(icon buttons),menu(dropdown/popover). Optionally appends a trailing delete button (off by default; opt in withtheme.showToolbarDelete). Fully replaceable via therenderNodeToolbarrender prop, which receives{ node, theme, patch, deleteNode }— in that case the library still positions the container and the consumer draws its contents.src/components/Breadcrumbs.tsx— Navigation breadcrumb trail overlay.src/components/LanesBackground.tsx— Renders column/row bands in canvas-space. Sits inside the transformable<g>behind all nodes/edges. Draws alternating fills, optional per-lane color overrides, and dividers between adjacent lanes.src/components/LaneHeaders.tsx— Screen-space overlay that renders pinned column labels (top strip) and row labels (left strip). Polls the viewport viarequestAnimationFrameto keep in sync with d3-zoom transforms. Supportspinned(sticky to viewport edges) and non-pinned (scrolls with content) modes.src/hooks/useViewport.ts— d3-zoom integration, fit-to-content. Rejects pan gestures originating inside.system-canvas-node,.system-canvas-resize-handles, or.system-canvas-connection-handlesso node/resize/edge-create drags don't also pan.src/hooks/useNavigation.ts— Ref-stack breadcrumb state; prefers the synchronouscanvasesmap when present, falls back toonResolveCanvaswith an internal async cache.src/hooks/useCanvasInteraction.ts— Click, double-click, navigate, and context menu handler wiring for both nodes and edges; owns the "clicking one clears selection of the other" rule in editable mode. The context-menu handlers compute bothposition(canvas-space, viascreenToCanvas) andscreenPosition(rawclientX/clientY) on every right-click — consumers picking floating-menu coordinates should usescreenPosition.src/hooks/useNodeDrag.ts— Pointer-event drag with group-children-follow; drag overrides are cleared on pointerup. WhencanDropNodeOnis supplied, the hook also runs a per-frame canvas-space hit-test (skipping the source node and any group children carried with it), exposes the topmost accepted target id asdropTargetId, and on release firesonNodeDrop(sources, target)and snaps source(s) back instead of committing the position. Topmost-rejected hits short-circuit (no see-through), and the target is re-validated againstnodesRef.currenton release so a target deleted mid-drag falls back to a normal drag-end.src/hooks/useNodeResize.ts— Pointer-event resize from the four corner handles of a selected node; resize overrides are cleared on pointerup.src/hooks/useEdgeCreate.ts— Manages the pending-edge drag: source node/side, cursor in canvas-space, live drop-target hit-test (groups excluded); builds a newCanvasEdgeand firesonCreateon release over a valid target.
Self-contained IIFE bundle for drop-in <script> tag use from a CDN. Bundles React, ReactDOM, d3-zoom, system-canvas, and system-canvas-react into a single file that exposes a window.SystemCanvas global. Built with tsup (esbuild).
src/index.tsx— Thin wrapper. Exportsrender(element, options)whichcreateRoots the element and renders<SystemCanvas>with auto-managed internal state. All six mutation callbacks (onNodeAdd,onNodeUpdate, etc.) are wired internally to the core helpers (addNode,updateNode, etc.) so consumers don't have to manageCanvasDatathemselves. Consumer callbacks (if provided) are still invoked for observation.tsup.config.ts— Emits three bundles:system-canvas.js(unminified IIFE),system-canvas.min.js(minified IIFE, ~90 KB gzipped), andsystem-canvas.esm.js(with.d.ts) for bundler consumers.examples/cdn.html— Reference page showing<script>tag usage with theme switching and canvas editing.
The returned StandaloneInstance exposes getCanvas(), getCanvases(), setCanvas(c), setCanvasesMap(m), update(partial) (swap any options like theme/editable without remount), on('change', cb), and destroy(). update() is the preferred way to mutate props post-mount; avoid destroy + re-render on the same element because React may race its async unmount against the new root.
Themes can be passed as an object, a Partial<CanvasTheme> override, or a string name ('dark' | 'midnight' | 'light' | 'blueprint' | 'warm'). The wrapper also re-exports themes on the global so users can do SystemCanvas.themes.midnight for advanced composition.
The library supports an optional generic lanes primitive — named horizontal or vertical bands rendered behind nodes and edges. Lanes are a pure rendering/snapping primitive: the library has no opinion about what they represent. Consumers use them for ordinal roadmap columns (Now/Next/Later), date-derived columns (Jan/Feb/Mar), phase names (Discovery/Build/Ship), swim-lane teams, kanban groupings, or anything else.
CanvasData.columns?: CanvasLane[]— vertical bands positioned along x.CanvasData.rows?: CanvasLane[]— horizontal bands positioned along y.- Each
CanvasLaneis{ id, label, start, size, color? }. The consumer computesstart/sizehowever they like (even widths viaevenLanes(labels), date math, custom bucketing — whatever). - Bands render inside the transformable
<g>so they pan and zoom with content; dividers are drawn withvectorEffect="non-scaling-stroke"so they stay crisp at any zoom. - Headers (pinned column labels on top, row labels on left) render as a screen-space SVG overlay above the viewport. Controlled by the
laneHeadersprop:'pinned'(default),'scroll', or'none'. - Setting the
snapToLanesprop onSystemCanvascauses drags to snap a node so it's centered within its column and/or row on commit (the node's resolved width/height is passed tosnapToLanewithedge: 'center'). Gated per-axis on whethercolumns/rowsare defined. - Core ships pure helpers in
packages/core/src/lanes.ts:findLaneAt(pos, lanes),snapToLane(pos, lanes, { edge: 'start' | 'center' | 'nearest', size? }),evenLanes(labels, size?, start?),lanesExtent(lanes). - The built-in
roadmaptheme (inpackages/core/src/themes/roadmap.ts) pairs well with lanes — it ships categories forinitiative,milestone,outcome,blocker,parked, plus alanegroup category, and includes bespoke 16x16 icons (initiative, milestone, outcome, blocker, parked) shipped via the theme'siconsmap.
We extend the JSON Canvas spec with two optional fields on nodes:
ref(string) — A URI pointing to a sub-canvas. Any node type can have a ref. Nodes with refs are "navigable" — the carved corner indicator on the node navigates via theonResolveCanvascallback or a synchronouscanvasesmap.category(string) — Maps to aCategoryDefinitionin the theme. Provides default width, height, fill, stroke, corner radius, icon, and an optionaltype(the JSON Canvas node type a category creates from the add-node menu; defaults totext). Whencategoryis set,widthandheightbecome optional.
Navigation is discrete, not continuous zoom. Navigable nodes render a clickable carved corner (bottom-right for text/file/link, top-right for groups) that continues the node's own stroke to form a small square containing an arrow/chevron. Clicking that corner pushes a new canvas onto a breadcrumb stack. The carve's edge length is driven by theme.node.refIndicator.size (default 18); the inner glyph and stroke widths scale proportionally so larger indicators stay visually balanced.
Clicking the node body itself never navigates — it fires onNodeClick and (in editable mode) selects the node. Double-clicking a node always opens the inline editor in editable mode.
Two resolution paths for sub-canvas data (in priority order):
canvasesprop — a synchronousRecord<string, CanvasData>. Always preferred when a ref is present in the map. Required foreditablemode so consumer-side edits to sub-canvases are observable by the library.onResolveCanvas(ref)— async callback. Results are cached internally by ref. Used as a fallback whencanvaseslacks the ref.
Breadcrumbs allow navigating back up. The library exposes currentCanvasRef (the ref of the currently-viewed canvas, undefined at root) so editing callbacks can identify which entry in the consumer's canvases map to mutate.
The viewport is driven by d3-zoom. defaultViewport (optional) sets the initial pan/zoom and suppresses all auto-fit behavior. Without it, the autoFit prop controls when the viewport re-centers to the visible content:
'canvas-change'(default) — fit on initial mount and when navigating to a different canvas. Edits (add / move / resize / delete) do not re-fit. This is the typical UX for editable canvases: the view stays stable while the user works.'always'— fit on every change to the nodes array, including after every edit. Legacy behavior; useful for read-only dashboards whose data streams in.'initial'— fit once on mount only. Navigation between sub-canvases keeps the current pan/zoom (usually not what you want).'never'— no auto-fit. Consumer is fully responsible for the viewport.
Navigation zoom-to-node animations set an internal flag that makes the very next fit instant (no animation), so the zoom-in-and-snap sequence looks continuous.
The library is stateless with respect to canvas data. When editable is true, it emits granular mutation callbacks; the consumer owns CanvasData and passes the updated object back as the canvas prop (or via the canvases map for sub-canvases).
Callbacks (all receive canvasRef: string | undefined — the ref of the canvas the node/edge lives on, or undefined for the root):
onNodeAdd(node, canvasRef)— fired when the user picks an option from the add-node menu.onNodeUpdate(nodeId, patch, canvasRef)— fired after drags and editor commits.patch: NodeUpdateisPartial<Omit<CanvasNode, 'id' | 'type'>>.onNodesUpdate(updates, canvasRef)— batched node-update callback. When provided, wins overonNodeUpdatefor drag commits — fires once per drag-end with every moved node, even for a group drag carrying many children. Solves a classic stale-state footgun: consumers that mirror React state into a ref viauseEffect(the common pattern when callbacks need synchronous "current state" reads) cannot observe per-call mutations between synchronous calls, because theuseEffectmirror has not yet run. Without batching, dragging a group of N nodes leaves N-1 nodes snapping back to their pre-drag positions on release because every per-nodesetStatereads the same stale starting state and overwrites the previous iteration's mutation. Resize commits go throughonNodeUpdateregardless — resize only ever moves one node so batching is moot. Skip this prop if youronNodeUpdateconsumer reads state synchronously (e.g.useReduceror a Zustand store); the fallback per-node loop is fine.onNodeDelete(nodeId, canvasRef)— fired when the user presses Delete/Backspace with a selected node.onEdgeAdd(edge, canvasRef)— fired when the user completes an edge-creation drag (connection handle → another node).onEdgeUpdate(edgeId, patch, canvasRef)— fired after edge label editor commits.patch: EdgeUpdateisPartial<Omit<CanvasEdge, 'id'>>.onEdgeDelete(edgeId, canvasRef)— fired when the user presses Delete/Backspace with a selected edge.
Consumers typically implement these by calling the core helpers addNode / updateNode / removeNode / addEdge / updateEdge / removeEdge on their own Record<string, CanvasData> map.
Editing UI:
- Add: a floating "+" button opens a popover listing categories (with color swatch + icon) above base JSON Canvas types. Fully replaceable via the
renderAddNodeButtonrender prop. - Drag: pointer-event drag on any node. Dragging a group moves its spatially-contained children (computed once at drag-start via
getGroupChildren). Drag overrides are local to the library and cleared on pointerup; the committed position flows throughonNodeUpdate. - Edit: double-click any node opens an inline editor in a
<foreignObject>—<textarea>fortext,<input>forfile/link/group. Enter commits, Escape cancels. - Edges: single-click selects an edge (thicker, high-contrast stroke). Double-click opens an inline label editor (
<foreignObject>centered on the edge midpoint). Selecting an edge clears any node selection and vice versa.onEdgeClickalways fires — in editable mode, selection happens alongside. - Connect: hovering a node in editable mode reveals four connection handles (one per side, fading in after ~300ms to avoid flashing on mouse fly-throughs). Dragging from a handle to another node creates a new edge, firing
onEdgeAdd(edge, canvasRef). The new edge'sfromSideis set to the handle that was grabbed;toSideis left undefined so the renderer auto-routes. Releasing over empty space cancels silently. A ghost edge (PendingEdgeRenderer) tracks the cursor during the drag; the hovered drop target gets a highlight halo. Groups are excluded from both hover hit-testing and drop-target hit-testing — they never expose handles and cannot be edge endpoints through this UI. - Delete: single-click selects (dashed outline for nodes, highlighted stroke for edges). The outer
<div>hastabIndex={0}so Delete/Backspace firesonNodeDeleteoronEdgeDeletedepending on what's selected. Keys are scoped to the canvas — no window listener. - Pan vs. drag: d3-zoom's
.filter()rejects any gesture whose target is inside.system-canvas-node, so node drags never double as canvas pans. Background drags still pan normally. - Drop on node: the optional
canDropNodeOn(sources, target) => booleanpredicate turns node-on-node drops into a first-class interaction. While dragging, the library hit-tests the pointer against renderable nodes and asks the predicate per hover; accepted targets paint a dashed "droppable" halo (.system-canvas-node-drop-target). On release over an accepted target,onNodeDrop(sources, target, { canvasRef })fires and the source(s) snap back to their pre-drag positions — the consumer's job is purely to mutate data and trigger a refetch (the most common follow-up moves the source onto a different sub-canvas, so leaving it at drop coords for one frame would be visually meaningless and risks an autosave race writing a position overlay for a node that no longer renders there). When the predicate rejects the hover, release falls through to a normal repositioning drag (onNodeUpdatefor x/y) — same UX as today. Self-drop is filtered by the library before the predicate is called. The signature uses an array (sources: CanvasNode[]) with a v1 length-1 invariant so future multi-select drags can extend without breaking callers. Skip the props entirely and the new code path is dormant — no predicate calls, no highlight, noonNodeDrop.
Categories own more than just a node's default dimensions and colors — they can also declare:
-
slots: CategorySlots— declarative visual add-ons (color,progress,count,pill,text,dot,icon,custom) placed in library-owned positional regions:topEdge/bottomEdge/leftEdge/rightEdge/bodyTop/topLeft/topRight/bottomLeft/bottomRight/topRightOuter/header/footer. Kind and position are orthogonal — any kind fits any region. One slot per position (v1). Slot values areNodeAccessor<T>— either a static value or a function(ctx: SlotContext) => T. The context carries{ node, theme, region, canvases, getSubCanvas, rollup }.ctx.rollup(pred)is a zero-arg convenience that rolls up the node's own sub-canvas (rollupNodes(getSubCanvas(node.ref), pred)); the fullrollupNodes/rollupNodesDeephelpers are exported from core for deeper cases. Color inheritance: every color-bearing slot (color,progress,count,pill,dot,icon) treatscoloras optional — when omitted it inheritsnode.resolvedStroke, so swatch-toolbar color changes propagate to every slot without per-slot accessor functions.kind: 'icon'specifics. Renders an SVG glyph centered in the region.name: NodeAccessor<string>looks up paths intheme.icons[name]first (consumer-provided, e.g. a brand-icon set), falling back to the library's built-in icon set (database,server,cloud,cog,package,lock,globe,code,folder,network,shield,zap,users,terminal,person). Unknown names render nothing — no warning, since accessor-driven names may legitimately have no icon yet. Default size auto-fits the region's shorter axis; override viasize. Reflow treats atopLefticon the same as atopLeftdot — reserves left padding so the body title clears the icon. Two render styles:mode: 'stroke'(default, the right shape for the lib's built-in line glyphs) paintsstroke={color}+fill="none";mode: 'fill'paintsfill={color}+ no stroke — the right shape for brand silhouettes (simple-icons, Lucide filled, FontAwesome solid). A stroked Vercel triangle is just an outline; a filled one is the Vercel logo. Two coordinate spaces viaviewBox:16(default, matches the lib's built-ins) or24(matches simple-icons and most brand-icon CDNs). The renderer rescales path data to the targetsizeregardless. BothmodeandviewBoxareNodeAccessors, so a singleIconSlotcan render line glyphs for fallbackcustomData.kindvalues and filled brand glyphs for known ones. Canonical use case: oneservicecategory whose icon switches per-node viacustomData.kind(Vercel / EC2 / Postgres / etc.) against a brand-icon registry on the theme — exactly what the showcase demo'sservicerow demonstrates.NodeRenderercomputescomputeReflowReservationsso node text and icons inset around header/footer/edge-strip slots (and any slot that triggers the dashboard layout applies baseline horizontal padding matching the header inset), andpickRefIndicatorCornermoves the carved navigation corner to the diagonally-opposite corner when its default position is occupied (or headers/footers block the whole top/bottom row). Edge-strip slots are rendered inside a clipPath matching the node's rounded rect so fills bleed naturally under the corners. All slot primitives render withpointerEvents="none"so they never intercept node clicks. The escape hatchkind: 'custom'receives the regionRectviactx.regionand returns arbitrary SVG — consumers typically reuse the exported primitives (NodeColorFill,NodeProgressBar,NodeCountBadge,NodeStatusPill,NodeDot,NodeText,NodeIcon) from thesystem-canvas-react/primitivesentry. -
toolbar: NodeActionGroup[]— per-category override of the floating node toolbar. Resolved bygetNodeActionsForNode(node, theme): category'stoolbarwins when present, else the theme'snodeActions, else a generated color-swatch group. No auto-merge — usebuildDefaultToolbar(theme)(spread) to explicitly include the default groups alongside category-specific ones. Because toolbar actions can patch any node field includingcategory, a category-switching toolbar transforms a node's visuals, toolbar, and editor in one click — no special machinery needed. -
editableFields: EditableField[]— per-category inline editor schema. When declared, double-clicking the node opens a multi-field form instead of the historical single-field variant. Each field has a dot-path ('text','label','customData.status'), akind(text/textarea/number/select/boolean), and optionallabel/options/min/max/step/placeholder. Commit fires on panel blur orCmd+Enter; cancel onEscape;Enteron a non-textarea field advances focus or commits on the last field. The built patch includes a mergedcustomDataobject so consumers can shallow-merge it without losing sibling keys. -
defaultCustomData: Record<string, unknown>— seedcustomDatafor new nodes created from this category via the add-node FAB. Deep-cloned per instance (viastructuredClone) so two new nodes never share nested references. Pairs naturally witheditableFields.
Text wrapping & gradient fill on kind: 'text' slots. A text slot in the body position auto-wraps value to region.width and honors \n for paragraph breaks (default wrap: true). All other positions render single-line by default; opt in with wrap: true. maxLines truncates with an ellipsis. lineHeight overrides per-line vertical advance (default ~fontSize * 1.25). verticalAlign: 'top' | 'center' | 'bottom' (default 'top') shifts the rendered block within the region — 'center' is what the node-type components use to vertically-center wrapped default labels. A fill: { from, to, angle? } field paints text with a per-node <linearGradient> def — the consumer gets the "gradient title" headline pattern declaratively without a kind: 'custom' body. The wrap helpers (wrapText, wrapTextWithBreaks, measureTextWidth) are exported from core so kind: 'custom' renderers can wrap consistently. With these defaults, a category that wants a wrapped body title is just body: { kind: 'text', value: ctx => ctx.node.text } — no custom React.
hideWhenZero on kind: 'progress'. A progress bar with hideWhenZero: true returns null when value resolves to 0 — useful for cards that only show progress once a denominator exists (a milestone's empty track would otherwise read as "0% complete" rather than "no data yet").
All four fields are optional and additive — existing themes behave identically until they opt in. The showcase demo mode (demo/src/showcase.ts) is the visual reference sheet — a 4×3 grid of nodes, each exercising a different slot kind or position, including kind: 'custom' via a tiny sparkline, a fully-dressed node with four coexisting slots, and a group with a topRight badge (which forces the ref indicator to bottomLeft via the collision resolver).
theme.nodeActions: NodeActionGroup[] drives the floating toolbar. Each group has a kind: 'swatches' (colored dots), 'buttons' (icon buttons), or 'menu' (dropdown whose trigger reflects the currently-active action). When a theme declares nodeActions, it fully replaces the default color-swatch group (no auto-merge). Each NodeAction has a patch (object or (node) => NodeUpdate) that can mutate any node field — not just color. Combined with isActive(node) and appliesTo(node) this is the extension point for status pickers, type switchers, toggles, and cycles. The canonical pattern for roadmap-style UIs is a swatches group where each dot sets { category } (and optionally color), with isActive keyed on category — so what looks like a color picker is actually a status picker. The trailing delete button is off by default — set theme.showToolbarDelete = true to opt in. Consumers who want confirmation dialogs, soft-delete, or a custom delete action wired via nodeActions get the empty default; users can still delete a selected node with the Delete/Backspace keys regardless of this flag.
Right-clicking a node can surface a small library-rendered menu of consumer-defined actions. There are two layers, and they coexist:
-
onContextMenu(event: ContextMenuEvent)(raw escape hatch) — fires on every node, edge, and canvas-background right-click. The library callsevent.preventDefault()for you.event.positionis in canvas-space (post-pan/zoom);event.screenPositionis the rawclientX/clientY(use this forposition: fixedfloating UI).event.targetis theCanvasNodeorCanvasEdgefor'node'/'edge'events; absent for'canvas'. -
nodeContextMenu: NodeContextMenuConfig(declarative) — drop in a list ofNodeContextMenuItemobjects and anonSelectcallback; the library renders the menu, filters per-node via each item'smatchpredicate, dismisses on outside-click / Escape / scroll / blur, and clamps to the viewport. Items supportlabel,icon,destructivestyling,match: { categories?, types?, when? }(ANDed), anddisabled(node, ctx).onSelectreceives(itemId, node, { canvasRef, screenPosition }). Filter helpersmatchesContextMenuItemandfilterContextMenuItemsare exported from core for consumers building their own menu UI on top ofonContextMenu.
Both fire on the same right-click — the declarative menu is the common case, the raw callback is the escape hatch (consumer-rendered submenus, async-loaded items, edge / canvas-background menus). When nodeContextMenu is set but no items match the right-clicked node, no menu opens (the right-click becomes a silent no-op apart from suppressing the browser default). The menu surface is themed via theme.contextMenu: ContextMenuTheme — every built-in theme ships a tuned palette; custom themes inherit darkTheme.contextMenu via resolveTheme.
The showcase demo (demo/src/main.tsx's showcaseContextMenu) is the live reference: status changers gated by match.categories with disabled reflecting the current state, a flip action gated by match.when, a universal "Copy id" item with no match, and a destructive "Delete node" item. Switch the demo to ?mode=showcase and right-click any node to see the menu.
Themes are plain objects implementing CanvasTheme. They control every visual aspect: background, grid, node styles, edge styles, group styles, breadcrumb styles, preset color mappings ("1"-"6"), and category definitions.
Resolution order for node visuals:
- Explicit node properties (
color,width,height) — highest priority - Category defaults from theme — fallback when node properties are absent
- Base theme defaults — final fallback
Trap when adding a new top-level
CanvasThemefield:resolveThemeinpackages/core/src/themes/resolve.tsbuilds the merged theme by enumerating known fields explicitly — there is no...partialspread. Any top-level field you add toCanvasThememust also be wired intoresolveTheme(e.g.myField: partial.myField ?? base.myField), otherwise it will be silently dropped from every resolved theme andtheme.myFieldwill beundefinedat render time. TypeScript will not catch this because everyCanvasThemefield is optional. When in doubt, grepresolveThemeafter touchingCanvasTheme.
Nodes use the double-rect technique: an opaque backer rectangle (matching the background color) is drawn first, then a semi-transparent styled rectangle on top. This prevents edges from bleeding through transparent node fills.
SVG paint order (painter's model, later = on top): groups → edges → non-group nodes → resize handles. Groups sit behind so edges can pass over their translucent fills and remain clickable; regular nodes sit above edges so arrow tips tuck cleanly under node borders at endpoints.
npm run build # Build all packages (core → react → standalone)
npm run build:core # Build system-canvas only
npm run build:react # Build system-canvas-react only
npm run build:standalone # Build system-canvas-standalone (IIFE + ESM bundles)
npm run dev # Start the Vite demo app at localhost:5173
npm run typecheck # Type-check all workspaces- No CSS files. All styling is inline via SVG attributes and React
styleprops, driven by the theme object. - No emojis in code or comments.
.jsextensions in imports — Required for ESM compatibility. All internal imports use.jseven though source is.ts(e.g.,from './types.js').- Core must have zero React imports. The boundary between packages is strict: core does math and data, react does DOM and events.
- Types live in core. Even types used primarily by React components (like
ContextMenuEvent) are defined insystem-canvasso framework adapters can use them. - Nodes are resolved before rendering. Raw
CanvasNodegoes throughresolveNode()to produceResolvedNodewith all dimensions and colors computed. Renderers only work withResolvedNode.
- Create
packages/core/src/themes/yourtheme.tsimplementingCanvasTheme - Export it from
packages/core/src/themes/index.ts - Add it to the
themesobject inpackages/core/src/index.ts
The JSON Canvas spec has 4 types: text, file, link, group. If you add a new type:
- Add the type string to
NodeTypeintypes.ts - Add any type-specific fields to
CanvasNodeintypes.ts - Create a new component in
packages/react/src/components/ - Register it in
NodeRenderer.tsx'sgetNodeComponent()switch
- Add the style string to
EdgeStyleintypes.ts - Implement the path computation function in
rendering/edge-routing.ts - Add the case to the switch in
computeEdgePath()