Design Document: Spatial Workspace Architecture Refactor Status: Draft Target System: Desk/Workspace (Canvas, Dragging, Physics) Primary Goal: Decompose "God Objects" into a composable, layered architecture to improve performance, maintainability, and testability. 1. Executive Summary The current implementation relies on a monolithic class (WorkspaceEngine) and an overloaded hook (useDeskPointer). This coupling forces React to handle high-frequency logic (physics/drag), resulting in brittle code and potential performance bottlenecks. The Proposal: Transition to a Layered Architecture. We will separate "Pure Math" (Physics/Geometry), "Mutable State" (Performance), and "React Interaction" (Events/Business Logic). 2. Architectural Overview We will adopt a unidirectional, event-driven flow for interactions, bypassing React's render cycle for high-frequency updates (dragging/animating), while using React for low-frequency updates (selection/mounting). The Four Layers The Physics Layer (Core): Stateless, pure functions for geometry and kinetics. The Scene Layer (Store): A lightweight, mutable registry that holds the "truth" of layout (x, y, rotation) and manages direct DOM updates. The Interaction Layer (Hooks): React hooks that bind DOM events to the Scene Layer. The Persistence Layer: An asynchronous observer that syncs the Scene Layer to the Backend/DB. Code-Snippet graph TD User[User Input] -->|Pointer Events| Interaction[Interaction Layer Hooks] Interaction -->|Calculate| Physics[Physics Layer Pure Math] Interaction -->|Update| Scene[Scene Layer Mutable Store] Scene -->|Direct Manipulation| DOM[DOM Elements 60fps] Scene -.->|Debounced Snapshot| DB[Persistence Layer] 3. Detailed Component Design Layer 1: Physics & Geometry (lib/spatial) Responsibility: Pure math. No side effects. No DOM references. Key Modules: geometry.ts: Hit testing, polygon intersection, coordinate projection (Screen <-> Canvas). kinetics.ts: Inertia decay, angular velocity calculation, clamping. Benefit: 100% Unit testable without mocking the DOM. Layer 2: The Scene Store (lib/scene) Responsibility: High-performance state management. It acts as the bridge between React and the DOM. Structure: TypeScript class SceneStore { // Fast lookups items: Map; // Updates DOM style immediately, skips React render updateItem(id, transform) { ... } // Used by Persistence Layer getSnapshot() { ... } } Why: React State is too slow for 60fps drag interactions on complex DOM trees. We need direct manipulation. Layer 3: Interaction Hooks (hooks/) We split the "God Hook" (useDeskPointer) into specific responsibilities. usePointerGesture: Role: The "driver." Handles down, move, up, cancel. Logic: Manages drag thresholds, long-press timers, and distinguishing taps from drags. Output: Emits high-level events: onTap, onDragStart, onDrag, onDragEnd. useSpatialQuery: Role: The "eyes." Logic: Wraps lib/spatial. Given an event (x, y), returns [DocID, StackInfo]. useDragController: Role: The "business logic." Logic: Listens to usePointerGesture. When a drag starts: Locks the React View (prevents re-renders). Calculates physics via lib/spatial. Pushes updates to SceneStore. On release, triggers inertia animation loop. Layer 4: Persistence (Observer) Role: Syncs the mutable SceneStore back to the database. Mechanism: Subscribes to onDragEnd or an internal dirty flag in the Store. Uses a debounce strategy (e.g., wait 500ms after last movement) to save to the backend. 4. Data Flow Scenarios Scenario A: Selecting a Card User: Clicks on a card. usePointerGesture: Detects pointerDown + pointerUp (no movement). Fires onTap. useDeskSelection: Receives onTap. Checks event.metaKey. Updates React State (setSelectedIds). React: Re-renders to show selection border. Scenario B: Dragging a Card (The Performance Path) User: Presses and moves mouse > 5px. usePointerGesture: Fires onDragStart. useDragController: Calculates initialOffsets. While moving: Calculates new x, y, rotation (using Physics Layer). Calls SceneStore.updateItem(). Result: The DOM element moves via CSS Transform. React does not re-render. User: Releases mouse. useDragController: Fires onDragEnd. Starts Inertia Animation loop (updating SceneStore via requestAnimationFrame). Persistence: Detects end of movement, saves new coordinates. 5. Migration Strategy We will apply the Strangler Fig Pattern: replacing pieces of the monolith gradually. Phase 1: Math Extraction (Safe) Extract geometry/physics logic from WorkspaceEngine and pointerUtils into pure functions in lib/spatial. Risk: Low. Phase 2: The Gesture Hook (Cleanup) Implement usePointerGesture. Replace the event listeners in useDeskPointer with this hook. Risk: Low. Phase 3: The Scene Store (Core Replacement) Build SceneStore. Modify useDocumentDrag to write to SceneStore instead of WorkspaceEngine. Risk: Medium. Visual synchronization bugs might occur during transition. Phase 4: Persistence Decoupling Move loadPersistedLayout and upsertLayoutRecords out of the Engine and into a specialized React Effect or standard async function triggered by the Store. 6. Comparison: Old vs. New Feature Old Architecture New Architecture State Monolithic Class (WorkspaceEngine) Mutable Store (SceneStore) + React State Dragging Mixed into Engine & Hooks Isolated Controller Hook Physics Hardcoded in Engine Pure Functional Module DOM Access Cached Refs inside Engine Direct management via Store Testing Difficult (Mocking Engine required) Easy (Test Physics/Store in isolation) 7. Open Questions / Risks Z-Index Management: Currently handled by zCounter in the Engine. The SceneStore must maintain a global Z-index counter to ensure "Bring to Front" works reliably. ** React Context vs. Global Singleton:** Should SceneStore be a global singleton or provided via Context? Decision: Context. This allows multiple independent Workspaces on one screen if needed in the future.