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
+11 -14
View File
@@ -2704,26 +2704,23 @@ public final class BoardStore: HealHost {
/// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop").
/// `created` survives because a copy is a fork `CopyStamps.fork`, the same stamps paste uses.
///
/// **The ranks are placed among the lane's *full* rendered set**, not among the set with the
/// dragged members removed the one place a copy's arithmetic differs from a move's. The
/// originals are lifted out of the layout for the duration of the drag whatever the effective
/// operation is ( can be pressed and released mid-drag; a layout that re-admitted them on every
/// flip would flap the whole board), but they are still *on disk* holding their ranks, and a
/// rank chosen in the gap they appear to have vacated would collide with them the instant they
/// reappear. So the drop's index is mapped through to the neighbour it names the card the run
/// lands in front of and the rank is taken there.
/// **`index` is counted among the lane's *full* rendered cards the originals included** and
/// that is the one place a copy's index space differs from a move's (`moveCards` counts among
/// the cards its run vacates). It is not a special case bolted on here: a copy's originals stay
/// in the source board's resting layout for the whole drag (`DragSession.hiddenMembers`, ruled
/// 2026-08-01), so the zones the shadow was resolved against already counted them, and the
/// geometry's number is this method's number unrewritten.
///
/// The ranks fall out of the same fact. The originals are still on disk holding their ranks, so
/// a rank chosen in a gap they had apparently vacated would collide with them the instant they
/// reappeared and with the index counted among them there is no such gap to choose in.
public func copyCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
guard let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return }
let members = draggedCards(ids)
guard !members.isEmpty else { return }
let rendered = destination.cards
let remaining = rendered.filter { !ids.contains($0.id) }
let target = min(max(0, index), remaining.count)
// The resting-layout index, re-read against the layout the originals are still part of.
let placement = target < remaining.count
? (rendered.firstIndex { $0.id == remaining[target].id } ?? rendered.count)
: rendered.count
let placement = min(max(0, index), rendered.count)
let root = rootURL
let laneFolder = ItemPath.lane(laneID).folder(under: root)