diff --git a/CLAUDE.md b/CLAUDE.md index c17a59ff..f9e54e19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,6 +261,12 @@ navigate(`/trees/${newTree.id}/edit`) **18. Pydantic partial updates — use `model_fields_set`:** When a PUT/PATCH endpoint needs to distinguish "field not sent" from "field sent as null", check `data.model_fields_set` instead of `data.field is not None`. This allows clients to explicitly clear nullable fields like `description`. +**19. `gh pr merge` fails with worktrees:** When `main` is checked out in the primary worktree, `gh pr merge` crashes with "fatal: 'main' is already used by worktree". Use the API directly instead: `gh api repos/ORG/REPO/pulls/N/merge --method PUT --field merge_method=squash` + +**20. `'answer'` node type in TreeStructure:** `answer` is a transient stub type used only in the canvas editor. Any code that switches on `node.type` (validation, markdown serializer, session nav guard) must explicitly handle `'answer'` or it will hit an unhandled-type error. + +**21. Test fixtures in `conftest.py`:** Available fixtures are `client` (async HTTP client), `test_db` (async session), `test_user` (registers user, returns email/password/user_data), `auth_headers` (Bearer token dict), `test_tree` (creates a tree), `test_admin` (super_admin user), `admin_auth_headers` (admin Bearer token). There is NO `async_client` or `engineer_token` fixture. + --- ## RBAC & Permissions diff --git a/docs/plans/IMPLEMENTATION-PLAN-TREE-EDITOR-CANVAS.md b/docs/plans/IMPLEMENTATION-PLAN-TREE-EDITOR-CANVAS.md new file mode 100644 index 00000000..74bd5d76 --- /dev/null +++ b/docs/plans/IMPLEMENTATION-PLAN-TREE-EDITOR-CANVAS.md @@ -0,0 +1,574 @@ +# Implementation Plan: Tree Editor Canvas Redesign + +> **Date:** February 17, 2026 +> **Scope:** Replace NodeList + NodeEditorModal + TreePreviewPanel in Flow mode with a visual canvas + inline card editing +> **Estimated Components:** 3 new files, 4 modified files +> **Phases:** 4 (sequential) +> **Branch:** `feature/tree-editor-canvas` + +--- + +## Overview + +Replace the current text-outline + modal + passive preview layout with a single-pane visual canvas where nodes are cards, editing is inline, and branches are visually connected. The tree IS the editor — no separate preview panel needed. + +### Current State + +The tree editor uses a text-outline metaphor: +- Nodes listed as indented rows with ASCII tree lines in `NodeList.tsx` +- Editing requires opening `NodeEditorModal.tsx` for each node (click row → modal opens → edit → Done → modal closes) +- Passive `TreePreviewPanel.tsx` takes 40% of space but offers no editing +- Adding nodes is a two-step picker-then-modal flow +- Options/branches are opaque — can't see where each branch leads without clicking into the node + +### Target State (5 Outcomes) + +1. Flow mode uses a full-width `TreeCanvas` editor; preview panel is removed from Flow mode +2. Node editing is inline in cards with local draft + ✓ save + ✕ cancel (no modal) +3. Branches are visually rendered with parent-child connector lines and horizontal splits +4. Metadata moves to a right slide-in panel, collapsed by default, opened from toolbar in Flow mode only +5. Code mode remains functionally unchanged (Monaco + preview split) + +### Layout After Redesign + +``` +┌─────────────────────────────────────────────────────────┐ +│ TOOLBAR: [Flow Name] [Undo] [Redo] [Metadata] [Save] [Publish] │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ [START] │ +│ │ │ +│ ┌─────────────────────────────┐ │ +│ │ ? What type of issue? │ ← Decision card │ +│ │ ↳ [A] Network Issues │ │ +│ │ ↳ [B] App Errors │ │ +│ └─────────────────────────────┘ │ +│ │ │ │ +│ [Network card] [App Errors card] │ +│ │ │ +│ [Solution card] │ +│ │ +│ [+ Add node here] ← contextual add buttons │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Reusable Code Inventory + +Before building anything, confirm these existing pieces are available and understand their APIs: + +| Asset | Location | How It's Used | +|-------|----------|---------------| +| `useTreeEditorStore()` | `store/treeEditorStore.ts` | All CRUD: `addNode`, `updateNode`, `deleteNode`, `duplicateNode`, `reorderNodes`, `selectNode`, `findNode`, `validationErrors` | +| `NodeFormDecision` | `components/tree-editor/NodeFormDecision.tsx` | Decision node fields (question, help_text, options) — will be rendered inline in card | +| `NodeFormAction` | `components/tree-editor/NodeFormAction.tsx` | Action node fields — will be rendered inline in card | +| `NodeFormResolution` | `components/tree-editor/NodeFormResolution.tsx` | Solution node fields — will be rendered inline in card | +| `DynamicArrayField` | `components/tree-editor/DynamicArrayField.tsx` | Reuse for options array in inline card | +| `NodePicker` | `components/tree-editor/NodePicker.tsx` | Reuse for option target selection (needs `allowCreate` prop added) | +| `TreeMetadataForm` | `components/tree-editor/TreeMetadataForm.tsx` | Wraps into MetadataSidePanel as-is | +| `cn()` | `@/lib/utils` | Tailwind class merging utility | +| Design tokens | `tailwind.config.js` | `bg-card`, `border-border`, `text-foreground`, `font-heading`, `font-label` | +| Brand colors | `tailwind.config.js` | Blue (decision), Yellow (action), Green (solution) | +| `buildSharedLinksMap()` | `components/tree-preview/TreePreviewPanel.tsx` | Shared node detection logic — extract and reuse for jump/reference indicators | + +--- + +## Link Model & Rendering Rules + +**IMPORTANT:** These rules govern how the canvas renders node connections. Read before implementing Phase 2. + +### Tree-First Rendering + +Render only structural children (nodes in `node.children[]`) with visual connector lines. Do NOT draw cross-canvas connector lines for shared/cross-linked nodes. + +For links to non-child/shared targets (where `option.next_node_id` points to a node that is NOT a direct child), show a compact "jump/reference" indicator badge in the card content instead of a connector line. + +### Decision Child Lane Ordering + +When a decision node has children, order the horizontal child lanes: +1. **First:** Children whose `id` matches an `option.next_node_id` — ordered by option order +2. **Then:** Append any remaining unlinked children + +### Action Next-Child Rule + +- If a child's `id` matches the action node's `next_node_id`, treat it as the primary "next" lane +- If no child matches `next_node_id`, keep child visible but show the link as an "unbound reference" indicator + +### Deletion Safety + +**Before calling `deleteNode(nodeId)`**, the canvas must clean up all inbound references: +- Scan all decision nodes: clear any `options[].next_node_id` that equals the deleted node's ID +- Scan all action nodes: clear any `next_node_id` that equals the deleted node's ID + +This prevents stale link references. The current `treeEditorStore.deleteNode()` does NOT do this cleanup — the canvas orchestration layer handles it. + +--- + +## Phase 1: TreeCanvasNode Component (Core Inline Editor Card) + +**File:** `frontend/src/components/tree-editor/TreeCanvasNode.tsx` (NEW) + +### Props Interface + +```typescript +interface TreeCanvasNodeProps { + node: TreeStructure + depth: number + fromOption?: string // Which parent option label leads here + isExpanded: boolean + isNew: boolean // Show "Unsaved" badge, cancel triggers delete + onToggleExpand: () => void + onSave: (nodeId: string, updates: Partial) => void + onCancelNew: (nodeId: string) => void // Delete unsaved node + onDelete: (nodeId: string) => void + onDuplicate: (nodeId: string) => void + onDragStart: (e: React.DragEvent, nodeId: string) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void +} +``` + +### Card States + +**Compact (default):** +- Node type badge/icon (Decision ?, Action ⚡, Solution ✓) +- Title text (question for decisions, title for action/solution) +- Option labels or option count for decisions +- Validation error badge (red dot if errors on this node) +- "Unsaved" badge (yellow, if `isNew`) +- Click anywhere on card → calls `onToggleExpand` + +**Expanded (editing):** +- Local draft state: `const [draft, setDraft] = useState>(() => cloneNodeWithoutChildren(node))` +- Renders the appropriate existing form subcomponent inline: + - Decision: `` + - Action: `` + - Solution: `` +- Header actions row: + - ✓ Save button → calls `onSave(node.id, draft)` (strip children from draft before passing) + - ✕ Cancel button → if `isNew`, calls `onCancelNew(node.id)`. Otherwise resets draft to node values and collapses + - Duplicate button (hide if root) + - Delete button (hide if root) +- Drag handle in header (hide if root) + +### Card Styling + +Aesthetic direction: "Precision engineering tool" — clean, minimal chrome, confident typography. + +``` +All cards: bg-card border-border rounded-xl shadow-sm +Decision: border-l-4 border-blue-500 +Action: border-l-4 border-yellow-500 +Solution: border-l-4 border-green-500 +Expanded ring: ring-1 ring-primary +Titles: font-heading (Plus Jakarta Sans) +Type badges: font-label (Outfit) +``` + +### Implementation Steps + +1. Create `TreeCanvasNode.tsx` with compact view only (type badge, title, option count) +2. Add expanded view with local draft state and inline form rendering +3. Wire save/cancel/delete/duplicate actions +4. Add drag handle events +5. Add validation badge and unsaved badge +6. Style with brand tokens + +### Verification + +- Render a single card in isolation with mock data +- Confirm compact → expanded toggle works +- Confirm save commits draft (log output), cancel resets +- Confirm cancel on `isNew=true` calls `onCancelNew` +- Run `npm run build` — no TypeScript errors + +--- + +## Phase 2: TreeCanvas Component (Layout & Orchestration) + +**File:** `frontend/src/components/tree-editor/TreeCanvas.tsx` (NEW) + +### Canvas State Model + +```typescript +// Local UI state (NOT in Zustand store — canvas-only concerns) +const [expandedNodeIds, setExpandedNodeIds] = useState>(new Set()) +const [newNodeIds, setNewNodeIds] = useState>(new Set()) +const [pendingAddTarget, setPendingAddTarget] = useState(null) +const [pendingLinkByNodeId, setPendingLinkByNodeId] = useState>(new Map()) +const [dragState, setDragState] = useState<{ + nodeId: string + parentId: string + index: number +} | null>(null) +``` + +**Single expanded card policy:** Only one card expanded at a time. When expanding a card, collapse the previously expanded one. + +### Rendering + +Recursive rendering of `treeStructure` from the store: +- Root at top, rendered as a "START" card +- Vertical flow downward +- When a decision node has multiple options with children, render children in horizontal lanes side-by-side +- Single-pane scrollable area (`overflow-auto`) +- Background: `bg-background` with subtle CSS radial dot grid pattern + +### Connector Lines + +Use CSS borders (not SVG) for connecting lines: +- **Parent-to-children trunk line:** `border-l border-border` extending down from parent +- **Horizontal fork line:** `border-t border-border` connecting sibling lane tops +- **Vertical stubs:** `border-l border-border` dropping into each child card + +### Add-Node Flow + +| Parent Type | Add Button Behavior | +|-------------|-------------------| +| Decision | Show `+ Add child` per option row (next to each option label) | +| Action | Show single `+ Add child` below the card | +| Solution | No add button (terminal node) | + +- `+ Add` buttons use dashed border, appear on hover of parent card bottom edge +- Clicking `+ Add` sets `pendingAddTarget` → shows inline type picker (decision/action/solution buttons) at that position +- Selecting a type: + 1. Calls `addNode(parentId, type)` → gets new node ID + 2. Adds node ID to `newNodeIds` + 3. Adds entry to `pendingLinkByNodeId` (parent ID + option ID if from a decision option) + 4. Auto-expands the new node card + 5. Clears `pendingAddTarget` + +### Save Behavior for New Child Nodes + +When user clicks ✓ on a new node: +1. Call `updateNode(nodeId, draft)` to save content to store +2. If `pendingLinkByNodeId.has(nodeId)`: + - Get the pending link info (`parentId`, `optionId`) + - If `optionId` exists: update parent's `options[].next_node_id` to point to this node + - If no `optionId` (action parent): update parent's `next_node_id` to point to this node +3. Remove from `newNodeIds` +4. Remove from `pendingLinkByNodeId` + +### Cancel Behavior for New Nodes + +When user clicks ✕ on a new (unsaved) node: +1. Call `deleteNode(nodeId)` +2. Remove from `newNodeIds` +3. Remove from `pendingLinkByNodeId` + +### Delete Behavior (Any Node) + +When user clicks delete on any node: +1. **Clean inbound references first** (see Link Model section above): + - Walk the full tree and clear any `options[].next_node_id` or `next_node_id` matching the node being deleted +2. Then call `deleteNode(nodeId)` +3. Remove from `expandedNodeIds` if present + +### Sibling Reorder + +- Drag handle in card header (Phase 1 wired the events) +- Drop zones rendered between sibling cards (visual indicator line) +- On drop: call `reorderNodes(parentId, fromIndex, toIndex)` + +### Selection Integration + +- Click on a card calls `selectNode(nodeId)` in the store +- Watch `selectedNodeId` from store (including changes from `ValidationSummary` clicks): + - Auto-expand the selected node's card + - Scroll the card into view with `scrollIntoView({ behavior: 'smooth', block: 'nearest' })` + +### Implementation Steps + +1. Create `TreeCanvas.tsx` with recursive tree rendering (compact cards only, no editing) +2. Add CSS connector lines between parent and children +3. Add horizontal branching for decision nodes with multiple children +4. Add canvas state model (expandedNodeIds, newNodeIds, etc.) +5. Wire card expand/collapse with single-expanded-card policy +6. Add inline type picker and add-node flow with pending link tracking +7. Wire save/cancel with pending link resolution +8. Wire delete with inbound reference cleanup +9. Add drag-and-drop sibling reorder +10. Add selection sync (auto-expand + scroll into view) +11. Add grid background pattern +12. Style add buttons (dashed border, hover reveal) + +### Verification + +- Create a new tree → see canvas with root START card +- Click root → expands inline (no modal) +- Add 2 options, save → see branch lanes +- Add child from each option → pending link resolves on save +- Cancel a new node → node deleted, link cleaned up +- Delete a linked node → parent's reference cleared +- Drag reorder siblings +- Run `npm run build` — no TypeScript errors + +--- + +## Phase 3: Form Refactoring + Layout Update + +### 3A: Form Refactoring for Inline Reuse Safety + +**Files to modify:** +- `frontend/src/components/tree-editor/NodeFormDecision.tsx` +- `frontend/src/components/tree-editor/NodePicker.tsx` + +#### NodeFormDecision.tsx Changes + +**Problem:** Currently, option reordering calls `reorderOptions()` directly on the store. In inline canvas editing, this would write to the store before the user clicks ✓ save (breaking the local draft model). + +**Fix:** Change option reordering to mutate the local `node.options` array through the `onUpdate` callback instead of calling the store directly. + +```typescript +// BEFORE (writes to store immediately): +const handleReorderOptions = (fromIndex: number, toIndex: number) => { + reorderOptions(node.id, fromIndex, toIndex) +} + +// AFTER (mutates local draft via onUpdate): +const handleReorderOptions = (fromIndex: number, toIndex: number) => { + const newOptions = [...(node.options || [])] + const [moved] = newOptions.splice(fromIndex, 1) + newOptions.splice(toIndex, 0, moved) + onUpdate({ options: newOptions }) +} +``` + +**Keep modal compatibility:** This change is backward-compatible. In the legacy modal path, `onUpdate` already propagates to the store. In the canvas path, `onUpdate` updates the local draft. + +#### NodePicker.tsx Changes + +**Problem:** `NodePicker` currently has create-new-node options (`__create_decision__`, etc.) that call `addNode()` on the store. In canvas inline editing, this would create nodes as a side effect of browsing the picker during draft editing. + +**Fix:** Add an `allowCreate` prop: + +```typescript +interface NodePickerProps { + // ... existing props + allowCreate?: boolean // default: true +} +``` + +- When `allowCreate={false}`, hide the "Create New" option group +- Pass `allowCreate={false}` from `TreeCanvasNode` expanded editing +- Pass `allowCreate={true}` (default) in legacy modal path + +### 3B: Layout Update + +**File:** `frontend/src/components/tree-editor/TreeEditorLayout.tsx` + +#### Changes + +```typescript +interface TreeEditorLayoutProps { + isMobile?: boolean + isMetadataOpen: boolean // NEW + onCloseMetadata: () => void // NEW +} +``` + +**Flow mode (replace the 60/40 split):** + +``` +BEFORE: +
+ + +
+
+ +
+ +AFTER: + (full width) + (overlay) +``` + +**Code mode:** Unchanged. Keep existing 60/40 Monaco + preview behavior. + +### 3C: MetadataSidePanel Component + +**File:** `frontend/src/components/tree-editor/MetadataSidePanel.tsx` (NEW) + +Right-side slide-in panel (320px wide) that wraps ``. + +```typescript +interface MetadataSidePanelProps { + isOpen: boolean + onClose: () => void +} +``` + +Behavior: +- Slides in from right edge, overlays the canvas (does NOT resize it) +- Close triggers: panel close button, backdrop click, Escape key +- Uses existing overlay/backdrop pattern from other modals in the app +- Only rendered in Flow mode + +### Implementation Steps + +1. Refactor `NodeFormDecision.tsx` — option reorder through `onUpdate` +2. Add `allowCreate` prop to `NodePicker.tsx` +3. Create `MetadataSidePanel.tsx` +4. Update `TreeEditorLayout.tsx` — swap Flow mode layout, add metadata panel props +5. Verify legacy modal path still works (if still referenced anywhere) + +### Verification + +- Open tree editor in Flow mode → see full-width canvas (no 60/40 split) +- Open tree editor in Code mode → see unchanged Monaco + preview layout +- Click Metadata button → panel slides in from right, canvas doesn't resize +- Close metadata panel via close button, backdrop click, and Escape key +- Edit options in inline card → reorder does NOT write to store until ✓ save +- NodePicker in inline card → no "Create New" options shown +- Run `npm run build` — no TypeScript errors + +--- + +## Phase 4: Toolbar Wiring + Exports + +**File:** `frontend/src/pages/TreeEditorPage.tsx` (modify) + +### Changes + +1. Add local state: `const [isMetadataOpen, setIsMetadataOpen] = useState(false)` +2. Add "Metadata" toolbar button — visible in Flow mode only +3. Auto-close metadata panel when switching to Code mode: + ```typescript + // In mode switch handler: + if (newMode === 'code') setIsMetadataOpen(false) + ``` +4. Pass metadata props into `TreeEditorLayout`: + ```typescript + setIsMetadataOpen(false)} + /> + ``` +5. Keep all existing toolbar actions unchanged: undo/redo, save/publish, validate, analytics + +**File:** `frontend/src/components/tree-editor/index.ts` (modify) + +Add exports: +```typescript +export { TreeCanvas } from './TreeCanvas' +export { TreeCanvasNode } from './TreeCanvasNode' +export { MetadataSidePanel } from './MetadataSidePanel' +``` + +Keep all legacy exports (`NodeList`, `NodeEditorModal`, `TreePreviewPanel`) — they remain in the codebase but are no longer imported in the active Flow path. + +### Implementation Steps + +1. Add metadata panel state and toolbar button to `TreeEditorPage.tsx` +2. Add auto-close on Code mode switch +3. Pass props through to `TreeEditorLayout` +4. Update `index.ts` exports +5. Verify no dead imports remain + +### Verification + +- Flow mode toolbar shows "Metadata" button +- Code mode toolbar does NOT show "Metadata" button +- Click Metadata → panel opens. Switch to Code → panel auto-closes +- Undo/redo/save/publish/validate all still work +- Run `npm run build` — no TypeScript errors + +--- + +## Critical Files Summary + +| File | Action | Phase | Notes | +|------|--------|-------|-------| +| `TreeCanvasNode.tsx` | Create | 1 | Inline-editing card with local draft + commit model | +| `TreeCanvas.tsx` | Create | 2 | Main canvas orchestration with full state model | +| `MetadataSidePanel.tsx` | Create | 3 | 320px right slide-in overlay wrapping TreeMetadataForm | +| `NodeFormDecision.tsx` | Refactor | 3 | Option reorder through onUpdate (remove store write) | +| `NodePicker.tsx` | Refactor | 3 | Add `allowCreate` prop (default true) | +| `TreeEditorLayout.tsx` | Modify | 3 | Replace 60/40 split with full-width canvas + overlay | +| `TreeEditorPage.tsx` | Modify | 4 | Add metadata panel toggle, auto-close on Code switch | +| `index.ts` | Update | 4 | Export new components, keep legacy exports | +| `treeEditorStore.ts` | No changes | — | Store logic is solid as-is | +| `NodeList.tsx` | Keep (legacy) | — | Removed from active Flow path | +| `NodeEditorModal.tsx` | Keep (legacy) | — | Removed from active Flow path | +| `TreePreviewPanel.tsx` | Keep (legacy) | — | Removed from active Flow path | + +--- + +## Assumptions & Defaults + +- **Link rendering:** Tree-first (no full cross-canvas graph lines for shared targets) +- **Inline edit commit:** On checkmark save (local draft, cancel discards) +- **Metadata drawer:** Flow mode only +- **Expanded card policy:** One expanded card at a time +- **Legacy components:** Remain in repo, removed from active Flow path only +- **Connector lines:** CSS borders (not SVG — simpler, matches existing patterns) + +--- + +## Testing + +### Automated Tests + +**Files:** `TreeCanvas.test.tsx`, `TreeCanvasNode.test.tsx` + +| Test Case | What It Verifies | +|-----------|-----------------| +| Select node expands and scrolls card | Selection sync works | +| Inline save commits store updates | Draft → store pipeline works | +| Cancel on new node triggers deletion | Unsaved node cleanup works | +| Add child from decision option sets `next_node_id` on save | Pending link resolution works | +| Delete node clears inbound references | Reference cleanup works | +| Sibling drag reorder calls `reorderNodes` | Drag-and-drop wiring works | +| `allowCreate={false}` hides create options in NodePicker | Form safety works | +| Option reorder in inline card does NOT write to store | Draft isolation works | + +Run: `npm run build && npm run test` (or targeted vitest files) + +### Manual Acceptance Checklist + +- [ ] Create a new tree — canvas shows root START card +- [ ] Click root card — expands inline with decision fields (no modal appears) +- [ ] Fill in question, add 2 options, click ✓ — saves inline, see branch lanes +- [ ] `+ Add child` buttons appear below each option +- [ ] Add a child node inline — verify parent link is set correctly +- [ ] Cancel a new unsaved node — confirm it is deleted from the tree +- [ ] Delete a node that is referenced by another — confirm references are cleaned +- [ ] Drag to reorder sibling nodes +- [ ] Open Metadata panel — edit metadata — close panel (button, backdrop, Escape) +- [ ] Validate and Publish — confirm tree saves correctly +- [ ] Switch Flow → Code mode — metadata panel auto-closes, Code mode works normally +- [ ] Switch Code → Flow mode — canvas renders correctly +- [ ] Run `npm run build` — no TypeScript errors +- [ ] Run `npm run test` — all tests pass + +--- + +## Git Strategy + +- Branch: `feature/tree-editor-canvas` +- One commit per phase (4 commits total) +- Commit messages: + 1. `feat: Add TreeCanvasNode inline editor card component` + 2. `feat: Add TreeCanvas layout with visual branching and orchestration` + 3. `refactor: Update forms for inline safety, add MetadataSidePanel, update layout` + 4. `feat: Wire toolbar metadata toggle and update exports` +- PR when all 4 phases pass `npm run build && npm run test` +- Include `Co-Authored-By: Claude Sonnet 4.5 ` + +--- + +## Notes for Implementation + +1. **Read existing code first:** Before creating any new file, read the files listed in the Reusable Code Inventory to understand current patterns and prop interfaces +2. **Follow existing patterns:** Match the component structure, Tailwind usage, and TypeScript conventions already in the tree-editor directory +3. **Dark mode:** All new components must support light/dark themes via existing Tailwind classes +4. **Keyboard navigation:** Support Escape to close metadata panel, Tab through form fields in expanded cards +5. **Loading states:** Canvas should handle the case where `treeStructure` is null (show empty state) +6. **No store changes:** The `treeEditorStore.ts` should NOT be modified. All new state is local to the canvas components +7. **Test each phase independently:** Each phase should leave the app in a buildable, testable state before moving to the next diff --git a/docs/plans/MASTER-PLAN-editor-ux-fixes.md b/docs/plans/MASTER-PLAN-editor-ux-fixes.md new file mode 100644 index 00000000..d949ecf9 --- /dev/null +++ b/docs/plans/MASTER-PLAN-editor-ux-fixes.md @@ -0,0 +1,745 @@ +# 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.