Realign code with the 2026-07-31 rulings

The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 18:35:07 -04:00
parent 542ab169a3
commit bec75e4282
37 changed files with 1200 additions and 551 deletions
+17
View File
@@ -535,6 +535,23 @@ struct BoardDropContext {
cancelDrop()
return false
}
// **A mixed-kind drag never leaves the trash** (04-interactions.md The trash, ruled
// 2026-07-31 with kind-blind trash selection): "pickup is allowed the selection is legal
// but every out-of-trash drop target refuses the mixed payload, and the release surfaces a
// notice explaining the rule the refused drag ends like any refusal, rows staying put".
//
// One guard for every out-of-trash target, because every one of them funnels through this
// commit the lane masonries, the strip, the cross-board arrivals, and the window fallback
// alike. A *within*-trash release cannot reach it: the column declines `.trash`-container
// sessions outright (`TrashDrop.accepts`), so a trash-origin drag never holds a `.trash`
// proposal, and "within-trash drops stay inert" needs no clause here.
//
// The notice is the destination board's strip, which is where the user is looking.
guard !(session.container == .trash && session.mixesKinds) else {
store.banners.postMixedTrashDrag()
cancelDrop()
return false
}
let ids = survivors.map { session.members[$0] }
let folders = survivors.map { session.folders[$0] }
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
+11 -5
View File
@@ -893,14 +893,20 @@ struct BoardView: View {
return .handled
}
/// **-arrow extends, and stops at both boundaries** (04 The trash, settled): "a -arrow whose
/// next step would cross from live cards into the trash (or back), or from card entries onto a
/// lane entry within it, is simply inert".
/// **-arrow extends, and stops at the container boundary** (04 The trash: "-arrow extension
/// still stops at the container boundary") a -arrow whose next step would cross from live
/// cards into the trash, or back, is simply inert.
///
/// **The kind boundary is no longer one of the stops inside the trash** (re-ruled 2026-07-31):
/// "-click ranges, -arrow extension all sweep every row" there, so a -arrow from a trash
/// card onto a lane row extends across it. On the board the kind test stands the live grammar
/// is still cards XOR lanes.
///
/// The *step* that would cross is what goes inert the crossing item is never stepped over in
/// search of a legal one, because that would silently drop the held range for a longer reach
/// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
/// tested: a different side or a different kind means this press does nothing at all.
/// tested: a different side (or, on the board, a different kind) means this press does nothing
/// at all.
private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
guard let origin = marqueeTargets.targets[head],
let nextID = NavigationMath.nearest(
@@ -910,7 +916,7 @@ struct BoardView: View {
),
let next = marqueeTargets.targets[nextID],
next.container == origin.container,
next.kind == origin.kind
next.container == .trash || next.kind == origin.kind
else { return .handled }
// An extension with no anchor makes one of where it started the keyboard's equivalent of
+10 -2
View File
@@ -356,12 +356,19 @@ struct CardFaceView: View {
/// every other card drop uses, committed by the same `moveCards`.
///
/// **Multi-drag carries the whole trash selection**, in the column's own order the order the
/// rows are drawn in, which is `order` ascending like any lane's.
/// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash).
///
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md
/// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather
/// than let them fall silently out of the payload the flag travels and the *drop* refuses with
/// the notice (`DragSession.mixesKinds`). Pickup stays allowed the selection is legal.
private func startTrashCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let ids = draggedIDs
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() }
let mixesKinds = SelectionGrammar.mixesKinds(
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
let root = store.rootURL
let payload = DragPayload(
@@ -381,7 +388,8 @@ struct CardFaceView: View {
folders: payload.folders,
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
container: .trash,
source: store
source: store,
mixesKinds: mixesKinds
)
return payload.itemProvider()
}
+41 -11
View File
@@ -60,12 +60,11 @@ enum TrashDrop {
/// The row the shadow takes, always: **the topmost**.
///
/// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank
/// above the current top" (04-interactions.md The trash), so a fresh delete 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.
/// Not arbitrary, and the stamp is what makes it honest: "every trash arrival stamps `modified`
/// and the trash sorts newest-first by that stamp" (04-interactions.md The trash, re-ruled
/// 2026-07-31), so a fresh delete 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
@@ -284,6 +283,18 @@ final class DragSession {
/// and that is the whole of what makes it a restore (04-interactions.md The trash).
private(set) var container: ItemContainer = .board
/// **Whether the selection this drag was picked up from spanned both kinds** only ever true in
/// the trash, whose selection went kind-blind (04-interactions.md The trash, ruled 2026-07-31).
///
/// A session carries items of exactly one `kind` the pasteboard type is per-kind and a
/// `DragPayload` names one so a mixed selection dragged from a trash row starts a session over
/// *its* kind and silently leaves the other behind. That silence is what this flag exists to
/// prevent: "pickup is allowed the selection is legal but every out-of-trash drop target
/// refuses the mixed payload, and the release surfaces a notice explaining the rule". The refusal
/// is at the commit (`BoardDropContext.commitDrop`), where every drop out of the trash funnels,
/// rather than at hover 04 puts the explanation at the release.
private(set) var mixesKinds = false
/// The dragged items in **flatten order** the order they will land in.
private(set) var members: [ItemID] = []
@@ -496,16 +507,33 @@ final class DragSession {
folders: [URL],
heights: [CGFloat],
container: ItemContainer,
source: BoardStore
source: BoardStore,
mixesKinds: Bool = false
) {
begin(kind: .cards, members: members, folders: folders, container: container, source: source)
begin(
kind: .cards, members: members, folders: folders,
container: container, source: source, mixesKinds: mixesKinds
)
cardHeights = heights
laneUnits = []
}
/// Begins a lane session.
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
begin(kind: .lanes, members: members, folders: folders, container: .board, source: source)
///
/// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in
/// `.trash` the restore at the lane level (04-interactions.md The trash Drag-to-restore).
func beginLanes(
_ members: [ItemID],
folders: [URL],
units: [Int],
container: ItemContainer = .board,
source: BoardStore,
mixesKinds: Bool = false
) {
begin(
kind: .lanes, members: members, folders: folders,
container: container, source: source, mixesKinds: mixesKinds
)
laneUnits = units
cardHeights = []
}
@@ -515,7 +543,8 @@ final class DragSession {
members: [ItemID],
folders: [URL],
container: ItemContainer,
source: BoardStore
source: BoardStore,
mixesKinds: Bool
) {
endHold()
self.kind = kind
@@ -523,6 +552,7 @@ final class DragSession {
self.memberSet = Set(members)
self.folders = folders
self.container = container
self.mixesKinds = mixesKinds
self.sourceStore = source
self.sourceRoot = source.rootURL
self.proposal = nil
+13 -1
View File
@@ -183,6 +183,11 @@ struct TrashLaneRowView: View {
/// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a
/// row outside the selection drags alone the card face's targeting rule at the other level.
///
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md
/// The trash, ruled 2026-07-31): a `.lanes` session cannot carry the cards in it, so rather than
/// drop them silently the flag travels and the release refuses with the notice
/// (`DragSession.mixesKinds`) "pickup is allowed the selection is legal".
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
private func startDrag() -> NSItemProvider {
@@ -190,6 +195,8 @@ struct TrashLaneRowView: View {
let ids = draggedIDs
let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() }
let mixesKinds = SelectionGrammar.mixesKinds(
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
let root = store.rootURL
let payload = DragPayload(
@@ -212,7 +219,12 @@ struct TrashLaneRowView: View {
// standard width; the restored lane then draws at whatever `width` its untouched
// frontmatter still says, on the next reload.
units: rows.map { _ in 1 },
source: store
// **The session's container is this row's**, matching the payload's which is what
// routes the release to `BoardStore.restoreLanes` (the move *out* of `.trash/`) rather
// than to `moveLanes`' strip permutation, and what the mixed-payload exit keys on.
container: .trash,
source: store,
mixesKinds: mixesKinds
)
return payload.itemProvider()
}
+10 -9
View File
@@ -12,7 +12,7 @@ import SwiftUI
///
/// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing
/// to draw differently: the column renders `store.snapshot.trash` which the loader parsed with the
/// same card parse the lanes use and sorted by `order` like any lane's children through the very
/// same card parse the lanes use and sorted by `modified` descending, the trash's own order through the very
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
@@ -24,11 +24,11 @@ import SwiftUI
/// **A trashed lane is an opaque unit** "one distinct dimmed row showing its title and held-card
/// count no styling accents, never expandable" (03 § Trash) so it gets its own small view
/// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what
/// the design says this row must not. The two kinds **interleave purely by trash rank**, which is
/// the design says this row must not. The two kinds **interleave by `modified` descending**, which is
/// `BoardModel.trashEntries`' merge and not a second one written here.
///
/// Newest-first falls out of the ranks (every arrival of either kind mints one above the current
/// top), so there is no timestamp sort and no entry type here at all.
/// Newest-first falls out of the stamp (every arrival of either kind restamps `modified` on the way
/// in 03 § Trash, re-ruled 2026-07-31), so no rank is minted anywhere in this container.
///
/// ### What makes it a column and not a lane
///
@@ -52,9 +52,9 @@ import SwiftUI
/// trash, lanes extended 2026-07-29) the drag becomes the pointer's delete gesture, and release
/// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop`
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board,
/// unmodified. It diverges from every other drop in one way, and the ranks are what make the
/// divergence honest: **the shadow always takes the topmost row**, because every arrival mints a rank
/// above the current top.
/// unmodified. It diverges from every other drop in one way, and the stamp is what makes the
/// divergence honest: **the shadow always takes the topmost row**, because every arrival restamps
/// `modified` and the column is sorted by it, newest first.
///
/// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's
/// drag is an ordinary `.cards` session in the `.trash` container
@@ -155,8 +155,9 @@ struct TrashLaneView: View {
))
}
/// The rows the column shows **both kinds, interleaved by rank** (03-board-ui.md § Trash:
/// "Lane rows and cards interleave in the one trash column purely by trash rank").
/// The rows the column shows **both kinds, interleaved by `modified` descending**
/// (03-board-ui.md § Trash: "Lane rows and cards interleave in the one trash column by `modified`
/// descending").
///
/// **Shown, the trash's contents "participate in the filter exactly like any other card"**
/// (03 § Trash "the point of the pivot"), so the search predicate narrows this collection