Clicks land the instant they happen — the empty-space gestures move behind the masonry

The measured defect: LaneView's empty-space double-click was a second
sequential .onTapGesture(count: 2) stacked over the single tap, and that
recogniser held every click in the lane — its own empty space and every
card face alike — hostage to the system double-click interval while it
disambiguated (~475 ms click-to-selection on a hosted board). The fix is
structural: the empty-space surfaces live on a background layer behind the
masonry, so a card's click never shares a gesture path with a lane
recogniser; one .onTapGesture branches on PointerClick.count (AppKit's own
clickCount, read the way ClickModifier reads the keyboard) — first click
selects, second creates, Finder's cadence with nothing to disambiguate; and
the layer carries a load-bearing empty .onDrag, because without a drag
source macOS holds primary clicks pending multi-click disambiguation
(measured: never fires alone, ~90 ms with one present). A measured viewport
floor makes each lane's blank space actually belong to the layer — a
ScrollView proposes nothing along its scroll axis, so only an explicit
minimum stretches the content — with the trash column as its twin, less the
padding that sits inside its scroll content. The template chooser's stacked
pair collapses to the same one-recogniser branch, and the attachment rows
move their double-click to a simultaneous gesture (instant there, because
the row's real .onDrag forces immediate delivery). PointerLatencyTests pins
the recovery with synthetic pointer events on a hosted board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-07 11:22:16 -04:00
parent cfee4a4b41
commit eef0a4539f
7 changed files with 602 additions and 39 deletions
+25 -8
View File
@@ -129,6 +129,11 @@ struct TrashLaneView: View {
/// Between the cards `LaneView.cardSpacing`, because these are the same cards.
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
/// The column's scroll viewport height the rows' height *floor* (`scrollableCards`), measured
/// for `LaneView.scrollViewportHeight`'s reason: a `ScrollView` proposes nothing along its
/// scroll axis, so only an explicit minimum can stretch the content to fill it.
@State private var scrollViewportHeight: CGFloat = 0
var body: some View {
// The strip's third body level, observed (`BoardRenderMetrics`) DEBUG only, and a
// `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
@@ -388,14 +393,22 @@ struct TrashLaneView: View {
// nothing else** (03-board-ui.md § Motion's narrow keys) the trash column's own copy of
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
// below reach the column's full height rather than stopping where the last card ends
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
// view sized to fit its cards leaves the blank space beneath them un-hit-testable. "The
// column's gesture surface is full height" (04-interactions.md The trash, settled)
// needs that blank space to actually belong to the view the gesture below is on.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// **The measured viewport is the rows' height floor** this is what makes the gesture
// surface below reach the column's full height rather than stopping where the last card
// ends. `maxHeight: .infinity` alone could not: a `ScrollView` proposes *nothing* along
// its scroll axis, so no frame maximum stretches the content, and a view sized to fit
// its cards leaves the blank space beneath them un-hit-testable. "The column's gesture
// surface is full height" (04-interactions.md The trash, settled) needs that blank
// space to actually belong to the view the gesture below is on, so the measured floor
// supplies it less the plate padding, which sits inside the scroll content here and
// would otherwise make an empty column scrollable by its own inset
// (`LaneView.scrollableCards` is the twin, with its padding outside).
.frame(
maxWidth: .infinity,
minHeight: max(0, scrollViewportHeight - 2 * BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)),
maxHeight: .infinity,
alignment: .topLeading
)
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
.contentShape(Rectangle())
// The band's trash-side surface. It arms from the column's empty space, full height
@@ -405,6 +418,10 @@ struct TrashLaneView: View {
// gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(in: .trash))
}
// The floor's measurement `LaneView`'s, on the trash side: the scroll view's own height
// is the space the rows must cover for the empty-surface gesture above, and the content
// growing to the floor never moves the floor.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
}
}