The legacy CurveFenceTool ignored grid:click for 150ms after mount —
otherwise the very click that activates a tool (e.g. the floating menu
"curve" button) cascades through the R3F drei <Html> portal into the
grid, fires grid:click on the just-mounted tool, and commits the drag
before any preview move runs. The new useDragAction was missing this
guard, so the Stage D fence curve port "click → place sfx → exit"
without ever letting the user adjust.
Adds `activationGraceMs` (default 150) on useDragAction; ports the
sfx:item-place commit emission into FenceCurveTool so the kind-owned
tool matches legacy UX. Same guard will cover the upcoming Stage D
ports (endpoint move, whole-fence move, placement, plus
slab/ceiling/wall D).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure orchestrator in core, thin React wrapper in editor:
- `core/services/drag-session.ts` — `createDragSession(action, scene,
options)` returns an imperative session with `start / move / commit
/ cancel / dispose / isActive / getDraft`. Pauses history on start,
resumes on terminate. Per-move runs preview → snap → apply, then
cascades dirty marks via the relations resolver (deduped across
ticks). Re-entry guard, idempotent dispose, fires onCommit/onCancel
callbacks. All tested in bun:test — no React needed.
- `editor/src/hooks/use-drag-action.ts` — wraps the session with the
editor's grid-event emitter and an Esc-to-cancel keyboard listener.
Builds a `SceneApi` once via `createSceneApi(useScene)` at module
init. The hook itself is small enough to read top-to-bottom; all
behavior lives in the session.
Tests (13 cases) cover the hard parts: history pause/resume bracket,
explicit cancel restoring all touched nodes, dispose mid-drag, commit
returning false short-circuiting to cancel, snap callback wired in,
re-entry rejected, deduped dirty-mark across multiple move ticks,
hosts cascade from the registry firing in apply.
No callers yet — Phase 2 column and shelf tools are the first
consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>