Skip to content

dnd: decouple target classification from the orchestrator#54

Merged
avanelsas merged 2 commits into
mainfrom
feature/dnd-classify-drop-target
May 12, 2026
Merged

dnd: decouple target classification from the orchestrator#54
avanelsas merged 2 commits into
mainfrom
feature/dnd-classify-drop-target

Conversation

@avanelsas
Copy link
Copy Markdown
Owner

Summary

Closes the final audit item (#7). `bareforge.dnd.drag/resolve-target!` was 78 lines of cond braiding four concerns — DOM walks, geometry classification, strip-mount logic, and highlight mutation — and resolving "which slot did the user drop into?" via an imperative self-recursion at the old line 437. The recursion existed because mounting slot strips inserts new DOM under the cursor, and the second pass needed to pick the freshly-mounted strip up via `element-under-cursor`; the `re-entered?` flag bounded the depth.

This refactor extracts the decision into a new pure namespace `bareforge.dnd.target` and rewires drag.cljs as a thin orchestrator. The recursion is gone.

Two commits

  1. `dnd: extract classify-drop-target as pure target classifier` — creates `src/bareforge/dnd/target.cljs` with two pure functions: `classify-position` (moved from drag.cljs) and the new `classify-drop-target`. The classifier consumes a snapshot map describing what the cursor sees and returns a tagged action — `:slot-row` / `:layer-row` / `:canvas-element` / `:needs-strips` / `:invalid`. drag.cljs continues to drive the state machine; the test file `drag_test.cljs` is replaced by `target_test.cljs` with the original 11 `classify-position` cases plus 13 new classifier cases against hand-curated snapshots.

  2. `dnd: rewrite resolve-target! to dispatch on the classifier result` — splits `resolve-target!` into three small effectful helpers (`collect-snapshot`, `apply-result!`, plus the dispatcher itself) over the pure classifier. The two-pass strip-mount loop is an explicit branch at the call site rather than a recursive call buried in an argument; the classifier's `:needs-strips` precondition (`not= id strip-host-id`) bounds it to exactly two iterations by construction.

Notable contract details

  • Classifier never returns `:needs-strips` when strips for the hovered id are already mounted. This is what bounds the orchestrator's two-pass loop. The legacy code enforced this at line 432 with the same `(not= id (slot-strips/current-host-id))` check; lifting it into the classifier makes the property explicit and testable.
  • `:hide-stale-strips?` rides on the result, not derived effectfully. The `:slot-row` branch flags when stale strips need cleanup (an Inspector slot row hovered while canvas strips for a different node are still mounted). Same observable behaviour as the legacy lines 403–405; the decision is now in the classifier.
  • `container?` true + `render-strips?` false (e.g. `x-button`) — classifier returns `:canvas-element :inside`, never `:needs-strips`. Pinned by a dedicated test.
  • `node-position` is gone — its work (BCR read + tag lookup + `classify-position` call) is now inlined into `collect-snapshot` since the snapshot already carries `:hovered-rect` and `:container?`.

File deltas

  • `src/bareforge/dnd/target.cljs` — new, 148 lines.
  • `src/bareforge/dnd/drag.cljs` — 809 → 837 lines (+28). The five-helper shape is wordier than the original braided cond; the win is structural.
  • `test/bareforge/dnd/target_test.cljs` — new, 24 tests.
  • `test/bareforge/dnd/drag_test.cljs` — deleted (tests moved to `target_test.cljs`).

Test plan

Out of scope (audit follow-ups)

  • `drag-state` JS-object → atom conversion. Same shape as the `inline_edit` refactor that was reverted due to the defonce hot-reload trap. Independent refactor.
  • Marquee geometry helpers (`rects-overlap?`, `marquee-hits`) — unrelated to drop classification, stay in drag.cljs.

🤖 Generated with Claude Code

avanelsas and others added 2 commits May 12, 2026 15:32
bareforge.dnd.drag's resolve-target! mixed DOM walks, geometry
classification, and effectful side-effects (highlight mutations,
strip mount, self-recursion). Pull the *decision* — which of slot-
row / layer-row / canvas-element / needs-strips / invalid does
this gesture target — out into a new pure namespace,
bareforge.dnd.target, that consumes a snapshot map and returns a
tagged result. drag.cljs continues to drive the state machine
(next commit will rewire resolve-target! to use the classifier).

The classifier is unit-tested in target_test.cljs against hand-
curated snapshots covering each branch of the legacy resolver:
- :invalid (outside canvas; stale canvas-element id)
- :canvas-element :before / :after / :inside
- :canvas-element :inside on a container? true / strips? false tag
  (e.g. x-button) — must not return :needs-strips
- :needs-strips with strip-host-id nil (first hover)
- :needs-strips with strip-host-id pointing at a different node
  (stale strips need replacement)
- :canvas-element :inside when strip-host-id equals hovered id
  (idempotency under stationary cursor — prevents flicker)
- :slot-row with :hide-stale-strips? true / false
- :layer-row with position classification

classify-position moves from drag.cljs to dnd/target with it.
drag_test.cljs is dropped — its 11 tests are reborn in target_test
alongside 13 new classifier tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolve-target! braided four concerns — collect cursor + DOM
context, decide which target type the gesture lands on, decide
whether strips need mounting, apply highlights — into one 78-line
cond with a self-recursive branch. The recursion existed because
mounting slot strips inserts new DOM under the cursor and the
second pass needs to pick the freshly-mounted strip up via
element-under-cursor; the `re-entered?` flag bounded the depth.

Split into three small effectful helpers plus a thin dispatcher:

- `collect-snapshot` reads the DOM / state / slot-strip host once
  and returns a Clojure map the classifier consumes.
- `target/classify-drop-target` (pure, previous commit) decides.
- `apply-result!` projects the tagged result onto drag-state +
  DOM, with no business logic of its own.
- `resolve-target!` runs collect → classify → if :needs-strips,
  mount strips → re-collect → re-classify → apply. The two-pass
  branch is explicit at the call site instead of a recursive call
  buried in a `re-entered?` argument, and the classifier's
  `:needs-strips` precondition (`not= id strip-host-id`) bounds
  the loop to exactly two iterations.

`node-position` is gone — its work (BCR read + tag lookup +
classify-position call) is inlined into `collect-snapshot` since
the snapshot already carries `:hovered-rect` and `:container?`.

drag.cljs grows ~28 lines net because the five-helper shape is
wordier than the original braided cond, but the wins are
structural: the decision is pure and unit-tested, and the strip-
mount lifecycle is visible at the call site rather than buried in
a recursive function argument. Manual smoke tests per the plan's
verification section pending before opening the PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@avanelsas avanelsas merged commit 619e35a into main May 12, 2026
1 check passed
@avanelsas avanelsas deleted the feature/dnd-classify-drop-target branch May 12, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant