Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
Implementation Plan: Tree Editor Canvas Redesign
Date: February 17, 2026 Scope: Replace NodeList + NodeEditorModal + TreePreviewPanel in Flow mode with a visual canvas + inline card editing Estimated Components: 3 new files, 4 modified files Phases: 4 (sequential) Branch:
feature/tree-editor-canvas
Overview
Replace the current text-outline + modal + passive preview layout with a single-pane visual canvas where nodes are cards, editing is inline, and branches are visually connected. The tree IS the editor — no separate preview panel needed.
Current State
The tree editor uses a text-outline metaphor:
- Nodes listed as indented rows with ASCII tree lines in
NodeList.tsx - Editing requires opening
NodeEditorModal.tsxfor each node (click row → modal opens → edit → Done → modal closes) - Passive
TreePreviewPanel.tsxtakes 40% of space but offers no editing - Adding nodes is a two-step picker-then-modal flow
- Options/branches are opaque — can't see where each branch leads without clicking into the node
Target State (5 Outcomes)
- Flow mode uses a full-width
TreeCanvaseditor; preview panel is removed from Flow mode - Node editing is inline in cards with local draft + ✓ save + ✕ cancel (no modal)
- Branches are visually rendered with parent-child connector lines and horizontal splits
- Metadata moves to a right slide-in panel, collapsed by default, opened from toolbar in Flow mode only
- Code mode remains functionally unchanged (Monaco + preview split)
Layout After Redesign
┌─────────────────────────────────────────────────────────┐
│ TOOLBAR: [Flow Name] [Undo] [Redo] [Metadata] [Save] [Publish] │
├─────────────────────────────────────────────────────────┤
│ │
│ [START] │
│ │ │
│ ┌─────────────────────────────┐ │
│ │ ? What type of issue? │ ← Decision card │
│ │ ↳ [A] Network Issues │ │
│ │ ↳ [B] App Errors │ │
│ └─────────────────────────────┘ │
│ │ │ │
│ [Network card] [App Errors card] │
│ │ │
│ [Solution card] │
│ │
│ [+ Add node here] ← contextual add buttons │
│ │
└─────────────────────────────────────────────────────────┘
Reusable Code Inventory
Before building anything, confirm these existing pieces are available and understand their APIs:
| Asset | Location | How It's Used |
|---|---|---|
useTreeEditorStore() |
store/treeEditorStore.ts |
All CRUD: addNode, updateNode, deleteNode, duplicateNode, reorderNodes, selectNode, findNode, validationErrors |
NodeFormDecision |
components/tree-editor/NodeFormDecision.tsx |
Decision node fields (question, help_text, options) — will be rendered inline in card |
NodeFormAction |
components/tree-editor/NodeFormAction.tsx |
Action node fields — will be rendered inline in card |
NodeFormResolution |
components/tree-editor/NodeFormResolution.tsx |
Solution node fields — will be rendered inline in card |
DynamicArrayField |
components/tree-editor/DynamicArrayField.tsx |
Reuse for options array in inline card |
NodePicker |
components/tree-editor/NodePicker.tsx |
Reuse for option target selection (needs allowCreate prop added) |
TreeMetadataForm |
components/tree-editor/TreeMetadataForm.tsx |
Wraps into MetadataSidePanel as-is |
cn() |
@/lib/utils |
Tailwind class merging utility |
| Design tokens | tailwind.config.js |
bg-card, border-border, text-foreground, font-heading, font-label |
| Brand colors | tailwind.config.js |
Blue (decision), Yellow (action), Green (solution) |
buildSharedLinksMap() |
components/tree-preview/TreePreviewPanel.tsx |
Shared node detection logic — extract and reuse for jump/reference indicators |
Link Model & Rendering Rules
IMPORTANT: These rules govern how the canvas renders node connections. Read before implementing Phase 2.
Tree-First Rendering
Render only structural children (nodes in node.children[]) with visual connector lines. Do NOT draw cross-canvas connector lines for shared/cross-linked nodes.
For links to non-child/shared targets (where option.next_node_id points to a node that is NOT a direct child), show a compact "jump/reference" indicator badge in the card content instead of a connector line.
Decision Child Lane Ordering
When a decision node has children, order the horizontal child lanes:
- First: Children whose
idmatches anoption.next_node_id— ordered by option order - Then: Append any remaining unlinked children
Action Next-Child Rule
- If a child's
idmatches the action node'snext_node_id, treat it as the primary "next" lane - If no child matches
next_node_id, keep child visible but show the link as an "unbound reference" indicator
Deletion Safety
Before calling deleteNode(nodeId), the canvas must clean up all inbound references:
- Scan all decision nodes: clear any
options[].next_node_idthat equals the deleted node's ID - Scan all action nodes: clear any
next_node_idthat equals the deleted node's ID
This prevents stale link references. The current treeEditorStore.deleteNode() does NOT do this cleanup — the canvas orchestration layer handles it.
Phase 1: TreeCanvasNode Component (Core Inline Editor Card)
File: frontend/src/components/tree-editor/TreeCanvasNode.tsx (NEW)
Props Interface
interface TreeCanvasNodeProps {
node: TreeStructure
depth: number
fromOption?: string // Which parent option label leads here
isExpanded: boolean
isNew: boolean // Show "Unsaved" badge, cancel triggers delete
onToggleExpand: () => void
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
onCancelNew: (nodeId: string) => void // Delete unsaved node
onDelete: (nodeId: string) => void
onDuplicate: (nodeId: string) => void
onDragStart: (e: React.DragEvent, nodeId: string) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
}
Card States
Compact (default):
- Node type badge/icon (Decision ?, Action ⚡, Solution ✓)
- Title text (question for decisions, title for action/solution)
- Option labels or option count for decisions
- Validation error badge (red dot if errors on this node)
- "Unsaved" badge (yellow, if
isNew) - Click anywhere on card → calls
onToggleExpand
Expanded (editing):
- Local draft state:
const [draft, setDraft] = useState<Partial<TreeStructure>>(() => cloneNodeWithoutChildren(node)) - Renders the appropriate existing form subcomponent inline:
- Decision:
<NodeFormDecision node={draft} onUpdate={setDraftField} /> - Action:
<NodeFormAction node={draft} onUpdate={setDraftField} /> - Solution:
<NodeFormResolution node={draft} onUpdate={setDraftField} />
- Decision:
- Header actions row:
- ✓ Save button → calls
onSave(node.id, draft)(strip children from draft before passing) - ✕ Cancel button → if
isNew, callsonCancelNew(node.id). Otherwise resets draft to node values and collapses - Duplicate button (hide if root)
- Delete button (hide if root)
- ✓ Save button → calls
- Drag handle in header (hide if root)
Card Styling
Aesthetic direction: "Precision engineering tool" — clean, minimal chrome, confident typography.
All cards: bg-card border-border rounded-xl shadow-sm
Decision: border-l-4 border-blue-500
Action: border-l-4 border-yellow-500
Solution: border-l-4 border-green-500
Expanded ring: ring-1 ring-primary
Titles: font-heading (Plus Jakarta Sans)
Type badges: font-label (Outfit)
Implementation Steps
- Create
TreeCanvasNode.tsxwith compact view only (type badge, title, option count) - Add expanded view with local draft state and inline form rendering
- Wire save/cancel/delete/duplicate actions
- Add drag handle events
- Add validation badge and unsaved badge
- Style with brand tokens
Verification
- Render a single card in isolation with mock data
- Confirm compact → expanded toggle works
- Confirm save commits draft (log output), cancel resets
- Confirm cancel on
isNew=truecallsonCancelNew - Run
npm run build— no TypeScript errors
Phase 2: TreeCanvas Component (Layout & Orchestration)
File: frontend/src/components/tree-editor/TreeCanvas.tsx (NEW)
Canvas State Model
// Local UI state (NOT in Zustand store — canvas-only concerns)
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set())
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
const [pendingAddTarget, setPendingAddTarget] = useState<string | null>(null)
const [pendingLinkByNodeId, setPendingLinkByNodeId] = useState<Map<string, {
parentId: string
optionId?: string // For decision option linking
}>>(new Map())
const [dragState, setDragState] = useState<{
nodeId: string
parentId: string
index: number
} | null>(null)
Single expanded card policy: Only one card expanded at a time. When expanding a card, collapse the previously expanded one.
Rendering
Recursive rendering of treeStructure from the store:
- Root at top, rendered as a "START" card
- Vertical flow downward
- When a decision node has multiple options with children, render children in horizontal lanes side-by-side
- Single-pane scrollable area (
overflow-auto) - Background:
bg-backgroundwith subtle CSS radial dot grid pattern
Connector Lines
Use CSS borders (not SVG) for connecting lines:
- Parent-to-children trunk line:
border-l border-borderextending down from parent - Horizontal fork line:
border-t border-borderconnecting sibling lane tops - Vertical stubs:
border-l border-borderdropping into each child card
Add-Node Flow
| Parent Type | Add Button Behavior |
|---|---|
| Decision | Show + Add child per option row (next to each option label) |
| Action | Show single + Add child below the card |
| Solution | No add button (terminal node) |
+ Addbuttons use dashed border, appear on hover of parent card bottom edge- Clicking
+ AddsetspendingAddTarget→ shows inline type picker (decision/action/solution buttons) at that position - Selecting a type:
- Calls
addNode(parentId, type)→ gets new node ID - Adds node ID to
newNodeIds - Adds entry to
pendingLinkByNodeId(parent ID + option ID if from a decision option) - Auto-expands the new node card
- Clears
pendingAddTarget
- Calls
Save Behavior for New Child Nodes
When user clicks ✓ on a new node:
- Call
updateNode(nodeId, draft)to save content to store - If
pendingLinkByNodeId.has(nodeId):- Get the pending link info (
parentId,optionId) - If
optionIdexists: update parent'soptions[].next_node_idto point to this node - If no
optionId(action parent): update parent'snext_node_idto point to this node
- Get the pending link info (
- Remove from
newNodeIds - Remove from
pendingLinkByNodeId
Cancel Behavior for New Nodes
When user clicks ✕ on a new (unsaved) node:
- Call
deleteNode(nodeId) - Remove from
newNodeIds - Remove from
pendingLinkByNodeId
Delete Behavior (Any Node)
When user clicks delete on any node:
- Clean inbound references first (see Link Model section above):
- Walk the full tree and clear any
options[].next_node_idornext_node_idmatching the node being deleted
- Walk the full tree and clear any
- Then call
deleteNode(nodeId) - Remove from
expandedNodeIdsif present
Sibling Reorder
- Drag handle in card header (Phase 1 wired the events)
- Drop zones rendered between sibling cards (visual indicator line)
- On drop: call
reorderNodes(parentId, fromIndex, toIndex)
Selection Integration
- Click on a card calls
selectNode(nodeId)in the store - Watch
selectedNodeIdfrom store (including changes fromValidationSummaryclicks):- Auto-expand the selected node's card
- Scroll the card into view with
scrollIntoView({ behavior: 'smooth', block: 'nearest' })
Implementation Steps
- Create
TreeCanvas.tsxwith recursive tree rendering (compact cards only, no editing) - Add CSS connector lines between parent and children
- Add horizontal branching for decision nodes with multiple children
- Add canvas state model (expandedNodeIds, newNodeIds, etc.)
- Wire card expand/collapse with single-expanded-card policy
- Add inline type picker and add-node flow with pending link tracking
- Wire save/cancel with pending link resolution
- Wire delete with inbound reference cleanup
- Add drag-and-drop sibling reorder
- Add selection sync (auto-expand + scroll into view)
- Add grid background pattern
- Style add buttons (dashed border, hover reveal)
Verification
- Create a new tree → see canvas with root START card
- Click root → expands inline (no modal)
- Add 2 options, save → see branch lanes
- Add child from each option → pending link resolves on save
- Cancel a new node → node deleted, link cleaned up
- Delete a linked node → parent's reference cleared
- Drag reorder siblings
- Run
npm run build— no TypeScript errors
Phase 3: Form Refactoring + Layout Update
3A: Form Refactoring for Inline Reuse Safety
Files to modify:
frontend/src/components/tree-editor/NodeFormDecision.tsxfrontend/src/components/tree-editor/NodePicker.tsx
NodeFormDecision.tsx Changes
Problem: Currently, option reordering calls reorderOptions() directly on the store. In inline canvas editing, this would write to the store before the user clicks ✓ save (breaking the local draft model).
Fix: Change option reordering to mutate the local node.options array through the onUpdate callback instead of calling the store directly.
// BEFORE (writes to store immediately):
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
reorderOptions(node.id, fromIndex, toIndex)
}
// AFTER (mutates local draft via onUpdate):
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
const newOptions = [...(node.options || [])]
const [moved] = newOptions.splice(fromIndex, 1)
newOptions.splice(toIndex, 0, moved)
onUpdate({ options: newOptions })
}
Keep modal compatibility: This change is backward-compatible. In the legacy modal path, onUpdate already propagates to the store. In the canvas path, onUpdate updates the local draft.
NodePicker.tsx Changes
Problem: NodePicker currently has create-new-node options (__create_decision__, etc.) that call addNode() on the store. In canvas inline editing, this would create nodes as a side effect of browsing the picker during draft editing.
Fix: Add an allowCreate prop:
interface NodePickerProps {
// ... existing props
allowCreate?: boolean // default: true
}
- When
allowCreate={false}, hide the "Create New" option group - Pass
allowCreate={false}fromTreeCanvasNodeexpanded editing - Pass
allowCreate={true}(default) in legacy modal path
3B: Layout Update
File: frontend/src/components/tree-editor/TreeEditorLayout.tsx
Changes
interface TreeEditorLayoutProps {
isMobile?: boolean
isMetadataOpen: boolean // NEW
onCloseMetadata: () => void // NEW
}
Flow mode (replace the 60/40 split):
BEFORE:
<div className="w-3/5">
<TreeMetadataForm />
<NodeList />
</div>
<div className="flex-1">
<TreePreviewPanel />
</div>
AFTER:
<TreeCanvas /> (full width)
<MetadataSidePanel isOpen={isMetadataOpen} onClose={onCloseMetadata} /> (overlay)
Code mode: Unchanged. Keep existing 60/40 Monaco + preview behavior.
3C: MetadataSidePanel Component
File: frontend/src/components/tree-editor/MetadataSidePanel.tsx (NEW)
Right-side slide-in panel (320px wide) that wraps <TreeMetadataForm />.
interface MetadataSidePanelProps {
isOpen: boolean
onClose: () => void
}
Behavior:
- Slides in from right edge, overlays the canvas (does NOT resize it)
- Close triggers: panel close button, backdrop click, Escape key
- Uses existing overlay/backdrop pattern from other modals in the app
- Only rendered in Flow mode
Implementation Steps
- Refactor
NodeFormDecision.tsx— option reorder throughonUpdate - Add
allowCreateprop toNodePicker.tsx - Create
MetadataSidePanel.tsx - Update
TreeEditorLayout.tsx— swap Flow mode layout, add metadata panel props - Verify legacy modal path still works (if still referenced anywhere)
Verification
- Open tree editor in Flow mode → see full-width canvas (no 60/40 split)
- Open tree editor in Code mode → see unchanged Monaco + preview layout
- Click Metadata button → panel slides in from right, canvas doesn't resize
- Close metadata panel via close button, backdrop click, and Escape key
- Edit options in inline card → reorder does NOT write to store until ✓ save
- NodePicker in inline card → no "Create New" options shown
- Run
npm run build— no TypeScript errors
Phase 4: Toolbar Wiring + Exports
File: frontend/src/pages/TreeEditorPage.tsx (modify)
Changes
- Add local state:
const [isMetadataOpen, setIsMetadataOpen] = useState(false) - Add "Metadata" toolbar button — visible in Flow mode only
- Auto-close metadata panel when switching to Code mode:
// In mode switch handler: if (newMode === 'code') setIsMetadataOpen(false) - Pass metadata props into
TreeEditorLayout:<TreeEditorLayout isMetadataOpen={isMetadataOpen} onCloseMetadata={() => setIsMetadataOpen(false)} /> - Keep all existing toolbar actions unchanged: undo/redo, save/publish, validate, analytics
File: frontend/src/components/tree-editor/index.ts (modify)
Add exports:
export { TreeCanvas } from './TreeCanvas'
export { TreeCanvasNode } from './TreeCanvasNode'
export { MetadataSidePanel } from './MetadataSidePanel'
Keep all legacy exports (NodeList, NodeEditorModal, TreePreviewPanel) — they remain in the codebase but are no longer imported in the active Flow path.
Implementation Steps
- Add metadata panel state and toolbar button to
TreeEditorPage.tsx - Add auto-close on Code mode switch
- Pass props through to
TreeEditorLayout - Update
index.tsexports - Verify no dead imports remain
Verification
- Flow mode toolbar shows "Metadata" button
- Code mode toolbar does NOT show "Metadata" button
- Click Metadata → panel opens. Switch to Code → panel auto-closes
- Undo/redo/save/publish/validate all still work
- Run
npm run build— no TypeScript errors
Critical Files Summary
| File | Action | Phase | Notes |
|---|---|---|---|
TreeCanvasNode.tsx |
Create | 1 | Inline-editing card with local draft + commit model |
TreeCanvas.tsx |
Create | 2 | Main canvas orchestration with full state model |
MetadataSidePanel.tsx |
Create | 3 | 320px right slide-in overlay wrapping TreeMetadataForm |
NodeFormDecision.tsx |
Refactor | 3 | Option reorder through onUpdate (remove store write) |
NodePicker.tsx |
Refactor | 3 | Add allowCreate prop (default true) |
TreeEditorLayout.tsx |
Modify | 3 | Replace 60/40 split with full-width canvas + overlay |
TreeEditorPage.tsx |
Modify | 4 | Add metadata panel toggle, auto-close on Code switch |
index.ts |
Update | 4 | Export new components, keep legacy exports |
treeEditorStore.ts |
No changes | — | Store logic is solid as-is |
NodeList.tsx |
Keep (legacy) | — | Removed from active Flow path |
NodeEditorModal.tsx |
Keep (legacy) | — | Removed from active Flow path |
TreePreviewPanel.tsx |
Keep (legacy) | — | Removed from active Flow path |
Assumptions & Defaults
- Link rendering: Tree-first (no full cross-canvas graph lines for shared targets)
- Inline edit commit: On checkmark save (local draft, cancel discards)
- Metadata drawer: Flow mode only
- Expanded card policy: One expanded card at a time
- Legacy components: Remain in repo, removed from active Flow path only
- Connector lines: CSS borders (not SVG — simpler, matches existing patterns)
Testing
Automated Tests
Files: TreeCanvas.test.tsx, TreeCanvasNode.test.tsx
| Test Case | What It Verifies |
|---|---|
| Select node expands and scrolls card | Selection sync works |
| Inline save commits store updates | Draft → store pipeline works |
| Cancel on new node triggers deletion | Unsaved node cleanup works |
Add child from decision option sets next_node_id on save |
Pending link resolution works |
| Delete node clears inbound references | Reference cleanup works |
Sibling drag reorder calls reorderNodes |
Drag-and-drop wiring works |
allowCreate={false} hides create options in NodePicker |
Form safety works |
| Option reorder in inline card does NOT write to store | Draft isolation works |
Run: npm run build && npm run test (or targeted vitest files)
Manual Acceptance Checklist
- Create a new tree — canvas shows root START card
- Click root card — expands inline with decision fields (no modal appears)
- Fill in question, add 2 options, click ✓ — saves inline, see branch lanes
+ Add childbuttons appear below each option- Add a child node inline — verify parent link is set correctly
- Cancel a new unsaved node — confirm it is deleted from the tree
- Delete a node that is referenced by another — confirm references are cleaned
- Drag to reorder sibling nodes
- Open Metadata panel — edit metadata — close panel (button, backdrop, Escape)
- Validate and Publish — confirm tree saves correctly
- Switch Flow → Code mode — metadata panel auto-closes, Code mode works normally
- Switch Code → Flow mode — canvas renders correctly
- Run
npm run build— no TypeScript errors - Run
npm run test— all tests pass
Git Strategy
- Branch:
feature/tree-editor-canvas - One commit per phase (4 commits total)
- Commit messages:
feat: Add TreeCanvasNode inline editor card componentfeat: Add TreeCanvas layout with visual branching and orchestrationrefactor: Update forms for inline safety, add MetadataSidePanel, update layoutfeat: Wire toolbar metadata toggle and update exports
- PR when all 4 phases pass
npm run build && npm run test - Include
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Notes for Implementation
- Read existing code first: Before creating any new file, read the files listed in the Reusable Code Inventory to understand current patterns and prop interfaces
- Follow existing patterns: Match the component structure, Tailwind usage, and TypeScript conventions already in the tree-editor directory
- Dark mode: All new components must support light/dark themes via existing Tailwind classes
- Keyboard navigation: Support Escape to close metadata panel, Tab through form fields in expanded cards
- Loading states: Canvas should handle the case where
treeStructureis null (show empty state) - No store changes: The
treeEditorStore.tsshould NOT be modified. All new state is local to the canvas components - Test each phase independently: Each phase should leave the app in a buildable, testable state before moving to the next