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
+15 -72
View File
@@ -127,63 +127,26 @@ struct TrashLaneView: View {
// MARK: - The delete gesture's landing
/// What the column draws at the drop proposal the shadow run while the drag is in flight, the
/// tombstoned rows themselves once the release has settled (`DragSession.trashLanding`). `nil`
/// Where the delete gesture's shadow run opens in this column always the topmost row or `nil`
/// when no proposal names the trash, which is every other moment of the app's life.
private var landing: DropLanding? {
drops.session.trashLanding(onBoardRooted: store.rootURL)
private var proposal: Int? {
drops.session.trashProposal(onBoardRooted: store.rootURL)
}
/// The rows the `VStack` lays out: the entries, with the delete gesture's run opened at the top.
/// The rows the `VStack` lays out: the entries, with the delete gesture's shadow run opened at
/// the top.
///
/// **The settled run displaces the entries it is standing in for.** For the one render pass where
/// the echo has landed but the hold has not yet been retired (`BoardView` hands off on the *next*
/// snapshot, an `onChange` later), the arriving card is in both collections at once so the real
/// entry steps aside and the overlay's row keeps the slot. That is `LaneView`'s hidden-members
/// rule at the other end of the same gesture: never draw the arrangement twice.
///
/// The two then swap **inside one element**, because both are keyed by the card's own identity: a
/// tombstone is not a remint the card keeps its GUID, its folder and its bytes so this
/// landing, alone among the board's, can always promise the key. No insert, no remove, no
/// transition to fire; "the handoff must read as one arrival" (02-architecture.md
/// TransientBoardState overlays).
/// The run stands until the echo reload brings the real tombstones the committed-overlay hold
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
/// other container's does (`CommittedHold`).
private var slots: [TrashSlot] {
guard let landing else { return entries.map(TrashSlot.entry) }
let arriving: [ItemID]
let run: [TrashSlot]
switch landing.run {
case .shadows:
arriving = []
run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
case let .dropped(drop):
arriving = drop.items.map(\.id)
run = drop.items.map(TrashSlot.dropped)
}
let displaced = Set(arriving)
var result: [TrashSlot] = entries.filter { !displaced.contains($0.id) }.map(TrashSlot.entry)
result.insert(contentsOf: run, at: min(max(0, landing.index), result.count))
var result = entries.map(TrashSlot.entry)
guard let proposal else { return result }
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
return result
}
/// One dropped card, drawn as the trash row it is about to become.
///
/// The card is still **live in this board's snapshot** at this instant the write is in flight
/// so its real symbol and title are right there to draw, and the payload's title is the fallback
/// for the case that cannot happen here but costs one `??` to survive.
private func droppedRow(_ item: DroppedItem) -> some View {
let card = store.snapshot.lanes.lazy
.flatMap(\.cards)
.first { $0.id == item.id }
return TrashRowPlate(
symbol: card.map { ItemSymbol.name($0.icon, fallback: ItemSymbol.card) } ?? ItemSymbol.card,
title: card?.title.value ?? item.title,
subtitle: nil,
isSelected: false
)
}
// MARK: - Header
/// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
@@ -278,11 +241,6 @@ struct TrashLaneView: View {
DragShadow(cornerRadius: 6)
.frame(maxWidth: .infinity)
.frame(height: nominalRowHeight)
case let .dropped(item):
// The same row one instant later: the release has settled and the
// tombstone is drawn where its shadow was, rather than the cards winking
// out for a round trip (`DragSession.trashLanding`).
droppedRow(item)
}
}
// A row is a tombstoned item, so it arrives and leaves in the card's dialect
@@ -297,7 +255,7 @@ struct TrashLaneView: View {
// The reflow that opens the topmost row for the shadow, keyed on **the drop proposal and
// nothing else** (03-board-ui.md § Motion's narrow keys) the trash column's own copy of
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
.animation(Motion.dragReflow(reduced: reduceMotion), value: landing?.index)
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
// below reach the column's full height rather than stopping where the last row ends
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
@@ -323,13 +281,10 @@ struct TrashLaneView: View {
/// The trash row's *appearance*, with none of anybody's behaviour the compact, dimmed plate a
/// tombstone wears.
///
/// **Three views draw it and none of them may drift**: the row itself, its own drag replica (a drag
/// **Two views draw it and neither may drift**: the row itself, and its own drag replica (a drag
/// image is a snapshot, and one built out of the live plate would re-register the row's frame from
/// inside the preview's geometry and then deregister it when the image went away quietly stealing
/// the row from the rubber band and the arrow keys), and the **settled delete's** stand-in row, which
/// draws a card that has been tombstoned on disk but is not in the snapshot yet
/// (`TrashLaneView.droppedRow`). The last one is why this is a view rather than a computed property:
/// it renders for an item that has no `TrashEntry` at all.
/// the row from the rubber band and the arrow keys).
private struct TrashRowPlate: View {
let symbol: String
@@ -387,21 +342,12 @@ private enum TrashSlot: Identifiable {
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md The trash).
case shadow(index: Int)
/// One of the **dropped** cards, drawn as the row it is about to be, from the instant of release
/// until the echo reload brings the real entry.
case dropped(DroppedItem)
var id: String {
switch self {
case let .entry(entry): Self.identity(of: entry.id)
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
// than blinking (`LaneSlot`'s rule).
case let .shadow(index): "shadow:\(index)"
// **The one landing on this board that can always promise a key.** A tombstone keeps the
// card's GUID a delete remints nothing so the settled row wears the arriving entry's own
// identity and the echo swaps content inside one element, where the masonry's `.dropped` has
// to key positionally whenever a copy or an import boundary might remint.
case let .dropped(item): Self.identity(of: item.id)
}
}
@@ -580,9 +526,6 @@ private struct TrashEntryRow: View {
drops.session.beginCards(
rows.map(\.id),
folders: payload.folders,
// What a cross-board arrival's overlay draws with (`DroppedItem`) the payload's own
// titles, which for a trash row are the tombstone's.
titles: payload.items.map(\.title),
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
side: .trashed,
source: store