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:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user