diff --git a/docs/canvas-lab-architecture.md b/docs/canvas-lab-architecture.md
index dfeef33afc..11f6b598e8 100644
--- a/docs/canvas-lab-architecture.md
+++ b/docs/canvas-lab-architecture.md
@@ -3,30 +3,29 @@
Root:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab`
-This doc explains current architecture, how nodes map to payload/import, and how to add new blocks safely.
+This doc reflects current code shape (React Flow UI node/edge shell + inline config split).
## 1) High-level flow
-1. UI renders canvas + dialogs in:
+1. Page shell + React Flow canvas:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx`
-2. Add-block sheet uses registry metadata to create config objects:
+2. Block picker sheet (plus/import/copy floating controls):
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx`
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx`
-3. Zustand store owns nodes/edges/configs and all mutation logic:
+3. Zustand state + all graph/config mutations:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts`
-4. Graph connection logic updates references + semantic edges:
+4. Connection validation + edge side-effects:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts`
-5. Export (preview/copy) converts in-memory graph/config to API payload:
+5. Export/payload map:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/build-payload.ts`
-6. Import reconstructs configs, nodes, edges from JSON:
+6. Import/rebuild:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts`
-## 2) Core types (single source of truth)
+## 2) Core types
-File:
+Source of truth:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts`
-`NodeConfig` is the main union the whole feature uses:
+`NodeConfig` union:
```ts
export type NodeConfig =
@@ -37,300 +36,231 @@ export type NodeConfig =
| ModelConfig;
```
-Canvas node UI data (`CanvasNodeData`) is derived from config via `nodeDataFromConfig`.
+`CanvasNodeData` is derived from config (`nodeDataFromConfig`), not edited directly.
## 3) Entrypoint wiring
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx`
-Key wiring:
+Current wiring:
```ts
const NODE_TYPES: NodeTypes = { builder: CanvasNode };
-const EDGE_TYPES: EdgeTypes = { canvas: CanvasEdge, semantic: CanvasEdge };
+const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: CanvasSemanticEdge };
```
-`CanvasLabPage` pulls actions/state from store and passes add handlers into `BlockSheet`:
+Default data edge style uses auto path selection:
```ts
-
+defaultEdgeOptions={{
+ type: "canvas",
+ data: { key: "name", path: "auto" },
+ style: { strokeWidth: 1.5, stroke: "var(--border)" },
+}}
```
-Preview/copy route through `buildCanvasPayload`, import route through `importCanvasPayload`.
+Node click selects config (`selectConfig`), does not auto-open dialog.
+Dialog opens via node `Details` button (`openConfig`) or explicit flows.
## 4) Registry-driven block system
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx`
-Registry defines each block in one place:
-- sheet title/icon/description
+Registry owns:
+- block metadata for sheet (title/icon/description)
- config factory (`createConfig`)
-- config dialog (`renderDialog`)
+- dialog renderer (`renderDialog`)
-Example (model blocks):
+If adding new `NodeConfig.kind`, keep `getBlockDefinitionForConfig` coverage complete.
-```ts
-{
- kind: "llm",
- type: "model_provider",
- createConfig: (id, existing) => makeModelProviderConfig(id, existing),
- renderDialog: ({ config, onUpdate }) =>
- config.kind === "model_provider" ? (
- onUpdate(config.id, patch)} />
- ) : null,
-}
-```
-
-Important: `getBlockDefinitionForConfig` must map every new `config.kind`, else dialog won't render.
-
-## 5) Config factories + node label mapping
-
-File:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/index.ts`
-
-Responsibilities:
-- create default config objects (`makeSamplerConfig`, `makeLlmConfig`, `makeModelProviderConfig`, `makeModelConfig`, `makeExpressionConfig`)
-- map `NodeConfig -> CanvasNodeData` via `nodeDataFromConfig`
-- sampler set includes `category`, `subcategory`, `uniform`, `gaussian`, `bernoulli`, `datetime`, `timedelta`, `uuid`, `person`, `person_from_faker`
-
-Example mapping:
-
-```ts
-if (config.kind === "model_provider") {
- return {
- title: "Model Provider",
- kind: "model_provider",
- subtype: config.provider_type || "Provider",
- blockType: "model_provider",
- name: config.name,
- layoutDirection,
- };
-}
-```
-
-This is what controls visible node title/subtitle in the canvas.
-
-## 6) Store responsibilities
+## 5) Store responsibilities
File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts`
Store owns:
-- graph state (`nodes`, `edges`)
-- config map (`configs[id]`)
-- add/update/remove/connect operations
-- layout direction + apply layout
+- `nodes`, `edges`, `configs`, `processors`
+- add/update/remove/connect logic
+- `layoutDirection` + dagre apply-layout
+- config selection/dialog state
-Add-node pattern (all block types follow same shape):
+Current config-selection API:
+- `selectConfig(id)`: select node config, keep dialog closed
+- `openConfig(id)`: select + open modal
-```ts
-const definition = getBlockDefinition("llm", "model_config");
-const config = definition.createConfig(id, existing);
-return buildNodeUpdate(state, config, state.layoutDirection);
-```
-
-When model config `provider` field changes, store auto-syncs semantic edge to matching provider name.
-
-## 7) Edge semantics + connection behavior
-
-File:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts`
-
-Semantic edge classifier:
-
-```ts
-function isSemanticEdge(source: NodeConfig, target: NodeConfig): boolean {
- if (source.kind === "model_provider" && target.kind === "model_config") return true;
- return source.kind === "model_config" && target.kind === "llm";
-}
-```
-
-Handle lanes:
-- data edges use `data-out -> data-in` (`right -> left`)
-- semantic edges use `semantic-out -> semantic-in` (`bottom -> top`)
-- semantic lane only used for `model_provider -> model_config -> llm`
-
-Connection side effects:
-- `model_provider -> model_config`: set `model_config.provider = source.name`
-- `model_config -> llm`: set `llm.model_alias = source.name`
-- `datetime -> timedelta`: set `timedelta.reference_column_name = source.name`
-- regular data edges into LLM/expression append `{{ source_name }}` refs
-
-Edge rendering (dotted semantic edges):
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx`
-
-```ts
-const nextStyle = type === "semantic"
- ? { ...style, strokeDasharray: "4 4" }
- : style;
-```
-
-## 8) Rename/remove propagation
-
-File:
+Add-node behavior is mode-aware via:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts`
-Centralized consistency updates:
-- rename updates:
- - Jinja refs in `llm.prompt/system_prompt/output_format`
- - expression `expr`
- - subcategory parent
- - `model_config.provider`
- - `llm.model_alias`
-- removal clears same references
+New nodes:
+- become selected
+- set `activeConfigId`
+- open dialog only for dialog-first config modes
-This keeps graph fields stable when upstream nodes renamed/deleted.
-
-## 9) Payload building (node graph -> API)
+## 6) Inline vs dialog config policy
File:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/build-payload.ts`
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts`
-`buildCanvasPayload(configs, nodes, edges)` outputs:
+Inline mode:
+- sampler: `uniform`, `gaussian`, `bernoulli`, `uuid`
+- `model_provider`
+- `model_config`
+- llm: `text`, `code`
+- `expression`
+
+Dialog mode:
+- sampler: `category`, `subcategory`, `datetime`, `timedelta`, `person`, `person_from_faker`
+- llm: `structured`, `judge`
+
+Inline editors:
+- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-sampler.tsx`
+- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-model.tsx`
+- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-llm.tsx`
+- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-expression.tsx`
+
+## 7) Node UI architecture (React Flow UI shell)
+
+File:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx`
+
+Node shell uses feature-local RF UI primitives:
+- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/rf-ui/base-node.tsx`
+- `/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/rf-ui/labeled-handle.tsx`
+
+Current node UX:
+- `corner-squircle` + `rounded-lg` container
+- inline editor shown by default for inline-capable configs
+- summary text for dialog-first configs
+- `Details` button opens modal dialog
+- node resizer logic enabled (`NodeResizer`), visuals hidden (no corner/box affordance)
+
+## 8) Handles + layout direction
+
+Handle IDs (kept stable):
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/handles.ts`
```ts
-{
- recipe: {
- model_providers: [...],
- model_configs: [...],
- columns: [...],
- processors: [...],
- },
- run: { rows: 5, preview: true, output_formats: ["jsonl"] },
- ui: { nodes: [...], edges: [...] }
-}
+dataIn: "data-in"
+dataOut: "data-out"
+semanticIn: "semantic-in"
+semanticOut: "semantic-out"
```
-Current processor UI surface:
-- `schema_transform` (sheet -> `Processors` -> `Schema Transform`)
-- mapped to recipe processor with `build_stage: "post_batch"` and JSON `template`.
+`canvas-node.tsx` switches handle positions by layout direction:
+- `LR`: data left/right, semantic top/bottom
+- `TB`: data top/bottom, semantic left/right
-Current drop policy:
-- column dialogs (`sampler` / `llm` / `expression`) expose `drop` toggle.
-- payload writes column `drop` directly (preferred over drop-columns processor in v1).
+After direction toggle or auto-layout, page refreshes node internals to avoid stale edge anchor offsets.
-How relation is enforced:
-- collect `model_alias` values used by LLM columns
-- ensure each alias exists in `recipe.model_configs`
-- validate `model_config.provider` points to existing provider
-- validate `timedelta.reference_column_name` points to a datetime sampler
-- require endpoint/provider_type only for providers that are actually referenced
-- category sampler supports typed `conditional_params` in payload output
+## 9) Edge architecture
-This is why unused provider/config blocks can exist without blocking preview.
+Data edge:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/rf-ui/data-edge.tsx`
-## 10) Import pipeline (API -> node graph)
+Features:
+- label from source node data key
+- `path: "auto"` chooses straight vs smoothstep/bezier based on geometry/positions
-Entry file:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts`
+Semantic edge:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/canvas-semantic-edge.tsx`
-Order of reconstruction:
-1. parse `recipe.model_providers` -> `ModelProviderConfig`
-2. parse `recipe.model_configs` -> `ModelConfig`
-3. parse `recipe.columns` -> sampler/llm/expression
-4. build nodes with positions
-5. build edges
+Features:
+- custom dashed smooth-step
+- muted stroke styling
-Edge inference file:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts`
+Legacy mixed edge component is removed (no `canvas-edge.tsx` path in active flow).
-If UI edges missing, infer edges from fields:
-- `subcategory_parent` (canvas edge)
-- `model_config.provider`
-- `llm.model_alias`
-- infer data edge from `timedelta.reference_column_name`
+## 10) Connection semantics + side effects
-## 11) Dialog routing and edit UIs
-
-Config dialog shell:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx`
-
-It calls:
-`renderBlockDialog(config, categoryOptions, onUpdate)`
-
-Model dialogs:
-- provider:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/models/model-provider-dialog.tsx`
-- model config:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/models/model-config-dialog.tsx`
-- processors:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/processors-dialog.tsx`
-
-`ModelConfigDialog` and `LlmDialog` use shadcn `Combobox` fed from store configs:
-- model config `provider` suggests model-provider node names
-- llm `model_alias` suggests model-config aliases
-- timedelta dialog suggests datetime columns for `reference_column_name`
-
-## 12) How to add a new block (checklist)
-
-Minimal path for a new block type:
-
-1. Add/extend type in:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts`
-2. Add default factory + node label mapping in:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/index.ts`
-3. Add block definition in:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx`
-4. Add dialog component and route it via `renderDialog` in registry.
-5. Add store add-action if block should be special-cased from sheet:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts`
-6. Add payload serialization/validation in:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/`
-7. Add import parsing + inferred edges in:
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts`
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts`
-`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts`
-8. If connection has semantic meaning, extend:
+File:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts`
-## 13) Practical mental model
+Lane rules:
+- semantic lane only: `semantic-out -> semantic-in`
+- data lane only: `data-out -> data-in`
+- model infra nodes (`model_provider`, `model_config`) blocked from data lane
-- `NodeConfig` is source-of-truth business state.
-- `CanvasNodeData` is derived display state.
-- registry = block metadata + factories + dialog routing.
-- store = mutation orchestration.
-- graph utils = connection semantics.
-- payload/import utils = external contract boundary.
+Semantic relations:
+- `model_provider -> model_config`
+- `model_config -> llm`
-If one piece changes, keep all six in sync.
+Connect side effects:
+- provider edge sets `model_config.provider`
+- model config edge sets `llm.model_alias`
+- datetime edge sets `timedelta.reference_column_name`
+- data edges into llm/expression append `{{ source_name }}` refs
+- category -> subcategory syncs mapping scaffold
-## 14) Processors roadmap (decision)
+Single-incoming enforcement (competing refs pruned on connect):
+- `provider`
+- `model_alias`
+- `reference_column_name`
+- `subcategory_parent`
-Current decision: **Option 3 (hybrid)**.
+Multi data refs remain allowed for llm/expression prompt/expr templates.
-v1 scope:
-- add `drop` toggle on column blocks (sampler/llm/expression).
-- add processor config surface for `schema_transform`.
-- keep payload builder as single mapper to `recipe.processors`.
-- keep processor state separate from node graph for now.
+## 11) Canvas controls UX
-Reason:
-- fastest ship path.
-- matches Data Designer column-level `drop`.
-- avoids duplicate/complex processor edge logic in v1.
+File:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx`
-Future option noted: **Option 2 (processor chain in graph)**.
+Top-right floating controls are icon-only:
+- `+` opens add-block sheet
+- import icon opens import dialog
+- copy icon copies recipe (brief check icon state on success)
-Option 2 structure:
-- add virtual node `Dataset Output`.
-- processors become graph nodes: `Schema Transform`, `Drop Columns`, future processors.
-- processor order derived from chain edges:
- `Dataset Output -> P1 -> P2 -> ...`
-- enforce chain rules:
- - no cycles
- - one incoming max per processor
- - one outgoing max per processor
- - chain must start at `Dataset Output`
+All use same no-bg bordered button style (`corner-squircle`, hover primary border/icon).
-Migration from option 3 -> 2:
-- keep same processor schema/payload contracts.
-- move order source from list/order field to edge traversal.
-- UI changes mostly in canvas rendering + validation; payload adapter stays mostly same.
+## 12) Dialog routing
+
+Config modal shell:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/dialogs/config-dialog.tsx`
+
+Routes block-specific forms through registry renderer:
+`renderBlockDialog(config, categoryOptions, onUpdate)`
+
+Current modal-only edits still live here (structured/judge/category/subcategory/etc).
+
+## 13) Payload + import boundary
+
+Payload map:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/build-payload.ts`
+
+Import map:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/importer.ts`
+
+If `ui.edges` missing on import, inferred edges are built from config refs in:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts`
+
+## 14) Add new block checklist
+
+1. Add/extend config type in:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/types/index.ts`
+2. Add factory + `nodeDataFromConfig` mapping in:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/index.ts`
+3. Add registry entry in:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/blocks/registry.tsx`
+4. Add dialog and wire in registry `renderDialog`
+5. Decide inline vs dialog mode; update:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/inline-policy.ts`
+6. If inline, add inline editor component under:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/components/inline/`
+7. Add payload mapping/validation updates in:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/payload/`
+8. Add import parse/infer updates in:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/`
+9. Extend connection semantics if needed in:
+`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/graph.ts`
+
+## 15) Mental model
+
+- `NodeConfig` = business truth
+- `CanvasNodeData` = derived presentational truth
+- registry = block metadata + factories + dialog routing
+- store = orchestration + consistency
+- graph utils = legal edges + side effects
+- payload/import = external contract boundary
+
+Keep all 6 synchronized when adding/changing block behavior.
diff --git a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx
index 9dc81a9849..39cdcf31d5 100644
--- a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx
+++ b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx
@@ -8,6 +8,7 @@ import {
Panel,
ReactFlow,
useReactFlow,
+ useUpdateNodeInternals,
} from "@xyflow/react";
import { type ReactElement, useCallback, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
@@ -45,21 +46,39 @@ function LayoutControls({
onLayout,
onToggleDirection,
}: LayoutControlsProps): ReactElement {
- const { fitView } = useReactFlow();
+ const { fitView, getNodes } = useReactFlow();
+ const updateNodeInternals = useUpdateNodeInternals();
+
+ const refreshNodeInternals = useCallback(() => {
+ const nodeIds = getNodes().map((node) => node.id);
+ if (nodeIds.length > 0) {
+ updateNodeInternals(nodeIds);
+ }
+ }, [getNodes, updateNodeInternals]);
const handleLayout = useCallback(() => {
onLayout();
requestAnimationFrame(() => {
- fitView({ duration: 250 });
+ refreshNodeInternals();
+ requestAnimationFrame(() => {
+ fitView({ duration: 250 });
+ });
});
- }, [fitView, onLayout]);
+ }, [fitView, onLayout, refreshNodeInternals]);
+
+ const handleToggleDirection = useCallback(() => {
+ onToggleDirection();
+ requestAnimationFrame(() => {
+ refreshNodeInternals();
+ });
+ }, [onToggleDirection, refreshNodeInternals]);
return (
-