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
+21
View File
@@ -554,6 +554,27 @@ public final class BannerCenter {
postLoss(Self.skippedFoldersMessage(count: count))
}
/// **The mixed-kind drag that tried to leave 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".
///
/// **A loss row**, with the relocation family and `postSkippedFolders`: nothing failed no write
/// was attempted and nothing is wrong with the board, but the gesture the user made did not
/// happen, which is exactly the warning-tone "didn't arrive" register. The wording is 04's own,
/// verbatim, and lives here because BannerCenter owns the phrasing ( Clipboard).
///
/// It is the *teaching* half of the guard: C/X grey out silently (menu validation says no
/// before the gesture starts), while the drag has no such surface to say it in advance so the
/// drop-time explanation "is where the rule teaches itself".
public func postMixedTrashDrag() {
postLoss(Self.mixedTrashDragMessage)
}
/// 04-interactions.md The trash's own sentence.
nonisolated static let mixedTrashDragMessage =
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. **An id that names
/// an in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel"
/// are different promises and a row that offers one must never quietly do the other.
+71 -98
View File
@@ -2343,8 +2343,10 @@ public final class BoardStore: HealHost {
let rendered = snapshot.lanes
let target = min(max(0, index), rendered.count)
// What an undo puts back: the rank the row held in the trash, captured before the write.
var arrivals: [(id: ItemID, order: Double, trashRank: Double, title: String?)] = []
// What an undo puts back: the `order` the row was **carrying** while trashed its old strip
// rank, which the trash move never rewrote (01-storage-format.md § Deletion, re-ruled
// 2026-07-31) and which this restore is about to overwrite with a drop-position rank.
var arrivals: [(id: ItemID, order: Double, carriedOrder: Double, title: String?)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// The shared two-step; the arriving lanes are not among the renumbered children.
guard let placed = try HealScheduler.placingRanks(
@@ -2365,29 +2367,31 @@ public final class BoardStore: HealHost {
arrivals.append((
id: member.id,
order: rank,
trashRank: member.order,
carriedOrder: member.order,
title: member.title.value
))
}
}
guard landed != nil, !arrivals.isEmpty else { return }
// restore-by-move **move back in** (13-native-undo.md Interaction with the trash), at the
// trash rank the row was holding the delete's step read from the other end.
// restore-by-move **move back in** (13-native-undo.md Interaction with the trash),
// carrying back the `order` the row had while it sat there. The restore is what overwrote it
// (a move writes a landing rank), so putting it back is what makes this a true inverse
// there is no *trash* rank involved either way, since the trash's own sequence is `modified`.
let trashFolder = Self.parentFolder(of: .trashLane(arrivals[0].id), under: root)
let steps = arrivals.map { arrival in
(
restored: ItemPath.lane(arrival.id).folder(under: root),
trashed: ItemPath.trashLane(arrival.id).folder(under: root),
order: arrival.order,
trashRank: arrival.trashRank
carriedOrder: arrival.carriedOrder
)
}
registerStep(
HistoryPhrase.name(.move, kind: .lane, count: steps.count),
subject: arrivals.count == 1 ? arrivals[0].title : nil,
undoExpects: steps.map { .present($0.restored, .order($0.order)) },
redoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) }
redoExpects: steps.map { .present($0.trashed, .order($0.carriedOrder)) }
) { _ in
for step in steps {
_ = try BoardWriter.moveItem(
@@ -2395,7 +2399,7 @@ public final class BoardStore: HealHost {
toParent: trashFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.trashRank
order: step.carriedOrder
)
}
} redo: { _ in
@@ -3492,18 +3496,17 @@ public final class BoardStore: HealHost {
return (lane, id)
}
/// **The delete write itself: a physical move into `<root>/.trash/`, at a freshly minted top
/// rank** one `performWrite` bracket whatever the set's size and whichever gesture asked.
/// **The delete write itself: a physical move into `<root>/.trash/`** one `performWrite`
/// bracket whatever the set's size and whichever gesture asked.
///
/// Spelled once so , drop-on-trash and the card window's button cannot drift apart on disk;
/// everything that differs between them is about the *selection*, and lives in the callers.
///
/// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the
/// trash's topmost position, minting an `order` rank above the current top"). That is a question
/// about the snapshot, which the stateless Writer does not have so `Ranks.insertAtHead` runs
/// here over `trashRanks`, and a multi-card delete threads the minted rank back through the
/// running list so each card in the run lands above the one before it. Newest-first therefore
/// falls out of ordinary ranks, with no timestamp sort anywhere.
/// **There is no rank to mint** (03-board-ui.md § Trash, re-ruled 2026-07-31: "newest-first with
/// no `order` rewrite, no rank minting, the item's `order` key riding along untouched for its
/// eventual restore"). The trash sorts by `modified` descending and the move stamps it, so the
/// position is the Writer's own doing and the store has no snapshot question left to answer
/// the head-of-the-trash ladder this method used to thread through the run retired with the rule.
///
/// - Returns: whether the write landed, so a caller can decide what to do with the selection.
@discardableResult
@@ -3517,23 +3520,11 @@ public final class BoardStore: HealHost {
guard !moves.isEmpty else { return false }
let root = rootURL
// The ranks, minted against the trash as it stands and threaded forward: each arrival is
// above the previous one, so a three-card reads newest-first in the column exactly as three
// separate deletes would.
var ladder = trashRanks
var ranks: [Double] = []
for _ in moves {
let rank = Ranks.insertAtHead(ofVisible: ladder)
ranks.append(rank)
ladder.insert(rank, at: 0)
}
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for (move, rank) in zip(moves, ranks) {
for move in moves {
try BoardWriter.deleteCardToTrash(
at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
inBoard: root,
order: rank
inBoard: root
)
}
}
@@ -3543,28 +3534,29 @@ public final class BoardStore: HealHost {
// trash: "a card delete is a move into `.trash/`, so its undo is the ordinary inverse move,
// returning the card to its source lane and rank").
//
// The redo replays the *forward* write with its own captured rank, exactly as every other
// redo in this file replays the values its gesture wrote so a redone delete lands the card
// back where the undo took it from, rather than at whatever the top of the trash has become
// in the meantime.
// The redo replays the forward write, which now takes no values at all a delete is a folder
// move plus a fresh stamp, and a redone delete lands where a first one would.
//
// **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`):
// the undo wants the card in the trash holding the rank the delete gave it; the redo wants it
// back in its lane holding the rank it left. A foreign restore empties the trash path and the
// undo skips; a foreign re-delete empties the lane path and the redo skips.
let steps = zip(moves, ranks).map { move, rank in
// the undo wants the card **in the trash**, and that is the whole of it 13's field-level
// predicate compares "what its write set", and this write sets no field an expectation can
// name (the `modified` stamp is a clock reading, not a value the step chose). Existence is
// the honest expectation, and it is the one that matters: a foreign restore empties the trash
// path and the undo skips. The redo's side is unchanged and still field-level, because the
// *undo* set it: the card back in its lane holding the rank it left. A foreign re-delete
// empties the lane path and the redo skips.
let steps = moves.map { move in
(
trashed: ItemPath.trashCard(move.id).folder(under: root),
origin: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
laneFolder: ItemPath.lane(move.laneID).folder(under: root),
priorOrder: move.order,
trashRank: rank
priorOrder: move.order
)
}
registerStep(
HistoryPhrase.name(.delete, kind: .card, count: steps.count),
subject: moves.count == 1 ? moves[0].title : nil,
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
undoExpects: steps.map { .present($0.trashed) },
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in
for step in steps {
@@ -3578,7 +3570,7 @@ public final class BoardStore: HealHost {
}
} redo: { _ in
for step in steps {
try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root, order: step.trashRank)
try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root)
}
}
return true
@@ -3589,8 +3581,8 @@ public final class BoardStore: HealHost {
/// exactly as a card moves The no-dialog posture survives for a better reason: the move is
/// recoverable, so nothing needs confirming").
///
/// **`moveToTrash`'s twin, and deliberately its mirror image**: the same head-of-the-trash rank
/// mint threaded through the run, the same one bracket, the same one step because on disk it is
/// **`moveToTrash`'s twin, and deliberately its mirror image**: no rank, the same one bracket,
/// the same one step because on disk it is
/// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard
/// it passes and the `kind` it stamps). What is *not* here any more is the whole capture layer:
/// the lane's bytes never leave the disk, so nothing has to hold them (13-native-undo.md
@@ -3607,20 +3599,11 @@ public final class BoardStore: HealHost {
guard !lanes.isEmpty else { return false }
let root = rootURL
var ladder = trashRanks
var ranks: [Double] = []
for _ in lanes {
let rank = Ranks.insertAtHead(ofVisible: ladder)
ranks.append(rank)
ladder.insert(rank, at: 0)
}
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for (lane, rank) in zip(lanes, ranks) {
for lane in lanes {
try BoardWriter.deleteLaneToTrash(
at: ItemPath.lane(lane.id).folder(under: root),
inBoard: root,
order: rank
inBoard: root
)
}
}
@@ -3628,26 +3611,24 @@ public final class BoardStore: HealHost {
// lane delete **the ordinary move back** (13-native-undo.md Interaction with the trash:
// "a lane [returns] to its strip position (subtree intact it never left the folder)"). The
// redo replays the forward write with its own captured trash rank, exactly as the card
// delete's does, so a redone delete lands the lane where the undo took it from rather than at
// whatever the top of the trash has become in the meantime.
// redo replays the forward write, which takes no values the card delete's shape exactly.
//
// The expectations are one swap, and the container rides in the path (`HistoryStaleness`):
// the undo wants the lane in the trash holding the rank the delete gave it, the redo wants it
// back on the strip holding the rank it left. A foreign restore empties the trash path and
// the undo skips; a foreign re-delete empties the strip path and the redo skips.
let steps = zip(lanes, ranks).map { lane, rank in
// the undo wants the lane **in the trash** (existence alone the delete sets no field an
// expectation can name, `moveToTrash`' note), the redo wants it back on the strip holding the
// rank the undo put back. A foreign restore empties the trash path and the undo skips; a
// foreign re-delete empties the strip path and the redo skips.
let steps = lanes.map { lane in
(
trashed: ItemPath.trashLane(lane.id).folder(under: root),
origin: ItemPath.lane(lane.id).folder(under: root),
priorOrder: lane.order,
trashRank: rank
priorOrder: lane.order
)
}
registerStep(
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
subject: lanes.count == 1 ? lanes[0].title.value : nil,
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
undoExpects: steps.map { .present($0.trashed) },
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
) { _ in
for step in steps {
@@ -3661,19 +3642,12 @@ public final class BoardStore: HealHost {
}
} redo: { _ in
for step in steps {
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root, order: step.trashRank)
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root)
}
}
return true
}
/// Every rank the trash currently holds, **both kinds** what a fresh arrival mints above
/// (03-board-ui.md § Trash: "Every arrival lands at the top regardless of kind", so the ladder
/// is the whole container and not one array of it).
private var trashRanks: [Double] {
snapshot.trash.map(\.order) + snapshot.trashedLanes.map(\.order)
}
/// **The trash's own Delete permanent** (03-board-ui.md § Trash: "on a trash selection, Delete
/// (/) is permanent in the trash it removes the folder").
///
@@ -3763,15 +3737,17 @@ public final class BoardStore: HealHost {
/// notice"). Old tombstoned lanes reappearing is the accepted cost, stated in the ruling; a
/// tombstoned lane's own cards still migrate on their own account, as ordinary tombstoned cards.
///
/// ### The order among migrating cards is `deleted:`-ascending, deliberately
/// ### The column order is the stamps', not the batch's
///
/// Every arrival mints a rank above the current top, so the *last* card migrated ends up topmost.
/// Migrating oldest-first therefore reproduces the newest-first column the tombstone model's
/// timestamp sort used to render the same board, read the same way, with ordinary ranks doing
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
/// walk order lane `order`, then card `order` which is the deterministic tie-break the whole
/// corpus already uses.
/// **Each migrated card takes its own `deleted:` timestamp as its `modified`** where it parses
/// (01-storage-format.md § Deletion, re-ruled 2026-07-31; `BoardWriter.migrateTombstonedCard`),
/// so the board's real deletion order survives into the trash's `modified`-descending sort no
/// matter what order the batch runs in the sequencing that used to *be* the ordering is now
/// only a batch order. It is kept, `deleted:`-ascending, for determinism: the notice's card list
/// and the commit's path order read the same way twice. A card whose stamp is missing or
/// unparseable sorts as **oldest** here and takes migration time as its `modified`, landing it
/// among the freshest the honest reading, since a stamp that cannot be read is no evidence of
/// when the card was deleted; ties fall to the loader's walk order.
///
/// ### The write half re-verifies against disk
///
@@ -3786,9 +3762,6 @@ public final class BoardStore: HealHost {
let cards = Self.migrationOrder(of: work, in: snapshot)
var movedCards: [String?] = []
// The ranks are minted exactly as a delete's are head of the trash, threaded forward so a
// migrated card is indistinguishable on disk from one the user deletes today.
var ladder = trashRanks
heals.run(
.legacyTombstone,
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
@@ -3797,9 +3770,7 @@ public final class BoardStore: HealHost {
for card in cards {
let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root)
guard Self.stillTombstoned(at: folder) else { continue }
let rank = Ranks.insertAtHead(ofVisible: ladder)
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
ladder.insert(rank, at: 0)
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root)
movedCards.append(card.title)
}
} posting: {
@@ -3823,8 +3794,9 @@ public final class BoardStore: HealHost {
return !document.deleted.isMissing
}
/// The tombstoned cards in the order they should be filed into the trash oldest `deleted:`
/// first, so the newest ends up on top (see `migrateLegacyTombstones`).
/// The tombstoned cards in the order the batch files them oldest `deleted:` first, a
/// deterministic batch order rather than the column's (see `migrateLegacyTombstones`: each card's
/// own stamp decides where it lands).
///
/// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the
/// key rather than relied on: an unparseable or missing stamp takes `Date.distantPast` and ties
@@ -4207,14 +4179,15 @@ public final class BoardStore: HealHost {
/// trash's own reading of the same command when the trash side is the one in play.
///
/// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**,
/// the selection is in the trash, and it still names a card the exact conditions under which
/// "all" could mean anything but the board (04 The map, resettled 2026-07-28: "with the trash
/// visible and a non-empty trash selection, Select All selects all visible trash cards; in every
/// other state, all visible live cards the container boundary decides which 'all' is meant").
/// A trash selection naming nothing (a foreign restore, a purge) falls through to the board
/// rather than selecting the trash wholesale on a guess. **Select All is card-scoped in both
/// containers, never lane rows** (04 The trash, re-affirmed 2026-07-29 with lanes back in the
/// trash), which is why the trash branch reads its cards and asks no kind question.
/// the selection is in the trash, and it still names a row the exact conditions under which
/// "all" could mean anything but the board (04 The map, resettled 2026-07-28: "the container
/// boundary decides which 'all' is meant"). A trash selection naming nothing (a foreign restore,
/// a purge) falls through to the board rather than selecting the trash wholesale on a guess.
///
/// **In the trash "all" is all *rows*, both kinds** (04 The trash and 11-command-nexus.md
/// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
/// trash selection selects **all visible trash rows**"). The board's own Select All stays
/// card-scoped, as everywhere.
///
/// The anchor and the navigation head with it **survives if it is still in the set** and is
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
@@ -4227,7 +4200,7 @@ public final class BoardStore: HealHost {
let filter = searchFilter
if transient.isTrashVisible, selection.container == .trash, !selection.isEmpty,
SelectionGrammar.kind(of: selection, in: snapshot) != nil {
apply(Set(SelectionGrammar.trashCards(in: snapshot, filter: filter)), in: .trash)
apply(Set(SelectionGrammar.trashRows(in: snapshot, filter: filter)), in: .trash)
return
}
apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board)
+37 -15
View File
@@ -76,12 +76,12 @@ extension ItemContainer {
// MARK: - The trash's two kinds, in one order
/// One row of the trash column a card, or a trashed lane's opaque unit (03-board-ui.md § Trash,
/// re-ruled 2026-07-29: "Lane rows and cards interleave in the one trash column purely by trash
/// rank").
/// re-ruled 2026-07-31: "Lane rows and cards interleave in the one trash column by `modified`
/// descending").
///
/// **It exists so the interleave is written once.** The container's two kinds are two arrays on the
/// snapshot, for the reason `BoardModel.trash` states a trashed card is an ordinary card every
/// card-shaped surface already reads, a trashed lane is an opaque row none of them may but *rank
/// card-shaped surface already reads, a trashed lane is an opaque row none of them may but *column
/// order* is a question about the container as a whole, and it is asked by the column that draws the
/// rows, by the grammar that ranges and navigates over them, and by the path resolver that batches
/// them. Three merges would be three chances to disagree about what "the row below this one" is.
@@ -100,8 +100,28 @@ public enum TrashEntry: Identifiable, Sendable, Equatable {
}
}
/// The rank that decides where this row sits among the others the one field both kinds carry
/// for the same purpose.
/// **The stamp that decides where this row sits among the others** the trash sorts by
/// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
/// move is what writes it. `nil` for an entry whose mover skipped the restamp, which sorts below
/// every dated sibling (`Ranks.isOrderedForTrash`).
public var modified: Date? {
switch self {
case let .card(card): card.modified.value
case let .lane(lane): lane.modified.value
}
}
/// The first tie-break's key the title as the row draws it, `nil` when the entry has none.
public var title: String? {
switch self {
case let .card(card): card.title.value
case let .lane(lane): lane.title.value
}
}
/// The rank the entry is *carrying*, untouched by the trash move its position among the lane
/// (or board) siblings it left, which a restore returns it to. Deliberately **not** what orders
/// this row: the trash is sorted by `modified` (above).
public var order: Double {
switch self {
case let .card(card): card.order
@@ -131,15 +151,17 @@ public enum TrashEntry: Identifiable, Sendable, Equatable {
extension BoardModel {
/// The trash's rows, top to bottom **the container's one order**, both kinds interleaved by
/// rank (03-board-ui.md § Trash).
/// `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "The merged order is one
/// derivation a second implementation of 'the row below this one' is a bug by definition").
///
/// The tie-break is the folder name's, `Ranks.sortedForDisplay`'s own, which is what the loader
/// already applied within each kind: two rows minted the same rank by two writers order the same
/// way twice.
/// The tail is `Ranks.isOrderedForTrash`'s title case-insensitively, then folder name which
/// is what the loader already applied within each kind, so the merge of two sorted arrays and
/// each array alone agree everywhere they overlap.
public var trashEntries: [TrashEntry] {
Ranks.sortedForDisplay(
Ranks.sortedForTrash(
trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane),
order: \.order,
modified: \.modified,
title: \.title,
name: { $0.id.rawValue }
)
}
@@ -230,10 +252,10 @@ extension ItemPath {
/// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice
/// for the same reason).
///
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows
/// and cards interleave in the one trash column purely by trash rank"), which is why the walk is
/// `trashEntries` rather than the two arrays concatenated: the column's order is the batch's
/// order, and it is stated in exactly one place.
/// **The trash's order interleaves its two kinds by `modified`** (03-board-ui.md § Trash: "lane
/// rows and cards interleave in the one trash column by `modified` descending"), which is why the
/// walk is `trashEntries` rather than the two arrays concatenated: the column's order is the
/// batch's order, and it is stated in exactly one place.
///
/// Ids the container does not hold are simply absent, which is every caller's standing posture:
/// a selection the next reload will drop writes nothing rather than being refused.
+139 -69
View File
@@ -58,14 +58,20 @@ public enum ClickModifier: Sendable, Equatable {
/// function of the click, the current selection, the anchor, and the snapshot
/// (`SelectionGrammarTests`).
///
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
/// homogeneous on **two** axes cards XOR lanes (§ Selection) and board XOR trash (§ The trash's
/// "single container rule replacing the old liveness law") and since lanes rejoined the trash
/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is
/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a
/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier
/// that would cross an axis degrades to a replace, which is the only answer that keeps the invariant
/// true without silently dropping what the user asked for.
/// **The container is the invariant, and it is enforced here or nowhere.** A selection never mixes
/// trash rows with board items (§ The trash's "single container rule replacing the old liveness
/// law"), and *on the board* it is also cards XOR lanes (§ Selection). **Inside the trash it is
/// kind-blind** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash
/// grammar): "within the trash cards and lane rows select together clicks, -click ranges,
/// -arrow extension, and the rubber band all sweep every row". The kind axis therefore stops at the
/// container boundary rather than reaching through it, and the guard the trash used to need moved to
/// the exits the mixed-payload drop refusal and C/X validation (§ The trash), since "inside the
/// trash the only verbs are Delete and the restore paths, so upstream homogeneity bought nothing the
/// exits don't".
///
/// What has not changed is what a modifier does when it *would* cross an axis that still stands: it
/// degrades to a replace, which is the only answer that keeps the invariant true without silently
/// dropping what the user asked for.
///
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
@@ -157,14 +163,20 @@ public enum SelectionGrammar {
)
}
/// **-click toggles** but only *within* a homogeneous set. Crossing either axis (a card
/// clicked while lanes are selected, a trash card clicked while board cards are) is not a mixed
/// selection and not a refusal: it is a **replace**, the same outcome a plain click would give,
/// because the click unambiguously names a new set of one.
/// **-click toggles** but only *within* a set it can legally join. Crossing the container (a
/// trash row clicked while board cards are selected) or, **on the board**, the kind (a card
/// clicked while lanes are selected) is not a mixed selection and not a refusal: it is a
/// **replace**, the same outcome a plain click would give, because the click unambiguously names
/// a new set of one.
///
/// **Inside the trash the kind clause simply does not apply** (04-interactions.md The trash,
/// re-ruled 2026-07-31): a -click on a lane row extends a set of trash cards, because there
/// "cards and lane rows select together". The container clause is untouched.
///
/// The current kind is derived from the snapshot rather than remembered (`kind(of:in:)`); a
/// selection whose members all name nothing the board renders counts as empty, so a -click
/// after a foreign delete starts a fresh set rather than extending a ghost.
/// after a foreign delete starts a fresh set rather than extending a ghost which is why the
/// call stands even in the trash, where its *answer* no longer gates anything.
private static func command(
_ target: SelectionTarget,
selection: ItemReferenceSet,
@@ -172,7 +184,7 @@ public enum SelectionGrammar {
) -> Outcome {
guard selection.container == target.container,
let current = kind(of: selection, in: snapshot),
current == target.kind
target.container == .trash || current == target.kind
else {
return plain(target, selection: selection, togglesOnRepeat: false)
}
@@ -196,8 +208,10 @@ public enum SelectionGrammar {
/// origin rather than walking it along.
///
/// The anchor is valid **iff both it and the target sit in the same order list** which folds
/// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is
/// exactly one (container, kind) pair. An invalid anchor makes the click a plain one, never a no-op:
/// the nil anchor, the vanished anchor, and every standing axis crossing into one test, since a
/// list is one container, and on the board one kind of it (`order(of:in:)`; inside the trash the
/// list is the whole column, so a range from a card to a lane row is an ordinary range).
/// An invalid anchor makes the click a plain one, never a no-op:
/// the keyboard's -arrow goes inert at a boundary because its next step is ambiguous, while a
/// click names an unambiguous target and so always has something to do.
private static func shift(
@@ -235,8 +249,8 @@ public enum SelectionGrammar {
/// pointer and the keyboard to disagree about what a range is.
///
/// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
/// anchor's caller-side absence, and every axis crossing into one test a list is exactly one
/// (container, kind) pair. The callers differ on what they do with that: a click degrades to a
/// anchor's caller-side absence, and every standing axis crossing into one test. The callers
/// differ on what they do with that: a click degrades to a
/// plain click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous).
///
@@ -276,21 +290,27 @@ public enum SelectionGrammar {
/// the opaque unit's own (03-board-ui.md § Trash: "The row matches the search filter by lane
/// title only") a trashed lane is a row in a column, not a container a query can empty.
///
/// **Both trash lists are kind-narrowed slices of one order** (`BoardModel.trashEntries`,
/// 04-interactions.md The trash: "-click ranges skip rows of the other kind"), which is what
/// makes a range skip the other kind rather than needing a rule that says so: a list is exactly
/// one (container, kind) pair, and the rows in between simply are not in it.
/// **The trash has one list, whatever the kind** (04-interactions.md The trash, re-ruled
/// 2026-07-31 superseding the kind-narrowed slices this returned while the trash's grammar was
/// kind-homogeneous): "-click ranges sweep every row". So a trash range walks
/// `BoardModel.trashEntries` the column as drawn and picks up rows of both kinds between its
/// endpoints, which is the ruling implemented as an absence rather than as a clause. The `kind`
/// argument is simply not consulted there; on the board it still names one of two lists, because
/// the live board is still cards XOR lanes.
public static func order(
of kind: SelectionKind,
in container: ItemContainer,
snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> [ItemID] {
switch (container, kind) {
case (.board, .card): boardCards(in: snapshot, filter: filter)
case (.board, .lane): lanes(in: snapshot)
case (.trash, .card): trashCards(in: snapshot, filter: filter)
case (.trash, .lane): trashLanes(in: snapshot, filter: filter)
switch container {
case .trash:
return trashRows(in: snapshot, filter: filter)
case .board:
switch kind {
case .card: return boardCards(in: snapshot, filter: filter)
case .lane: return lanes(in: snapshot)
}
}
}
@@ -325,12 +345,15 @@ public enum SelectionGrammar {
}
/// The trash's cards, top to bottom `snapshot.trash` itself, which the loader already sorted
/// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like
/// any lane", newest-first falling out of the ranks rather than a timestamp sort).
/// by `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "the trash sorts by
/// `modified` descending", newest-first falling out of the stamp rather than a minted rank).
///
/// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the
/// filter exactly like any other card") the same predicate `TrashLaneView` applies to the same
/// cards, so a trash-side range walks exactly what the column is showing.
/// filter exactly like any other card").
///
/// **Not the ranging grammar's list any more** (kind-blind trash selection, 2026-07-31 see
/// `order(of:in:)`): this is the kind-narrowed slice, kept for the consumers that genuinely mean
/// "the trash's *cards*" and for the search suite that pins the predicate.
public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trash.filter { filter.matches($0) }.map(\.id)
}
@@ -338,20 +361,21 @@ public enum SelectionGrammar {
/// The trash's **lane rows**, top to bottom the opaque units (03-board-ui.md § Trash, lanes
/// rejoined 2026-07-29), filtered by title alone (`SearchFilter.matches(_ lane:)`).
///
/// Its own list rather than a kind flag on `trashCards` because that is what an order list *is*
/// here: one (container, kind) pair, and the pair is what a -range walks. The rows' positions
/// among the cards are `trashRows`' business.
/// Its own list rather than a kind flag on `trashCards`, and for the same reason that one
/// survives: some consumers mean the rows of one kind. The rows' positions among the cards are
/// `trashRows`' business.
public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id)
}
/// **Every row the trash column shows, both kinds, in rank order** what *navigation* walks
/// (04-interactions.md The trash: "inside, plain arrows walk every row, card and lane row
/// alike (navigation crosses kinds)").
/// **Every row the trash column shows, both kinds, in the column's own order** what
/// *everything* in the trash walks (04-interactions.md The trash: "inside, plain arrows walk
/// every row, card and lane row alike", and since 2026-07-31 the ranging grammar too: "clicks,
/// -click ranges, -arrow extension, and the rubber band all sweep every row").
///
/// The deliberate counterpart to the two lists above: extension and ranging are per-kind, so
/// they stop at a kind boundary, while navigation is over the column as drawn and crosses it.
/// One merge for both `BoardModel.trashEntries`.
/// Navigation and ranging read the same sequence, which is what the kind-blind ruling bought:
/// there is no longer a per-kind list that could disagree with the column about "the row below
/// this one". One merge for all of it `BoardModel.trashEntries`.
public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
snapshot.trashEntries.filter { filter.matches($0) }.map(\.id)
}
@@ -360,15 +384,19 @@ public enum SelectionGrammar {
/// Which level the selection holds, or `nil` when it holds nothing its container renders.
///
/// **Any member answers, because the set is homogeneous** but the walk is the snapshot's order
/// rather than the set's iteration order, so the answer is deterministic even for a set that
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
/// as empty: a selection the next reload will drop must not decide what a click does now.
/// **On the board any member answers, because the set is homogeneous there** but the walk is
/// the snapshot's order rather than the set's iteration order, so the answer is deterministic
/// even for a set that somehow was not. Members that name nothing are ignored, and a set of only
/// such members reads as empty: a selection the next reload will drop must not decide what a
/// click does now.
///
/// **The trash answers for both kinds** (04-interactions.md The trash, lanes rejoined
/// 2026-07-29: "a trash selection is either cards or lane rows, kind-homogeneous like the live
/// board's own grammar"), walked in the column's own rank order so a set that somehow held both
/// answers by what is topmost rather than by array iteration order.
/// **In the trash it answers for the topmost row, and a mixed set is legal** (04-interactions.md
/// The trash, re-ruled 2026-07-31 trash selection is kind-blind). Two callers still want it
/// there and neither is asking about homogeneity: the -click branch uses it as a liveness test
/// ("does this selection still name anything"), and the clipboard's capture wants the payload's
/// kind which is sound precisely because Cut and Copy are validated against
/// `mixesKinds(_:in:)` first. Anything that needs to know whether the set is of one kind asks
/// that, never this.
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
guard !selection.isEmpty else { return nil }
switch selection.container {
@@ -383,6 +411,45 @@ public enum SelectionGrammar {
}
}
/// **Whether the selection names rows of both kinds** the predicate the *exits* are validated
/// against now that the trash's selection grammar is kind-blind (04-interactions.md The trash,
/// ruled 2026-07-31: "The guard moves to the exits (the mixed-payload drop refusal and C/X
/// validation)").
///
/// Only the trash can answer `true`: the live board's grammar is still cards XOR lanes, and its
/// branch is a walk rather than a `false` so a caller cannot be misled by a set some future
/// gesture built wrongly.
///
/// Rows the container no longer holds are ignored, like everywhere else here a selection whose
/// lane row a foreign purge took is a single-kind selection now, and greying out C for a ghost
/// would be a refusal the user cannot see the reason for.
public static func mixesKinds(_ selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
guard !selection.isEmpty else { return false }
var seen: SelectionKind?
switch selection.container {
case .trash:
for entry in snapshot.trashEntries where selection.ids.contains(entry.id) {
guard let seen else {
seen = entry.kind
continue
}
if seen != entry.kind { return true }
}
case .board:
for lane in snapshot.lanes {
if selection.ids.contains(lane.id) {
if seen == .card { return true }
seen = .lane
}
if lane.cards.contains(where: { selection.ids.contains($0.id) }) {
if seen == .lane { return true }
seen = .card
}
}
}
return false
}
// MARK: - Successor on delete
/// What selects after deleting `ids` 04-interactions.md The map's Finder-style
@@ -407,7 +474,7 @@ public enum SelectionGrammar {
/// staged by place"): `container` says which side the gesture ran on, and the trash walks its own
/// ordered rows exactly as a lane walks its own cards. The permanent delete is as deliberate an
/// act as the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
/// **The trash's own successor crosses kinds** an interim, and the branch below says why.
/// **The trash's own successor is kind-blind** ruled, and the branch below says so.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
@@ -430,15 +497,13 @@ public enum SelectionGrammar {
let siblings: [ItemID]
switch (container, kind) {
case (.trash, _):
// **The siblings are every row, both kinds** the interim answer to an open gap. 04
// The trash settles navigation (plain arrows cross kinds) and extension (-arrows stop
// at the kind boundary) for the trash's two kinds, but says nothing about which row the
// *successor* lands on after a lane row is purged. Until that is ruled, this follows
// navigation rather than extension: the successor is the next row down the column
// whatever its kind, so a purge never strands the selection with nothing selected while
// rows the user can see sit right below it. The conservative direction the alternative
// (kind-scoped siblings) clears the selection whenever the purged row was its kind's
// last, which is a deselect wearing a successor's clothes.
// **The siblings are every row, both kinds** (04-interactions.md The map, ruled
// 2026-07-31 ratifying what stood here as an interim): "In the trash the successor walk
// is kind-blind: the next row of either kind, in the same all-rows order plain arrows
// walk a successor is a fresh singleton selection, so the landing violates no grammar,
// and repeated empties a mixed trash without dead-ends". The alternative kind-scoped
// siblings clears the selection whenever the purged row was its kind's last, which is a
// deselect wearing a successor's clothes.
siblings = trashRows(in: snapshot, filter: filter)
case (.board, .lane):
siblings = lanes(in: snapshot)
@@ -489,16 +554,15 @@ public struct MarqueeTarget: Sendable, Equatable {
/// - **The band stays on the side of the boundary it started on** ( The trash), which is why
/// `container` is a parameter rather than something derived from what the rect happens to touch: a
/// band begun on the board and dragged over the trash column selects board cards and nothing else.
/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands
/// across lanes" across them, not over them). A live lane is never registered as a target at
/// all; a **trashed lane row is**, because the arrows navigate by the same frames
/// (`NavigationMath`) and the band's begin guard reads them too so the kind filter below is
/// load-bearing rather than belt over braces.
///
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the
/// side it started on and selects cards only (as the board marquee does); lane rows join by click
/// grammar" (04-interactions.md The trash, re-affirmed 2026-07-29). A band never has to break a
/// tie between a card and a lane row, because a lane row is not in the answer whatever it sweeps.
/// - **On the board the band never selects lanes** (§ Selection gives it to cards: "click-drag
/// rubber-bands across lanes" across them, not over them). A live lane is never registered as a
/// target at all, so that half is true twice over.
/// - **In the trash it sweeps every row** (04-interactions.md The trash, re-ruled 2026-07-31
/// superseding the card-only band this enforced while the trash's grammar was kind-homogeneous):
/// "the rubber band [sweeps] every row (the band's full-height backdrop covers both kinds)". A
/// trashed lane row is registered as a target the arrows navigate by the same frames
/// (`NavigationMath`) and the band's begin guard reads them so dropping the kind filter on that
/// side is the whole of the change.
public enum MarqueeMath {
/// The ids `rect` sweeps.
@@ -509,7 +573,13 @@ public enum MarqueeMath {
) -> Set<ItemID> {
Set(
targets.lazy
.filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) }
.filter { target in
guard target.container == container, rect.intersects(target.frame) else {
return false
}
// Kind-blind in the trash, card-only on the board.
return container == .trash || target.kind == .card
}
.map(\.id)
)
}
+3 -3
View File
@@ -11,9 +11,9 @@ import Foundation
/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to
/// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a
/// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes`
/// already parsed by the loader, already in `order` display order, already newest-first because
/// every arrival mints a rank above the current top. There is nothing to derive, and no second
/// definition to keep in step with the loader's.
/// already parsed by the loader, already in the column's own order, already newest-first because
/// every arrival stamps `modified` and the container sorts by it descending (re-ruled 2026-07-31).
/// There is nothing to derive, and no second definition to keep in step with the loader's.
///
/// What genuinely remains is what the *commands* need and no view can answer: the two purge
/// confirmations' phrasing which since lanes rejoined the trash (2026-07-29) has to **count the