Build the drop-slot model and the drop commits — drag & drop, first half

The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md
travels with it, rewritten for lanes, the interior masonry, multi-drag,
cross-board sessions, the re-grounding trio, and the committed-overlay hold):

- DropSlotMath — resting-layout zones from analytic lane arithmetic and the
  pure masonry placement (MasonryLayout now lays out through the same
  MasonryPlacement the drag reads, so geometry cannot drift), span-capped
  triggers sized to the dragged run's future footprint, hysteresis holds with
  the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold.
- DragAutoScrollMath — the activation bands and velocity ramp, pure.
- The drop commits, one performWrite bracket each: moveCards/copyCards within
  a board (insertion ranks touch only the dragged cards; renumber fallback);
  receiveCards/receiveLanes/receiveRestoredCards on the destination store for
  cross-board copy and ⌘-move with the import-boundary remint, lane copies
  stripping tombstoned cards while moves carry them; restoreByDrag is now
  positional, writing order only when the drop names a new one.

Gestures, sessions, previews, and delegates are the second half.

773 unit tests (87 new since the keyboard grammar).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 20:10:24 -04:00
parent 4035ba7986
commit 21a5a6dbfd
14 changed files with 2641 additions and 55 deletions
+7 -1
View File
@@ -396,7 +396,13 @@ private struct TrashEntryRow: View {
// A drop over anything but a live lane the trash itself, a gap, the outer margin
// writes nothing. There is no replica to snap back; the row never left.
guard let lane = drag.laneUnder(value.location.x) else { return }
store.restoreByDrag(cardID: entry.id, intoLane: lane)
// m5-drag phase 2: the drop position comes from `DropSlotMath.cardSlot` once this
// gesture is replaced by the real drag session. Until then the interim is the
// destination lane's bottom, which is the index past its last rendered card.
let bottom = store.snapshot.lanes
.first { $0.id == lane }?
.cards.filter { !$0.isDeleted }.count ?? 0
store.restoreByDrag(cardID: entry.id, intoLane: lane, at: bottom)
}
}