# 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.
