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
+45 -14
View File
@@ -379,18 +379,33 @@ final class DragSession {
///
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
/// card to it.
/// card to it. Those rows dim in place instead (`CardFaceView`, `TrashLaneRowView`), which is
/// the same treatment a re-admitted board original now wears one vocabulary for "this is the
/// source of the drag", wherever the source stays visible.
///
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
/// Resting-layout zones): can be pressed and released mid-drag, and a layout that re-admitted
/// the originals on every modifier flip would flap the whole board under the cursor. The copy's
/// originals reappear when the write lands the hold keeps them lifted for that round trip, so
/// the arrangement on screen is the one the release proposed and stays still until the echo.
/// **A move lifts the run out; a copy leaves it in** (04-interactions.md Drag and drop:
/// "originals stay", for the within-board -drag and for the cross-board default alike ruled
/// 2026-08-01, retiring the operation-blind carve-out this used to be). The layout on screen is
/// therefore always the arrangement the release would produce: a copy's source board shows what
/// it will still hold, a move's shows what it will have given away. Flipping or mid-drag
/// reflows the source board once, and that one-shot reflow *is* the feedback the modifier is
/// asking for the previous rule's fear of "flapping" traded the answer to "what will this
/// drop leave behind?" for a stillness nobody asked for.
///
/// Reading `operation` here is what makes the flip visible at all: the property is observed, so
/// every surface that builds a resting layout off this method re-renders when it changes. The
/// flip lands on the next `dropUpdated`, since that is where the operation is re-resolved; a
/// modifier pressed with the mouse perfectly still waits for the next motion (Backlog
/// Stationary modifier flips).
///
/// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
/// has settled: a settled copy keeps its originals on screen and a settled move keeps them
/// lifted, in both cases until the echo snapshot lands and the real faces take over.
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
guard isActive, container == .board, let sourceRoot,
DragLocality.isSameBoard(root, sourceRoot)
else { return [] }
return memberSet
return operation == .copy ? [] : memberSet
}
/// The proposal's index when it names this board's strip, else `nil` the lane strip's shadow
@@ -575,14 +590,27 @@ final class DragSession {
/// Re-resolves the effective operation against the board under the cursor and the modifiers
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
///
/// **A settled release freezes it**, `propose`'s guard for a reason that is now the same one:
/// the operation decides what the source board's resting layout holds (`hiddenMembers`) as well
/// as which write went out, so a stray `dropUpdated` arriving after the commit with the user's
/// finger already off would re-lift originals the write is about to leave in place. The
/// write named an operation; the overlay draws that operation until the echo lands.
///
/// `modifiers` defaults to the live flags, which is the only thing the app ever passes; it is a
/// parameter so the resolution is checkable without a keyboard, exactly as `DragLocality`'s own
/// pure function is.
@discardableResult
func resolveOperation(destinationRoot: URL) -> TransferOperation {
guard let kind, let sourceRoot else { return operation }
func resolveOperation(
destinationRoot: URL,
modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags
) -> TransferOperation {
guard let kind, let sourceRoot, hold == nil else { return operation }
let resolved = DragLocality.operation(
kind: kind,
container: container,
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
modifiers: NSEvent.modifierFlags
modifiers: modifiers
)
if resolved != operation { operation = resolved }
return resolved
@@ -609,10 +637,13 @@ final class DragSession {
/// Enters the committed phase: the arrangement the session was showing stays on screen until
/// `store` applies its next snapshot (`CommittedHold`).
///
/// Everything that drives the rendering the members, the proposal, the source root is kept
/// exactly as it was, so "keeps rendering the arrangement it was showing" needs no second
/// mechanism: the shadows stay at the landing slots and the originals stay lifted out until the
/// snapshot carrying the write arrives and the real faces take their place.
/// Everything that drives the rendering the members, the proposal, the source root, the
/// operation is kept exactly as it was, so "keeps rendering the arrangement it was showing"
/// needs no second mechanism: the shadows stay at the landing slots and the originals stay
/// exactly where the operation put them lifted out for a move, standing dimmed in place for a
/// copy until the snapshot carrying the write arrives and the real faces take their place.
/// Freezing the operation is `resolveOperation`'s own guard; the hold this method arms is what
/// that guard reads.
func commit(into store: BoardStore) {
guard isActive else { return }
sourceStore?.transient.dragMembers = .empty