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
+34
View File
@@ -313,6 +313,40 @@ struct CardFaceViewEquatableTests {
))
}
/// **The Navigation inputs are compared too** (2026-08-09 "Give the card context menu's
/// Navigation rows real card-move behavior", card 06322636) `selectednessIsADifference`'s own
/// reason: `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` are what let
/// `moveLeftEnabled`/`moveRightEnabled` answer without a `store` read inside this face's own
/// `.disabled`, so a gate that swallowed any of the three would leave a face's Navigation rows
/// wearing a stale enabled state after a purely lane-side change nothing else here would compare.
@Test("Each Navigation input is a compared difference on its own")
func navigationInputsAreADifference() throws {
let fixture = try makeFixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let card = try firstCard(fixture.snapshot())
let marquee = MarqueeControl(
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
)
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
func face(
hasLeftNeighbor: Bool = false, hasRightNeighbor: Bool = false, selectionSpansLanes: Bool = false
) -> CardFaceView {
CardFaceView(
store: store, card: card, role: .board(openCard: { _ in }),
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1,
hasLeftNeighbor: hasLeftNeighbor, hasRightNeighbor: hasRightNeighbor,
selectionSpansLanes: selectionSpansLanes
)
}
#expect(face() == face(), "identical inputs, defaults included, still compare equal")
#expect(face() != face(hasLeftNeighbor: true))
#expect(face() != face(hasRightNeighbor: true))
#expect(face() != face(selectionSpansLanes: true))
}
/// **The hero is a compared input too** (03-board-ui.md § Card face Hero image) a resolution
/// the parent does, like selected-ness, and the one input that changes the face's *height*. A gate
/// that swallowed it would leave a card banding a picture it no longer names, or naming one it