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
+13 -11
View File
@@ -369,13 +369,15 @@ struct TrashDragRestoreTests {
let store = try BoardStore(rootURL: fixture.root)
let original = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
store.restoreByDrag(cardID: card2, intoLane: lane1)
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 1)
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(!after.contains("deleted"))
// The `order` is untouched, so the card returns where it was rather than at the bottom
// the position-perfect restore a pure-view trash makes possible.
// Dropped at index 1 past lane one's single live card, which is exactly where the card's
// recorded 2048 already puts it. The rank the drop names and the rank on disk agree, so no
// `order` is written at all: the `order` line comes through byte-for-byte and the card
// returns where it was, the position-perfect restore a pure-view trash makes possible.
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
#expect(untouchedLines(after) == untouchedLines(original))
}
@@ -386,14 +388,14 @@ struct TrashDragRestoreTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardID: card2, intoLane: lane2)
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card2)"))
let after = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
#expect(!after.contains("deleted"))
// m5-drag: the positional drop replaces this append. Lane two's one visible card is at
// 1024, so the arrival lands at 2048 the Writer's own append over visible siblings.
// Dropped at index 1 lane two's one visible card is at 1024, so the drop's own rank is
// the append 2048, carried by the move rather than left to the Writer to compute.
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
let model = try BoardLoader.load(boardRoot: fixture.root).model
@@ -413,13 +415,13 @@ struct TrashDragRestoreTests {
// A tombstoned destination lane is never a drop target (04 Drag and drop: "a card is
// never filed under a `deleted:` parent").
store.restoreByDrag(cardID: card2, intoLane: lane3)
store.restoreByDrag(cardID: card2, intoLane: lane3, at: 0)
// A lane that is not on the board at all.
store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless))
store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless), at: 0)
// A card that is not a trash row: live, and for `card4` hidden by its lane rather than
// by its own flag, so it has no row to drag in the first place.
store.restoreByDrag(cardID: card1, intoLane: lane2)
store.restoreByDrag(cardID: card4, intoLane: lane1)
store.restoreByDrag(cardID: card1, intoLane: lane2, at: 0)
store.restoreByDrag(cardID: card4, intoLane: lane1, at: 0)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashedCard.modified)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == liveCard.modified)
@@ -434,7 +436,7 @@ struct TrashDragRestoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
store.restoreByDrag(cardID: card2, intoLane: lane2)
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1)
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:"))