LaneWidthHold: after the release the session's frozen standard and the WRITTEN unit count keep governing the strip until a landed snapshot carries that width back — dividing the already-grown window by the stale unit total in between was the visible two-step. A deliberately separate type from CommittedHold: that hold stands in for an arrangement and any landing retires it; this one stands in for a value and only a landing that carries it will do. Echo reads through LaneLayoutMath.displayUnits so the width-1 key-removal case compares right; the watch rides landedReloads so a value-equal echo still answers; width writes now return whether bytes reached disk so a refused or no-op write dissolves the hold instead of arming it; the 1500ms timeout family covers the rest. Drag-perf card 231e3693. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
196 lines
32 KiB
Markdown
196 lines
32 KiB
Markdown
# Drag-Reorder Model
|
||
|
||
How dragging reorders items on a board. Ported from the pathfinder's document of the same name and rewritten for Lanework's two layouts: the **lane strip** (horizontal, mixed widths via the `width` unit multiplier) and the **card masonry** inside every lane (as many interior columns of standard width as the lane has units, each column stacking independently). The pathfinder wrote its model for columns and kept a "Generalizing to 2D" coda for the day cards could differ in size; in Lanework that day is the first one, so the coda is the present tense and lives inline.
|
||
|
||
Implementation, in two halves.
|
||
|
||
**The arithmetic**: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `moveLanes`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`.
|
||
|
||
**The session**: `Kanban/UI/Board/DragPayload.swift` (the exported UTTypes and the pasteboard JSON), `Kanban/UI/Board/DragSession.swift` (the app-wide session state, `DragLocality`, the `CommittedHold`, the watchdog), `Kanban/UI/Board/BoardDrops.swift` (`BoardDropContext`'s shared retargets and commit, plus the three drop delegates and the measured-geometry registry), `Kanban/UI/Board/DragAutoScroller.swift` (the ticking driver), and `Kanban/UI/Board/DragShadow.swift` (the shadow and the multi-drag count badge). The gestures live where the design puts the handles — `LaneView` (the header's `.onDrag`, the card face's, and each lane's drop target) and `TrashLaneView` (a row's). Tests in `KanbanTests/DragSessionTests.swift`; the delegates and gestures are deliberately thin over the tested values.
|
||
|
||
## The pieces
|
||
|
||
A drag session involves four visual actors:
|
||
|
||
1. **Drag handle** — the affordance that starts the drag (a lane's title bar and empty space, 03-board-ui.md ▸ Lane; a card's whole face). Only the handle initiates; everything else about the session is about the item, not the handle.
|
||
2. **Drag replica** — the image travelling under the cursor. A faithful, full-size replica of the dragged item (the whole lane, not the strip of title bar that was grabbed), fanned with ghosts and a count badge for multi-drags. Its tracking is verbatim input echo and never animates; only its bracketing transitions do — the pickup lift and the release's fly-to-slot or fly-back (03-board-ui.md § Motion, settled).
|
||
3. **Shadow placeholder** — a dashed outline occupying the item's proposed landing spot in the layout. At drag start it replaces the item's original space; thereafter it marks wherever the current proposal is. A multi-drag shows **N contiguous shadows**. The drop always lands exactly where the shadows show.
|
||
4. **The reflow** — siblings animating aside to make room when the proposal moves ("pre-drop"), under the structural-voice spring keyed on the proposal and nothing broader (03-board-ui.md § Motion).
|
||
|
||
## Resting-layout zones
|
||
|
||
While a drag is in flight, the proposal (an insertion index) is computed geometrically against the **resting layout**: the visible siblings laid out with the dragged items removed and no placeholder inserted. Each slot `i` owns a *zone*: item `i`'s entire resting extent plus half the inter-item gap on each side. Zones tile the container with no dead space between them; before the first item and past the last item lie the outermost slots.
|
||
|
||
The proposal is a pure function of the cursor over these fixed zones. This is deliberately **not** derived from per-item hover events, which feed back off the very reflow they cause (items move under the cursor → a different item fires → the proposal moves again) and jitter. Because the zones are reconstructed analytically rather than measured, they do not move when the placeholder does.
|
||
|
||
**The resting layout follows the effective operation: a move lifts the dragged run out, a copy leaves it in.** 04-interactions.md says the originals stay for both copies — the within-board ⌥-drag and the cross-board default — so the source board draws what it will still hold: the originals stand in place, dimmed (`ClipboardTreatment.dimmedOpacity`, the same treatment a trash row being dragged out wears), and the shadow run opens beside them. A move's originals are lifted out at pickup and stay out until the echo lands. The single expression is `DragSession.hiddenMembers(onBoardRooted:)`, which every surface that builds a resting layout goes through.
|
||
|
||
Pressing or releasing ⌥/⌘ mid-drag therefore reflows the source board **once**, and that one-shot reflow is the point rather than a cost: the modifier's whole job is to change what the drop will leave behind, and the board answering the question is the feedback being asked for. (This retires an earlier carve-out that lifted the run out for every operation to keep the board still. The stillness was real; it was bought by never answering the question.) The flip lands on the next `dropUpdated`, because that is where the operation is re-resolved — a modifier pressed with the mouse perfectly still waits for the next motion. Once a release has settled, the operation is frozen for the duration of the committed hold (`DragSession.resolveOperation` guards on `hold == nil`, exactly as `propose` does), so a settled copy keeps its originals on screen and a settled move keeps them lifted until the echo snapshot lands.
|
||
|
||
The index-space consequence is real and is carried by the writers rather than papered over: **the drop index means different things for `moveCards` and `copyCards`**, and each one is counted in the layout its own gesture was showing. A move's index counts slots among the cards the run vacates; a copy's counts them among the lane's full rendered set, originals included. Neither writer has to guess — the operation picks the writer and the operation picked the layout — and a within-board ⌥-copy therefore lands exactly at the shadow's slot, including when the shadow sits below the dragged run in the same lane (see **The drop commits** below).
|
||
|
||
Lanes get this for free and the invariant is worth stating: a within-board lane drag never resolves to `.copy` (⌥ is ignored there), so the strip's zones never see a re-admitted lane under the cursor; a **cross-board** lane copy re-admits the lane into the *source* strip while the cursor is over the foreign board, where none of the source strip's zones are being consulted. `moveLanes` and `receiveLanes` keep the index space they always had. The strip's lane rendering has no dim of its own, so a re-admitted lane reads as an ordinary lane rather than as the source of the drag — the card face and the trash rows are the only surfaces wearing that treatment today.
|
||
|
||
Two stability rules on top:
|
||
|
||
- **Exact-boundary tie** — a cursor resting on a boundary pixel keeps the current proposal when it adjoins that boundary; the shadow can never oscillate on a single pixel.
|
||
- **Own-slot pickup** — zones derive from the resting layout, so picking an item up over its original spot proposes its own slot: a no-op, no reflow, and the store's commits refuse to write for it.
|
||
|
||
## The lane strip's resting layout is arithmetic
|
||
|
||
The strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run* — `LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide — the same origin `LaneLayoutMath.laneIndex` hit-tests against and the session's cursor conversion lands in.
|
||
|
||
`standard` is **not** recomputed with the dragged lanes removed. It is a function of the board's unit total, and a lane in flight is still a lane on the board — the shadow occupies its units. Recomputing would re-divide the whole strip at pickup and again at release, which is the "motion feeds back into logic" failure this model exists to avoid. For a cross-board arrival the destination board's own `standard` is the one that counts, and the arriving run is measured in the destination's units.
|
||
|
||
The trash quasi-lane consumes one unit while shown and is never a landing spot for anything (04-interactions.md ▸ The trash: no move or paste ever targets the trash). It is excluded from the strip's slot list, and the terminal slot's uncapped reach past the last real lane is clamped by the session rather than by the arithmetic.
|
||
|
||
## Span-capped trigger regions (mixed sizes)
|
||
|
||
When items can differ in size, "hovering anywhere over an item" is the wrong trigger. Dropping a 1× lane before a 3× lane puts the 1× lane at the 3× lane's *leading edge* — so a cursor over the 3× lane's far side is nowhere near where the dragged lane would actually land, and reflowing there feels wrong and twitchy. 04-interactions.md ▸ Drag and drop asks for exactly this: "no reflow until the cursor reaches where the dragged lane would actually land".
|
||
|
||
The rule: slot `i` triggers only while the cursor is over the span the dragged run would **actually occupy** once dropped there —
|
||
|
||
```
|
||
trigger(i) = [ leading(i) − gap/2, leading(i) + draggedSpan + gap/2 ]
|
||
```
|
||
|
||
where `leading(i)` is item `i`'s resting leading edge and `draggedSpan` is the total extent of the dragged run (sum of the dragged items' slot widths plus the gaps between them — `DropSlotMath.laneRunSpan`). Note this is exactly where the shadows will sit if the proposal is accepted: the trigger region *is* the shadow run's future footprint.
|
||
|
||
The remainder of a wider item's zone — beyond the cap — is a **dead region**.
|
||
|
||
Two slots are never capped, because nothing beyond them could be confused for a different target:
|
||
|
||
- the **end slot** (past the last item): appending is the only reading;
|
||
- the region **before the first item**: slot 0 is the only reading (it falls out of the arithmetic — the cap only ever truncates a zone's far side).
|
||
|
||
If the dragged run is at least as large as the item whose zone it crosses, the cap covers the whole zone and behavior is identical to the uncapped model.
|
||
|
||
## Hysteresis
|
||
|
||
A dead region changes nothing: the current proposal — and therefore the shadow — **holds** until the cursor enters another slot's live trigger region. Combined with the tiling zones (a zone is left only by entering another) this gives the drag its hysteresis: the shadow never bounces while the cursor drifts through ambiguous territory, it only moves when a genuinely new landing spot is reached.
|
||
|
||
The math says this by **returning `nil`**, not by returning the caller's own value back to it: `nil` is "hold", and a session that has no proposal yet still has none. That is the difference between the hold and the fresh-entry case below, and it is why the API is `Int?`.
|
||
|
||
Edge case: if a dead region is hovered with **no valid prior proposal** — a fresh cross-board entry, or the first sample after a reload invalidated the last proposal — the containing slot is proposed anyway. A drag in flight over a live target must always have *some* landing spot.
|
||
|
||
## The card masonry (2D)
|
||
|
||
A lane lays its cards out with `MasonryLayout`, dealing **column-major** (ruled 2026-07-31, replacing the pathfinder's round-robin): with `n` cards and `C` columns, `base = n / C` and the first `n % C` columns take one more, so column `c` holds the contiguous run `[start(c), start(c+1))` and logical order runs *down* each column before crossing to the next. Each column stacks its cards top-aligned and independently, with no row alignment across columns (03-board-ui.md § Lane). Card widths are uniform — the column width — and heights vary, so the grid is genuinely two-dimensional and the span-cap applies on the vertical axis.
|
||
|
||
The resting grid is **re-run, not measured**: `MasonryPlacement.frames(heights:)` replays the same placement over the lane's rendered cards minus the dragged ones, from `(columnCount, columnWidth, spacing, heights)`. `MasonryLayout` itself places subviews through that one function, so the resting grid a drag reasons about and the grid SwiftUI draws cannot drift apart.
|
||
|
||
**Heights are frozen at drag start** and passed in, never measured mid-flight — the animation-proof rule below, and the reason a lane whose cards are reflowing under a ~0.18s spring still resolves stable proposals.
|
||
|
||
Cursor → proposal, in three steps (`DropSlotMath.cardSlot`):
|
||
|
||
1. **Column.** The interior columns' x-bands tile the lane's card area — column `c` plus half a spacing on each side — and the cursor's band picks `c`. Outside the outermost bands the cursor clamps inward, so the lane's padding and its header target the nearest column rather than nothing. Exact-boundary ties keep the current proposal's column, as in 1D.
|
||
2. **Row.** Column `c`'s cards are the contiguous logical range `[start(c), start(c+1))`; their vertical extents feed the *same* 1D span-capped machinery the strip uses, with `draggedSpan` = the **first dragged card's frozen height** (the run's footprint at the landing spot; the remaining shadows stack below it, and the trigger rect that matters is the one the cursor is over). Dead regions hold, the tail slot below the column's last card is uncapped, and the region above the first card is uncapped.
|
||
3. **Logical index.** Column `c`, row `r` is logical position `start(c) + r` — no clamp needed, since `r` never exceeds the column's own card count. A non-final column's tail slot is a **genuine mid-list position** (`start(c+1)`, immediately before the next column's first card); only the last column's tail is the end slot — appending. This is a landing spot the round-robin deal could not offer, where every column's tail collapsed to the end.
|
||
|
||
The insertion index is therefore always a position in the lane's **logical card order**, which is what the store writes and what 10-accessibility.md's logical-order rule requires. Because the columns re-deal on every count change, inserting at index `k` slides later cards down within their columns and moves at most one card across each column boundary — far gentler than the round-robin deal this replaced, which sent every later card sideways. `MasonryLayout` is a `Layout` over a single `ForEach` precisely so the moves that do happen animate as positional slides rather than as remove/insert blinks. One presentation consequence of the re-deal, accepted with the ruling: a *tail* proposal's shadow draws at the head of the **next** column — exactly where the card will sit once the columns re-deal around it — so the shadow is not always directly under the cursor; the drop still lands exactly where the shadows show.
|
||
|
||
Everything else carries over unchanged: resting-layout reconstruction, boundary ties, own-slot no-op, uncapped terminal slots.
|
||
|
||
## Multi-drag
|
||
|
||
Dragging any member of a multi-selection drags the whole selection (04-interactions.md ▸ Drag and drop). Three rules follow, and the first two are the whole of what the math has to know:
|
||
|
||
- **One proposal for the whole run.** A multi-drag proposes a single insertion index and inserts contiguously there. There is no per-item targeting and no interleaving.
|
||
- **The run's span is the run's span.** `draggedSpan` sums the dragged items' extents plus the gaps between them, so a two-lane drag has to travel twice as far before a wider neighbour's slot triggers. In the masonry the vertical cap uses the first dragged card's frozen height, since that is the shadow the cursor is over.
|
||
- **Preserved relative order** is *flatten order* — "lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom)" — the same order the ⌘N target rule and paste anchor on. `SelectionGrammar.liveCards` is its single definition; the drop commits sort their members through it rather than through the `Set`'s iteration order, which has none.
|
||
|
||
N contiguous shadows are rendered by the session; the *index* is all this document's arithmetic produces.
|
||
|
||
## Cross-board sessions and the locality model
|
||
|
||
**Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop, settled). Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring, with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘ always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary, which means the operation is a function of (source board, board under the cursor, modifiers) sampled every frame, not a decision taken at pickup.
|
||
|
||
Two carve-outs:
|
||
|
||
- **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy. The within-board lane duplicate exists, but its home is the clipboard (04-interactions.md ▸ Clipboard, Lane paste) — the usual shape, where the keyboard path is canonical and the drag is the enhancement.
|
||
- **A lane copy strips tombstoned cards**; a lane **move** carries them whole, and they land in the destination's trash by rendering. Copies transfer content, and trash isn't content (09-templates.md's instantiation precedent).
|
||
|
||
Geometry does not change across the boundary. The destination board's own resting layout answers the proposal, in the destination's own `standard` and gap; the arriving run's span is its unit counts measured against the destination's standard. What changes is only which commit runs and on which store — see below.
|
||
|
||
## The drop commits
|
||
|
||
The commit is the store's, and it is one `performWrite` bracket per gesture whatever the set's size: one app-mediated reload, and (on git boards) one commit rather than N. Every one of them takes an index counted **against the destination's rendered items as the resting layout showed them** — so the number the geometry produced is the number the writer consumes, unrewritten. For every move that means "with the dragged run removed"; for the within-board ⌥-copy it means "with the originals still there", because a copy leaves them there and the zones counted them (see **Resting-layout zones**). Cross-board arrivals never face the question: the destination never held the originals.
|
||
|
||
| Gesture | Store method | Writer |
|
||
| --- | --- | --- |
|
||
| Within-board card drag | `moveCards(_:toLane:at:)` | `moveItem` per card — same parent degrades to a rank rewrite |
|
||
| Within-board ⌥-drag | `copyCards(_:toLane:at:)` | `copyItem` per card, `.fork` stamps |
|
||
| Within-board lane drag | `moveLanes(_:toIndex:)` | `moveItem`, same-parent reorder |
|
||
| Cross-board cards | `receiveCards(_:operation:toLane:at:)` on the **destination** store | `copyItem` / `moveItem` |
|
||
| Cross-board lanes | `receiveLanes(_:operation:at:)` on the **destination** store | `copyItem` + tombstone strip / `moveItem` |
|
||
| Trash → live lane, same board | `restoreByDrag(cardID:intoLane:at:)` | `restoreItem` then `moveItem` |
|
||
| Trash → another board | `receiveRestoredCards(_:operation:toLane:at:)` | `copyItem`/`moveItem` then `restoreItem` |
|
||
|
||
Three properties of that table are load-bearing:
|
||
|
||
- **Ranks are inserted, never permuted.** A drop writes only the dragged items' `order` — the siblings' files are not touched, so `modified` (and a git commit) stays honest about what actually moved. `Ranks.insertionRanks(amongVisible:at:count:)` produces the N ranks the contiguous run needs; `nil` from it is the renumber trigger, exactly as an exhausted midpoint is everywhere else, and the fallback compacts the destination and places again (`moveLane`'s and `sortSelection`'s pattern).
|
||
- **A copy's ranks are computed against the destination's *full* rendered set**, because the originals stay and are still on disk holding their ranks. A move's are computed against the set the moving members vacate. One expression covers both: the ranks are placed among the rendered cards minus whatever will actually leave — which is also, exactly, the layout each gesture's zones were built over, so the index needs no translation on the way in.
|
||
- **Cross-board writes are executed by the destination store**, inside *its* bracket. The source board's tree changes outside its own store's bracket, which is correct and needs no coordination: the source store's watcher sees a foreign change and reloads, which is what a foreign change is.
|
||
|
||
Identity follows 01-storage-format.md exactly. A copy mints fresh UUIDs at every level and keeps `created` (a copy is a fork). A move keeps the UUID; only the **import boundary** remints, per folder, at the finest grain — a lane arriving with one colliding card is still a lane move with one reminted card.
|
||
|
||
## The mid-drag re-grounding trio
|
||
|
||
**A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (04-interactions.md ▸ Drag and drop, settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose, and each one is a property of something already in this document:
|
||
|
||
1. **Geometry re-derives.** The only inputs frozen at drag start are the *dragged items'* sizes and nothing else; the resting zones are recomputed against each new snapshot. A foreign lane add or tombstone re-divides the strip, the zones move with it, and the next proposal targets the board as it now is. Nothing is cached across a reload because nothing needs to be.
|
||
2. **Proposals re-validate by liveness.** A proposal whose target lane was tombstoned or vanished in the reload is invalidated — tombstoned lanes are never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a live target. **Release with no valid proposal cancels**: items return, nothing is written, and a card is never filed under a `deleted:` parent. The store's commits enforce the same rule independently (a destination lane that is gone or tombstoned is a silent no-op), so the gesture and the write cannot disagree.
|
||
3. **An emptied drag cancels itself.** Drag membership is a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item leaves it, the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent.
|
||
|
||
## The committed-overlay hold
|
||
|
||
At release the write goes to disk and the *snapshot does not change*. The watcher's bracket closes, a reload runs, and only then does the board show the new order — one-way flow, deliberately (02-architecture.md). In between, for one round trip, the snapshot still describes the pre-drop arrangement.
|
||
|
||
That gap is what makes "the replica flies to its slot" hard to reconcile with the one-way flow: the slot it should fly to is a position that does not exist yet, and dropping the drag state at release would snap every sibling back to the pre-drop layout for a frame before the reload lands.
|
||
|
||
The resolution is the **committed-overlay hold**, and it is the new-card placeholder's `awaitingArrival` precedent applied to the drag: at release the session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing, and stands until the app-mediated reload that carries the write arrives — then hands off and dissolves. The hand-off condition is the same shape as the placeholder's: the overlay watches the snapshot for the state it is standing in for, and discards itself the moment the snapshot has it, because holding a moment longer would draw the arrangement twice.
|
||
|
||
Like the placeholder, it is store-transient overlay state and a **named exception** to the one-way flow rather than a hole in it: it renders nothing that is not already on disk or already refused, and every failure path — a write that throws, a reload that fails, a session emptied mid-flight — dissolves it and lets the snapshot be the authority again. The banner says what went wrong; the board shows what is true.
|
||
|
||
This is the drag session's mechanism, not the math's — it belongs to the same milestone's second half.
|
||
|
||
## Animation-proof inputs (implementation constraint)
|
||
|
||
**Motion never feeds back into logic** (03-board-ui.md § Motion, a hard constraint, inherited from the pathfinder's rule of the same name). Every proposal change animates a reflow (~0.18s). During that window, anything *measured* is mid-flight: item frames, the placeholder's frame, and even the drop location reported by the system (it is expressed in the drop target's space, and that view may itself be moving). Retargeting from measured values while the board animates produces garbage zones and a proposal that thrashes — the shadow chases the cursor and all hysteresis is lost. Three rules follow:
|
||
|
||
- **Compute resting zones analytically, never from measured frames.** The strip's layout is a closed form over `(stripWidth, gap, unit counts)`; the masonry's is a closed form over `(columnCount, columnWidth, spacing, frozen heights)`. Both are stable no matter what is animating.
|
||
- **Read the cursor from the physical mouse** (`NSEvent.mouseLocation`, converted through the window), not from the drop callback's location. This is also what `LaneResizeSession` and `MarqueeSession` already do.
|
||
- **Freeze the dragged items' sizes at drag start.** The pickup transition fires geometry updates while the dragged item lifts and scales; its lingering "last measured frame" is a few per cent off, which would mis-size the shadow and the span-cap.
|
||
|
||
One lifecycle trap in the same family, recorded because the pathfinder paid for it: a finished session's phase events can be delivered *after the user has already started the next drag*, and a naive cleanup handler wipes the new session's state (no shadow, drop dead). Cleanup on session-phase events must be gated on the physical button being up; a mouse-polling watchdog remains the guaranteed termination path.
|
||
|
||
## Single-target dispatch (implementation constraint)
|
||
|
||
SwiftUI/macOS delivers a drag session to the **deepest drop region under the cursor — with no fall-through**, not even when that target's declared content types don't match the session's payload. A region whose topmost target only understands one drag type is therefore a *dead zone* for the other type: no hover callbacks, and a release there snaps back instead of committing.
|
||
|
||
Consequence: every drop delegate must accept **all session types** — card, lane, and external Finder file drags (04-interactions.md ▸ Drag and drop: files onto a card become attachments, files onto lane empty space become cards) — and route internally. Without file support at every fall-through layer, the same dead-region hit-testing bug would strand a file session: it would fall through to a target that only declares the board's own types, get no hover callbacks, and refuse the drop outright, with no highlight and no snapback to explain why.
|
||
|
||
The lane body's delegate resolves card sessions against its masonry zones, forwards lane sessions (cursor converted to strip space) to the strip's logic, and resolves file sessions against the same masonry zones; the strip delegate (gaps, margins, placeholder regions) retargets lane sessions, retargets card and file sessions against an analytically reconstructed per-lane grid — the safety net for a lane whose own drop region goes dead — and commits the current proposal on release: the drop always lands where the shadows show. Shadow placeholders are hit-transparent, so the strip target stays live beneath them.
|
||
|
||
## Edge autoscroll
|
||
|
||
A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below the fold. Nothing in the model above can reach them — the proposal is a function of the cursor over the *visible* resting layout — so a card session hovering near either end of a lane's scroll area scrolls it, continuously, until the pointer leaves the band or the drag ends. Implementation: `Kanban/UI/Board/DragAutoScrollMath.swift`, tests in `KanbanTests/DragAutoScrollMathTests.swift`.
|
||
|
||
The geometry is a pure function of viewport-local coordinates: each end of the visible extent owns a 56pt **activation band**, and a pointer inside one scrolls that way at a speed ramping linearly from 90pt/s at the band's inner edge to 800pt/s at (and beyond) the viewport's own edge. Outside both bands the velocity is exactly zero, so a drag crossing a lane's middle never scrolls it. The floor at the band boundary is deliberate — entering a band should produce visible motion, not an imperceptible crawl.
|
||
|
||
The pointer may also sit outside the visible area and still drive it: generously above and below (a lane's header and the strip's padding are still "this lane"), but only ~12pt sideways, so a drag over the neighbouring lane never scrolls this one.
|
||
|
||
Three constraints shape the driver, which is the session's half of the work:
|
||
|
||
- **The pointer is the physical mouse**, partly for the general reason above, but mostly because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is exactly the gesture that must keep scrolling. A ticking task plus `NSEvent.mouseLocation` needs no events at all.
|
||
- **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space while the *content* moves under it, so without this the shadow would freeze at whatever slot the last mouse movement proposed and the drop would land there. The lane's drop delegate and the autoscroll driver must go through one shared retarget, so they can never disagree.
|
||
- **Termination is structural**, like the rest of the session lifecycle: the driver is a `.task(id:)` keyed on the session, so it is cancelled the moment the session ends — and the watchdog guarantees that flag clears no matter how the drag finished.
|
||
|
||
The board strip itself has nothing to autoscroll: every lane is always visible (the window width divides across the lanes' width units) and the strip fills the window height, so there is no board-level scroller in either axis. The geometry above is axis-agnostic and would serve one unchanged if that ever changes.
|
||
|
||
## Adjacent interaction: the lane resize drag (not a drag session)
|
||
|
||
Dragging a lane's trailing edge resizes it between whole unit counts — see `LaneResizeSession.swift` and `LaneLayoutMath`. It deliberately lives OUTSIDE the drag-session machinery above: the handle is a plain `DragGesture`, carries no drop target, and refuses to start while a card/lane session is in flight. Its layout trick inverts this document's premise: instead of reflowing siblings around a shadow, the session freezes the strip's standard width and resizes the *window* by one standard-plus-gap per snap tick, so every other lane keeps its exact pixels and the release settles the dragged lane into a slot that's already in place. Snapping is asymmetric ("shadow leads"): tick up the instant the live edge clears the inter-lane gap; tick down only after retreating 10pt back into it — the 10pt re-entry band is the only hysteresis, cousin to the dead-region hold above.
|
||
|
||
It does borrow one thing from the machinery it lives outside: **the release gets its own hold** (`LaneWidthHold`). The release writes the new unit count, and that write takes the one-way flow's round trip, so a session that cleared there would hand the strip back to a standard divided from the already-grown window by the snapshot's still-stale unit total — a visible two-step, once into the wrong arrangement and again when the echo lands. So the frozen standard and the *written* unit count keep governing until the snapshot carries that width, with the same timeout-and-dissolve guarantee as the committed-overlay hold. The condition is the only real difference, and it follows from the subject: that hold stands in for an arrangement and any landing on the board retires it, while this one stands in for a value and only a landing that actually carries it will do.
|