# 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 `` everywhere creates maintenance debt. Extract to a tiny `` 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 `
` (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 `
` (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 && ( )} ``` 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 ``` **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 ( i ) } ``` 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 `

` block ("What's the main question to diagnose the issue?") — the input placeholder already conveys this. 3. Replace the options hint `

` paragraphs (both root and non-root variants) with an `` on the label: ```tsx ``` 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 `

` with InfoTip on the label: ```tsx ``` 3. Commands field — replace the hint `

` with InfoTip on the label: ```tsx ``` **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 `

` with InfoTip on the label (same pattern as NodeFormAction). 3. Resolution Steps field — replace the hint `

` with InfoTip: ```tsx ``` **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 (

!picking && setPicking(true)} > {/* Label */}
{label}
{/* Prompt / type picker */} {!picking ? (
+ Choose Type
) : (
)}
) } 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 (
{letter} 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 && (

{optionLabelError.message}

)}
) }} ``` 3. Remove the `optionNextError` validation lookup (no longer displayed since NodePicker is gone). 4. Remove the old `
` 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' ? ( ) : ( )} ``` **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 = { decision: , action: , solution: , answer: } const nodeTypeColors: Record = { 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' && (

This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.

)} ``` **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.