Land Finder file drops positionally, header release topmost

04's settled clauses were mostly shipped already — the create landing
resolved through DropSlotMath.cardSlot with one nominal shadow per
importable file — but a release on the lane header fell through to the
card zones, which clamp inward, so a scrolled lane could propose behind
the header stripe. FileDropZones now folds header, attach hit-test, and
card-slot resolution into one pure seam asked in that order, the header
answering topmost per the ruling; lane headers register their frames
for it. FinderDrop.shadowCount names the floor-at-one rule. New tests
pin the header boundary, a differential against cardSlot's own zones
(same zones, not similar), and a store-level differential proving a
file landing takes the very ranks a card move there takes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:28:28 -04:00
parent 7be9bb2345
commit 524488122f
7 changed files with 392 additions and 72 deletions
+82
View File
@@ -285,3 +285,85 @@ enum DropSlotMath {
return remaining
}
}
// MARK: - A Finder file drag's zones
/// Where an external **Finder file** drag resolves inside one lane, as pure arithmetic
/// (`FileDropZoneTests`) the three answers a lane's own geometry gives it.
///
/// **Drops are positional everywhere** (04-interactions.md Drag and drop, settled 2026-07-28):
/// "created cards land at the drop position resolved through the same card-grid zones an ordinary
/// card drag uses, shadow included", and append-at-bottom stays the creation *trio*'s rule (N,
/// Return, a double click on empty space), not the drop's. So the create landing is
/// `DropSlotMath.cardSlot` and nothing else: the very function a card drag proposes through, with the
/// incoming run's **nominal** footprint standing in for the frozen height a card drag freezes at
/// pickup the cards being proposed do not exist yet to have been measured, exactly as a cross-board
/// arrival's do not.
///
/// Kept out of `BoardDropContext` for `TrashDrop`'s reason: the ruling is then checkable without a
/// window, and the hover and the release read one answer rather than two that can drift.
enum FileDropZones {
/// What the lane's geometry says the files would become.
enum Landing: Equatable, Sendable {
/// The cursor is over the card at this position in the lane's **logical** card order the
/// files join its `attachments/`. Attach beats create anywhere on a card's bounds.
case attach(index: Int)
/// One card per file, opening at this position in the logical card order.
case create(index: Int)
/// A dead region (`DropSlotMath.slot`'s `nil`): **hold** whatever the create slot already was.
case hold
}
/// Resolves `cursor` against one lane, in three questions asked in this order.
///
/// 1. **The lane header is the topmost position** (04-interactions.md Drag and drop, settled
/// 2026-07-28: "a release on the lane header resolves to the topmost position forgiving beats
/// a dead stripe: the header's chrome roles don't collide with a file payload"). The header
/// does not scroll, so its own edge is the honest boundary; the accent band and the plate's top
/// padding sit above it and are its chrome, which is why the test is *at or above* rather than
/// containment. It is asked **first** because a scrolled masonry can place a card's analytic
/// frame behind the header stripe, and the ruling admits no exception there.
/// 2. **A card under the cursor always wins** over the lane behind it. The bounds are the resting
/// frames `MasonryPlacement.frames(heights:)` replays the same reconstruction the slot zones
/// are built from, never a measured frame (03-board-ui.md § Motion). Closed containment, first
/// match wins, so the answer is deterministic however the frames abut.
/// 3. **Otherwise the create slot**, from `DropSlotMath.cardSlot`.
///
/// - Parameters:
/// - cursor: the pointer, in `placement.origin`'s space.
/// - headerBottom: the lane header's bottom edge in that same space, or `nil` for a lane whose
/// header has not registered a frame yet where the masonry answers alone.
/// - placement: the lane's resting grid geometry.
/// - heights: the lane's rendered cards' heights, in logical order.
/// - nominalHeight: the height an incoming, unmeasured card is assumed to have the span the
/// create zone's cap is measured against, and the height each shadow draws at.
/// - current: the create slot currently proposed for this lane, or `nil`.
static func landing(
cursor: CGPoint,
headerBottom: CGFloat?,
placement: MasonryPlacement,
heights: [CGFloat],
nominalHeight: CGFloat,
current: Int?
) -> Landing {
if let headerBottom, cursor.y <= headerBottom { return .create(index: 0) }
let frames = placement.frames(heights: heights)
if let index = frames.firstIndex(where: { frame in
cursor.x >= frame.minX && cursor.x <= frame.maxX
&& cursor.y >= frame.minY && cursor.y <= frame.maxY
}) {
return .attach(index: index)
}
guard let slot = DropSlotMath.cardSlot(
cursor: cursor,
placement: placement,
heights: heights,
draggedHeight: nominalHeight,
current: current
) else { return .hold }
return .create(index: slot)
}
}