# 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 5–13) 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 89–93 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 133–144, 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 91–93:

```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 77–79):

```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 124–126:

```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 86–88 (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 118–120:

```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 580–594). 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 91–101) 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 92–96):

```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 53–56):

```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 75–81), 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 758–760 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 |
