Files
resolutionflow/docs/plans/MASTER-PLAN-editor-ux-fixes.md
2026-02-19 01:53:54 -05:00

29 KiB

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:
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
  1. 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:
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:

const [isFullScreen, setIsFullScreen] = useState(() => {
  if (!allowFullScreen) return false
  try {
    return localStorage.getItem('rf-editor-fullscreen') === 'true'
  } catch {
    return false
  }
})
  1. Persist preference on toggle:
const toggleFullScreen = () => {
  const next = !isFullScreen
  setIsFullScreen(next)
  try {
    localStorage.setItem('rf-editor-fullscreen', String(next))
  } catch {}
}
  1. Add Maximize2 and Minimize2 imports from lucide-react.

  2. Render expand/collapse button in the modal header (next to the close button) only when allowFullScreen is true:

{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>
)}
  1. 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:

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

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:

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

<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>
  1. Commands field — replace the hint <p> with InfoTip on the label:
<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:

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

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

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:

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

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>
  )
}}
  1. Remove the optionNextError validation lookup (no longer displayed since NodePicker is gone).

  2. 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:
import { AnswerStubCard } from './AnswerStubCard'
  1. Add handleSelectAnswerType callback (converts answer stub to a real type):
const handleSelectAnswerType = useCallback(
  (nodeId: string, type: 'decision' | 'action' | 'solution') => {
    updateNode(nodeId, { type })
    setExpandedNodeId(nodeId)
    selectNode(nodeId)
  },
  [updateNode, selectNode]
)
  1. Update handleSave — after updateNode(nodeId, updates), auto-create answer stubs for any decision option that has a label but no next_node_id:
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 })
    }
  })
}
  1. Add handleSelectAnswerType to the renderNode useCallback dependency array.

  2. In renderNode, conditionally render AnswerStubCard for answer-type nodes instead of TreeCanvasNode:

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

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:
elif node_type == "answer":
    # Answer nodes are draft-only placeholders — no structural validation needed
    pass
  1. Add a recursive helper 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
  1. In validate_tree_structure, after the recursive _validate_children call and before the return, add:
# 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:
function hasAnswerNodes(node: TreeStructure): boolean {
  if (node.type === 'answer') return true
  return (node.children || []).some(hasAnswerNodes)
}
  1. In handlePublish, after the tree name check and before validate(), add:
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.:
### [ANSWER PLACEHOLDER] Server
> This is an unresolved answer stub. Convert it to a Decision, Action, or Solution before publishing.
  1. Parser: Accept type: answer in parsed markdown without errors. Map it back to a node with type: 'answer'.

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

{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

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