From b127b287115c7564c7d322b190b1f9ed0a0c5bf7 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 00:55:36 -0500 Subject: [PATCH] docs: add implementation plan for flow editor UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/2026-02-18-flow-editor-ux-impl.md | 1327 ++++++++++++++++++ 1 file changed, 1327 insertions(+) create mode 100644 docs/plans/2026-02-18-flow-editor-ux-impl.md diff --git a/docs/plans/2026-02-18-flow-editor-ux-impl.md b/docs/plans/2026-02-18-flow-editor-ux-impl.md new file mode 100644 index 00000000..4eb4c2b6 --- /dev/null +++ b/docs/plans/2026-02-18-flow-editor-ux-impl.md @@ -0,0 +1,1327 @@ +# 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 `
` (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: + +```tsx +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: + +```tsx +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 `
` (around line 324) — it's the one that appears under `{isExpanded && (`: + +```tsx +
+``` + +Change it to: + +```tsx +
+``` + +**Step 3: Build** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: `✓ built in Xs` with zero errors. + +**Step 4: Commit** + +```bash +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 " +``` + +--- + +### 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 5–13) 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): + +```tsx +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 ( +
+ {/* Backdrop */} + + ) +} + +export default Modal +``` + +**Step 2: Pass `allowFullScreen` to NodeEditorModal** + +In `frontend/src/components/tree-editor/NodeEditorModal.tsx`, find line 86: + +```tsx + +``` + +Change to: + +```tsx + +``` + +**Step 3: Build** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build, zero errors. + +**Step 4: Commit** + +```bash +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 " +``` + +--- + +## 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** + +```tsx +interface InfoTipProps { + text: string +} + +export function InfoTip({ text }: InfoTipProps) { + return ( + + i + + ) +} +``` + +**Step 2: Build** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 3: Commit** + +```bash +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 " +``` + +--- + +### 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: + +```tsx +import { InfoTip } from '@/components/common/InfoTip' +``` + +**Step 2: Remove the root node question hint paragraph** + +Around line 89–93 there is: + +```tsx +{isRootNode && ( +

+ What's the main question to diagnose the issue? +

+)} +``` + +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 133–144, the Options section label and hints look like: + +```tsx + +{isRootNode ? ( +

+ Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path. +

+) : ( +

+ Each option can branch to a different next step. +

+)} +``` + +Replace with: + +```tsx + +``` + +**Step 4: Build** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 5: Commit** + +```bash +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 " +``` + +--- + +### 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: + +```tsx +import { InfoTip } from '@/components/common/InfoTip' +``` + +**Step 2: Replace the Description hint paragraph** + +Around lines 91–93: + +```tsx +

+ Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code` +

+``` + +And the Description label above it (around line 77–79): + +```tsx + +``` + +Replace both with (combine label + infotip, remove paragraph): + +```tsx + +``` + +**Step 3: Replace the Commands hint paragraph** + +Around lines 124–126: + +```tsx +

+ PowerShell or CLI commands to execute +

+``` + +And the Commands label above it: + +```tsx + +``` + +Replace with: + +```tsx + +``` + +**Step 4: Build** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 5: Commit** + +```bash +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 " +``` + +--- + +### Task 2.4: Replace hint text in NodeFormResolution + +**Files:** +- Modify: `frontend/src/components/tree-editor/NodeFormResolution.tsx` + +**Step 1: Add the InfoTip import** + +```tsx +import { InfoTip } from '@/components/common/InfoTip' +``` + +**Step 2: Replace the Description hint paragraph** + +Around lines 86–88 (same markdown hint as NodeFormAction). Replace: + +```tsx + +

+ Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code` +

+``` + +With: + +```tsx + +``` + +**Step 3: Replace the Resolution Steps hint paragraph** + +Around lines 118–120: + +```tsx + +

+ Step-by-step instructions for resolving the issue +

+``` + +Replace with: + +```tsx + +``` + +**Step 4: Build** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 5: Commit** + +```bash +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 " +``` + +--- + +## 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: + +```typescript +export type NodeType = 'decision' | 'action' | 'solution' +``` + +Change to: + +```typescript +export type NodeType = 'decision' | 'action' | 'solution' | 'answer' +``` + +**Step 2: Run build — note the expected error** + +```bash +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` 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) + +```bash +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 " +``` + +--- + +### Task 3.2: Create the AnswerStubCard component + +**Files:** +- Create: `frontend/src/components/tree-editor/AnswerStubCard.tsx` + +**Step 1: Create the file** + +```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 +``` + +**Step 2: Build to confirm no errors in this new file** + +```bash +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** + +```bash +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 " +``` + +--- + +### 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): + +```tsx +const config = NODE_TYPE_CONFIG[node.type] +const TypeIcon = config.icon +``` + +Replace with: + +```tsx +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** + +```bash +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)** + +```bash +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** + +```bash +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 " +``` + +--- + +### 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: + +```tsx +import { NodePicker } from './NodePicker' +``` + +**Step 2: Replace the DynamicArrayField renderItem** + +Find the existing `renderItem` prop inside ``. The current version renders a box with a letter badge + label input + NodePicker. Replace the entire `renderItem` callback with: + +```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 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 && ( +

{optionLabelError.message}

+ )} +
+
+ ) +}} +``` + +**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: + +```tsx +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** + +```bash +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** + +```bash +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 " +``` + +--- + +### 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: + +```tsx +import { AnswerStubCard } from './AnswerStubCard' +``` + +**Step 2: Add `handleSelectAnswerType` callback** + +After the `handleDuplicate` callback (around line 278), add: + +```tsx +// ── 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: + +```tsx +const handleSave = useCallback( + (nodeId: string, updates: Partial) => { + 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: + +```tsx + // 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 580–594). Add `handleSelectAnswerType` to the array. + +**Step 5: Render AnswerStubCard for answer-type nodes in `renderNode`** + +In `renderNode`, find where `` is rendered (it's the card component, around line 468). Wrap it with a conditional: + +Replace: + +```tsx +{/* The node card itself */} + +``` + +With: + +```tsx +{/* The node card — answer stubs get their own component */} +{node.type === 'answer' ? ( + +) : ( + 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** + +```bash +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** + +```bash +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 " +``` + +--- + +### Task 3.6: Guard NodeList against `'answer'` type + +**Files:** +- Modify: `frontend/src/components/tree-editor/NodeList.tsx` + +The `nodeTypeIcons` and `nodeTypeColors` objects (lines 91–101) use `Record` which now requires an `'answer'` entry. + +**Step 1: Add `'answer'` entries to both records** + +Find: + +```tsx +const nodeTypeIcons: Record = { + decision: , + action: , + solution: +} + +const nodeTypeColors: Record = { + 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: + +```tsx +const nodeTypeIcons: Record = { + decision: , + action: , + solution: , + answer: +} + +const nodeTypeColors: Record = { + 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** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: `✓ built in Xs` — zero TypeScript errors. + +**Step 3: Commit** + +```bash +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 " +``` + +--- + +## 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 92–96): + +```python +else: + errors.append({ + "field": f"{path}.type", + "message": f"Unknown node type: {node_type}" + }) +``` + +Insert a new `elif` before the `else`: + +```python +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: + +```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 +``` + +**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 53–56): + +```python + # 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: + +```python + # 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** + +```bash +cd backend +pytest --override-ini="addopts=" -q 2>&1 | tail -15 +``` + +Expected: All existing tests pass. No new failures. + +**Step 5: Commit** + +```bash +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 " +``` + +--- + +### 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: + +```typescript +/** 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: + +```typescript + // 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** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 4: Commit** + +```bash +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 " +``` + +--- + +## 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 75–81), add an `else if` for `'answer'`: + +```typescript +} 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** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 3: Commit** + +```bash +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 " +``` + +--- + +### 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 758–760 there is a comment `{/* Current Node */}` followed by a `
`. Inside this `
`, node type is dispatched via conditionals: + +```tsx +{currentNode && currentNode.type === 'decision' && ( + ... +)} +``` + +Before any of these existing conditionals (before the `decision` block), add a guard for `'answer'` nodes: + +```tsx +{currentNode && currentNode.type === 'answer' && ( +
+

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

+
+)} +``` + +**Step 2: Build** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: Clean build. + +**Step 3: Commit** + +```bash +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 " +``` + +--- + +## Phase 6: Final Verification + +### Task 6.1: Full build and backend test suite + +**Step 1: Frontend build** + +```bash +cd frontend && npm run build 2>&1 | tail -5 +``` + +Expected: `✓ built in Xs` — zero errors. + +**Step 2: Backend tests** + +```bash +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 |