Files
resolutionflow/docs/plans/archive/2026-02-18-flow-editor-ux-impl.md
chihlasm 932927b9df chore: archive old plan docs + add survey foundation files
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>
2026-03-05 02:03:38 -05:00

1328 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Flow Editor UX Fixes Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix three UX problems in the flow editor — unreachable card content, noisy hint text, and forced child-type selection while naming answer options.
**Architecture:** Five phases in order: scrollability + fullscreen modal, reusable InfoTip component + tooltip replacements, answer stub type system (frontend types → new component → canvas wiring → NodeList guard), backend draft/publish validation, then markdown serializer and runtime navigation guard. Each phase builds on the previous and must produce a clean `npm run build` before the next begins.
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Zustand (`treeEditorStore`), FastAPI (`tree_validation.py`), `frontend/src/lib/treeMarkdownSync.ts`
**Working directory:** `/home/michaelchihlas/dev/patherly` (main branch — this plan targets the main codebase, not the worktree, since the canvas code was already merged or will be)
> **Note on worktree vs main:** If the `feature/tree-editor-canvas` branch has not yet been merged to main, run all frontend tasks in `.worktrees/tree-editor-canvas/frontend/` and all backend tasks in `.worktrees/tree-editor-canvas/backend/`. If it has been merged, use the repo root. Check with `git branch --show-current` at the start.
---
## Phase 1: Scrollability + Fullscreen Editor
### Task 1.1: Fix canvas inline card scroll (TreeCanvasNode)
**Files:**
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
**Step 1: Make the card header sticky when expanded**
Open the file. Find the card header `<div>` (around line 165) — it's the one with class `flex items-center gap-2 px-3 py-2.5`. It currently has a `cn()` call like this:
```tsx
className={cn(
'flex items-center gap-2 px-3 py-2.5',
!isExpanded && 'cursor-pointer hover:bg-accent/50 rounded-t-xl',
!isExpanded && 'rounded-xl'
)}
```
Add a sticky class when expanded:
```tsx
className={cn(
'flex items-center gap-2 px-3 py-2.5',
!isExpanded && 'cursor-pointer hover:bg-accent/50 rounded-t-xl',
!isExpanded && 'rounded-xl',
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
)}
```
**Step 2: Make the expanded editing area scrollable**
Find the expanded content `<div>` (around line 324) — it's the one that appears under `{isExpanded && (`:
```tsx
<div className="border-t border-border px-3 pb-3 pt-3">
```
Change it to:
```tsx
<div className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto">
```
**Step 3: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: `✓ built in Xs` with zero errors.
**Step 4: Commit**
```bash
git add frontend/src/components/tree-editor/TreeCanvasNode.tsx
git commit -m "fix: make canvas card expanded area scrollable with sticky header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### 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`
**Step 1: Update Modal.tsx**
The current `Modal.tsx` is ~103 lines. The `ModalProps` interface (lines 513) and the component signature (line 15) need a new `allowFullScreen` optional prop.
Replace the entire `Modal.tsx` content with the following (it's short enough to replace in full to be safe):
```tsx
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { X, Maximize2, Minimize2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: ReactNode
/** Optional footer content that stays fixed at bottom (doesn't scroll) */
footer?: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
/** If true, a fullscreen toggle button appears in the modal header */
allowFullScreen?: boolean
}
export function Modal({ isOpen, onClose, title, children, footer, size = 'md', allowFullScreen = false }: ModalProps) {
const [isFullScreen, setIsFullScreen] = useState(() => {
if (!allowFullScreen) return false
try {
return localStorage.getItem('rf-editor-fullscreen') === 'true'
} catch {
return false
}
})
const toggleFullScreen = () => {
const next = !isFullScreen
setIsFullScreen(next)
try {
localStorage.setItem('rf-editor-fullscreen', String(next))
} catch {}
}
// Close on Escape key
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [isOpen, handleKeyDown])
if (!isOpen) return null
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-full sm:max-w-lg',
xl: 'max-w-full sm:max-w-4xl',
}
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal Content */}
<div
className={cn(
'relative flex w-full flex-col border border-border bg-card shadow-lg',
'animate-scale-in transition-all duration-200',
isFullScreen
? 'fixed inset-4 max-w-none w-auto h-auto rounded-2xl'
: cn(
'max-h-[100vh] rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl',
sizeClasses[size]
)
)}
>
{/* Header - Fixed at top */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 id="modal-title" className="text-lg font-semibold text-foreground">
{title}
</h2>
<div className="flex items-center gap-1">
{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>
)}
<button
onClick={onClose}
className={cn(
'rounded-md p-1.5 text-muted-foreground transition-colors sm:p-1',
'hover:bg-accent hover:text-foreground',
'focus:outline-none focus:ring-2 focus:ring-primary/20'
)}
aria-label="Close modal"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Body - Scrollable */}
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6">
{children}
</div>
{/* Footer - Fixed at bottom */}
{footer && (
<div className="flex-shrink-0 border-t border-border px-4 py-3 sm:px-6 sm:py-4">
{footer}
</div>
)}
</div>
</div>
)
}
export default Modal
```
**Step 2: Pass `allowFullScreen` to NodeEditorModal**
In `frontend/src/components/tree-editor/NodeEditorModal.tsx`, find line 86:
```tsx
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
```
Change to:
```tsx
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>
```
**Step 3: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build, zero errors.
**Step 4: Commit**
```bash
git add frontend/src/components/common/Modal.tsx frontend/src/components/tree-editor/NodeEditorModal.tsx
git commit -m "feat: add fullscreen toggle to Modal, enable in NodeEditorModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Phase 2: Info-On-Demand Tooltips
### Task 2.1: Create the InfoTip component
**Files:**
- Create: `frontend/src/components/common/InfoTip.tsx`
**Step 1: Create the file**
```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>
)
}
```
**Step 2: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build.
**Step 3: Commit**
```bash
git add frontend/src/components/common/InfoTip.tsx
git commit -m "feat: add reusable InfoTip component for field-level help
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 2.2: Replace hint text in NodeFormDecision
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
**Step 1: Add the InfoTip import**
After the existing imports at the top of the file, add:
```tsx
import { InfoTip } from '@/components/common/InfoTip'
```
**Step 2: Remove the root node question hint paragraph**
Around line 8993 there is:
```tsx
{isRootNode && (
<p className="mt-0.5 text-xs text-muted-foreground">
What's the main question to diagnose the issue?
</p>
)}
```
Delete this entire block. The input placeholder `"e.g., What type of issue are you experiencing?"` already conveys the intent.
**Step 3: Replace the options hint paragraphs with an InfoTip on the label**
Around lines 133144, the Options section label and hints look like:
```tsx
<label className="block text-sm font-medium text-foreground">
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
</label>
{isRootNode ? (
<p className="mt-0.5 text-xs text-muted-foreground">
Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
Each option can branch to a different next step.
</p>
)}
```
Replace with:
```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>
```
**Step 4: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build.
**Step 5: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
git commit -m "fix: replace hint paragraphs with InfoTip tooltips in NodeFormDecision
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 2.3: Replace hint text in NodeFormAction
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
**Step 1: Add the InfoTip import**
Add at the top after existing imports:
```tsx
import { InfoTip } from '@/components/common/InfoTip'
```
**Step 2: Replace the Description hint paragraph**
Around lines 9193:
```tsx
<p className="mb-1 text-xs text-muted-foreground">
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
</p>
```
And the Description label above it (around line 7779):
```tsx
<label className="block text-sm font-medium text-foreground">
Description
</label>
```
Replace both with (combine label + infotip, remove paragraph):
```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>
```
**Step 3: Replace the Commands hint paragraph**
Around lines 124126:
```tsx
<p className="mb-2 text-xs text-muted-foreground">
PowerShell or CLI commands to execute
</p>
```
And the Commands label above it:
```tsx
<label className="block text-sm font-medium text-foreground">
Commands
</label>
```
Replace with:
```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>
```
**Step 4: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build.
**Step 5: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormAction.tsx
git commit -m "fix: replace hint paragraphs with InfoTip tooltips in NodeFormAction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 2.4: Replace hint text in NodeFormResolution
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormResolution.tsx`
**Step 1: Add the InfoTip import**
```tsx
import { InfoTip } from '@/components/common/InfoTip'
```
**Step 2: Replace the Description hint paragraph**
Around lines 8688 (same markdown hint as NodeFormAction). Replace:
```tsx
<label className="block text-sm font-medium text-foreground">
Description
</label>
<p className="mb-1 text-xs text-muted-foreground">
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
</p>
```
With:
```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>
```
**Step 3: Replace the Resolution Steps hint paragraph**
Around lines 118120:
```tsx
<label className="block text-sm font-medium text-foreground">
Resolution Steps
</label>
<p className="mb-2 text-xs text-muted-foreground">
Step-by-step instructions for resolving the issue
</p>
```
Replace with:
```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>
```
**Step 4: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build.
**Step 5: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormResolution.tsx
git commit -m "fix: replace hint paragraphs with InfoTip tooltips in NodeFormResolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Phase 3: Answer Stub Placeholder System
### Task 3.1: Add `'answer'` to the NodeType union
**Files:**
- Modify: `frontend/src/types/tree.ts:4`
**Step 1: Edit the NodeType line**
Find line 4:
```typescript
export type NodeType = 'decision' | 'action' | 'solution'
```
Change to:
```typescript
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
```
**Step 2: Run build — note the expected error**
```bash
cd frontend && npm run build 2>&1 | grep "error TS" | head -10
```
Expected: You will see TypeScript errors in `TreeCanvasNode.tsx` (and possibly `NodeList.tsx`) because their `Record<NodeType, ...>` maps don't include `'answer'`. This is expected and will be fixed in Tasks 3.3 and 3.6.
**Step 3: Commit the type change now** (before fixing downstream errors)
```bash
git add frontend/src/types/tree.ts
git commit -m "feat: add 'answer' to NodeType union for branch placeholder stubs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 3.2: Create the AnswerStubCard component
**Files:**
- Create: `frontend/src/components/tree-editor/AnswerStubCard.tsx`
**Step 1: Create the file**
```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
```
**Step 2: Build to confirm no errors in this new file**
```bash
cd frontend && npm run build 2>&1 | grep "AnswerStubCard"
```
Expected: No errors mentioning AnswerStubCard (the earlier `TreeCanvasNode.tsx` errors from Task 3.1 are still present, that's fine).
**Step 3: Commit**
```bash
git add frontend/src/components/tree-editor/AnswerStubCard.tsx
git commit -m "feat: add AnswerStubCard component for unresolved branch placeholders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 3.3: Guard TreeCanvasNode against `'answer'` type
**Files:**
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
**Step 1: Fix the NODE_TYPE_CONFIG lookup**
Find (around line 135):
```tsx
const config = NODE_TYPE_CONFIG[node.type]
const TypeIcon = config.icon
```
Replace with:
```tsx
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)
const TypeIcon = config.icon
```
**Step 2: Build — confirm the TS error from Task 3.1 is now gone**
```bash
cd frontend && npm run build 2>&1 | grep "TreeCanvasNode"
```
Expected: No errors mentioning TreeCanvasNode.
**Step 3: Confirm full clean build (NodeList errors may still exist)**
```bash
cd frontend && npm run build 2>&1 | grep "error TS" | head -5
```
Note: NodeList errors will be fixed in Task 3.6. Only TreeCanvasNode should be clean now.
**Step 4: Commit**
```bash
git add frontend/src/components/tree-editor/TreeCanvasNode.tsx
git commit -m "fix: guard NODE_TYPE_CONFIG lookup against 'answer' type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 3.4: Redesign NodeFormDecision — label-only options (remove NodePicker)
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
The old form had a NodePicker per option that forced users to pick a child node type during the same editing session as writing the question. The new form is label-only — stubs are created automatically on save.
**Step 1: Remove the NodePicker import**
Find and delete:
```tsx
import { NodePicker } from './NodePicker'
```
**Step 2: Replace the DynamicArrayField renderItem**
Find the existing `renderItem` prop inside `<DynamicArrayField>`. The current version renders a box with a letter badge + label input + NodePicker. Replace the entire `renderItem` callback with:
```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>
<div className="flex-1">
<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 w-full 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>
</div>
)
}}
```
**Step 3: Remove the optionNextError validation lookup** (it referenced `options[N].next_node_id`, no longer needed since there's no NodePicker)
Inside the old renderItem, there was:
```tsx
const optionNextError = validationErrors.find(
e => e.nodeId === node.id && e.field === `options[${index}].next_node_id`
)
```
This is now gone (it was inside the old renderItem you just replaced). Verify there are no remaining references.
**Step 4: Build**
```bash
cd frontend && npm run build 2>&1 | grep -E "NodeFormDecision|NodePicker" | head -10
```
Expected: No errors. If you see "Cannot find module './NodePicker'" that means the import wasn't fully removed — double-check Step 1.
**Step 5: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
git commit -m "feat: redesign NodeFormDecision to label-only options, remove NodePicker
Users now type answer labels only. Stub nodes are created automatically
by TreeCanvas when the decision node is saved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 3.5: Wire up auto-creation and AnswerStubCard rendering in TreeCanvas
**Files:**
- Modify: `frontend/src/components/tree-editor/TreeCanvas.tsx`
**Step 1: Add the AnswerStubCard import**
After the existing `TreeCanvasNode` import, add:
```tsx
import { AnswerStubCard } from './AnswerStubCard'
```
**Step 2: Add `handleSelectAnswerType` callback**
After the `handleDuplicate` callback (around line 278), add:
```tsx
// ── Convert answer stub to a real node type ──
const handleSelectAnswerType = useCallback(
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
updateNode(nodeId, { type })
setExpandedNodeId(nodeId)
selectNode(nodeId)
},
[updateNode, selectNode]
)
```
**Step 3: Update `handleSave` to auto-create stubs for unlinked options**
Find `handleSave` (around line 202). It currently starts:
```tsx
const handleSave = useCallback(
(nodeId: string, updates: Partial<TreeStructure>) => {
updateNode(nodeId, updates)
// Resolve pending link for new nodes
const link = pendingLinks.get(nodeId)
```
After `updateNode(nodeId, updates)` and before the pending link resolution, insert:
```tsx
// For decision nodes: create answer stubs for any option without a next_node_id
if (updates.options) {
const options = updates.options
const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = []
options.forEach((opt) => {
if (!opt.next_node_id && opt.label.trim()) {
const stubId = addNode(nodeId, 'answer')
updateNode(stubId, { title: opt.label })
stubsToCreate.push({ opt, stubId })
}
})
if (stubsToCreate.length > 0) {
const updatedOptions = options.map((o) => {
const stub = stubsToCreate.find((s) => s.opt.id === o.id)
return stub ? { ...o, next_node_id: stub.stubId } : o
})
updateNode(nodeId, { options: updatedOptions })
}
}
```
> **Why this shape:** We build the list of stubs first, then do a single `updateNode` with the fully updated options array, to avoid multiple sequential calls stomping on each other.
**Step 4: Add `handleSelectAnswerType` to the `renderNode` dependency array**
Find the `useCallback` dependency array at the end of `renderNode` (around line 580594). Add `handleSelectAnswerType` to the array.
**Step 5: Render AnswerStubCard for answer-type nodes in `renderNode`**
In `renderNode`, find where `<TreeCanvasNode>` is rendered (it's the card component, around line 468). Wrap it with a conditional:
Replace:
```tsx
{/* The node card itself */}
<TreeCanvasNode
node={node}
...
/>
```
With:
```tsx
{/* The node card — answer stubs get their own component */}
{node.type === 'answer' ? (
<AnswerStubCard
node={node}
fromOption={optionLabel}
onSelectType={handleSelectAnswerType}
/>
) : (
<TreeCanvasNode
node={node}
depth={0}
fromOption={optionLabel}
isExpanded={isExpanded}
isNew={isNew}
onToggleExpand={() => handleToggleExpand(node.id)}
onSave={handleSave}
onCancelNew={handleCancelNew}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onDragStart={handleDragStart}
onDragOver={(e) => handleDragOver(e, parentId, index)}
onDrop={(e) => handleDrop(e, parentId, index)}
/>
)}
```
**Step 6: Build**
```bash
cd frontend && npm run build 2>&1 | tail -15
```
Expected: Clean build, zero errors (NodeList may still have errors — check next task).
**Step 7: Commit**
```bash
git add frontend/src/components/tree-editor/TreeCanvas.tsx
git commit -m "feat: auto-create answer stubs on decision save, render AnswerStubCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 3.6: Guard NodeList against `'answer'` type
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeList.tsx`
The `nodeTypeIcons` and `nodeTypeColors` objects (lines 91101) use `Record<NodeType, ...>` which now requires an `'answer'` entry.
**Step 1: Add `'answer'` entries to both records**
Find:
```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" />
}
const nodeTypeColors: Record<NodeType, string> = {
decision: 'bg-blue-500/20 text-blue-400',
action: 'bg-yellow-500/20 text-yellow-400',
solution: 'bg-green-500/20 text-green-400'
}
```
Replace with:
```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-400',
action: 'bg-yellow-500/20 text-yellow-400',
solution: 'bg-green-500/20 text-green-400',
answer: 'bg-muted text-muted-foreground border border-dashed border-border'
}
```
**Step 2: Build — confirm full clean build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: `✓ built in Xs` — zero TypeScript errors.
**Step 3: Commit**
```bash
git add frontend/src/components/tree-editor/NodeList.tsx
git commit -m "fix: add answer type to NodeList icon and color maps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Phase 4: Backend + Frontend Validation
### Task 4.1: Backend — allow `'answer'` in drafts, block on publish
**Files:**
- Modify: `backend/app/core/tree_validation.py`
**Step 1: Add the `'answer'` elif in `_validate_node`**
Find the `_validate_node` function. Inside it, find the `else` branch at the end (around lines 9296):
```python
else:
errors.append({
"field": f"{path}.type",
"message": f"Unknown node type: {node_type}"
})
```
Insert a new `elif` before the `else`:
```python
elif node_type == "answer":
# Answer nodes are draft-only placeholders — no structural validation needed
pass
else:
errors.append({
"field": f"{path}.type",
"message": f"Unknown node type: {node_type}"
})
```
**Step 2: Add the `_has_answer_nodes` helper**
After the `_validate_children` function (ends around line 115), add a new 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
```
**Step 3: Add publish-time check in `validate_tree_structure`**
Find `validate_tree_structure`. After the `_validate_children` call and before `return len(errors) == 0, errors` (around line 5356):
```python
# Validate all child nodes recursively
if "children" in tree_structure:
_validate_children(tree_structure["children"], "root.children", errors)
return len(errors) == 0, errors
```
Change to:
```python
# Validate all child nodes recursively
if "children" in tree_structure:
_validate_children(tree_structure["children"], "root.children", errors)
# 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."
})
return len(errors) == 0, errors
```
**Step 4: Run backend tests**
```bash
cd backend
pytest --override-ini="addopts=" -q 2>&1 | tail -15
```
Expected: All existing tests pass. No new failures.
**Step 5: Commit**
```bash
git add backend/app/core/tree_validation.py
git commit -m "feat: allow 'answer' type in tree drafts, block on publish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 4.2: Frontend publish guard
**Files:**
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
**Step 1: Add utility function before the component**
Find the component declaration (`export function TreeEditorPage` or similar). Immediately before it, add:
```typescript
/** Recursively check if any node in the tree has type 'answer' */
function hasAnswerNodes(node: TreeStructure): boolean {
if (node.type === 'answer') return true
return (node.children || []).some(hasAnswerNodes)
}
```
Ensure `TreeStructure` is imported from `@/types` — check the existing imports at the top of the file (it should already be there).
**Step 2: Add the guard in `handlePublish`**
Find `handlePublish` (around line 269). It starts with a code-mode markdown validation check. After the tree name check (around line 293, after `if (!currentState.name.trim()) {...}`) and before `const errors = validate()`, insert:
```typescript
// Block publish if any answer placeholder nodes remain
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
}
```
**Step 3: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build.
**Step 4: Commit**
```bash
git add frontend/src/pages/TreeEditorPage.tsx
git commit -m "feat: block publish if unresolved answer stub nodes exist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Phase 5: Markdown Serializer + Runtime Guard
### Task 5.1: Handle `'answer'` in the markdown serializer
**Files:**
- Modify: `frontend/src/lib/treeMarkdownSync.ts`
**Step 1: Locate the `serializeNode` function**
In `treeMarkdownSync.ts`, find `serializeNode`. It has a chain of `if (node.type === 'decision') ... else if (node.type === 'action') ... else if (node.type === 'solution')`. After the final `else if` (around line 7581), add an `else if` for `'answer'`:
```typescript
} else if (node.type === 'answer') {
// Answer placeholder — render as a clearly marked stub
body.push(`## [ANSWER PLACEHOLDER] ${node.title || 'Untitled'}`, '')
body.push('> This is an unresolved answer stub. Convert it to a Decision, Action, or Solution before publishing.')
}
```
**Step 2: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build.
**Step 3: Commit**
```bash
git add frontend/src/lib/treeMarkdownSync.ts
git commit -m "feat: serialize 'answer' stub nodes in markdown output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 5.2: Add runtime defensive guard in TreeNavigationPage
**Files:**
- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
**Step 1: Find the "Current Node" rendering block**
Around line 758760 there is a comment `{/* Current Node */}` followed by a `<div>`. Inside this `<div>`, node type is dispatched via conditionals:
```tsx
{currentNode && currentNode.type === 'decision' && (
...
)}
```
Before any of these existing conditionals (before the `decision` block), add a guard for `'answer'` nodes:
```tsx
{currentNode && 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-400">
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
</p>
</div>
)}
```
**Step 2: Build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Clean build.
**Step 3: Commit**
```bash
git add frontend/src/pages/TreeNavigationPage.tsx
git commit -m "fix: add defensive guard for answer nodes in session navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Phase 6: Final Verification
### Task 6.1: Full build and backend test suite
**Step 1: Frontend build**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: `✓ built in Xs` — zero errors.
**Step 2: Backend tests**
```bash
cd backend && pytest --override-ini="addopts=" -q 2>&1 | tail -10
```
Expected: All existing tests pass.
---
### Task 6.2: Manual test checklist
Confirm all of the following in the browser:
1. **Canvas scroll** — Open a decision node in the canvas editor → resize browser to a short viewport → form content scrolls → sticky header (save/cancel) stays visible at top
2. **Modal scroll** — Open a node via the modal editor (`NodeEditorModal`) → content scrolls, header and footer are fixed
3. **Fullscreen toggle** — Click the expand icon in the modal header → modal fills viewport with margin → click again → returns to normal size smoothly → refresh → preference is remembered
4. **Other modals unaffected** — Open any other modal (step library, share session, etc.) → no fullscreen button appears
5. **InfoTip tooltips** — Hover over `ⓘ` badges on NodeFormDecision / NodeFormAction / NodeFormResolution labels → tooltip text appears → no always-visible hint paragraphs remain
6. **Answer stubs — creation** — Create or edit a decision node → type a question → type answer labels ("Server", "Desktop") → save → two dashed stub cards appear below the decision
7. **Answer stubs — conversion** — Click a dashed stub → three type buttons appear (Decision / Action / Solution) → click one → stub converts to a real node card in expanded editing mode
8. **Draft save with stubs** — Save draft with unresolved stubs → no backend error
9. **Publish blocked** — Leave an unresolved stub → click Publish → toast: "Resolve all answer placeholders before publishing."
10. **Publish succeeds after resolution** — Convert all stubs → Publish → succeeds
---
## Summary of All Files Changed
### New Files
| File | Description |
|------|-------------|
| `frontend/src/components/common/InfoTip.tsx` | Reusable info tooltip badge |
| `frontend/src/components/tree-editor/AnswerStubCard.tsx` | Dashed stub card with inline type picker |
### Modified Files
| File | Changes |
|------|---------|
| `frontend/src/components/tree-editor/TreeCanvasNode.tsx` | Sticky header + scrollable area + answer type guard |
| `frontend/src/components/common/Modal.tsx` | `allowFullScreen` prop + toggle button + localStorage |
| `frontend/src/components/tree-editor/NodeEditorModal.tsx` | Pass `allowFullScreen={true}` |
| `frontend/src/components/common/InfoTip.tsx` | (new) |
| `frontend/src/components/tree-editor/NodeFormDecision.tsx` | InfoTip tooltips + label-only options |
| `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 + AnswerStubCard rendering |
| `frontend/src/components/tree-editor/NodeList.tsx` | Add answer type to icon/color maps |
| `frontend/src/pages/TreeEditorPage.tsx` | Publish guard |
| `frontend/src/pages/TreeNavigationPage.tsx` | Runtime guard for answer nodes |
| `frontend/src/lib/treeMarkdownSync.ts` | Serialize answer nodes |
| `backend/app/core/tree_validation.py` | Allow answer in drafts, block on publish |