# @seeflow/canvas

Embeddable React Flow canvas. See `README.md` for the full public API.

## Rules

- **Public API:** `src/index.ts` only. Host apps must `import { ... } from '@seeflow/canvas'` — never deep-import from `packages/canvas/src/...`.
- **Internal imports:** relative paths (`'../lib/foo.ts'`). No `@/` aliases here — those belong to `apps/web`.
- **Peer deps** (`react`, `react-dom`, `@xyflow/react`, `lucide-react`, `react-markdown`, `remark-gfm`): the host provides them. Do not move them into `dependencies`. `react-markdown` + `remark-gfm` back the built-in sidebar's status section.
- **Tailwind v4:** package ships its own compiled CSS at `dist/style.css`. Internal classes use the v4 namespace prefix `sf:` (configured via `@import "tailwindcss" prefix(sf);` in `src/styles/index.css`); the codemod at `scripts/prefix-tailwind.mjs` keeps the source in sync. v4 emits classes in PREFIX-FIRST order: `sf:hover:bg-card`, `sf:data-[state=open]:animate-in`, `sf:-mx-1`, `sf:!opacity-100`. All non-utility globals (tokens, react-flow overrides, keyframes) live in `src/styles/index.css` scoped under `.seeflow-canvas-root` so the consumer's `:root` and global stylesheet stay untouched — the `@theme inline` form keeps Tailwind's `--color-*` vars OUT of the consumer's `:root` too. Keep class strings literal (no dynamic `sf:${var}` interpolation) so the build picks them up. Re-run `bun run --filter @seeflow/canvas build` after edits; the GitHub Action commits `dist/` on `main` for external consumers. Note: Biome 1.9.4 cannot parse v4 `prefix(sf)` syntax, so `src/styles/index.css` is in `biome.json` ignore.
- **Radix portals:** every Radix `Portal` in `src/ui/` and `src/components/` threads `container={useCanvasPortalContainer()}` so popovers/dropdowns/dialogs land INSIDE `.seeflow-canvas-root` and inherit the scoped CSS. New wrappers must do the same; otherwise their content gets portaled to `document.body` and loses the canvas's theme tokens.
- **Adapter seam:** edit mode requires a `CanvasAdapter` (`src/adapter/types.ts`). All mutations route through it — never call `fetch` from inside the canvas.
- **Modes:** `mode: 'edit' | 'view' | 'mini'`. `edit` = full studio, adapter required. `view` = embedder read-only, chrome off but pan/zoom/Controls/selection on, adapter optional. `mini` = static thumbnail, every chrome affordance + every input path off, `autoFitView` defaults to `true`, adapter optional. Per-feature overrides on `CanvasFeatureOverrides` (incl. the mini-mode flags `showControls`, `enableSelection`, `enableNodeMove`) compose on top of the mode preset — the resolver lives in `resolveFlags`.
- **Built-in sidebar:** `<SeeflowCanvas>` renders `<DetailPanel>` internally — driven by `selectedNodeIds[0]` / `selectedConnectorIds[0]`. Hosts pass `onNameChange` / `onDescriptionChange` / `onDetailChange` field-edit callbacks and a single `statusReport` for the selected node directly into the canvas. `disableSidebar={true}` suppresses it (custom inspector). `CanvasAdapter` does NOT expose its bound flowId — use the existing `projectId` prop for the sidebar's `flowId` lookup.
- **Auto-fit-view:** opt-in via `autoFitView?: boolean | { onMount?: boolean; onExternalNodeChange?: boolean }`. `undefined` / `false` → no auto-fit. `true` → fit on mount once nodes load AND fit when `autoFitViewSignal` bumps. Every internal fitView call (manual button, mount-fit, signal-fit, deferred flush) goes through the module-level `FIT_VIEW_OPTIONS` constant — never inline literals. The mount-fit fires from BOTH the `<ReactFlow>` `onInit` callback (primary path) AND a late-nodes `useEffect`; both serialize through `didMountFitRef` so the fit fires exactly once.
- **External-change fit (`autoFitViewSignal`):** host bumps a monotonic counter when an SSE / adapter update changes the node set. The signal-watching effect (deps: `[autoFitViewSignal]`) skips its first run via `signalEffectMountedRef` so it doesn't double-fire with the mount-fit. While a node drag (`draggingRef`) or resize (`resizingRef`) is in flight, the would-be fit is stashed in `pendingFitRef` and flushed by `flushPendingFit()` from `setResizing(false)` / `onNodeDragStop` / `onSelectionDragStop`. The flag itself (`onExternalNodeChange`) is read from `resolvedAutoFitViewRef` inside the effect — listing it in the deps array would cause spurious fits when the host flips the flag without bumping the signal.
- **Imperative ref handle:** `<SeeflowCanvas>` is a `forwardRef` component. Hosts pass `ref={useRef<SeeflowCanvasHandle>(null)}` to call `exportPdf()` / `exportPng()` / `openEmbedDialog()` / `capturePreview()` from a command palette, keyboard shortcut, or host-owned dialog (e.g. an Export-to-cloud preview thumbnail) without owning the underlying state. PNG / PDF capture lives in `src/hooks/use-canvas-export.ts` (jspdf is dynamic-imported on click); `capturePreview()` reuses the same fit-view + snapshot + restore path and returns the PNG dataUrl without triggering a download. The Embed dialog is mounted inside the top-right `ShareMenu`, so `openEmbedDialog()` is a no-op when `flags.showShareMenu` is off (mini mode or explicit `showShareMenu: false`). When extending the handle, add to BOTH `UseCanvasExportApi` and `SeeflowCanvasHandle`, wire via `useImperativeHandle` (deps array must include every function the returned object references), and update the US-014 imperative-handle test.
- **ShareMenu + chrome flags:** the top-right ShareMenu is gated on `flags.showShareMenu` (ON for edit + view, OFF for mini). The menu's `mode` is mapped `'mini' → 'view'` defensively. `<SeeflowCanvas>` owns `shareEmbedDialogOpen` state and threads `embedOpen` + `onEmbedOpenChange` into ShareMenu (controlled) so the imperative ref handle can flip the dialog without going through the menu click. ShareMenu still supports an uncontrolled fallback for standalone consumers. The Embed item is additionally gated on `flags.enableEmbed` (default OFF for every mode — opt-in via `enableEmbed={true}`); the existing edit-mode + `projectId` requirements still apply on top.
- **Hook-shim tests + useState placement:** the test suite uses a synchronous React dispatcher with `useStateOverrides[N]` indexed by useState DECLARATION order. When extending `SeeflowCanvas` with new internal state, ALWAYS append the `useState` call at the END of the body — never insert at the top — or every downstream test's slot reference silently shifts. Current order (12 slots): `connecting`, `dropPopover`, `drawStart`, `drawCurrent`, `contextMenuPos`, `contextOnNode`, `contextNodeType`, `contextEndpoint`, `rfNodes`, `spaceHeld`, `spaceDragging`, `shareEmbedDialogOpen`.
- **Stable test/a11y attributes on edges:** the React Flow edge wrapper `<g class="react-flow__edge">` is owned by xyflow's sibling render path — JSX inside `EditableEdge` paints inside that `<g>` but can't directly modify its attributes. To attach a stable `data-*` to the wrapper (e.g. `data-handoff`, `data-edge-kind`), use the imperative pattern in `editable-edge.tsx`: a useEffect that does `document.querySelector('.react-flow__edge[data-id=<id>]')` + `setAttribute()` with a cleanup that calls `removeAttribute()`. Same shape every time.
- **Canvas-readiness signals:** for one-shot signals like `data-canvas-ready` (test bootstrap, a11y gating, etc.), set the attribute imperatively on the existing `wrapperRef.current` inside `<ReactFlow onInit>` — DO NOT add a new useState slot (would shift every hook-shim test's `useStateOverrides[N]` index per the rule above). `onInit` fires once after first layout, which is exactly the readiness moment Playwright + screen readers need.

## Workflow

- Tests live beside sources (`foo.ts` + `foo.test.ts`). Run `bun test` from the repo root.
- Typecheck: `cd packages/canvas && bun run typecheck`, or `bun run typecheck` from root for all workspaces.
- When adding a public export, add it to `src/index.ts` in the matching numbered section and keep the barrel sorted within the section.
