Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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:
- Can't reach bottom of editor — scrollable content + optional fullscreen toggle
- Form clutter — replace always-visible hint paragraphs with info-on-demand tooltips
- 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:
- Make the card header sticky when expanded. Find the header
<div>(the one withflex items-center gap-2 px-3 py-2.5). Add conditional sticky classes:
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
- Make the expanded editing area scrollable. Find the expanded content
<div>(the one withborder-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:
-
Add new optional prop:
allowFullScreen?: boolean(defaultfalse). -
Add state inside Modal:
const [isFullScreen, setIsFullScreen] = useState(() => {
if (!allowFullScreen) return false
try {
return localStorage.getItem('rf-editor-fullscreen') === 'true'
} catch {
return false
}
})
- Persist preference on toggle:
const toggleFullScreen = () => {
const next = !isFullScreen
setIsFullScreen(next)
try {
localStorage.setItem('rf-editor-fullscreen', String(next))
} catch {}
}
-
Add
Maximize2andMinimize2imports fromlucide-react. -
Render expand/collapse button in the modal header (next to the close button) only when
allowFullScreenistrue:
{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>
)}
- 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-200for smooth animation between modes. - The modal body must remain
overflow-y-autoin both modes.
- Default: existing size classes (whatever
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:
-
Import
InfoTipfrom@/components/common/InfoTip. -
Remove the root node hint
<p>block ("What's the main question to diagnose the issue?") — the input placeholder already conveys this. -
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>
- 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:
-
Import
InfoTip. -
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>
- 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:
-
Import
InfoTip. -
Description field — replace the markdown hint
<p>with InfoTip on the label (same pattern as NodeFormAction). -
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:
-
Remove the
NodePickerimport — it's no longer used in this form. -
Replace the
DynamicArrayFieldrenderItemfor 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>
)
}}
-
Remove the
optionNextErrorvalidation lookup (no longer displayed since NodePicker is gone). -
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:
- Import
AnswerStubCard:
import { AnswerStubCard } from './AnswerStubCard'
- Add
handleSelectAnswerTypecallback (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]
)
- Update
handleSave— afterupdateNode(nodeId, updates), auto-create answer stubs for any decision option that has a label but nonext_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 })
}
})
}
-
Add
handleSelectAnswerTypeto therenderNodeuseCallbackdependency array. -
In
renderNode, conditionally renderAnswerStubCardfor answer-type nodes instead ofTreeCanvasNode:
{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:
- In
_validate_node, add aneliffor'answer'before theelse(unknown type) branch:
elif node_type == "answer":
# Answer nodes are draft-only placeholders — no structural validation needed
pass
- 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
- In
validate_tree_structure, after the recursive_validate_childrencall 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:
- Add utility function before the component:
function hasAnswerNodes(node: TreeStructure): boolean {
if (node.type === 'answer') return true
return (node.children || []).some(hasAnswerNodes)
}
- In
handlePublish, after the tree name check and beforevalidate(), 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:
- Serializer (
treeStructureToMarkdownPreviewor 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.
-
Parser: Accept
type: answerin parsed markdown without errors. Map it back to a node withtype: 'answer'. -
Validator: If a markdown validator exists, treat
answernodes 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
- Open a decision node in canvas editor → card expands → resize browser short → form scrolls, header stays sticky
- Open a node via modal editor → content scrolls → header/footer fixed
- Click fullscreen toggle in modal → fills viewport → click again → returns to normal → preference persists on refresh
- Other modals (step library, custom step, etc.) have NO fullscreen button
- Hover ⓘ badges on all form fields → tooltip text appears → no always-visible hint paragraphs remain
- Create a new decision node → type question → type answer labels ("Server", "Desktop") → save
- Two dashed stub cards appear below the decision node
- Click "Server" stub → three type buttons appear (Decision / Action / Solution)
- Click "Action" → stub converts to Action card in expanded editing mode
- Save draft → succeeds (answer stubs allowed in drafts)
- Leave an unresolved stub → click Publish → blocked with toast: "Resolve all answer placeholders before publishing."
- Convert all stubs → Publish → succeeds
npm run buildpasses with zero TypeScript errors- 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.