Redraw a copy's originals at the source — the resting layout follows the operation

The modifier flip (⌥ copy / ⌘ move) now reflows the source board once:
a copy re-admits the dragged originals into the resting layout, standing
dimmed in place, and a move lifts them out as before. This retires
DRAG-REORDER.md's operation-blind carve-out and makes 04-interactions.md's
"originals stay" true in flight, not just at echo. The one seam is
DragSession.hiddenMembers reading the observed operation.

Consequences carried honestly: copyCards now takes its index in the
lane's full rendered space (the zones counted the originals, so the
geometry's number is the writer's number — the old neighbour remap is
deleted); resolveOperation freezes under the committed hold exactly as
propose does, fixing a real bug where a settled within-board ⌥-copy drew
the move arrangement until the echo and visibly re-shuffled. Stationary
flips still wait for the next dropUpdated (their own Backlog card).

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 08:07:54 -04:00
parent adf4fc5973
commit 8206a78ecc
9 changed files with 383 additions and 51 deletions
+22
View File
@@ -168,6 +168,15 @@ struct BoardDropContext {
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
/// propose over the column is not a slot at all it is the column's own target answering
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
///
/// **These zones never see a re-admitted lane, and that is an invariant rather than an
/// accident.** `hiddenMembers` now leaves a copy's originals in the resting layout, but a lane
/// can only be re-admitted into the strip it was picked up from, and a lane drag over its own
/// board never resolves to `.copy` (`DragLocality.operation`'s first carve-out). A cross-board
/// lane copy does re-admit into the **source** strip, while the cursor is over the foreign
/// board, where no zone of this board's is being consulted at all. So the strip's slot list is
/// still "the lanes minus the run" every time this function actually runs, and `moveLanes` /
/// `receiveLanes` keep the index space they always had.
func retargetLanes() {
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
@@ -191,6 +200,13 @@ struct BoardDropContext {
/// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
/// driver all go through "the lane's drop delegate and the autoscroll driver must go through
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
///
/// **The zones are built over whatever `hiddenMembers` leaves standing**, which is what makes
/// the proposal's index space follow the operation for free: a within-board move counts slots in
/// the lane minus the dragged run, a within-board -copy counts them in the lane *including* the
/// originals, because that is the layout the user is looking at. `DropSlotMath` has no opinion
/// on the matter the caller decides what the resting layout contains and the commit's two
/// writers are counted in exactly these two spaces (`BoardStore.moveCards` / `copyCards`).
func retargetCards(inLane laneID: ItemID) {
guard session.isDraggingCards, let cursor = globalCursor() else { return }
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
@@ -644,6 +660,12 @@ struct BoardDropContext {
// restore *is* the within-board move and a cross-board restore *is* the ordinary
// arrival which is exactly what retiring the restore-specific machinery bought.
if within {
// **Each writer is counted in the layout its own gesture was showing.** The zones
// that produced `target.index` were built over the resting layout for *this*
// operation (`retargetCards` `DragSession.hiddenMembers`): a move's excludes the
// dragged run, a copy's includes the originals, because a copy leaves them there.
// So the number goes to each writer unrewritten and the drop lands where the shadow
// was the two index spaces differ, and neither writer has to guess which it got.
if operation == .copy {
store.copyCards(Set(ids), toLane: laneID, at: target.index)
} else {