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 {
+8 -5
View File
@@ -428,9 +428,11 @@ struct BoardView: View {
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
/// off the tidy snapped layout regardless of the live overflow.
///
/// A lane being **dragged** is simply absent from the strip: it is lifted out of the resting
/// layout at pickup and stays out until release, whatever the effective operation is
/// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica.
/// A lane being **dragged as a move** is simply absent from the strip: it is lifted out of the
/// resting layout at pickup and stays out until release, while the system drag session carries
/// its replica. A lane being dragged as a **copy** stays in the strip instead, because the copy
/// leaves it there (DRAG-REORDER.md § Resting-layout zones) which on this board means the
/// cross-board case alone, being ignored by a within-board lane drag.
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
let resizing = resize.isResizing(lane.id)
@@ -628,8 +630,9 @@ struct BoardView: View {
}
}
/// What the strip lays out: the resting lanes the dragged run lifted out, whatever the
/// effective operation is with the shadows opened at the proposal.
/// What the strip lays out: the resting lanes the dragged run lifted out when the release
/// would be a move, left standing when it would be a copy (`DragSession.hiddenMembers`) with
/// the shadows opened at the proposal.
private var stripSlots: [StripSlot] {
let session = appModel.dragSession
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
+8 -4
View File
@@ -212,10 +212,14 @@ struct CardFaceView: View {
// genuinely fires on the trash side too: "X works cut in the trash, paste into a lane is
// the keyboard-native restore" (04 The trash, resettled 2026-07-28).
.cutTreatment(of: card.id, in: store)
// The face being dragged out dims the same way while the session is in flight on the trash
// side, where the source stays visible: a restore is not a removal until the write lands.
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
// has anything to act on there `LaneView.renderedCards`.)
// The face being dragged out dims the same way while the session is in flight, wherever the
// source stays visible which is now two places rather than one. On the **trash** side a
// restore is not a removal until the write lands, so the row never leaves. On the **board**
// side a drag whose effective operation is `.copy` leaves its originals in the resting
// layout, because the copy leaves them there (`DragSession.hiddenMembers`), and this is what
// marks them as the source of the drag rather than as ordinary neighbours. A `.move`'s
// originals are lifted out entirely and this has nothing to act on
// (`LaneView.renderedCards`).
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
.contentShape(Rectangle())
// **A card is one flattened accessibility element** (10-accessibility.md The board through
+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
+7 -5
View File
@@ -859,11 +859,13 @@ struct LaneView: View {
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
/// effective-liveness predicate are retired with the flag they read.
///
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
/// of the resting layout at pickup and stays out until release *whatever the effective operation
/// is* a -copy's originals really do stay, but can be pressed and released mid-drag, and a
/// layout that re-admitted them on every flip would flap the board under the cursor
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
/// **A card being dragged *as a move* renders nowhere, for as long as the session lasts.** It is
/// lifted out of the resting layout at pickup and stays out until release. A card being dragged
/// as a **copy** stays exactly where it is and dims (`CardFaceView`), because that is what the
/// copy will leave behind so flipping or mid-drag reflows this lane once, which is the
/// feedback the modifier is asking for (DRAG-REORDER.md § Resting-layout zones, ruled
/// 2026-08-01). Either way the drop's index is counted in the layout on screen, which is why
/// `copyCards` and `moveCards` take their index in different spaces.
///
/// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
/// "cards whose title *and* body both miss the query animate out". This is the one collection