From fc9a5d02a01159af25f9b2e52b76d86b25f3200f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 14:02:29 -0400 Subject: [PATCH] MoveTool: dispatch by capabilities.movable, not nodeRegistry.has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: after wall registered, the move tool's smart sims-style arrow UX (endpoint handles + linked-wall corner cascade + ALT-detach) was replaced by a generic whole-wall-translate. The dispatch was unconditionally routing every registered kind through MoveRegistryNodeTool — but MoveRegistryNodeTool is for kinds whose move semantics are "translate position on X/Z plane" (shelf, spawn, single-position items). Wall / fence / slab / stair endpoint drags are bespoke and need their legacy movers until each gets a proper DragAction-based affordance port. Fix: gate the registry-mover dispatch on `def.capabilities.movable`. When a kind opts in (`movable: { axes, gridSnap }`), use the generic mover; when a kind omits the capability deliberately (wall and fence do), fall through to the legacy per-kind branch below. This is the registry-aware analogue of "the registry doesn't limit custom behavior — it lets kinds opt in to generic dispatch". Adding `movable` is an opt-in; omitting it is an opt-out. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/tools/item/move-tool.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index b7eddca1..1eaffb0c 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -103,13 +103,22 @@ export const MoveTool: React.FC<{ if (!movingNode) return null - // Registry-first dispatch. Any kind registered via @pascal-app/nodes - // gets the imperative MoveRegistryNodeTool (smooth, framerate-locked - // motion via sceneRegistry — see plan: "Validated patterns from the - // spike"). The legacy per-kind movers below are short-circuited for - // any kind in the registry — that's how Phase 5 will progressively - // delete them as kinds migrate. - if (nodeRegistry.has(movingNode.type)) { + // Capability-driven dispatch. A registered kind opts INTO the generic + // mover by declaring `capabilities.movable` — that's the "I'm a simple + // translate-on-the-X/Z-plane node" signal (shelf, spawn, future + // single-position items). Kinds with bespoke move semantics (wall + // endpoint drag + linked-wall corner cascade + ALT-detach, fence + // endpoint drag + curve sagitta, slab polygon vertex edit, stair + // endpoint drag, etc.) deliberately OMIT `capabilities.movable` so + // this branch falls through to their legacy per-kind movers below. + // + // Without this guard, every registered kind would be force-routed + // through MoveRegistryNodeTool's "translate position" pattern, + // breaking wall / fence / slab / stair endpoint UX (the smart + // sims-style arrows that move the dragged endpoint while cascading + // to linked walls / re-anchoring hosted children / etc.). + const def = nodeRegistry.get(movingNode.type) + if (def?.capabilities?.movable) { return }