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:
@@ -276,35 +276,22 @@ struct CommittedHoldTests {
|
||||
func theTimeoutFigure() {
|
||||
#expect(CommittedHold.timeout == .milliseconds(1500))
|
||||
}
|
||||
|
||||
/// The two questions the effective operation settles at once, which is why the hold carries it
|
||||
/// rather than a pair of flags (`CommittedHold`).
|
||||
@Test("A move takes the originals away and keeps their identities; a copy does neither")
|
||||
func theOperationDecidesBothHalves() {
|
||||
var hold = Self.hold
|
||||
hold.operation = .move
|
||||
#expect(hold.removesOriginals)
|
||||
#expect(hold.keepsIdentity)
|
||||
hold.operation = .copy
|
||||
#expect(!hold.removesOriginals)
|
||||
#expect(!hold.keepsIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The settle
|
||||
// MARK: - The committed-overlay hold, in the session
|
||||
|
||||
/// **The drop settle** (03-board-ui.md § Motion, sharpened 2026-07-28): what the session renders
|
||||
/// between the release and the echo. The claims here are the pure state the board's surfaces read —
|
||||
/// what the proposal's slot draws (`DragSession.cardLanding`) and which originals stay lifted out
|
||||
/// (`hiddenMembers`) — so the whole ruling is checkable without a view: "at release the shadow is
|
||||
/// replaced by the dropped card(s) drawn in place immediately … a lingering shadow over a hidden
|
||||
/// card is the hold failing its one job".
|
||||
/// **What the session renders between the release and the echo** (DRAG-REORDER.md § The
|
||||
/// committed-overlay hold): the write is in flight and the snapshot has not moved, so the session
|
||||
/// keeps drawing the arrangement it was showing — the shadows at their landing slots, the originals
|
||||
/// lifted out — until the destination store applies its next snapshot, or the deadline says none is
|
||||
/// coming. The claims here are the pure state the board's surfaces read (`laneProposal`,
|
||||
/// `trashProposal`, `hiddenMembers`), so the whole ruling is checkable without a view.
|
||||
///
|
||||
/// A **real store over a real temp board**, like the write suites: `commit` names the destination
|
||||
/// store, and the session's own re-grounding reads that store's transient state, so a stub would be
|
||||
/// standing in for exactly the thing under test. Nothing here writes.
|
||||
@MainActor
|
||||
@Suite("The drop settle")
|
||||
@Suite("The committed hold")
|
||||
struct DropSettleTests {
|
||||
|
||||
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
||||
@@ -324,6 +311,9 @@ struct DropSettleTests {
|
||||
}
|
||||
|
||||
/// A session mid-drag: `members` picked up out of `lane1`, proposing into it at `index`.
|
||||
///
|
||||
/// The titles ride along only so the fixtures read as the cards they name — nothing in the
|
||||
/// session reads them.
|
||||
private func proposing(
|
||||
_ store: BoardStore,
|
||||
members: [(id: ItemID, title: String?)] = [(card1, "First")],
|
||||
@@ -349,7 +339,6 @@ struct DropSettleTests {
|
||||
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||
.appendingPathComponent($0.id.rawValue, isDirectory: true)
|
||||
},
|
||||
titles: members.map(\.title),
|
||||
heights: members.map { _ in 44 },
|
||||
side: .live,
|
||||
source: store
|
||||
@@ -369,7 +358,7 @@ struct DropSettleTests {
|
||||
|
||||
// MARK: In flight
|
||||
|
||||
@Test("While the drag is in flight the slot is a run of shadows and the originals are lifted out")
|
||||
@Test("While the drag is in flight the shadow run opens at the proposal and the originals are lifted out")
|
||||
func inFlightDrawsShadows() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -377,81 +366,42 @@ struct DropSettleTests {
|
||||
let session = proposing(store)
|
||||
|
||||
#expect(!session.isSettled)
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.run == .shadows)
|
||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
||||
}
|
||||
|
||||
// MARK: The settle
|
||||
// MARK: The hold
|
||||
|
||||
@Test("A settled move draws the dropped card at the proposal, and draws no shadow")
|
||||
func settledMoveDrawsTheCard() throws {
|
||||
/// "The session flips from proposing to committed and keeps rendering the arrangement it was
|
||||
/// showing": the write is on its way but the snapshot has not moved, so nothing about the
|
||||
/// release may change what is on screen — the shadows stay at their landing slot and the
|
||||
/// originals stay lifted out until the echo reload brings the real faces.
|
||||
@Test("A committed hold keeps the shadows at their landing slot and the originals lifted out")
|
||||
func theHoldKeepsTheArrangement() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store)
|
||||
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
session.commit(into: store)
|
||||
|
||||
#expect(session.isSettled)
|
||||
let landing = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1))
|
||||
// The slot has not moved — only what it contains has, which is what keeps the settle out of
|
||||
// every animation key on the board.
|
||||
#expect(landing.index == 2)
|
||||
let drop = try #require(landing.dropped)
|
||||
#expect(drop.items == [DroppedItem(id: Self.card1, title: "First")])
|
||||
// A within-board move: the arriving card wears the identity it travelled under, so the
|
||||
// overlay's slot can key by it and the echo is a content swap inside one element.
|
||||
#expect(drop.keepsIdentity)
|
||||
#expect(drop.isLocal)
|
||||
// The original stays lifted, because the write really did take it away — the overlay is
|
||||
// drawing it at its landing slot instead.
|
||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
||||
}
|
||||
|
||||
@Test("A settled copy puts the originals back in the same render pass that draws the copies")
|
||||
func settledCopyRestoresTheOriginals() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store)
|
||||
|
||||
session.commit(into: store, survivors: [0], operation: .copy)
|
||||
|
||||
// A copy left them exactly where they were, and the arrangement the hold renders says so.
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||
let drop = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.dropped)
|
||||
#expect(drop.items.map(\.id) == [Self.card1])
|
||||
// Fresh GUIDs are coming, so no slot may claim one: the landing keys positionally.
|
||||
#expect(!drop.keepsIdentity)
|
||||
}
|
||||
|
||||
@Test("The run the overlay draws is the run the commit wrote — a vanished member is not drawn")
|
||||
func onlySurvivorsAreDrawn() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store, members: [(Self.card1, "First"), (Self.card2, nil)])
|
||||
|
||||
// Rule 3 of the re-grounding trio: a partly emptied drag drops the survivors, and the
|
||||
// overlay must show exactly those.
|
||||
session.commit(into: store, survivors: [1], operation: .move)
|
||||
|
||||
let drop = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.dropped)
|
||||
#expect(drop.items == [DroppedItem(id: Self.card2, title: nil)])
|
||||
}
|
||||
|
||||
@Test("A settled release is past retargeting: a late callback cannot move or withdraw it")
|
||||
func settledProposalsAreFinal() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store)
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
session.commit(into: store)
|
||||
|
||||
session.propose(nil)
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0))
|
||||
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2)
|
||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
||||
}
|
||||
|
||||
// MARK: The trash's landing
|
||||
@@ -476,44 +426,32 @@ struct DropSettleTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposingIntoTheTrash(store)
|
||||
|
||||
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
|
||||
#expect(landing.index == 0)
|
||||
#expect(landing.run == .shadows)
|
||||
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
||||
// The proposal names one container and one only: the lane the cards came out of draws
|
||||
// nothing, and neither does the strip.
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.stripProposal(onBoardRooted: store.rootURL) == nil)
|
||||
// And they are still lifted out of the lane while the drag is in flight, as ever.
|
||||
// And they are lifted out of the lane, as ever.
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
||||
}
|
||||
|
||||
/// The settle, at the one landing whose cards would otherwise wink out of existence: the write
|
||||
/// takes them off the live side, so the trash has to draw them from the instant of release.
|
||||
@Test("A settled trash drop draws the tombstoned rows on top and keeps the originals lifted")
|
||||
func trashSettleDrawsTheRows() throws {
|
||||
/// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep
|
||||
/// the space they landed in until the echo brings the real tombstones.
|
||||
@Test("A committed trash drop keeps its shadow rows on top and the originals lifted")
|
||||
func trashHoldKeepsTheRows() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")])
|
||||
|
||||
// A delete is committed as the move it is — the write really did take the originals away.
|
||||
session.commit(into: store, survivors: [0, 1], operation: .move)
|
||||
session.commit(into: store)
|
||||
|
||||
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
|
||||
#expect(landing.index == 0, "the slot does not move at the settle — only what it contains")
|
||||
let drop = try #require(landing.dropped)
|
||||
#expect(drop.items == [
|
||||
DroppedItem(id: Self.card1, title: "First"),
|
||||
DroppedItem(id: Self.card2, title: "Second")
|
||||
])
|
||||
// A tombstone remints nothing, so the settled rows may wear the cards' own identities and the
|
||||
// echo reload swaps content inside one element rather than removing and inserting.
|
||||
#expect(drop.keepsIdentity)
|
||||
#expect(drop.isLocal)
|
||||
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
||||
#expect(session.shadowCount == 2)
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
||||
}
|
||||
|
||||
@Test("A lane session never draws a trash landing")
|
||||
@Test("A lane session never proposes into the trash")
|
||||
func laneSessionsHaveNoTrashLanding() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -522,13 +460,12 @@ struct DropSettleTests {
|
||||
session.beginLanes(
|
||||
[Self.lane1],
|
||||
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
||||
titles: ["Todo"],
|
||||
units: [1],
|
||||
source: store
|
||||
)
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
|
||||
|
||||
#expect(session.trashLanding(onBoardRooted: store.rootURL) == nil)
|
||||
#expect(session.trashProposal(onBoardRooted: store.rootURL) == nil)
|
||||
}
|
||||
|
||||
// MARK: The hand-off
|
||||
@@ -539,13 +476,13 @@ struct DropSettleTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store)
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
session.commit(into: store)
|
||||
|
||||
session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1)
|
||||
|
||||
#expect(!session.isSettled)
|
||||
#expect(!session.isActive)
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||
}
|
||||
|
||||
@@ -555,12 +492,12 @@ struct DropSettleTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store)
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
session.commit(into: store)
|
||||
|
||||
session.end()
|
||||
|
||||
#expect(!session.isSettled)
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||
#expect(store.transient.dragMembers.ids.isEmpty)
|
||||
}
|
||||
@@ -580,12 +517,12 @@ struct DropSettleTests {
|
||||
// wall clock in the suite for a claim about the discard rather than about the clock.
|
||||
session.holdTimeout = .milliseconds(20)
|
||||
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
session.commit(into: store)
|
||||
#expect(session.isSettled)
|
||||
|
||||
#expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
|
||||
#expect(!session.isActive)
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||
#expect(store.transient.dragMembers.ids.isEmpty)
|
||||
}
|
||||
@@ -598,7 +535,7 @@ struct DropSettleTests {
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store)
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
session.commit(into: store)
|
||||
let hold = try #require(session.hold)
|
||||
|
||||
session.expire(CommittedHold(boardRoot: store.rootURL, generation: 999))
|
||||
@@ -616,7 +553,7 @@ struct DropSettleTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposing(store)
|
||||
session.holdTimeout = .milliseconds(20)
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
session.commit(into: store)
|
||||
|
||||
// The echo lands well inside the deadline, and the user starts another drag immediately —
|
||||
// the lifecycle trap the watchdog was written for, at the hold's end of the session.
|
||||
@@ -628,15 +565,3 @@ struct DropSettleTests {
|
||||
#expect(session.hold == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reading a landing
|
||||
|
||||
extension DropLanding {
|
||||
|
||||
/// The dropped run, or `nil` while the slot is still a run of shadows — a test-side convenience
|
||||
/// so a claim about the settle reads as one line rather than as a `case let` dance.
|
||||
var dropped: Dropped? {
|
||||
if case let .dropped(drop) = run { return drop }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user