From 33bf425f255bd64da46db46d0c44a3f6ab35872e Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 08:12:09 -0400 Subject: [PATCH] Drop a card on the shown trash to delete it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 04's ruling makes the drag the pointer's delete gesture: the shown trash column accepts live same-board card drags, the shadow pinned topmost — honest, since the trash sorts by deleted newest-first — and release tombstones through the same write path as Backspace, extracted so the two gestures cannot drift. DropTarget grew a container case for the quasi-lane (it has no lane id by construction); lane drags, cross-board arrivals, option-copies (re-checked at release, the one input that can flip without a callback), trashed-side payloads, hidden trash, and the read-only lock all refuse — and a refusal falls through to the strip retarget, never cancelling the drag. The settle draws the tombstoned rows in the trash under the cards' own GUIDs, so the echo is an invisible content swap and nothing winks out for a round trip. Selection needs no surgery: the reload's resolve rule ejects tombstoned members as the vanish it is, pinned by a test contrasting both gestures. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Kanban/LiveStore/BoardStore.swift | 52 ++++- Kanban/UI/Board/BoardDrops.swift | 151 ++++++++++++++- Kanban/UI/Board/DragSession.swift | 123 +++++++++++- Kanban/UI/Board/TrashLaneView.swift | 285 +++++++++++++++++++++++----- KanbanTests/DragSessionTests.swift | 168 +++++++++++++++- KanbanTests/TrashWriteTests.swift | 112 +++++++++++ 6 files changed, 819 insertions(+), 72 deletions(-) diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 07041ef..0d1ca14 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1918,11 +1918,8 @@ public final class BoardStore { // walks the filtered lane rather than selecting a card the query has hidden. let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter) - try? performWrite { () throws(BoardWriteError) -> Void in - for folder in folders { - try BoardWriter.deleteItem(at: folder) - } - } + tombstone(folders) + if let successor { select([successor], liveness: .live, anchor: successor, head: successor) } else { @@ -1930,6 +1927,51 @@ public final class BoardStore { } } + /// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28): "the drag + /// becomes the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫ + /// tombstone". + /// + /// *Exactly* the ⌫ tombstone is a claim about the disk, and `tombstone(_:)` is what makes it + /// structural rather than a matter of two call sites staying in step: one write op, one bracket, + /// one set of stamps, so a card deleted by drop and a card deleted by keystroke are + /// byte-indistinguishable afterwards (`TrashDropWriteTests`). + /// + /// ### The one thing it does not share is the successor + /// + /// ⌫ moves the selection to the deleted item's successor sibling because *the selection* lost its + /// cards and "repeated ⌫ walks down a lane" — the rule exists to keep a keyboard gesture + /// repeatable. A drag has no such continuation, and its run is **not necessarily the selection at + /// all**: dragging a card outside the selection drags that card alone and leaves the selection + /// exactly where it was (`LaneView.startCardDrag`), so picking a successor for it would re-point a + /// selection that never lost anything. + /// + /// So this writes and says nothing about the selection, and the ordinary reload does the rest: a + /// live-side set ejects members that flip to tombstoned, as the vanish it is (02-architecture.md's + /// reload-survival rule). Drag the selection itself onto the trash and the selection empties; + /// drag something else and it is untouched. Neither case needs surgery here. + /// + /// Cards only, by the gesture's own gate (`TrashDrop.accepts`) — but nothing here depends on + /// that: the paths resolve on the live side exactly as `delete(_:)`'s do. + public func deleteByDrag(cardIDs: [ItemID]) { + let folders = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot) + .map { $0.folder(under: rootURL) } + guard !folders.isEmpty else { return } + tombstone(folders) + } + + /// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and + /// whichever gesture asked** (DRAG-REORDER.md § The drop commits; the style batch's rule). + /// + /// Spelled once so ⌫ and drop-on-trash cannot drift apart on disk; everything that differs + /// between them is about the *selection*, and lives in the callers. + private func tombstone(_ folders: [URL]) { + try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + try BoardWriter.deleteItem(at: folder) + } + } + } + /// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket /// (03-board-ui.md § Trash). /// diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 4c634da..b24a81a 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -159,7 +159,7 @@ struct BoardDropContext { ) guard let slot else { return } // a dead region: hold the current proposal let index = min(max(0, slot), restingUnits.count) - session.propose(DropTarget(boardRoot: store.rootURL, laneID: nil, index: index)) + session.propose(DropTarget(boardRoot: store.rootURL, container: .strip, index: index)) } /// Where a **card** session would land in `laneID`'s masonry. @@ -195,7 +195,7 @@ struct BoardDropContext { current: session.laneProposal(onBoardRooted: store.rootURL, laneID: laneID) ) guard let slot else { return } // a dead region: hold - session.propose(DropTarget(boardRoot: store.rootURL, laneID: laneID, index: slot)) + session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(laneID), index: slot)) } /// The strip's fall-through for card sessions: which lane is under the cursor, analytically. @@ -227,6 +227,49 @@ struct BoardDropContext { } } + // MARK: Retargeting — the trash column + + /// Whether the trash column would take the session in flight right now — `TrashDrop.accepts` + /// with this board's state read in (04-interactions.md ▸ The trash, settled 2026-07-28). + /// + /// A method rather than a property because it is not free of consequence: the operation is + /// re-resolved against the modifiers *at this instant*, which is also what keeps the badge honest + /// while the cursor sits over the column (`dropProposal`). + func acceptsTrashDrop() -> Bool { + guard let sourceRoot = session.sourceRoot else { return false } + return TrashDrop.accepts( + kind: session.kind, + side: session.side, + isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL), + operation: session.resolveOperation(destinationRoot: store.rootURL), + isTrashShown: store.transient.isTrashVisible, + acceptsMutations: store.acceptsBoardMutations + ) + } + + /// Where a session over the **trash column** would land: the topmost row, or nowhere. + /// + /// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**, + /// which is precisely what this column did before it had a drop target of its own: a cursor over + /// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show + /// and `retargetLanes` clamps the terminal slot in front of the quasi-lane. That is the + /// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the + /// trash proposes nothing for you": the column declines to be a target, it does not cancel the + /// drag the user is still holding. So a lane drag reorders across the column exactly as it always + /// did, and an ⌥-copy released over it still lands where its shadows are. + func retargetTrash() { + revalidateProposal() + guard acceptsTrashDrop() else { + retargetFromStrip() + return + } + session.propose(DropTarget( + boardRoot: store.rootURL, + container: .trash, + index: TrashDrop.landingIndex + )) + } + // MARK: Retargeting — external Finder file sessions /// Whether `info` is an **external Finder file** session rather than one of ours. @@ -457,6 +500,11 @@ struct BoardDropContext { /// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the /// set's size (DRAG-REORDER.md § The drop commits). /// + /// **One of the containers is not a destination but a verb.** A proposal naming the trash commits + /// a tombstone — the same write ⌫ performs, through the same `BoardWriter.deleteItem` in the same + /// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk + /// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28). + /// /// The write is the first half; the second is the **settle** (`DragSession.commit`). The write is /// still in flight when this returns, so the session flips from proposing to committed and the /// slot the shadows were holding starts drawing the dropped cards themselves — "at release the @@ -487,6 +535,15 @@ struct BoardDropContext { switch kind { case .lanes: + // **A lane drag never targets the trash**, and never a masonry either — a lane session + // proposes only lane slots (04-interactions.md ▸ The trash). True by construction, since + // `retargetLanes` is the only thing that proposes for one and the quasi-lane is absent + // from its slot list; written down because a commit that trusted the container implicitly + // would be the one place the invariant could break silently. + guard target.container == .strip else { + cancelDrop() + return false + } if within { store.moveLanes(Set(ids), toIndex: target.index) } else { @@ -494,6 +551,28 @@ struct BoardDropContext { } case .cards: + if target.isTrash { + // **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled + // 2026-07-28): "release tombstones the dragged card(s), exactly the ⌫ tombstone". + // + // The gate is re-asked here rather than trusted from the hover, because the one input + // that can change between them arrives through no callback at all: ⌥ pressed after + // the proposal stood would otherwise tombstone an original the copy grammar had just + // promised to leave alone. A refusal cancels — items return, nothing is written. + guard TrashDrop.accepts( + kind: kind, + side: session.side, + isWithinBoard: within, + operation: operation, + isTrashShown: store.transient.isTrashVisible, + acceptsMutations: store.acceptsBoardMutations + ) else { + cancelDrop() + return false + } + store.deleteByDrag(cardIDs: ids) + break + } guard let laneID = target.laneID else { cancelDrop() return false @@ -773,9 +852,12 @@ struct LaneDropDelegate: DropDelegate { } } -/// The strip's drop target — the backdrop, the gaps, the outer margin, and the trash column's -/// footprint, which is never a landing spot of its own (04-interactions.md ▸ The trash) and so -/// simply falls through to here. +/// The strip's drop target — the backdrop, the gaps and the outer margin. +/// +/// The trash column used to fall through to here too, being no landing spot of its own; it has its +/// own target now that a live card drag can *delete* into it (`TrashDropDelegate`), and that target +/// hands every session it declines straight back to this logic, so the behaviour over the column is +/// unchanged for everything but the one gesture that is new. /// /// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the /// same shared function the lane delegates use, resolving the lane under the cursor analytically — @@ -818,6 +900,65 @@ struct StripDropDelegate: DropDelegate { } } +/// The **trash column's** drop target — the pointer's delete gesture (04-interactions.md ▸ The +/// trash, settled 2026-07-28: "dropping a live card on the shown trash deletes it"). +/// +/// It exists only while the column does. Hidden, the trash renders nothing, so there is no region +/// here to enter and "the trash stays undroppable-into while hidden, like every gesture" needs no +/// code — `TrashDrop.accepts` restates it anyway, because an invariant that is only true by +/// construction is worth being able to point at. +/// +/// Like every other delegate it accepts **all three** session types, because single-target dispatch +/// gives the deepest region the session whether it wants it or not (see the note above), and it +/// routes them three ways: +/// +/// - **card sessions** through `retargetTrash`, which proposes the topmost row for the ones the trash +/// takes and falls through to the strip's own answer for the rest; +/// - **lane sessions** the same way, and `TrashDrop.accepts` refuses them there, so what actually +/// runs is the strip's `retargetLanes` — lane reordering keeps working across the column exactly as +/// it did when the column was a hole in the strip's target; +/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment +/// import) on tombstoned cards are inert" (▸ The trash), and the column has nothing else to offer +/// them — no lane, no card, nothing to attach to. +struct TrashDropDelegate: DropDelegate { + + let context: BoardDropContext + + func validateDrop(info: DropInfo) -> Bool { + if context.isFileSession(info) { return context.acceptsFileDrop(info) } + return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) + } + + func dropEntered(info: DropInfo) { retarget(info) } + + func dropUpdated(info: DropInfo) -> DropProposal? { + retarget(info) + return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal() + } + + /// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions, + /// whose proposals are meant to hold across ambiguous territory. + func dropExited(info: DropInfo) { + guard context.isFileSession(info) else { return } + context.session.proposeFile(nil) + } + + /// A release on the column commits whatever stands — the tombstone when the trash is the + /// proposal, and otherwise the proposal the column declined to displace, which is the same + /// "the drop lands where the shadows show" promise as anywhere else. + func performDrop(info: DropInfo) -> Bool { + context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop() + } + + private func retarget(_ info: DropInfo) { + if context.isFileSession(info) { + context.session.proposeFile(nil) + return + } + context.retargetTrash() + } +} + /// The window-level fallback, behind every specific target: a release over any in-window region they /// do not cover (the banner strip, the window's edges) commits the current proposal rather than /// leaking the session into a cancel-snapback. It retargets nothing — the drop lands where the diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index 9c5a41c..f37cde2 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -7,16 +7,91 @@ import SwiftUI /// The drop proposal: which board, which container, which slot. /// -/// One value for both layouts, because they differ only in what the container is: `laneID == nil` -/// is the **lane strip** (the index counts live lanes with the dragged run removed), and a lane id -/// is that lane's **masonry** (the index is a position in its logical card order — DRAG-REORDER.md -/// § The card masonry). `boardRoot` is what makes a proposal cross-board-aware: only the board whose -/// root it names renders the shadows, and only that board's delegate may commit it. +/// One value for every layout, because they differ only in what the container is, and `boardRoot` is +/// what makes a proposal cross-board-aware: only the board whose root it names renders the shadows, +/// and only that board's delegate may commit it. struct DropTarget: Equatable, Sendable { + + /// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be + /// written down at all. + /// + /// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the + /// snapshot — it is `TrashModel.entries` derived from it — so there is nothing to put in a + /// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`). + enum Container: Equatable, Sendable { + /// The **lane strip**: the index counts live lanes with the dragged run removed. + case strip + /// That lane's **masonry**: the index is a position in its logical card order + /// (DRAG-REORDER.md § The card masonry). + case lane(ItemID) + /// The **trash quasi-lane**, which a live card drag proposes into to delete it + /// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row. + case trash + } + var boardRoot: URL - /// `nil` == the lane strip. - var laneID: ItemID? + var container: Container var index: Int + + /// The lane this proposal names, or `nil` for the strip and the trash — the shape the container + /// wore before there were three of them, kept because most readers only ask this one question. + var laneID: ItemID? { + if case let .lane(id) = container { return id } + return nil + } + + /// Whether this proposal names the trash column, and therefore means *delete*. + var isTrash: Bool { container == .trash } +} + +// MARK: - Dropping on the trash + +/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28: "the drag becomes +/// the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫ tombstone"), +/// as the two pure facts the gesture is made of (`TrashDropTests`). +/// +/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the +/// **hover** and the **release** cannot disagree about what the trash takes — the hazard being a +/// modifier pressed *after* the proposal stood, which no `dropUpdated` need ever report. +enum TrashDrop { + + /// The row the shadow takes, always: **the topmost**. + /// + /// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted` + /// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop + /// therefore still lands exactly where the shadow shows — the one positional promise every other + /// drop in this app makes — while being the only proposal on the board the *pointer* does not + /// choose. + static let landingIndex = 0 + + /// Whether the shown trash takes this session — the whole of the gate, and every clause is a + /// refusal 04 states in its own words: + /// + /// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's + /// slot list has never contained the quasi-lane, so this is belt over braces; it is written down + /// because a guard that is only true by construction is one refactor from being false.) + /// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out; + /// dropping it back where it came from writes nothing. + /// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card + /// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the + /// design gives no name and no undo story. The card stays where it is. + /// - **⌥ is refused.** Copying into the trash is not a thing — the copy grammar promises the + /// original stays exactly where it was, and there is nothing to tombstone but the original. + /// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is + /// not rendered, so it has no drop region), and stated here so the claim is testable. + /// - **The mutating-gesture rule**, like every other write the pointer can start. + static func accepts( + kind: DragKind?, + side: Liveness, + isWithinBoard: Bool, + operation: TransferOperation, + isTrashShown: Bool, + acceptsMutations: Bool + ) -> Bool { + guard isTrashShown, acceptsMutations else { return false } + guard kind == .cards, side == .live, isWithinBoard else { return false } + return operation == .move + } } // MARK: - Where an external file drag would land @@ -374,6 +449,10 @@ final class DragSession { /// 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`). func hiddenMembers(onBoardRooted root: URL) -> Set { guard isActive, side == .live, let sourceRoot, DragLocality.isSameBoard(root, sourceRoot) @@ -385,7 +464,7 @@ final class DragSession { /// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow /// run position. func stripProposal(onBoardRooted root: URL) -> Int? { - guard kind == .lanes, let proposal, proposal.laneID == nil, + guard kind == .lanes, let proposal, proposal.container == .strip, DragLocality.isSameBoard(proposal.boardRoot, root) else { return nil } return proposal.index @@ -394,7 +473,20 @@ final class DragSession { /// The proposal's index when it names `laneID` on this board, else `nil` — the masonry's shadow /// run position, in the lane's logical card order. func laneProposal(onBoardRooted root: URL, laneID: ItemID) -> Int? { - guard kind == .cards, let proposal, proposal.laneID == laneID, + guard kind == .cards, let proposal, proposal.container == .lane(laneID), + DragLocality.isSameBoard(proposal.boardRoot, root) + else { return nil } + return proposal.index + } + + /// The proposal's index when it names this board's **trash column**, else `nil` — the delete + /// gesture's shadow row (04-interactions.md ▸ The trash). + /// + /// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the + /// same "is it me?" question every other container asks, and gets the position from the same + /// place. + func trashProposal(onBoardRooted root: URL) -> Int? { + guard kind == .cards, let proposal, proposal.container == .trash, DragLocality.isSameBoard(proposal.boardRoot, root) else { return nil } return proposal.index @@ -419,6 +511,19 @@ final class DragSession { 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 diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index b102e08..be90ed5 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -19,13 +19,32 @@ import SwiftUI /// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent /// from the drop proposal's slot list by construction, since `BoardView` builds that from the /// snapshot's live lanes; -/// - it is **never a drop target** — "no move or paste ever targets the trash" (04-interactions.md ▸ -/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls -/// through to the strip's own target, where a cursor over no lane simply holds the proposal. That -/// covers **Finder file drops** too, which "on tombstoned cards are inert" (▸ The trash): a file -/// session over this column resolves to no lane, so no row highlights and a release refuses; +/// - it is **no destination for a move** — "no move or paste ever targets the trash" +/// (04-interactions.md ▸ The trash). It is a destination for exactly one thing, below; /// - it has **no new-card button**: nothing is created in the trash. /// +/// ### The one drop it takes: the delete gesture +/// +/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash, settled +/// 2026-07-28) — "the drag becomes the pointer's delete gesture; release tombstones the dragged +/// card(s), exactly the ⌫ tombstone". So the column does declare an `onDrop` +/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this** +/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would +/// be a transfer-and-delete compound), ⌥ is not (copying into the trash is not a thing), and hidden +/// the column is not rendered at all, so it has no region to enter. Every one of those refusals hands +/// the session back to the strip's own logic, which is exactly what happened here before this target +/// existed — so nothing about the column's behaviour changed except the gesture that is new +/// (`TrashDrop`). +/// +/// It diverges from every other drop on the board in one way, and the sort is what makes the +/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by +/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly +/// where the shadow shows. +/// +/// **Finder file drops stay inert** — "attachment import on tombstoned cards is inert" (▸ The trash) +/// — and now say so directly: the delegate clears the file highlight over the column rather than +/// relying on the strip resolving to no lane. +/// /// ### No editing in the trash /// /// "Tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out @@ -71,6 +90,12 @@ struct TrashLaneView: View { private let rowSpacing: CGFloat = 6 + /// The height a shadow row holds open. A trash row's height is content-driven (one or two title + /// lines, plus a lane entry's count line) and the cards being proposed have no row yet to be + /// measured, so the shadow is drawn at the nominal single-line plate — `LaneDropRegistry`'s own + /// answer to the same question, in this column's smaller idiom. + private let nominalRowHeight: CGFloat = 32 + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -80,6 +105,12 @@ struct TrashLaneView: View { RoundedRectangle(cornerRadius: cornerRadius) .fill(.quaternary.opacity(0.35)) ) + // **The delete gesture's drop target**, over the whole column — the header included, since + // the ruling is "dropping a live card on the shown trash", not on one of its rows. It + // declares every type the board's other targets do, because single-target dispatch hands the + // deepest region whatever session is in flight and a narrower target would strand the rest + // (`TrashDropDelegate`). + .onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops)) } /// The rows the column shows. @@ -94,6 +125,65 @@ struct TrashLaneView: View { return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) } } + // 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` + /// 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) + } + + /// The rows the `VStack` lays out: the entries, with the delete gesture's 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). + 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.. 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 @@ -158,7 +248,7 @@ struct TrashLaneView: View { scrollableRows .onChange(of: store.transient.selectionHead) { _, head in guard let head, entries.contains(where: { $0.id == head }) else { return } - proxy.scrollTo(head) + proxy.scrollTo(TrashSlot.identity(of: head)) } } } @@ -171,23 +261,43 @@ struct TrashLaneView: View { // has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable // because a trash is small: it holds one board's tombstones, and Empty Trash… exists. VStack(alignment: .leading, spacing: rowSpacing) { - ForEach(entries) { entry in - TrashEntryRow( - store: store, - entry: entry, - confirmations: confirmations, - drops: drops, - registry: marquee.registry - ) + ForEach(slots) { slot in + Group { + switch slot { + case let .entry(entry): + TrashEntryRow( + store: store, + entry: entry, + confirmations: confirmations, + drops: drops, + registry: marquee.registry + ) + case .shadow: + // The delete gesture's shadow, holding the topmost row open + // (04-interactions.md ▸ The trash). + 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 — // a delete files one in, a Put Back or a purge takes one out, and both halves of // that pair should read alike from either side of the strip. The transaction is // the reload's, like the lanes' (`Motion.reloadAnimates`). .transition(Motion.cardTransition(reduced: reduceMotion)) // The scroll target — `LaneView`'s rule, and outermost for its reason. - .id(entry.id) + .id(slot.id) } } + // 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) // `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 @@ -208,6 +318,98 @@ struct TrashLaneView: View { } } +// MARK: - The plate + +/// 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 +/// 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. +private struct TrashRowPlate: View { + + let symbol: String + + /// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a value + /// (03-board-ui.md § Card face). + let title: String? + + /// A lane entry's returning-card count, and nothing else takes a second line. + let subtitle: String? + + var isSelected: Bool = false + + private let cornerRadius: CGFloat = 6 + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: symbol) + .foregroundStyle(.secondary) + .imageScale(.small) + VStack(alignment: .leading, spacing: 2) { + Text(title ?? "Untitled") + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6))) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5) + ) + } +} + +// MARK: - What the column lays out + +/// One row of the trash column — an entry, or one slot of the delete gesture's run. +/// +/// `LaneSlot`'s smaller sibling, and keyed on the same principle: a slot's id decides whether the +/// echo reload reads as a *swap* or as a removal and an insertion. +private enum TrashSlot: Identifiable { + + /// A tombstone the snapshot already holds. + case entry(TrashEntry) + + /// 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) + } + } + + /// An entry slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the + /// scroll reader is looking for (`LaneSlot.identity(of:)`). + static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" } +} + // MARK: - The hatch /// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for, @@ -238,8 +440,8 @@ private struct DiagonalHatch: Shape { /// /// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it /// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no -/// Style…, no attachment carousel. That is 03-board-ui.md's no-editing-in-the-trash rule expressed -/// as an absence rather than as a pile of `disabled` modifiers. +/// Style…. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather +/// than as a pile of `disabled` modifiers. private struct TrashEntryRow: View { let store: BoardStore @@ -251,8 +453,6 @@ private struct TrashEntryRow: View { /// side (`View.marqueeTarget`). let registry: MarqueeTargetRegistry - private let cornerRadius: CGFloat = 6 - /// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable — /// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is /// simply *absent* for them rather than refused — no session, no image, no snap-back. A click @@ -283,39 +483,15 @@ private struct TrashEntryRow: View { .contextMenu { menu } } - /// The plate's *appearance*, with none of its behaviour — no gesture, no context menu, and - /// crucially no marquee registration. - /// - /// The split exists for the drag replica, which renders this and nothing else: a drag image is a - /// snapshot, and one built out of the live plate would re-register this row's frame from inside - /// the preview's own geometry and then *deregister* it when the image went away, quietly - /// stealing the row from the rubber band and the arrow keys. + /// This entry as the shared plate draws it — appearance only, no gesture, no context menu and + /// crucially no marquee registration, which is what makes it safe for the drag replica to render + /// (see `TrashRowPlate`). private var rowFace: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback)) - .foregroundStyle(.secondary) - .imageScale(.small) - VStack(alignment: .leading, spacing: 2) { - Text(entry.title ?? "Untitled") - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(2) - if case let .lane(_, returning) = entry { - // "N cards" — what Put Back brings back with the lane, not how many folders sit - // inside it (`TrashModel.entries`' returning-count rule). - Text("\(returning) card\(returning == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6))) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius) - .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5) + TrashRowPlate( + symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback), + title: entry.title, + subtitle: laneSubtitle, + isSelected: isSelected ) } @@ -323,6 +499,13 @@ private struct TrashEntryRow: View { entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card } + /// "N cards" — what Put Back brings back with a tombstoned lane, not how many folders sit inside + /// it (`TrashModel.entries`' returning-count rule). Card entries have no second line. + private var laneSubtitle: String? { + guard case let .lane(_, returning) = entry else { return nil } + return "\(returning) card\(returning == 1 ? "" : "s")" + } + // MARK: - Selection private var isSelected: Bool { diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index 749dad0..fb7384d 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -145,6 +145,93 @@ struct DragLocalityTests { } } +// MARK: - Dropping on the trash + +/// **The delete gesture's gate** (04-interactions.md ▸ The trash, settled 2026-07-28: "dropping a +/// live card on the shown trash deletes it"). +/// +/// One pure function decides it, and it is asked twice — once at hover for the shadow and once at +/// release for the write — so every clause below is a claim about both. +@Suite("TrashDrop") +struct TrashDropTests { + + /// The accepted session, with one clause at a time knocked out by the cases. + private func accepts( + kind: DragKind? = .cards, + side: Liveness = .live, + isWithinBoard: Bool = true, + operation: TransferOperation = .move, + isTrashShown: Bool = true, + acceptsMutations: Bool = true + ) -> Bool { + TrashDrop.accepts( + kind: kind, + side: side, + isWithinBoard: isWithinBoard, + operation: operation, + isTrashShown: isTrashShown, + acceptsMutations: acceptsMutations + ) + } + + /// "The shadow always takes the topmost position — which the sort makes honest, not arbitrary: + /// the trash orders by `deleted` newest-first, so a fresh tombstone genuinely lands on top." + @Test("The landing is the topmost row, always") + func theTopmostRow() { + #expect(TrashDrop.landingIndex == 0) + } + + @Test("A live, same-board, unmodified card drag is the one session the trash takes") + func theOneItTakes() { + #expect(accepts()) + // ⌘ forces move, which is already the default here, so it changes nothing. + #expect(accepts(operation: .move)) + } + + /// "Lanes are not deliverable this way (a lane drag proposes only lane slots)." + @Test("A lane drag never proposes into the trash") + func lanesAreNotDeliverable() { + #expect(!accepts(kind: .lanes)) + // And no session at all is no proposal either — the column is inert between drags. + #expect(!accepts(kind: nil)) + } + + /// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes + /// nothing, so it never proposes. + @Test("A trash row dropped back on the trash is refused") + func theTrashedSideIsRefused() { + #expect(!accepts(side: .trashed)) + #expect(!accepts(side: .trashed, isWithinBoard: false)) + } + + /// "No move or paste ever targets the trash": a foreign card delivered into this board's trash + /// would be a transfer-and-delete compound, which the design names nowhere. + @Test("A foreign board's card is refused") + func crossBoardIsRefused() { + #expect(!accepts(isWithinBoard: false)) + // Not even with ⌘, which forces the move a cross-board drag would otherwise only copy. + #expect(!accepts(isWithinBoard: false, operation: .move)) + } + + /// Copying into the trash is not a thing — and the alternative would be tombstoning an original + /// the copy grammar had just promised to leave exactly where it was. + @Test("⌥ is refused rather than reinterpreted") + func optionCopyIsRefused() { + #expect(!accepts(operation: .copy)) + } + + /// "The trash stays undroppable-into while hidden, like every gesture." + @Test("Hidden, the trash is invisible to the gesture") + func hiddenIsInert() { + #expect(!accepts(isTrashShown: false)) + } + + @Test("The mutating-gesture rule applies, like every other write the pointer can start") + func theLockAndTheEditorRefuse() { + #expect(!accepts(acceptsMutations: false)) + } +} + // MARK: - The committed-overlay hold @Suite("CommittedHold") @@ -244,7 +331,7 @@ struct DropSettleTests { ) -> DragSession { let session = DragSession() pickUp(session, from: store, members: members) - session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: index)) + session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: index)) return session } @@ -362,11 +449,88 @@ struct DropSettleTests { session.commit(into: store, survivors: [0], operation: .move) session.propose(nil) - session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: 0)) + session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0)) #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2) } + // MARK: The trash's landing + + /// A session proposing into the trash column rather than into a lane — the delete gesture + /// (04-interactions.md ▸ The trash). + private func proposingIntoTheTrash(_ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")]) -> DragSession { + let session = DragSession() + pickUp(session, from: store, members: members) + session.propose(DropTarget( + boardRoot: store.rootURL, + container: .trash, + index: TrashDrop.landingIndex + )) + return session + } + + @Test("The trash's shadow opens at the topmost row, and no lane draws one") + func trashInFlightDrawsTheTopRow() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + 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) + // 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.stripProposal(onBoardRooted: store.rootURL) == nil) + // And they are still lifted out of the lane while the drag is in flight, 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 { + 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) + + 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.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2]) + } + + @Test("A lane session never draws a trash landing") + func laneSessionsHaveNoTrashLanding() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = DragSession() + 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) + } + // MARK: The hand-off @Test("The hand-off clears the hold and the overlay with it — the snapshot is the authority again") diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index 7b6bbc2..c31d308 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -188,6 +188,118 @@ struct TrashDeleteTests { } } +// MARK: - Delete by drop + +/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28): "release +/// tombstones the dragged card(s), exactly the ⌫ tombstone". +/// +/// *Exactly* is the claim under test, and it is a claim about the disk — so these run the two +/// gestures over two identical fixtures and compare the bytes. Where the drop *may* land and what it +/// draws on the way are `TrashDropTests`' and `DropSettleTests`'; here it has already landed. +@MainActor +@Suite("BoardStore ▸ delete by drop") +struct TrashDropWriteTests { + + @Test("A drop-delete is byte-for-byte the ⌫ tombstone") + func indistinguishableFromTheKeystroke() throws { + let byKey = try makeBoard() + defer { byKey.tearDown() } + let byDrop = try makeBoard() + defer { byDrop.tearDown() } + + try BoardStore(rootURL: byKey.root).delete([card1]) + try BoardStore(rootURL: byDrop.root).deleteByDrag(cardIDs: [card1]) + + let keyed = try byKey.indexText("\(Ident.lane1)/\(Ident.card1)") + let dropped = try byDrop.indexText("\(Ident.lane1)/\(Ident.card1)") + // Everything but the two stamps that are clocks rather than content, which differ between any + // two writes at all — including two ⌫ presses. + #expect(untouchedLines(dropped) == untouchedLines(keyed)) + #expect(try FrontmatterDocument.parse(dropped).deleted.value != nil) + #expect(!dropped.contains("modified-by"), "an app-mediated write clears an external writer's attribution") + } + + /// A multi-selection drag carries its whole run across lanes, and the tombstone is the card's own + /// `index.md` and nothing else — the parents are not rewritten to record a child's departure, + /// because nothing departed. + @Test("A cross-lane run lands whole and touches nothing it did not carry") + func theWholeRunLands() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let lane = try stat(fixture, Ident.lane1) + + store.deleteByDrag(cardIDs: [card1, card3]) + + #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) + .deleted.value != nil) + #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane2)/\(Ident.card3)")) + .deleted.value != nil) + let laneAfter = try stat(fixture, Ident.lane1) + #expect(laneAfter.data == lane.data) + #expect(laneAfter.modified == lane.modified) + #expect(store.banners.oneShots.isEmpty) + } + + /// The one thing the drop deliberately does *not* share with ⌫. The keystroke picks a successor + /// because the selection lost its cards and "repeated ⌫ walks down a lane"; a drag's run is not + /// necessarily the selection at all, so re-pointing one that lost nothing would be a bug. The + /// reload's resolve rule ejects tombstoned members from a live-side set on its own. + @Test("A drop-delete never touches the selection, where ⌫ moves it to the successor") + func theSelectionIsLeftAlone() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + // Something else entirely is selected — dragging a card outside the selection drags it alone + // and leaves the selection standing (`LaneView.startCardDrag`). + store.select([card3], liveness: .live, anchor: card3, head: card3) + + store.deleteByDrag(cardIDs: [card1]) + + #expect(store.selection.ids == [card3]) + #expect(store.selection.liveness == .live) + + // The keystroke's contrasting half, over an identical board: ⌫ re-points the selection + // whatever was in it, because it is the gesture that promises to walk down a lane. + let keyed = try makeBoard() + defer { keyed.tearDown() } + let keyedStore = try BoardStore(rootURL: keyed.root) + keyedStore.select([card3], liveness: .live, anchor: card3, head: card3) + + keyedStore.delete([card1]) + + #expect(keyedStore.selection.ids != [card3]) + } + + @Test("Already-tombstoned ids are skipped, and an empty run writes nothing") + func liveOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") + + store.deleteByDrag(cardIDs: [card2]) + store.deleteByDrag(cardIDs: []) + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A read-only board refuses the drop without a second banner") + func readOnlyRefusesQuietly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + + store.deleteByDrag(cardIDs: [card1]) + + #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) + #expect(store.banners.oneShots.isEmpty, "the lock row is already standing") + } +} + // MARK: - Put Back @MainActor