Files
resolutionflow/docs/plans/2026-02-18-flow-editor-ux-impl.md
chihlasm b127b28711 docs: add implementation plan for flow editor UX fixes
6-phase, 16-task plan covering: canvas card scroll + fullscreen modal,
InfoTip component + tooltip replacements in all 3 NodeForm components,
answer stub type system (types → AnswerStubCard → TreeCanvas wiring →
NodeList guard), backend draft/publish validation, markdown serializer
compatibility, and session navigation defensive guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 14:10:10 -05:00

38 KiB
Raw Blame History

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:

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:

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 && (:

<div className="border-t border-border px-3 pb-3 pt-3">

Change it to:

<div className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto">

Step 3: Build

cd frontend && npm run build 2>&1 | tail -10

Expected: ✓ built in Xs with zero errors.

Step 4: Commit

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):

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:

<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>

Change to:

<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>

Step 3: Build

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build, zero errors.

Step 4: Commit

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

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

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build.

Step 3: Commit

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:

import { InfoTip } from '@/components/common/InfoTip'

Step 2: Remove the root node question hint paragraph

Around line 8993 there is:

{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:

<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:

<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

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build.

Step 5: Commit

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:

import { InfoTip } from '@/components/common/InfoTip'

Step 2: Replace the Description hint paragraph

Around lines 9193:

<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):

<label className="block text-sm font-medium text-foreground">
  Description
</label>

Replace both with (combine label + infotip, remove paragraph):

<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:

<p className="mb-2 text-xs text-muted-foreground">
  PowerShell or CLI commands to execute
</p>

And the Commands label above it:

<label className="block text-sm font-medium text-foreground">
  Commands
</label>

Replace with:

<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

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build.

Step 5: Commit

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

import { InfoTip } from '@/components/common/InfoTip'

Step 2: Replace the Description hint paragraph

Around lines 8688 (same markdown hint as NodeFormAction). Replace:

<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:

<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:

<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:

<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

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build.

Step 5: Commit

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:

export type NodeType = 'decision' | 'action' | 'solution'

Change to:

export type NodeType = 'decision' | 'action' | 'solution' | 'answer'

Step 2: Run build — note the expected error

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)

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

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

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

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):

const config = NODE_TYPE_CONFIG[node.type]
const TypeIcon = config.icon

Replace with:

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

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)

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

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:

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:

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:

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

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

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:

import { AnswerStubCard } from './AnswerStubCard'

Step 2: Add handleSelectAnswerType callback

After the handleDuplicate callback (around line 278), add:

// ── 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:

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:

    // 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:

{/* The node card itself */}
<TreeCanvasNode
  node={node}
  ...
/>

With:

{/* 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

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

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:

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:

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

cd frontend && npm run build 2>&1 | tail -10

Expected: ✓ built in Xs — zero TypeScript errors.

Step 3: Commit

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):

else:
    errors.append({
        "field": f"{path}.type",
        "message": f"Unknown node type: {node_type}"
    })

Insert a new elif before the else:

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:

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):

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

    # 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

cd backend
pytest --override-ini="addopts=" -q 2>&1 | tail -15

Expected: All existing tests pass. No new failures.

Step 5: Commit

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:

/** 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:

      // 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

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build.

Step 4: Commit

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':

} 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

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build.

Step 3: Commit

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:

{currentNode && currentNode.type === 'decision' && (
  ...
)}

Before any of these existing conditionals (before the decision block), add a guard for 'answer' nodes:

{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

cd frontend && npm run build 2>&1 | tail -10

Expected: Clean build.

Step 3: Commit

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

cd frontend && npm run build 2>&1 | tail -5

Expected: ✓ built in Xs — zero errors.

Step 2: Backend tests

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