Procedural flow/flow editor fixes

This commit is contained in:
chihlasm
2026-02-19 01:53:54 -05:00
parent 757ce6306c
commit 51243130e5
3 changed files with 1325 additions and 0 deletions

View File

@@ -261,6 +261,12 @@ navigate(`/trees/${newTree.id}/edit`)
**18. Pydantic partial updates — use `model_fields_set`:** When a PUT/PATCH endpoint needs to distinguish "field not sent" from "field sent as null", check `data.model_fields_set` instead of `data.field is not None`. This allows clients to explicitly clear nullable fields like `description`.
**19. `gh pr merge` fails with worktrees:** When `main` is checked out in the primary worktree, `gh pr merge` crashes with "fatal: 'main' is already used by worktree". Use the API directly instead: `gh api repos/ORG/REPO/pulls/N/merge --method PUT --field merge_method=squash`
**20. `'answer'` node type in TreeStructure:** `answer` is a transient stub type used only in the canvas editor. Any code that switches on `node.type` (validation, markdown serializer, session nav guard) must explicitly handle `'answer'` or it will hit an unhandled-type error.
**21. Test fixtures in `conftest.py`:** Available fixtures are `client` (async HTTP client), `test_db` (async session), `test_user` (registers user, returns email/password/user_data), `auth_headers` (Bearer token dict), `test_tree` (creates a tree), `test_admin` (super_admin user), `admin_auth_headers` (admin Bearer token). There is NO `async_client` or `engineer_token` fixture.
---
## RBAC & Permissions

View File

@@ -0,0 +1,574 @@
# 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.tsx` for each node (click row → modal opens → edit → Done → modal closes)
- Passive `TreePreviewPanel.tsx` takes 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)
1. Flow mode uses a full-width `TreeCanvas` editor; preview panel is removed from Flow mode
2. Node editing is inline in cards with local draft + ✓ save + ✕ cancel (no modal)
3. Branches are visually rendered with parent-child connector lines and horizontal splits
4. Metadata moves to a right slide-in panel, collapsed by default, opened from toolbar in Flow mode only
5. 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:
1. **First:** Children whose `id` matches an `option.next_node_id` — ordered by option order
2. **Then:** Append any remaining unlinked children
### Action Next-Child Rule
- If a child's `id` matches the action node's `next_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_id` that equals the deleted node's ID
- Scan all action nodes: clear any `next_node_id` that 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
```typescript
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} />`
- Header actions row:
- ✓ Save button → calls `onSave(node.id, draft)` (strip children from draft before passing)
- ✕ Cancel button → if `isNew`, calls `onCancelNew(node.id)`. Otherwise resets draft to node values and collapses
- Duplicate button (hide if root)
- Delete button (hide if root)
- 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
1. Create `TreeCanvasNode.tsx` with compact view only (type badge, title, option count)
2. Add expanded view with local draft state and inline form rendering
3. Wire save/cancel/delete/duplicate actions
4. Add drag handle events
5. Add validation badge and unsaved badge
6. 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=true` calls `onCancelNew`
- 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
```typescript
// 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-background` with subtle CSS radial dot grid pattern
### Connector Lines
Use CSS borders (not SVG) for connecting lines:
- **Parent-to-children trunk line:** `border-l border-border` extending down from parent
- **Horizontal fork line:** `border-t border-border` connecting sibling lane tops
- **Vertical stubs:** `border-l border-border` dropping 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) |
- `+ Add` buttons use dashed border, appear on hover of parent card bottom edge
- Clicking `+ Add` sets `pendingAddTarget` → shows inline type picker (decision/action/solution buttons) at that position
- Selecting a type:
1. Calls `addNode(parentId, type)` → gets new node ID
2. Adds node ID to `newNodeIds`
3. Adds entry to `pendingLinkByNodeId` (parent ID + option ID if from a decision option)
4. Auto-expands the new node card
5. Clears `pendingAddTarget`
### Save Behavior for New Child Nodes
When user clicks ✓ on a new node:
1. Call `updateNode(nodeId, draft)` to save content to store
2. If `pendingLinkByNodeId.has(nodeId)`:
- Get the pending link info (`parentId`, `optionId`)
- If `optionId` exists: update parent's `options[].next_node_id` to point to this node
- If no `optionId` (action parent): update parent's `next_node_id` to point to this node
3. Remove from `newNodeIds`
4. Remove from `pendingLinkByNodeId`
### Cancel Behavior for New Nodes
When user clicks ✕ on a new (unsaved) node:
1. Call `deleteNode(nodeId)`
2. Remove from `newNodeIds`
3. Remove from `pendingLinkByNodeId`
### Delete Behavior (Any Node)
When user clicks delete on any node:
1. **Clean inbound references first** (see Link Model section above):
- Walk the full tree and clear any `options[].next_node_id` or `next_node_id` matching the node being deleted
2. Then call `deleteNode(nodeId)`
3. Remove from `expandedNodeIds` if 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 `selectedNodeId` from store (including changes from `ValidationSummary` clicks):
- Auto-expand the selected node's card
- Scroll the card into view with `scrollIntoView({ behavior: 'smooth', block: 'nearest' })`
### Implementation Steps
1. Create `TreeCanvas.tsx` with recursive tree rendering (compact cards only, no editing)
2. Add CSS connector lines between parent and children
3. Add horizontal branching for decision nodes with multiple children
4. Add canvas state model (expandedNodeIds, newNodeIds, etc.)
5. Wire card expand/collapse with single-expanded-card policy
6. Add inline type picker and add-node flow with pending link tracking
7. Wire save/cancel with pending link resolution
8. Wire delete with inbound reference cleanup
9. Add drag-and-drop sibling reorder
10. Add selection sync (auto-expand + scroll into view)
11. Add grid background pattern
12. 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.tsx`
- `frontend/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.
```typescript
// 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:
```typescript
interface NodePickerProps {
// ... existing props
allowCreate?: boolean // default: true
}
```
- When `allowCreate={false}`, hide the "Create New" option group
- Pass `allowCreate={false}` from `TreeCanvasNode` expanded editing
- Pass `allowCreate={true}` (default) in legacy modal path
### 3B: Layout Update
**File:** `frontend/src/components/tree-editor/TreeEditorLayout.tsx`
#### Changes
```typescript
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 />`.
```typescript
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
1. Refactor `NodeFormDecision.tsx` — option reorder through `onUpdate`
2. Add `allowCreate` prop to `NodePicker.tsx`
3. Create `MetadataSidePanel.tsx`
4. Update `TreeEditorLayout.tsx` — swap Flow mode layout, add metadata panel props
5. 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
1. Add local state: `const [isMetadataOpen, setIsMetadataOpen] = useState(false)`
2. Add "Metadata" toolbar button — visible in Flow mode only
3. Auto-close metadata panel when switching to Code mode:
```typescript
// In mode switch handler:
if (newMode === 'code') setIsMetadataOpen(false)
```
4. Pass metadata props into `TreeEditorLayout`:
```typescript
<TreeEditorLayout
isMetadataOpen={isMetadataOpen}
onCloseMetadata={() => setIsMetadataOpen(false)}
/>
```
5. Keep all existing toolbar actions unchanged: undo/redo, save/publish, validate, analytics
**File:** `frontend/src/components/tree-editor/index.ts` (modify)
Add exports:
```typescript
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
1. Add metadata panel state and toolbar button to `TreeEditorPage.tsx`
2. Add auto-close on Code mode switch
3. Pass props through to `TreeEditorLayout`
4. Update `index.ts` exports
5. 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 child` buttons 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:
1. `feat: Add TreeCanvasNode inline editor card component`
2. `feat: Add TreeCanvas layout with visual branching and orchestration`
3. `refactor: Update forms for inline safety, add MetadataSidePanel, update layout`
4. `feat: 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
1. **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
2. **Follow existing patterns:** Match the component structure, Tailwind usage, and TypeScript conventions already in the tree-editor directory
3. **Dark mode:** All new components must support light/dark themes via existing Tailwind classes
4. **Keyboard navigation:** Support Escape to close metadata panel, Tab through form fields in expanded cards
5. **Loading states:** Canvas should handle the case where `treeStructure` is null (show empty state)
6. **No store changes:** The `treeEditorStore.ts` should NOT be modified. All new state is local to the canvas components
7. **Test each phase independently:** Each phase should leave the app in a buildable, testable state before moving to the next

View File

@@ -0,0 +1,745 @@
# Master Plan: Flow Editor UX Fixes + Answer Stub Placeholders
> **For Claude Code:** Implement this plan task-by-task in order. Each phase must build and pass tests before proceeding to the next. Commit after each task.
>
> **Working directory:** Use the active tree-editor-canvas worktree or main branch as appropriate.
---
## Plan Overview
This plan fixes three UX pain points in the tree editor:
1. **Can't reach bottom of editor** — scrollable content + optional fullscreen toggle
2. **Form clutter** — replace always-visible hint paragraphs with info-on-demand tooltips
3. **Forced child-type selection slows branching** — introduce `'answer'` placeholder stubs so users can name branches first and pick types later
---
## Plan Comparison Notes
This master plan was synthesized from two candidate plans. Here's what was chosen and why:
| Area | Plan 1 (Strategy Doc) | Plan 2 (Canvas Implementation) | Master Plan Choice | Rationale |
|------|----------------------|-------------------------------|-------------------|-----------|
| **Scroll fix** | Modal-level `allowFullScreen` prop on `Modal.tsx` with localStorage persistence | Canvas-level CSS fix: `max-h-[70vh] overflow-y-auto` + sticky header on `TreeCanvasNode.tsx` | **Both** — Canvas CSS fix for inline cards AND Modal fullscreen for modal editor | They fix different surfaces. The canvas inline editor and the modal editor are separate code paths. Both need the fix. |
| **Fullscreen toggle** | `Maximize2`/`Minimize2` icons, `allowFullScreen` opt-in prop, localStorage persistence | Not included | **Include** (Plan 1) | Fullscreen editing is a meaningful UX upgrade for complex nodes. The opt-in prop pattern keeps other modals unaffected. |
| **Info tooltips** | Conceptual — mentions `FieldHelp.tsx` helper component + "Show tips" toggle | Line-by-line implementation — native `title` attribute on inline ⓘ badge spans | **Plan 2's inline approach, but extract to a reusable component** | Plan 2's approach is concrete and proven. But repeating the same 4-line `<span>` everywhere creates maintenance debt. Extract to a tiny `<InfoTip text="..." />` component, then use it everywhere. Skip the "Show tips" toggle — it adds complexity without clear user value. |
| **Placeholder node naming** | Calls it `'choice'` | Calls it `'answer'` | **`'answer'`** | In a troubleshooting tree, decision options ARE answers to the question. "Choice" is ambiguous — it could mean the decision itself. "Answer" is intuitive: "What type of device?" → answers: "Server", "Desktop", "Laptop". |
| **Answer stub creation** | Manual — user clicks "Create Placeholder" per option | Automatic — saving a decision node auto-creates stubs for any option without a `next_node_id` | **Automatic** (Plan 2) | Automatic creation is faster and requires zero extra clicks. The whole point of stubs is reducing friction. Making users manually create them defeats the purpose. |
| **Answer stub UI** | Conversion via node editor modal (Convert to Decision/Action/Solution buttons) | Dedicated `AnswerStubCard` component — click card → inline type picker with color-coded buttons | **`AnswerStubCard`** (Plan 2) | A dedicated visual component with dashed border and inline type picker is more discoverable and faster than opening a modal just to convert. Users see the stub, click it, pick a type — done in one interaction. |
| **NodePicker removal** | Keeps NodePicker, adds choice creation alongside it | Removes NodePicker from decision form entirely — options become label-only inputs | **Remove NodePicker** (Plan 2) | This is the key UX insight. The old flow forced users to pick a child type while still writing the question. The new flow: write your question → name your answers → save → stubs appear → convert each stub when ready. This matches how humans actually think about branching. |
| **Publish validation** | Backend `can_publish_tree` check + frontend disabled publish button | Backend `validate_tree_structure` check + frontend `hasAnswerNodes` guard with toast message | **Both layers** (combined) | Defense in depth. Frontend gives instant feedback via toast. Backend prevents bad data regardless of client. |
| **Markdown parser/code mode** | Explicitly handles `answer` in markdown parser, validator, and serializer | Not addressed | **Include** (Plan 1) | Important for data integrity. If a user switches to code/markdown mode, answer nodes shouldn't get silently dropped or cause parse errors. |
| **Runtime defensive guard** | Includes guard in session navigation — if `answer` encountered at runtime, show blocking message | Not addressed | **Include** (Plan 1) | Published trees should never have answer nodes, but defensive programming matters. A clear "this tree has unresolved placeholders" message is better than a crash. |
| **Testing plan** | Comprehensive list of frontend + backend + manual test scenarios | Build verification per task + final manual checklist | **Plan 1's scope with Plan 2's per-task verification** | Plan 1 defines what to test; Plan 2's approach of verifying builds after every task catches issues early. |
---
## Phase 1: Scrollability + Fullscreen Editor
### Task 1.1: Fix canvas inline card scroll (TreeCanvasNode)
**Files:**
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
**Changes:**
1. Make the card header sticky when expanded. Find the header `<div>` (the one with `flex items-center gap-2 px-3 py-2.5`). Add conditional sticky classes:
```tsx
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
```
2. Make the expanded editing area scrollable. Find the expanded content `<div>` (the one with `border-t border-border px-3 pb-3 pt-3`). Add max height and scroll:
```tsx
className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto"
```
**Verify:** `npm run build` — clean build, no errors.
**Commit:** `fix: make canvas card expanded area scrollable with sticky header`
---
### Task 1.2: Add fullscreen toggle to Modal component
**Files:**
- Modify: `frontend/src/components/common/Modal.tsx`
- Modify: `frontend/src/components/tree-editor/NodeEditorModal.tsx`
**Changes to Modal.tsx:**
1. Add new optional prop: `allowFullScreen?: boolean` (default `false`).
2. Add state inside Modal:
```tsx
const [isFullScreen, setIsFullScreen] = useState(() => {
if (!allowFullScreen) return false
try {
return localStorage.getItem('rf-editor-fullscreen') === 'true'
} catch {
return false
}
})
```
3. Persist preference on toggle:
```tsx
const toggleFullScreen = () => {
const next = !isFullScreen
setIsFullScreen(next)
try {
localStorage.setItem('rf-editor-fullscreen', String(next))
} catch {}
}
```
4. Add `Maximize2` and `Minimize2` imports from `lucide-react`.
5. Render expand/collapse button in the modal header (next to the close button) only when `allowFullScreen` is `true`:
```tsx
{allowFullScreen && (
<button
type="button"
onClick={toggleFullScreen}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
title={isFullScreen ? 'Exit full screen' : 'Full screen'}
>
{isFullScreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
)}
```
6. Apply conditional sizing classes on the modal container:
- Default: existing size classes (whatever `size="lg"` currently maps to, e.g. `max-w-2xl`)
- Full screen: `fixed inset-4 max-w-none w-auto h-auto` (fills viewport with small margin)
- Add `transition-all duration-200` for smooth animation between modes.
- The modal body must remain `overflow-y-auto` in both modes.
**Changes to NodeEditorModal.tsx:**
Pass the new prop to Modal:
```tsx
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>
```
**Do NOT change:** Any other modal usage in the app. Only NodeEditorModal opts in.
**Verify:** `npm run build` — clean build.
**Commit:** `feat: add fullscreen toggle to Modal component, enable in NodeEditorModal`
---
### Task 1.3: Verify scroll contract across both editor surfaces
**Manual verification checklist:**
- [ ] Open a decision node in canvas inline editor → resize browser to short viewport → form scrolls, sticky header (save/cancel) stays visible
- [ ] Open a node in the modal editor → content scrolls, header/footer fixed
- [ ] Click fullscreen toggle → modal fills viewport with margin → content still scrolls
- [ ] Click collapse → returns to normal size smoothly
- [ ] Refresh page → fullscreen preference persisted
- [ ] Other modals (StepDetailModal, CustomStepModal, etc.) are unaffected
---
## Phase 2: Info-On-Demand Tooltips
### Task 2.0: Create reusable InfoTip component
**Files:**
- Create: `frontend/src/components/common/InfoTip.tsx`
**Content:**
```tsx
interface InfoTipProps {
text: string
}
export function InfoTip({ text }: InfoTipProps) {
return (
<span
className="inline-flex items-center justify-center h-3.5 w-3.5 rounded-full border border-muted-foreground/40 text-[9px] text-muted-foreground cursor-help shrink-0"
title={text}
>
i
</span>
)
}
```
This is a tiny component but it prevents repeating the same 4-line span pattern in every form file. Import it as `import { InfoTip } from '@/components/common/InfoTip'`.
**Verify:** `npm run build` — clean build.
**Commit:** `feat: add reusable InfoTip component for field-level help`
---
### Task 2.1: Replace hint text in NodeFormDecision
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
**Changes:**
1. Import `InfoTip` from `@/components/common/InfoTip`.
2. Remove the root node hint `<p>` block ("What's the main question to diagnose the issue?") — the input placeholder already conveys this.
3. Replace the options hint `<p>` paragraphs (both root and non-root variants) with an `<InfoTip>` on the label:
```tsx
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
<InfoTip text={isRootNode
? "Add as many options as needed (A, B, C, D...). Each option leads to a different troubleshooting path."
: "Each option can branch to a different next step."} />
</label>
```
4. Keep all required markers (`*`) and field-level validation error messages visible — only remove the instructional paragraphs.
**Verify:** `npm run build` — clean build.
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormDecision`
---
### Task 2.2: Replace hint text in NodeFormAction
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
**Changes:**
1. Import `InfoTip`.
2. Description field — replace the markdown hint `<p>` with InfoTip on the label:
```tsx
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Description
<InfoTip text="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`" />
</label>
```
3. Commands field — replace the hint `<p>` with InfoTip on the label:
```tsx
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Commands
<InfoTip text="PowerShell or CLI commands to execute" />
</label>
```
**Verify:** `npm run build` — clean build.
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormAction`
---
### Task 2.3: Replace hint text in NodeFormResolution
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormResolution.tsx`
**Changes:**
1. Import `InfoTip`.
2. Description field — replace the markdown hint `<p>` with InfoTip on the label (same pattern as NodeFormAction).
3. Resolution Steps field — replace the hint `<p>` with InfoTip:
```tsx
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Resolution Steps
<InfoTip text="Step-by-step instructions for resolving the issue" />
</label>
```
**Verify:** `npm run build` — clean build.
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormResolution`
---
## Phase 3: Answer Stub Placeholder System
### Task 3.1: Add `'answer'` to the NodeType union
**Files:**
- Modify: `frontend/src/types/tree.ts`
**Change:**
```typescript
// Before
export type NodeType = 'decision' | 'action' | 'solution'
// After
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
```
**Note:** This will cause a TypeScript error in `TreeCanvasNode.tsx` because `NODE_TYPE_CONFIG` doesn't have an `'answer'` key. That's expected and fixed in Task 3.3.
**Verify:** `npm run build` — note the expected error, proceed.
**Commit:** `feat: add 'answer' to NodeType union for branch placeholder stubs`
---
### Task 3.2: Create the AnswerStubCard component
**Files:**
- Create: `frontend/src/components/tree-editor/AnswerStubCard.tsx`
**Content:**
```tsx
import { useState } from 'react'
import { HelpCircle, Zap, CheckCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure } from '@/types'
interface AnswerStubCardProps {
node: TreeStructure // type === 'answer'
fromOption?: string
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
export function AnswerStubCard({ node, fromOption, onSelectType }: AnswerStubCardProps) {
const [picking, setPicking] = useState(false)
const label = fromOption || node.title || 'Answer'
return (
<div
className={cn(
'min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
'transition-all duration-150',
!picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30'
)}
onClick={() => !picking && setPicking(true)}
>
{/* Label */}
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
{label}
</div>
{/* Prompt / type picker */}
{!picking ? (
<div className="pb-2.5 text-center text-[10px] text-muted-foreground font-label">
+ Choose Type
</div>
) : (
<div className="flex items-center justify-center gap-1.5 pb-2.5 px-2">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'decision') }}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
'border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20'
)}
>
<HelpCircle className="h-2.5 w-2.5" /> Decision
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'action') }}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
'border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20'
)}
>
<Zap className="h-2.5 w-2.5" /> Action
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'solution') }}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
'border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20'
)}
>
<CheckCircle className="h-2.5 w-2.5" /> Solution
</button>
</div>
)}
</div>
)
}
export default AnswerStubCard
```
**Design rationale:** Dashed border visually distinguishes stubs from real nodes. Color-coded type buttons match the existing node type color scheme. Single-click interaction (click card → pick type) is the fastest possible conversion flow.
**Verify:** `npm run build` — no errors mentioning AnswerStubCard.
**Commit:** `feat: add AnswerStubCard component for unresolved branch placeholders`
---
### Task 3.3: Guard TreeCanvasNode against `'answer'` type
**Files:**
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
**Change:** Guard the `NODE_TYPE_CONFIG` lookup so `'answer'` doesn't crash:
```tsx
// Before
const config = NODE_TYPE_CONFIG[node.type]
// After
const config = node.type in NODE_TYPE_CONFIG
? NODE_TYPE_CONFIG[node.type as keyof typeof NODE_TYPE_CONFIG]
: NODE_TYPE_CONFIG.decision // fallback for 'answer' (rendered by AnswerStubCard instead)
```
**Note:** Answer nodes should never be rendered by TreeCanvasNode — TreeCanvas routes them to AnswerStubCard. This is a safety fallback only.
**Verify:** `npm run build` — the TypeScript error from Task 3.1 should now be resolved. Clean build.
**Commit:** `fix: guard NODE_TYPE_CONFIG lookup against 'answer' type`
---
### Task 3.4: Redesign NodeFormDecision — label-only options (remove NodePicker)
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
**This is the biggest UX change in the plan.** The old flow forced users to pick a child node type for each option while still writing the decision question. The new flow lets them just name their answers — stub nodes are created automatically on save.
**Changes:**
1. Remove the `NodePicker` import — it's no longer used in this form.
2. Replace the `DynamicArrayField` `renderItem` for options. The new renderItem shows only a letter badge + label text input per option. No NodePicker, no next_node_id selector:
```tsx
renderItem={(option, index) => {
const optionLabelError = validationErrors.find(
e => e.nodeId === node.id && e.field === `options[${index}].label`
)
const letter = indexToLetter(index)
return (
<div className="flex items-center gap-2">
<span className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
isRootNode ? 'bg-blue-500/20 text-blue-400' : 'bg-accent text-muted-foreground'
)}>
{letter}
</span>
<input
type="text"
value={option.label}
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
placeholder={isRootNode
? `Branch ${letter}: e.g., "Network Issues"...`
: `Option ${letter} label`}
className={cn(
'block flex-1 rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
optionLabelError ? 'border-red-400' : 'border-border'
)}
/>
{optionLabelError && (
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
)}
</div>
)
}}
```
3. Remove the `optionNextError` validation lookup (no longer displayed since NodePicker is gone).
4. Remove the old `<div className="rounded-md border border-border bg-accent/50 p-3">` wrapper from the old renderItem if present — the new renderItem renders flat rows.
**Verify:** `npm run build` — clean build. Ensure no unused `NodePicker` import warnings.
**Commit:** `feat: redesign NodeFormDecision to label-only options (remove NodePicker)`
---
### Task 3.5: Wire up auto-creation and rendering in TreeCanvas
**Files:**
- Modify: `frontend/src/components/tree-editor/TreeCanvas.tsx`
**Changes:**
1. Import `AnswerStubCard`:
```tsx
import { AnswerStubCard } from './AnswerStubCard'
```
2. Add `handleSelectAnswerType` callback (converts answer stub to a real type):
```tsx
const handleSelectAnswerType = useCallback(
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
updateNode(nodeId, { type })
setExpandedNodeId(nodeId)
selectNode(nodeId)
},
[updateNode, selectNode]
)
```
3. Update `handleSave` — after `updateNode(nodeId, updates)`, auto-create answer stubs for any decision option that has a label but no `next_node_id`:
```tsx
if (updates.type === 'decision' || updates.options) {
const options = updates.options || []
options.forEach((opt) => {
if (!opt.next_node_id && opt.label.trim()) {
const stubId = addNode(nodeId, 'answer')
updateNode(stubId, { title: opt.label })
const updatedOptions = options.map((o) =>
o.id === opt.id ? { ...o, next_node_id: stubId } : o
)
updateNode(nodeId, { options: updatedOptions })
}
})
}
```
4. Add `handleSelectAnswerType` to the `renderNode` `useCallback` dependency array.
5. In `renderNode`, conditionally render `AnswerStubCard` for answer-type nodes instead of `TreeCanvasNode`:
```tsx
{node.type === 'answer' ? (
<AnswerStubCard
node={node}
fromOption={optionLabel}
onSelectType={handleSelectAnswerType}
/>
) : (
<TreeCanvasNode ... />
)}
```
**Verify:** `npm run build` — clean build.
**Commit:** `feat: auto-create answer stubs on decision save, render AnswerStubCard`
---
### Task 3.6: Guard NodeList against `'answer'` type (list editor compatibility)
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeList.tsx`
**Changes:**
The `nodeTypeIcons` and `nodeTypeColors` Record types in `NodeListItem` only have keys for `decision`, `action`, `solution`. Add `answer`:
```tsx
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4" />,
action: <Zap className="h-4 w-4" />,
solution: <CheckCircle className="h-4 w-4" />,
answer: <HelpCircle className="h-4 w-4 opacity-50" />
}
const nodeTypeColors: Record<NodeType, string> = {
decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
solution: 'bg-green-500/20 text-green-600 dark:text-green-400',
answer: 'bg-muted text-muted-foreground border border-dashed border-border'
}
```
**Verify:** `npm run build` — clean build.
**Commit:** `fix: add answer type to NodeList icon and color maps`
---
## Phase 4: Validation + Backend Safety
### Task 4.1: Backend — allow `'answer'` in drafts, block on publish
**Files:**
- Modify: `backend/app/core/tree_validation.py`
**Changes:**
1. In `_validate_node`, add an `elif` for `'answer'` before the `else` (unknown type) branch:
```python
elif node_type == "answer":
# Answer nodes are draft-only placeholders — no structural validation needed
pass
```
2. Add a recursive helper function:
```python
def _has_answer_nodes(node: dict[str, Any]) -> bool:
"""Recursively check if any node in the tree has type 'answer'."""
if node.get("type") == "answer":
return True
for child in node.get("children", []):
if _has_answer_nodes(child):
return True
return False
```
3. In `validate_tree_structure`, after the recursive `_validate_children` call and before the return, add:
```python
# Block publish if any answer placeholder nodes remain
if _has_answer_nodes(tree_structure):
errors.append({
"field": "tree_structure",
"message": "Answer placeholders must be resolved to a node type before publishing."
})
```
**Verify:** Run backend tests — `pytest --override-ini="addopts=" -q` — all tests pass.
**Commit:** `feat: allow 'answer' type in tree drafts, block on publish`
---
### Task 4.2: Frontend — publish guard with toast message
**Files:**
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
**Changes:**
1. Add utility function before the component:
```typescript
function hasAnswerNodes(node: TreeStructure): boolean {
if (node.type === 'answer') return true
return (node.children || []).some(hasAnswerNodes)
}
```
2. In `handlePublish`, after the tree name check and before `validate()`, add:
```typescript
const currentStructure = useTreeEditorStore.getState().treeStructure
if (currentStructure && hasAnswerNodes(currentStructure)) {
toast.error('Resolve all answer placeholders before publishing. Click each dashed stub card to assign a type.')
setSaving(false)
return
}
```
**Verify:** `npm run build` — clean build.
**Commit:** `feat: block publish if unresolved answer stub nodes exist`
---
### Task 4.3: Markdown parser/serializer compatibility
**Files:**
- Modify: `frontend/src/utils/treeMarkdownSync.ts` (or wherever markdown sync lives)
- Modify: `backend/app/core/tree_markdown_parser.py` (if exists)
- Modify: `backend/app/core/tree_markdown_validator.py` (if exists)
**Changes:**
Ensure the markdown serializer and parser handle `type: 'answer'` gracefully:
1. **Serializer** (`treeStructureToMarkdownPreview` or equivalent): Serialize answer nodes with a clear marker, e.g.:
```markdown
### [ANSWER PLACEHOLDER] Server
> This is an unresolved answer stub. Convert it to a Decision, Action, or Solution before publishing.
```
2. **Parser**: Accept `type: answer` in parsed markdown without errors. Map it back to a node with `type: 'answer'`.
3. **Validator**: If a markdown validator exists, treat `answer` nodes as a publish-blocking warning (same rule as the structural validator).
**Note:** If these files don't exist yet, skip this task — the backend structural validation in Task 4.1 is the primary safety net.
**Verify:** `npm run build` + backend tests pass.
**Commit:** `feat: handle 'answer' type in markdown parser/serializer`
---
### Task 4.4: Runtime defensive guard in session navigation
**Files:**
- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
**Changes:**
In the session player's node rendering logic, add a guard for `answer` type nodes. If the current node has `type === 'answer'`, display a blocking message instead of the normal node UI:
```tsx
{currentNode.type === 'answer' && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-6 text-center">
<p className="text-sm font-medium text-yellow-600 dark:text-yellow-400">
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
</p>
</div>
)}
```
**Rationale:** Published trees should never have answer nodes (blocked by validation), but this guard prevents crashes if data is somehow inconsistent. It shows a clear, non-technical message.
**Verify:** `npm run build` — clean build.
**Commit:** `fix: add defensive guard for answer nodes in session navigation`
---
## Phase 5: Final Verification
### Task 5.1: Full build and test suite
```bash
# Frontend
cd frontend && npm run build
# Backend
cd backend && pytest --override-ini="addopts=" -q
```
Both must pass with zero errors.
### Task 5.2: Manual test checklist
1. [ ] Open a decision node in canvas editor → card expands → resize browser short → form scrolls, header stays sticky
2. [ ] Open a node via modal editor → content scrolls → header/footer fixed
3. [ ] Click fullscreen toggle in modal → fills viewport → click again → returns to normal → preference persists on refresh
4. [ ] Other modals (step library, custom step, etc.) have NO fullscreen button
5. [ ] Hover ⓘ badges on all form fields → tooltip text appears → no always-visible hint paragraphs remain
6. [ ] Create a new decision node → type question → type answer labels ("Server", "Desktop") → save
7. [ ] Two dashed stub cards appear below the decision node
8. [ ] Click "Server" stub → three type buttons appear (Decision / Action / Solution)
9. [ ] Click "Action" → stub converts to Action card in expanded editing mode
10. [ ] Save draft → succeeds (answer stubs allowed in drafts)
11. [ ] Leave an unresolved stub → click Publish → blocked with toast: "Resolve all answer placeholders before publishing."
12. [ ] Convert all stubs → Publish → succeeds
13. [ ] `npm run build` passes with zero TypeScript errors
14. [ ] All backend tests pass
---
## Summary of Files Changed
### New Files
| File | Description |
|------|-------------|
| `frontend/src/components/common/InfoTip.tsx` | Reusable info tooltip badge component |
| `frontend/src/components/tree-editor/AnswerStubCard.tsx` | Visual stub card with inline type picker |
### Modified Files
| File | Changes |
|------|---------|
| `frontend/src/components/tree-editor/TreeCanvasNode.tsx` | Sticky header + scrollable expanded area + answer type guard |
| `frontend/src/components/common/Modal.tsx` | `allowFullScreen` prop + expand/collapse toggle + localStorage persistence |
| `frontend/src/components/tree-editor/NodeEditorModal.tsx` | Pass `allowFullScreen={true}` |
| `frontend/src/components/tree-editor/NodeFormDecision.tsx` | InfoTip tooltips + label-only options (NodePicker removed) |
| `frontend/src/components/tree-editor/NodeFormAction.tsx` | InfoTip tooltips |
| `frontend/src/components/tree-editor/NodeFormResolution.tsx` | InfoTip tooltips |
| `frontend/src/types/tree.ts` | Add `'answer'` to NodeType union |
| `frontend/src/components/tree-editor/TreeCanvas.tsx` | Auto-create stubs + render AnswerStubCard + handleSelectAnswerType |
| `frontend/src/components/tree-editor/NodeList.tsx` | Add answer type to icon/color maps |
| `frontend/src/pages/TreeEditorPage.tsx` | Publish guard with hasAnswerNodes check |
| `frontend/src/pages/TreeNavigationPage.tsx` | Runtime defensive guard for answer nodes |
| `backend/app/core/tree_validation.py` | Allow answer in drafts, block on publish |
| `frontend/src/utils/treeMarkdownSync.ts` | Handle answer type in serializer (if exists) |
### No REST API Changes Required
The tree structure is stored as JSONB — the `answer` type flows through existing create/update endpoints without schema changes. Only the validation layer needs to know about it.