Spatial workspace for patching AI signal graphs. Next.js 16, TypeScript, Tailwind CSS 4, @xyflow/react.
Client and server are distinct applications sharing a kernel. The Dependency Rule is the law: source code dependencies point inward.
src/
kernel/ ← INNERMOST: shared pure types + transforms. Zero ports, zero frameworks.
entities/ ← Data types (WorkspaceNode, Connection, Position, Viewport, PdfDocument)
transforms/ ← Pure functions (createNode, moveNode, validateConnection, etc.)
source-kinds/ ← Source kind plugin registry (registry, contribution type). Pure data, no concrete kinds.
client/ ← CLIENT APPLICATION: its own Clean Architecture boundary
domain/
use-cases/ ← Port-dependent orchestration (loadWorkspace, uploadPdf, executePipeline)
ports/ ← Interfaces defined by the client (StoragePort, PdfRendererPort, etc.)
adapters/
storage/ ← StoragePort, BlobStoragePort implementations (server, localStorage, IndexedDB)
canvas/ ← xyflow ↔ domain mapping (flow-node-mapper, use-canvas-binding)
pdf/ ← PdfRendererPort implementation (pdf.js)
execution/ ← TransformExecutorPort implementation (Web Worker)
chat/ ← ChatPort implementation (fetch to /api/chat)
model-roster/ ← ModelRosterPort implementation (fetch to /api/models)
ai-executor/ ← AiExecutorPort implementation (fetch to /api/chat, non-streaming)
source-kinds/ ← Concrete source kind contributions (markdown, pdf, derived) registered into the kernel registry
ui/
hooks/ ← React hooks bridging UI to domain use cases (via DI context)
components/ ← React components (receive data + callbacks via props)
app/ ← AdaptersContext + WorkspaceManagerContext (DI providers)
lib/ ← Utilities (cn, parseStructuredOutput)
server/ ← SERVER APPLICATION: Node.js-only utilities and adapters.
config/ ← Provider roster and configuration (model-roster, provider dispatch config)
storage/ ← Filesystem persistence (.context-canvas/manifest.json, workspaces/, blobs/)
adapters/ ← External service adapters (Anthropic API, OpenAI-compatible via Vercel AI SDK)
app/ ← COMPOSITION ROOT: Next.js App Router pages + API routes. Wires adapters to context.
- Kernel imports NOTHING from client, React, Next.js, xyflow, or any framework. Pure TypeScript only. No ports. If you're adding a framework import inside
src/kernel/, stop. - Client domain never imports from client adapters. Ports are defined in
client/domain/ports/, implemented inclient/adapters/. The dependency arrow points inward. - Kernel transforms are pure functions. Data in, new data out. No mutation, no side effects, no port parameters.
- Client use cases may take ports as arguments. They coordinate between kernel transforms and infrastructure via dependency injection.
- Hooks never import concrete adapters. They receive port implementations via
useAdapters()context. This makes them testable and adapter-swappable. - Components receive data and callbacks via props. They never import adapters or domain use cases directly.
- One composition root (
src/app/page.tsx) wires concrete adapters intoAdaptersProvider. No other file creates adapter instances. - All state updates must be immutable (new object references). xyflow won't re-render if you mutate.
- Server layer imports only from kernel, Node.js built-ins, and server-side dependencies. Never from client/. Server utilities are consumed by API routes in
src/app/api/.
xyflow imports are allowed in:
src/client/adapters/canvas/— flow-node-mapper, use-canvas-bindingsrc/client/ui/components/— any component whose primary responsibility is rendering or wiring a canvas node or edge. The rule is "node/edge rendering files only." Current examples:Canvas.tsx,CanvasProvider.tsx, the shared chrome (NodeChrome.tsx,NodeIOHandles.tsx,NodeShell.tsx,CellShell.tsx), the per-type renderers (MarkdownNode,PdfNode,TransformNode,ChatNode,AiTransformNode,CellNode), and the edge renderer (LabeledEdge).
xyflow imports are NOT allowed in:
src/kernel/— neversrc/client/domain/— neversrc/client/ui/hooks/— hooks receive framework-agnostic callbackssrc/app/— uses CanvasProvider wrapper, not @xyflow directly
During an active drag, xyflow owns node position transiently (for 60fps). On onNodeDragStop, the final position is committed back to domain state via moveNode. Do not try to sync every pixel through React state.
Cell source kinds (markdown, pdf, derived, ...) are pluggable through a shared registry. The kernel owns the registry shape; the client owns the concrete contributions and the registration entrypoint.
- Kernel side (
src/kernel/source-kinds/):SourceKindContributiontype +SourceKindRegistryclass + thesourceKindRegistrysingleton. Pure data, no framework dependencies. The registry'sregisteris idempotent for hot-reload (Next.js Fast Refresh re-evaluates contribution modules). - Client side (
src/client/source-kinds/): one file per concrete kind (markdown-source-kind.ts,pdf-source-kind.ts,derived-source-kind.ts) plus anindex.tsthat imports the kernel registry and registers each contribution as a side effect. - Single registration point: both the main thread and the cell worker import
client/source-kinds/index.tsto populate their context's registry. Adding a new source kind = one new contribution file + oneregister()call. No other file changes. - The four named consumers (cell worker, cell executor, type-def generator, cascade) all go through
sourceKindRegistry.get(kind)— they never import a specific contribution file directly.
StoragePortinterface inclient/domain/ports/, adapters inclient/adapters/storage/- Multi-workspace: Each workspace is a separate scope with its own nodes, connections, and viewport
- Scoped adapter factory:
createScopedServerStorageAdapter(workspaceId)returns aStoragePortbound to a specific workspace. TheStoragePortinterface is unchanged — consumers don't know they're scoped - Server layout:
.context-canvas/manifest.json(workspace registry) +.context-canvas/workspaces/{id}.json(per-workspace data). Legacyworkspace.jsonpreserved as backup after migration - Manifest store:
server/storage/fs-manifest-store.ts—readManifest(),writeManifest(),withManifestLock(). Independent mutex from workspace lock - Lazy migration:
server/storage/migrate-to-multi-workspace.ts— idempotent, triggered on firstGET /api/workspaces. Migrates legacy single-file format to multi-workspace - API routes:
GET/POST/PATCH /api/workspaces(collection + activeId),GET/PUT/DELETE/PATCH /api/workspaces/[id](instance),POST /api/workspaces/[id]/merge(external writes). Legacy/api/workspaceroutes preserved as backward-compatible shims - localStorage cache key:
"context-canvas:workspace:{workspaceId}", JSON withversion: 12envelope - 300ms trailing-edge debounce on save after any mutation
- Synchronous
beforeunloadflush to prevent data loss on tab close load()returnsnullon any failure (parse error, missing key) — never throws- Merge-on-save: PUT endpoint reads disk before writing, preserves nodes/connections absent from the incoming payload (unless in
deletedIds). Merge logic inserver/storage/merge-workspace.ts, serialized by in-process lock inserver/storage/fs-workspace-store.ts - Deletion manifest: Client tracks deleted IDs per workspace, includes them in save payloads. Scoped via
createScopedDeletionManifest(workspaceId)inclient/adapters/storage/deletion-manifest.ts - External node detection: Client polls server every 2s for the active workspace only, absorbs nodes/connections it doesn't have. Skips polling while a save is in-flight
WorkspaceRegistryPortinclient/domain/ports/— lifecycle operations (list, getActiveId, setActiveId, create, remove, rename)serverRegistryAdapterinclient/adapters/storage/implements the port, talks to/api/workspacesendpoints, cachesactiveIdin localStorage for fast reload- Use cases:
deleteWorkspace(with last-workspace guard) andrenameWorkspaceinclient/domain/use-cases/. Switch and create are hook-level orchestrations inuseWorkspaceManager useWorkspaceManagerhook inclient/ui/hooks/— consumes registry port, exposes workspace CRUD +registerFlushfor flush-before-switchWorkspaceManagerProviderinclient/ui/app/— sits aboveAdaptersProvider, holdsactiveIdstate, receivescreateScopedAdaptersfactory from composition root, keysAdaptersProvideronactiveIdfor full subtree remount on switchWorkspaceSidepanelinclient/ui/components/— collapsible left panel, consumesuseWorkspaceManagerContext()
AdaptersContextinclient/ui/app/adapters-context.tsxprovides per-workspace concrete adaptersuseAdapters()hook returns{ storage, blobStorage, pdfRenderer, transformExecutor, chat, modelRoster, aiExecutor, deletionManifest }WorkspaceManagerContextinclient/ui/app/workspace-manager-context.tsxprovides workspace lifecycle operations (aboveAdaptersContext)src/app/page.tsxcreates theWorkspaceManagerProviderwith registry adapter, shared adapters, and scoped adapter factory. Concrete adapter creation happens only in the composition root- Hooks and components consume via
useAdapters()oruseWorkspaceManagerContext()— never by direct import
This project follows the Geist design system. All UI decisions must conform to these rules.
- Geist Sans (
font-sans) — all UI text: headings, body, labels, buttons - Geist Mono (
font-mono) — code blocks, technical content, transform editor - Never import or use other fonts
Never use hardcoded Tailwind colors (bg-red-500, text-gray-400, bg-zinc-900, etc.). Always use shadcn semantic tokens.
| Geist level | Purpose | shadcn token |
|---|---|---|
| Gray 1-3 | Component backgrounds | bg-background, bg-card, bg-muted |
| Gray 4-6 | Borders | border-border, border-input |
| Gray 9 | Secondary text/icons | text-muted-foreground |
| Gray 10 | Primary text/icons | text-foreground, text-card-foreground |
| Destructive | Danger/delete actions | bg-destructive, text-destructive-foreground |
| Primary | Primary actions | bg-primary, text-primary-foreground |
| Indicator | Progress, activity, attention signals | bg-indicator, text-indicator-foreground |
Canvas nodes must use bg-background text-foreground, not bg-card.
bg-white,bg-black,bg-gray-*,bg-zinc-*,bg-slate-*,bg-neutral-*text-white,text-black,text-gray-*,text-zinc-*- Any raw color:
bg-red-*,bg-blue-*,bg-green-*, etc. - Exception:
prose dark:prose-invertfor rendered markdown
Use shadcn/ui components (src/client/ui/components/ui/) for all standard UI elements. Install with npx shadcn@latest add <component>.
- Files: kebab-case (
create-node.ts,flow-node-mapper.ts) - Components: PascalCase (
MarkdownNode.tsx,Canvas.tsx) - Types/entities: PascalCase (
WorkspaceNode,Viewport) - Kernel transforms: camelCase exported functions (
createNode,moveNode) - Ports: PascalCase with
Portsuffix (StoragePort) - Barrel exports:
index.tsinkernel/entities/,kernel/transforms/. NO barrels for components (conflicts with'use client').
| What you're adding | Where it goes | What it can import |
|---|---|---|
| New data type | kernel/entities/ |
Nothing (other entities only) |
| New pure transform | kernel/transforms/ |
Kernel entities only |
| New port interface | client/domain/ports/ |
Kernel entities |
| New orchestration/use case | client/domain/use-cases/ |
Kernel + ports |
| New adapter | client/adapters/ |
Kernel + ports + the framework it adapts |
| New UI component | client/ui/components/ |
Other components, kernel types via props |
| New hook | client/ui/hooks/ |
Kernel transforms + client use cases + useAdapters() |
@xyflow/reactv12: UseOnNodeDragtype, notNodeDragHandler(doesn't exist)fitView()must be called after nodes render, not before- Client components need
'use client'directive (Next.js App Router) - Tailwind v4:
@import "tailwindcss"+@pluginsyntax, not v3@tailwinddirectives - localStorage ~5MB limit — sufficient for text nodes, IndexedDB for binary
- PDF upload limit is 200 MB (
kernel/transforms/validate-pdf-upload.ts), but Vercel serverless functions cap request bodies at 4.5 MB. Uploads >4.5 MB will 413 in production. To support large PDFs on Vercel, use direct-to-storage uploads (Vercel Blob client uploads, presigned S3/R2 URLs) instead of POSTing through/api/blobs components.jsonpaths must matchclient/ui/structure fornpx shadcn addto work- PdfRendererPort.renderPage returns HTMLCanvasElement — known tech debt (DOM type in port interface)
Feature specs live in .specs/features/. The context module is context.md. Both should be kept in sync with this file.