Back out the render-at-release drop presentation

The settle rule 3f4125e introduced — at release the shadow swaps for the
dropped card(s) drawn in place immediately — is withdrawn on review. The
board returns to the presentation the committed-overlay hold has always
had: the shadows keep standing at their landing slot and the originals
keep their lift until the echo reload lands, whatever the operation was,
and the real faces arrive with the snapshot that carries the write.

Gone with it: the DropLanding/DroppedItem seam and the three landing
accessors, the hold's landing/operation payload and the removesOriginals
and keepsIdentity halves it decided, the pickup-captured titles a
cross-board arrival's face needed, LaneSlot.dropped with DroppedFace and
DroppedCardFace, the strip's dropped-lane run, and the trash column's
settled tombstone rows — the same presentation at the delete gesture's
end, which goes for the same reason while the drop-to-delete write and
its shadow row stay exactly as they were.

Kept: the hold itself, which predates the commit, along with the 1500 ms
failed-write timeout, its injectable duration and its extracted expire,
and the guard that keeps a settled release past retargeting. The tests
pinning those stay; the ones asserting what the landing slot drew are
gone, and the suite now pins the hold's own claim — the arrangement does
not move at the release.

The pause between the release and the echo is therefore an open design
question again, filed on the Redesign board.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-28 09:57:32 -04:00
parent 1e65b7c986
commit 7f1adf47c5
7 changed files with 108 additions and 619 deletions
+17 -184
View File
@@ -144,61 +144,8 @@ struct FileDropTarget: Equatable, Sendable {
// MARK: - The committed-overlay hold
/// One item the hold is drawing: the identity it travelled under, and the title it wore on the way.
///
/// The title is not a convenience. A **cross-board arrival** has no presence in the board it just
/// landed on the write is in flight and the destination has never seen that folder so the
/// payload's title is the whole of what its face can say until the echo brings the real card. A
/// within-board landing finds itself in the snapshot and draws its real face instead (`LaneView`).
struct DroppedItem: Equatable, Sendable {
var id: ItemID
var title: String?
}
/// What a container draws at the drop proposal **one value for both phases of a release**, because
/// the slot is the same slot throughout and only its content changes.
///
/// 03-board-ui.md § Motion (sharpened 2026-07-28): "at release the shadow is replaced by the dropped
/// card(s) drawn in place immediately, the appear never waiting for the echo a lingering shadow
/// over a hidden card is the hold failing its one job". Keeping the *index* outside the phase split
/// is what lets the reflow's animation key stay put across the release: the settle renders, it does
/// not move, so nothing about it may key motion.
struct DropLanding: Equatable, Sendable {
/// The dropped run, as the settled overlay draws it.
struct Dropped: Equatable, Sendable {
/// The items that landed, in landing order.
var items: [DroppedItem]
/// Whether the arriving items keep the identities they travelled under, so the overlay's
/// slots may wear the arriving cards' own keys and the echo becomes a content swap inside
/// one element the new-card placeholder's handoff exactly (`LaneSlot`). True for a
/// **within-board move** and nothing else: a copy mints fresh GUIDs, and a cross-board
/// arrival may be reminted at the import boundary, so neither can promise a key.
var keepsIdentity: Bool
/// Whether this board already holds these items a rearrangement of its own, whose faces it
/// can therefore draw straight from its snapshot. False for an arrival from another board,
/// which has only `DroppedItem.title` to go on.
var isLocal: Bool
}
enum Run: Equatable, Sendable {
/// The drag is in flight: N hit-transparent shadows hold the space open (`DragShadow`).
case shadows
/// The release has settled: the dropped items themselves, drawn at the slot the shadows were
/// holding.
case dropped(Dropped)
}
/// Where the run opens, in the container's own order.
var index: Int
var run: Run
}
/// **The committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold; 03-board-ui.md §
/// Motion) the drop proposal, and the run that landed under it, kept as overlay state past the
/// release. A value, so the state machine is testable without a filesystem.
/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
/// committed-overlay hold), as a value so the state machine is testable without a filesystem.
///
/// At release the write goes to disk and the *snapshot does not change* the one-way flow means the
/// board only shows the new order once the watcher's reload lands (02-architecture.md). Dropping the
@@ -208,17 +155,6 @@ struct DropLanding: Equatable, Sendable {
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
/// anyway.
///
/// ### Rendering the arrangement means rendering the card
///
/// The hold carries *what* landed and not only *where*, because the arrangement is not an outline:
/// "at release the shadow is replaced by the dropped card(s) drawn in place immediately" the
/// system drag image's fade then dissolves over a card that is already there, which is the whole
/// promise of the settle. `landing` is that run, in the order it lands.
///
/// `operation` is the other half of what the overlay draws, and it settles two questions at once
/// because a move and a copy differ in exactly those two ways see `removesOriginals` and
/// `keepsIdentity`.
///
/// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write
/// that was refused outright (a read-only board) produces no reload at all, and an overlay with no
/// hand-off coming must still dissolve and let the snapshot be the authority again.
@@ -230,23 +166,6 @@ struct CommittedHold: Equatable, Sendable {
/// That board's `snapshotGeneration` at the moment of the commit.
var generation: Int
/// The run that landed, in landing order the dropped cards the overlay draws in place.
/// Defaulted so the hand-off condition above can still be stated on its own.
var landing: [DroppedItem] = []
/// The effective operation the release committed, re-resolved at the drop.
var operation: TransferOperation = .move
/// Whether the source board keeps its originals lifted out of the resting layout. A move took
/// them away, so it does; a **copy left them exactly where they were**, so they come back the
/// instant the write is issued the arrangement the hold renders has the originals *and* the
/// arrivals in it, which is what the echo will show.
var removesOriginals: Bool { operation == .move }
/// Whether the arriving items keep the identities they travelled under (`Dropped.keepsIdentity`
/// is this, narrowed to a within-board landing).
var keepsIdentity: Bool { operation == .move }
/// How long the hold may stand with no snapshot arriving. Comfortably longer than a write plus
/// a watcher round trip, short enough that a refused write does not leave the board drawing an
/// arrangement it never got.
@@ -366,11 +285,6 @@ final class DragSession {
/// The dragged items' folders, aligned 1:1 with `members` what the cross-board commits take.
@ObservationIgnored private(set) var folders: [URL] = []
/// The dragged items' titles, aligned 1:1 with `members` captured at pickup off the very
/// payload the pasteboard carries, and read again at the drop so the committed hold can draw a
/// **cross-board arrival**'s face before that board has ever heard of it (`DroppedItem`).
@ObservationIgnored private(set) var titles: [String?] = []
/// The board the drag started in. Root and store are kept separately because the store may go
/// away with its window mid-drag while the root the left-hand side of the locality
/// comparison stays perfectly usable.
@@ -432,9 +346,8 @@ final class DragSession {
var isDraggingCards: Bool { kind == .cards }
var isDraggingLanes: Bool { kind == .lanes }
/// Whether the release has **settled**: the write is issued, the proposal is being held as
/// overlay state, and every surface that was drawing shadows is now drawing the dropped items
/// (`CommittedHold`).
/// Whether the release has **settled**: the write is issued and the arrangement the session was
/// showing is being held as overlay state until the echo reload lands (`CommittedHold`).
var isSettled: Bool { hold != nil }
/// N the number of contiguous shadows the proposal draws.
@@ -449,27 +362,15 @@ final class DragSession {
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
/// card to it.
///
/// **The dragged run is lifted out whatever the effective operation is** *while the drag is in
/// flight* (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.
///
/// **At release the operation stops being a guess**, and a settled copy's originals come back at
/// once (`CommittedHold.removesOriginals`): the copy left them exactly where they were, so the
/// arrangement the hold is drawing has both them and the arrivals in it, and hiding them a round
/// trip longer would be the same lie the lingering shadow was. A settled *move* keeps hiding
/// them, because the write really did take them away the overlay draws them at their landing
/// slot instead, which is the whole of "rendering the arrangement means rendering the card"
/// (03-board-ui.md § Motion).
///
/// **A drop on the trash is a move by this rule and needs no clause of its own**: the tombstone
/// really did take the cards off the live side, and the landing slot the overlay draws them at is
/// the trash's topmost row (`trashLanding`).
/// **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.
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
guard isActive, side == .live, let sourceRoot,
DragLocality.isSameBoard(root, sourceRoot)
else { return [] }
if let hold, !hold.removesOriginals { return [] }
return memberSet
}
@@ -504,54 +405,6 @@ final class DragSession {
return proposal.index
}
/// **What `laneID`'s masonry draws at the proposal** the shadow run while the drag is in
/// flight, the dropped cards themselves once the release has settled (`DropLanding`), and `nil`
/// when no proposal names this lane.
///
/// The index is the same index in both phases, deliberately: the settle changes what the slot
/// *contains*, never where it is, so a view can key its reflow on the index and be sure the
/// release itself animates nothing (03-board-ui.md § Motion the un-hide is rendering).
func cardLanding(onBoardRooted root: URL, laneID: ItemID) -> DropLanding? {
guard let index = laneProposal(onBoardRooted: root, laneID: laneID) else { return nil }
return DropLanding(index: index, run: landingRun)
}
/// The lane strip's twin of `cardLanding` the shadow run, or the dropped lanes drawn at the
/// slot they landed in.
func laneLanding(onBoardRooted root: URL) -> DropLanding? {
guard let index = stripProposal(onBoardRooted: root) else { return nil }
return DropLanding(index: index, run: landingRun)
}
/// The **trash column's** twin: the shadow rows the delete gesture opens at the top, and once
/// the release has settled the tombstoned rows themselves, drawn there from the instant of
/// release until the echo reload brings the real ones (`TrashLaneView`).
///
/// The settle is not decoration here, it is the whole of the gesture being legible: at release
/// the dragged cards are already lifted out of their lanes (`hiddenMembers` a delete removes
/// its originals exactly as a move does), so with nothing drawn in the trash they would simply
/// wink out of existence for a round trip.
func trashLanding(onBoardRooted root: URL) -> DropLanding? {
guard let index = trashProposal(onBoardRooted: root) else { return nil }
return DropLanding(index: index, run: landingRun)
}
/// The phase, as the two accessors above read it.
///
/// `isLocal` is what keeps a **colliding cross-board arrival** from being drawn twice: only a
/// board that already holds the dragged items may resolve their faces (and their identities)
/// against its own snapshot, and an arrival's payload title is the honest answer everywhere
/// else.
private var landingRun: DropLanding.Run {
guard let hold else { return .shadows }
let isLocal = sourceRoot.map { DragLocality.isSameBoard($0, hold.boardRoot) } ?? false
return .dropped(DropLanding.Dropped(
items: hold.landing,
keepsIdentity: hold.keepsIdentity && isLocal,
isLocal: isLocal
))
}
/// The members that are still there **rule 3 of the re-grounding trio**: drag membership is a
/// UUID set that vanished items leave silently (`TransientBoardState.dragMembers`), and when the
/// last one goes the drag has emptied itself. Partial vanishing drops the survivors, matching
@@ -629,19 +482,18 @@ final class DragSession {
func beginCards(
_ members: [ItemID],
folders: [URL],
titles: [String?],
heights: [CGFloat],
side: Liveness,
source: BoardStore
) {
begin(kind: .cards, members: members, folders: folders, titles: titles, side: side, source: source)
begin(kind: .cards, members: members, folders: folders, side: side, source: source)
cardHeights = heights
laneUnits = []
}
/// Begins a lane session.
func beginLanes(_ members: [ItemID], folders: [URL], titles: [String?], units: [Int], source: BoardStore) {
begin(kind: .lanes, members: members, folders: folders, titles: titles, side: .live, source: source)
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
laneUnits = units
cardHeights = []
}
@@ -650,7 +502,6 @@ final class DragSession {
kind: DragKind,
members: [ItemID],
folders: [URL],
titles: [String?],
side: Liveness,
source: BoardStore
) {
@@ -659,7 +510,6 @@ final class DragSession {
self.members = members
self.memberSet = Set(members)
self.folders = folders
self.titles = titles
self.side = side
self.sourceStore = source
self.sourceRoot = source.rootURL
@@ -703,7 +553,6 @@ final class DragSession {
members = []
memberSet = []
folders = []
titles = []
cardHeights = []
laneUnits = []
proposal = nil
@@ -718,32 +567,16 @@ 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 proposal, the members, the source root is kept
/// 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. What *changes* at this instant is what the proposal's slot draws: the shadows are
/// over, and `survivors` the run this drop actually wrote, vanished members already dropped
/// becomes the hold's `landing`, drawn there as ordinary card faces (`DropLanding`). A copy's
/// originals come back in the same render pass (`hiddenMembers`); a move's stay lifted, because
/// the overlay is now drawing them at their landing slot.
///
/// - Parameters:
/// - survivors: indices into `members` what `BoardDropContext.commitDrop` is writing, which
/// is exactly what the overlay must show.
/// - operation: the effective operation, re-resolved at the drop. It decides both halves of
/// the overlay's grammar (`CommittedHold.removesOriginals`, `.keepsIdentity`).
func commit(into store: BoardStore, survivors: [Int], operation: TransferOperation) {
/// 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.
func commit(into store: BoardStore) {
guard isActive else { return }
sourceStore?.transient.dragMembers = .empty
watchdog?.cancel()
watchdog = nil
let hold = CommittedHold(
boardRoot: store.rootURL,
generation: store.snapshotGeneration,
landing: survivors.map {
DroppedItem(id: members[$0], title: titles.indices.contains($0) ? titles[$0] : nil)
},
operation: operation
)
let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration)
self.hold = hold
let timeout = holdTimeout
holdTimeoutTask?.cancel()