# Freeform Board — implementation blueprint Vetted by a 13-agent design panel (winner: reuse-robust, with freeform-ux box-cards and the editorScrollView-nesting fix grafted in). ## Core decisions - **Board = 4th WorkspaceController.Mode** (`editor`, `split`, `board`). Canvas mode is ABSORBED into the board — the board's shapes layer is the drawing surface. - **The magnifying `boardScroll` is the only scroll view.** Cards are bare views (NSTextView / NSImageView / CALayer box). NEVER nest `editorScrollView` inside it. - **Main card hosts the document's real `textView`** (reparented bare, no scroll view), so dirty/undo/find/line-col all keep working. Its text = the `.txt` bytes (never serialized to the sidecar — no second copy can diverge). - **Secondary text cards** are board-only annotations; their text lives in the sidecar, not the `.txt`. - **Clean boxes** = `BoardCard(kind: .box)` CALayer cards (fill `accent@0.18`, stroke `accent`, cornerRadius 8). Freehand pen/shapes stay on the reused `DrawCanvasView`. ## View hierarchy (Sources/BoardContainer.swift) ``` BoardContainerView : NSView (sibling of splitView; same 4 pins; isHidden=true) ├─ palette : NSStackView (left, fixed width) Select/Box/Pen/Line/Arrow/Oval/Eraser, │ stroke well, width slider, New Text Card, Add Image…, Portrait/Landscape, │ zoom-to-fit, undo/redo, export PNG, send-to-tailnet (pinned, NOT zoomed) └─ boardScroll : NSScrollView allowsMagnification, min .15 / max 4 └─ boardView : BoardView (NSView, isFlipped, wantsLayer, world 8000×6000, dot grid) ├─ shapes : DrawCanvasView (reused, canvasBackground=.clear, bottom) ├─ cardViews : [CardView] by z (TextCardView / ImageCardView / BoxCardView) └─ selectionOverlay : SelectionOverlayView (8 handles, top, forwards events) ``` ## Data model (Sources/Board.swift), Codable - `BoardCardKind { mainText, text, image, box }` - `PageOrientation { portrait(612×792), landscape(792×612) }` - `BoardCard { id, kind, frame(world), z, text?, orientation?, imageFile?, fillHex?, strokeHex?, cornerRadius?, title? }` - `BoardDoc { version, cards, shapes: DrawCanvasView.CanvasDoc, magnification, scrollOrigin, boardBackgroundHex? }` - Hand-written `init(from:)` that `try?`-decodes each field with a default → missing/old sidecar never throws. ## Persistence: bundle dir `foo.txt.board/` - `board.json` (atomic write via replaceItemAt, pretty+sortedKeys) - `img-.png` assets (referenced by filename; bytes never in JSON; GC orphans on save) - `preview.png` (derived, for send-to-tailnet) - Load after Document.swift:41; save after Document.swift:121, all `try?`-guarded so a sidecar failure NEVER fails the plaintext save. Main card text is never written here. - Migration: if `foo.txt.board/` absent but `foo.txt.canvas.json` exists, import its CanvasDoc into `BoardDoc.shapes` (one-way, leaves the old file). ## Focus transition - Enter: double-click text card OR Return on selection → record preFocus mag/visibleRect, raise z, animate `boardScroll.animator().setMagnification(clamp 1.0–1.5, centeredAt:)`, textView editable+selectable, makeFirstResponder(textView). - Exit: Esc / click empty / select other / leave mode → breakUndoCoalescing(), copy secondary card text back + updateChangeCount, editable=false, makeFirstResponder(boardView), animate back. - **Mandatory:** CardView.hitTest returns self while unfocused (a non-selectable NSTextView still returns self for a plain click). Shapes layer hit-tests only when a draw tool active. ## Must-handle pitfalls 1. Reparent only the bare textView, never editorScrollView (the #1 sleeper bug). 2. rulersVisible=false in board; restore on exit; keep editorScrollView alive. 3. Gate applyWrap when mode==.board (it reads editorScrollView.contentSize). 4. Return editor to splitView via insertArrangedSubview(at:0), never add (pane order). 5. NSScrollView.allowsMagnification only — no manual CGAffineTransform. convert(from:nil) keeps working. 6. CardView hitTest override (above). 7. Centralize textView reparenting in attachTextViewToBoard()/detachTextViewToEditor(); one superview owns it; never recreate it. Test editor→board→editor→split round-trip FIRST. 8. shapes DrawCanvasView canvasBackground=.clear (it opaque-fills bounds otherwise). 9. Two undo managers (text=document, structural=boardView); every structural mutation also updateChangeCount(.changeDone). 10. Board PNG export at magnification 1.0 over the union of content, not the 8000×6000 world. 11. Re-resolve CALayer cgColors in applyAppearance() (they don't auto-update on appearance change). 12. saveBoardSidecar wrapped in try?, after super.save succeeds. ## MVP cuts (defer) connectors, marquee multi-select, alignment guides (keep grid-snap), card snapshotting, interleaved shape/card z, multi-page-into-one-.txt, JSON forward-merge, QuickLook plugin.