Compare commits

..
2 Commits
Author SHA1 Message Date
rzen 71b112d04c Card drag preview renders at the source face's laid-out width
The replica framed to a font-derived nominal width, so faces in wide
lanes' interior masonry columns and narrow lanes dragged a preview of
the wrong size and the cursor could sit over empty space beside it.
CardFaceView's existing onGeometryChange now reports size, not height
alone - height still feeds LaneDropRegistry, width feeds the replica -
and BoardMetrics.cardReplicaWidth(measured:bodyPointSize:) keeps the
old figure as the documented not-yet-laid-out fallback. Same row, same
paddings, same lineLimit at the same width means the same height, so
the preview is now an exact overlay of the face it left.

Manual verification pending alongside the lane grab-point card: 3x vs
1x lanes, interior masonry columns, window re-divide, trash rows,
large text sizes, drag-at-creation fallback.

3 tests. 1661 green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 13:02:57 -04:00
rzen f83385e79f Lane drag preview attaches at the grabbed title bar, not its middle
SwiftUI's .onDrag(_:preview:) exposes no anchor or grab-point API and
lays the preview centered over the grabbed view, so a full-height lane
replica grabbed by its ~1.5-line title bar hung half a lane above the
pointer. DragPreviewAnchor is the pure fix: transparent one-sided
padding computed so the padded image's center IS the replica's title
bar - the system's centering then lands the bar under the cursor and
the body over the lane it was lifted from. LaneView measures the real
bar height in the geometry observer it already runs, accounting for the
accent band when the lane's color resolves.

Pixel-exact grab preservation would need re-homing the whole gesture
stack onto an AppKit beginDraggingSession path; declined for a polish
card - this gets the cursor onto the grabbed bar under either of
SwiftUI's possible placement rules. Manual verification pending (no
display here): grab specific bar pixels, colored + uncolored lanes,
large text sizes, multi-lane fan, cross-board drop geometry.

9 tests. 1661 green on both schemes with the paired card-width fix.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 13:02:57 -04:00
6 changed files with 301 additions and 18 deletions
+18 -2
View File
@@ -164,8 +164,24 @@ enum BoardMetrics {
// MARK: - The drag replica
/// The width the card drag's replica is drawn at a card at a representative lane width, since
/// the image under the cursor has no lane to measure itself against.
/// The width the card drag's replica is drawn at: **the width of the face it was lifted from**.
///
/// A face is as wide as the interior masonry column it sits in a function of its lane's slot
/// width and its lane's column count (`LaneLayoutMath`, `MasonryLayout`) so it is not a figure
/// this file can derive at all, only one the live face can report (`CardFaceView` measures it
/// alongside the height the drop model already takes). Drawn at anything else, the image under
/// the cursor is a card the board does not contain, and the pointer sits beside it rather than on
/// it.
///
/// `measured` is that width, and the fallback below is for the face that has not reported one
/// yet.
static func cardReplicaWidth(measured: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
guard measured.isFinite, measured > 0 else { return cardReplicaWidth(bodyPointSize: bodyPointSize) }
return measured
}
/// The replica's fallback width a card at a representative lane width, for the face that has
/// not laid out yet and so has no width of its own to give.
static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat {
em(17, bodyPointSize: bodyPointSize)
}
+30 -11
View File
@@ -129,6 +129,13 @@ struct CardFaceView: View {
/// treatment shootout).
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
/// This face's drawn width **the replica's** (`replicaFace`). Measured rather than derived,
/// because a face is as wide as the interior masonry column its lane gives it, and that is a
/// function of the lane's slot width and its column count rather than of the font
/// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's
/// fallback is for.
@State private var measuredWidth: CGFloat = 0
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
@@ -251,13 +258,18 @@ struct CardFaceView: View {
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven
// and does not animate under the reflow only positions do, and those are never measured
// (`LaneDropRegistry`). The trash side registers too: a trash card dragged out is an
// ordinary card session, and the shadow it opens in the destination lane should be its real
// footprint rather than the nominal guess.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
// The card's drawn size. Its **height** goes to the drop model's analytic resting grid a
// height is content-driven and does not animate under the reflow, only positions do, and
// those are never measured (`LaneDropRegistry`). The trash side registers too: a trash card
// dragged out is an ordinary card session, and the shadow it opens in the destination lane
// should be its real footprint rather than the nominal guess.
//
// Its **width** stays here, for the drag replica: a face is as wide as its lane's interior
// column, which no metric can derive and only the laid-out face can report
// (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`).
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
drops.registry.update(height: size.height, for: card.id)
measuredWidth = size.width
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
@@ -414,10 +426,17 @@ struct CardFaceView: View {
}
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.padding(.leading, stripeWidth)
// The replica has no lane to measure itself against, so it takes a representative card
// width font-derived like everything else here, so the image under the cursor is the size
// the cards on the board actually are at this text size (`BoardMetrics`).
.frame(width: BoardMetrics.cardReplicaWidth(bodyPointSize: pointSize), alignment: .leading)
// **The size of the face it was lifted from**, taken from that face's own measurement rather
// than from a representative figure: a card is as wide as its lane's interior column, so a
// replica drawn at a nominal width is visibly a different card from the one under the cursor,
// and since the system centres a preview on the view the drag started from leaves the
// pointer sitting beside the image instead of on it. The width is the only frame this needs:
// the replica lays the same row out with the same paddings and the same `lineLimit`, so at
// the face's width it comes out at the face's height (`BoardMetrics`).
.frame(
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
alignment: .leading
)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
+54
View File
@@ -0,0 +1,54 @@
import CoreGraphics
/// Where a drag preview attaches to the pointer, as pure arithmetic (`DragPreviewAnchorTests`).
///
/// **SwiftUI gives no anchor control.** `.onDrag(_:preview:)` lays the preview over the *grabbed
/// view's* frame, centred on it which is exactly right while the preview is the size of what was
/// grabbed (a card face lifting off its own plate), and wrong the moment it is not. A lane is
/// grabbed by its title bar and its replica is the whole lane (03-board-ui.md § Motion: "a faithful,
/// full-size replica of the dragged item the whole lane, not the strip of title bar that was
/// grabbed"), so centring that replica on the bar hangs half a lane above the pointer: the cursor
/// ends up in the middle of the image instead of on the pixel it grabbed.
///
/// The one lever the API does leave is the preview's own geometry, and **transparent padding moves a
/// view's centre without moving the view**: pad the side opposite the anchor by however much the
/// anchor is off-centre and the padded box's centre lands exactly on it. The system then centres
/// *that* box on the grabbed view, which puts the replica's title bar over the real one and, since
/// the replica is drawn at the lane's own size, every other pixel of it over the lane it was lifted
/// from. That is the Finder-icon promise stated as geometry: the cursor stays where it grabbed.
///
/// Axis-agnostic, and said once because both axes ask the same question. The horizontal one answers
/// zero for a lane a lane replica is as wide as its lane, so its header's centre is already the
/// box's and the arithmetic is written for both anyway rather than assuming that stays true.
///
/// The compensation is **invariant under symmetric padding**: the drag replicas wear a transparent
/// margin so their shadows are not clipped (`BoardMetrics.replicaPadding`), and adding `p` to both
/// ends adds `p` to the length and `p` to the anchor, which cancels. So a caller may compute this
/// from the replica's own size and apply it outside that margin, in either order.
enum DragPreviewAnchor {
/// Extra transparent length to add before and after a preview along one axis.
struct Padding: Equatable, Sendable {
var before: CGFloat = 0
var after: CGFloat = 0
static let none = Padding()
}
/// The padding that makes `anchor` a distance from the preview's leading edge the centre of
/// a preview `length` long.
///
/// Only ever one side of it: an anchor above the middle needs room before it, one below needs
/// room after it, and the exact middle needs none. The anchor is clamped into the preview, so a
/// stale measurement can shift the image by at most its own length.
///
/// Degenerate input an unmeasured, zero or non-finite length pads nothing, which draws the
/// preview exactly as it was drawn before this existed rather than flinging it off the pointer.
static func padding(length: CGFloat, anchor: CGFloat) -> Padding {
guard length.isFinite, anchor.isFinite, length > 0 else { return .none }
let clamped = min(max(anchor, 0), length)
let delta = length - 2 * clamped
return delta > 0 ? Padding(before: delta) : Padding(after: -delta)
}
}
+48 -5
View File
@@ -100,6 +100,11 @@ struct LaneView: View {
/// tall as the strip gives it.
@State private var measuredHeight: CGFloat = 0
/// The title bar's drawn height **the replica's anchor** (`dragReplica`), measured for the same
/// reason the lane's height is: the bar is as tall as the text in it, at whatever size the system
/// is set to.
@State private var measuredHeaderHeight: CGFloat = 0
/// This lane's edge-autoscroll driver one per lane, ticking only while a card session is in
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
@State private var autoScroller = DragAutoScroller()
@@ -190,6 +195,10 @@ struct LaneView: View {
// past the header and would stop being a boundary at all. The bar itself never moves.
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
drops.registry.update(header: frame, for: lane.id)
// The same measurement answers a second question, so it is read once: the bar is
// what a lane drag is grabbed by, and its height is where the replica has to hang
// from the pointer (`replicaHeaderCenterY`).
measuredHeaderHeight = frame.height
}
.onDisappear { drops.registry.removeHeader(lane.id) }
.overlay(alignment: .trailing) { newCardButton }
@@ -530,8 +539,17 @@ struct LaneView: View {
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
/// past the lane's height is clipped anyway.
///
/// **The pointer keeps the point it grabbed** the Finder-icon promise, and the whole of what
/// the anchoring padding below buys. SwiftUI centres a preview on the view the drag started
/// from, which here is the *title bar*: uncompensated, a full-height replica centred on a bar a
/// line and a half tall hangs half a lane above the cursor, and the cursor lands in the middle of
/// the image rather than on the bar it grabbed. Padding the replica so its own title bar is the
/// image's centre undoes exactly that, and lands every other pixel of the replica over the lane
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
private var dragReplica: some View {
let count = max(1, draggedLaneCount)
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
return ZStack {
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
@@ -539,6 +557,11 @@ struct LaneView: View {
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
// Transparent, and only ever on one side see `DragPreviewAnchor`, whose figure is
// deliberately invariant under the symmetric margin above, so the two paddings compose in
// any order.
.padding(.top, anchor.before)
.padding(.bottom, anchor.after)
// **Back to the window's own appearance**, undoing `header`'s runtime-contrast override for
// this one subtree (`boardTextInk`). The preview is attached inside that modifier and would
// otherwise inherit it but the replica is not text on the board background: it draws its
@@ -583,15 +606,35 @@ struct LaneView: View {
}
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
}
.frame(
width: max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)),
height: max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)),
alignment: .topLeading
)
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
/// The replica's size **the lane's own**, floored for a lane that has not measured itself yet
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
/// ask the same question the drawing does, and two derivations of one figure would be two
/// answers.
private var replicaWidth: CGFloat {
max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
}
private var replicaHeight: CGFloat {
max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize))
}
/// Where the title bar sits inside the replica its **centre**, measured down from the replica's
/// top edge, which is the point the pointer must hold (`dragReplica`).
///
/// The replica stacks exactly what the lane stacks, in the same order and off the same figures,
/// so this is the lane's own layout arithmetic rather than a second description of it: the accent
/// band when the lane's colour resolves to one (`accentBand` draws nothing when it does not, and
/// contributes no height either), the plate's inset, and half the measured bar.
private var replicaHeaderCenterY: CGFloat {
let band = Palette.color(for: lane.background) == nil ? 0 : bandHeight
return band + BoardMetrics.lanePlatePadding(bodyPointSize: pointSize) + measuredHeaderHeight / 2
}
// MARK: - Body
/// The card stack. Its empty space is a click target in its own right (04 Selection): one
+36
View File
@@ -0,0 +1,36 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `BoardMetrics` the board strip's arithmetic. Most of it is a multiple of the body font and
/// needs no assertion beyond the one every figure already carries (it renders what the board always
/// rendered at 13pt); what is worth pinning is the one figure that is *not* font-derived at all.
private let body: CGFloat = 13
@Suite("BoardMetrics · the card replica's width")
struct CardReplicaWidthTests {
@Test("The measured face's width wins — the replica is the face it was lifted from")
func measuredWidthWins() {
// A face in a wide lane, and one in a narrow three-column lane: neither is the nominal
// figure, and both are what the drag image must be drawn at.
#expect(BoardMetrics.cardReplicaWidth(measured: 340, bodyPointSize: body) == 340)
#expect(BoardMetrics.cardReplicaWidth(measured: 148, bodyPointSize: body) == 148)
}
@Test("A face that has not laid out yet falls back to the representative width")
func unmeasuredFallsBack() {
let fallback = BoardMetrics.cardReplicaWidth(bodyPointSize: body)
#expect(BoardMetrics.cardReplicaWidth(measured: 0, bodyPointSize: body) == fallback)
#expect(BoardMetrics.cardReplicaWidth(measured: -20, bodyPointSize: body) == fallback)
#expect(BoardMetrics.cardReplicaWidth(measured: .nan, bodyPointSize: body) == fallback)
#expect(BoardMetrics.cardReplicaWidth(measured: .infinity, bodyPointSize: body) == fallback)
}
@Test("The fallback scales with the body font, like every other figure here")
func fallbackScales() {
#expect(BoardMetrics.cardReplicaWidth(bodyPointSize: 26)
> BoardMetrics.cardReplicaWidth(bodyPointSize: body))
}
}
+115
View File
@@ -0,0 +1,115 @@
import CoreGraphics
import Testing
@testable import Kanban
/// `DragPreviewAnchor` given a preview's length and the point in it the pointer grabbed, how much
/// transparent room goes on either side so the system's centred placement lands that point under the
/// cursor? The visual result is a real drag session's and cannot be asserted here; this is the one
/// decision it is made of.
/// The centre of the padded box, and the anchor's position in it the two figures the padding
/// exists to make equal.
private func centreAndAnchor(
length: CGFloat,
anchor: CGFloat
) -> (centre: CGFloat, anchor: CGFloat) {
let padding = DragPreviewAnchor.padding(length: length, anchor: anchor)
let padded = padding.before + length + padding.after
return (centre: padded / 2, anchor: padding.before + anchor)
}
private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool {
abs(value - expected) <= tolerance
}
@Suite("DragPreviewAnchor")
struct DragPreviewAnchorTests {
// MARK: The promise
@Test("The padded preview's centre is the anchor, wherever the anchor is")
func anchorBecomesTheCentre() {
let length: CGFloat = 640
for anchor in stride(from: CGFloat(0), through: length, by: 16) {
let result = centreAndAnchor(length: length, anchor: anchor)
#expect(
isClose(result.centre, result.anchor),
"anchor \(anchor) landed at \(result.anchor), centre is \(result.centre)"
)
}
}
@Test("A lane grabbed by its title bar hangs from the bar, not from its middle")
func laneHeaderAnchor() {
// A tall lane whose bar sits near the top: the compensation is most of a second lane's
// height, all of it transparent and all of it above.
let padding = DragPreviewAnchor.padding(length: 700, anchor: 20)
#expect(padding.before == 660)
#expect(padding.after == 0)
// The bar's centre and the image's centre are then the same point 20 down from the
// replica's top edge, which is 680 down from the padded image's.
let result = centreAndAnchor(length: 700, anchor: 20)
#expect(isClose(result.centre, result.anchor))
#expect(isClose(result.centre, 680))
}
// MARK: Which side
@Test("An anchor above the middle pads before it, one below pads after it")
func padsTheOppositeSide() {
let high = DragPreviewAnchor.padding(length: 100, anchor: 30)
#expect(high.before == 40)
#expect(high.after == 0)
let low = DragPreviewAnchor.padding(length: 100, anchor: 70)
#expect(low.before == 0)
#expect(low.after == 40)
}
@Test("An anchor already at the centre pads nothing")
func centredAnchorPadsNothing() {
#expect(DragPreviewAnchor.padding(length: 100, anchor: 50) == .none)
}
@Test("Either extreme pads a whole length — the preview hangs entirely from one edge")
func extremesPadAWholeLength() {
#expect(DragPreviewAnchor.padding(length: 100, anchor: 0) == .init(before: 100, after: 0))
#expect(DragPreviewAnchor.padding(length: 100, anchor: 100) == .init(before: 0, after: 100))
}
// MARK: Symmetric padding does not disturb it
@Test("The figure is invariant under the replica's own transparent margin")
func invariantUnderSymmetricPadding() {
// `BoardMetrics.replicaPadding` wraps every replica so its shadow is not clipped. Adding it
// to both ends adds to the length and to the anchor alike, so the compensation is the same
// number and the two paddings compose in either order.
let margin: CGFloat = 12
let bare = DragPreviewAnchor.padding(length: 700, anchor: 20)
let wrapped = DragPreviewAnchor.padding(length: 700 + 2 * margin, anchor: 20 + margin)
#expect(bare == wrapped)
}
// MARK: Degenerate input
@Test("An unmeasured preview pads nothing")
func unmeasuredPadsNothing() {
#expect(DragPreviewAnchor.padding(length: 0, anchor: 0) == .none)
#expect(DragPreviewAnchor.padding(length: -100, anchor: 20) == .none)
}
@Test("Non-finite input pads nothing")
func nonFinitePadsNothing() {
#expect(DragPreviewAnchor.padding(length: .infinity, anchor: 20) == .none)
#expect(DragPreviewAnchor.padding(length: .nan, anchor: 20) == .none)
#expect(DragPreviewAnchor.padding(length: 700, anchor: .infinity) == .none)
#expect(DragPreviewAnchor.padding(length: 700, anchor: .nan) == .none)
}
@Test("An anchor outside the preview clamps to its edges")
func anchorClampsIntoThePreview() {
#expect(DragPreviewAnchor.padding(length: 100, anchor: -50) == .init(before: 100, after: 0))
#expect(DragPreviewAnchor.padding(length: 100, anchor: 500) == .init(before: 0, after: 100))
}
}