Navigation's Move Left/Right learn a real card — the context menu's disabled rows compute a per-card cross-lane destination and land it through the drop's own rank machinery

CardMoveTarget (BoardCommands.swift), LaneMoveTarget's cousin: validates
the clicked card's own current lane against the live lane order rather
than the board's live selection, refuses a target that reaches outside
that lane (no coherent left for a spread), and answers an index —
the clicked card's own position in its lane, clamped — for the
adjacent live lane in either direction. Trash is never a candidate
(not a Lane); a collapsed lane is a fine landing (a fold hides cards,
it doesn't close the lane).

CardFaceView's Navigation rows now call moveCardAcrossLane(by:), which
hands CardMoveTarget's answer straight to BoardStore.moveCards(_:toLane:at:)
— the exact call a released drag makes, so rank-minting, the undo step,
the watcher echo and the banner all come free. Targeting is Copy/Cut's
own widening (targetIDs): the clicked card, or the live selection when
the clicked card is a member of it.

Enablement stays render-safe the way isSelected/selectedCount already
are: three new CardFaceView parameters (hasLeftNeighbor, hasRightNeighbor,
selectionSpansLanes) are hoisted once per lane in LaneView.scrollableCards
and handed down as compared parameters, never read from inside a card
face's own .disabled. Caught and fixed a real regression here during
development: an early cut of the multi-lane-spread check answered true
for any lane that simply didn't contain the selected card, which
flipped a compared parameter for most of the board on an ordinary
single-card select and defeated CardFaceView's equality gate wholesale
(BoardRenderPerformanceTests.selectionStillRepaints caught it at 151 of
180 card faces).

Tests: CardMoveTargetTests (KeyboardGrammarTests.swift) pins the pure
predicate — leftmost/rightmost lane, single lane, index clamping, a
widened group anchoring on the clicked member rather than its own
extent, the multi-lane-spread refusal, and an integration test feeding
the answer straight through moveCards. CardFaceViewEquatableTests
gains a case pinning the three new compared parameters.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 10:37:55 -04:00
parent c27cc93ec1
commit 085a84abb2
5 changed files with 519 additions and 43 deletions
+14 -1
View File
@@ -1051,6 +1051,16 @@ struct LaneView: View, Equatable {
// per card. A face cannot resolve its own path it knows its card and its container, not
// where the card sits and finding it from the snapshot would be a board walk per face.
let cardsFolder = store.rootURL.appendingPathComponent(lane.id.rawValue, isDirectory: true)
// **The card menu's Navigation inputs, hoisted the same way** (2026-08-09, card 06322636):
// one `store.snapshot` read for the lane's own neighbours (`CardMoveTarget.hasNeighbor`) and
// one `Set` comparison over the selection already read above for the multi-lane-spread
// refusal (`CardMoveTarget.selectionSpansOtherLanes`) never a per-face read, `selectedIDs`'
// own reason. `store.snapshot` costs this body nothing new: `headerInk` already subscribes
// the whole lane to it.
let hasLeftNeighbor = CardMoveTarget.hasNeighbor(of: lane.id, delta: -1, in: store.snapshot)
let hasRightNeighbor = CardMoveTarget.hasNeighbor(of: lane.id, delta: 1, in: store.snapshot)
let selectionSpansLanes = CardMoveTarget.selectionSpansOtherLanes(
selectedIDs, laneCardIDs: Set(lane.cards.map(\.id)))
return ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
@@ -1071,7 +1081,10 @@ struct LaneView: View, Equatable {
// 1 for an unselected face: the replica's fan and count badge want
// "how many ride along", and a card outside the selection drags
// alone (`CardFaceView.draggedIDs`).
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1,
hasLeftNeighbor: hasLeftNeighbor,
hasRightNeighbor: hasRightNeighbor,
selectionSpansLanes: selectionSpansLanes
)
// **The value gate** (`CardFaceView.==`) the lane's own, one level
// down: this body re-runs on every proposal change while a drag is over