The drag learns the stepper's trick — past the screen's edge, lane growth re-divides instead of stopping

The right-edge drag's growth was capped at the screen's visible frame,
because each snap tick grows the window; on a window near the screen edge
that left a lane stuck at a tick or two of headroom. Settled 2026-08-08
(03-board-ui.md § Lane, superseding the pathfinder's hard stop): at the
screen the window pins and each further tick re-divides the fixed strip
width across one more unit — siblings compress, the stepper's mechanism
arriving under the drag's fingers. The regimes meet with no pixel jump
(the re-divided standard at the fit IS the frozen standard, by the
exact-fill identity), shrinking mirrors the way back, the rubber band
moves to the strip's own capacity, and a window with no headroom at all —
full screen included — re-divides from the very first snap.

New pure arithmetic in LaneLayoutMath (pinnedStripWidth, resizeStandard,
resizeMaxUnits, resizeWindowDelta, snappedUnits over per-count slots);
LaneResizeSession splits the tick across the regimes and derives its
standard from the live count; the handle and BoardView hand the session
the strip's whole divide. 2709 unit tests green (+11).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 21:36:56 -04:00
parent 05b1a787de
commit 9a52b795b2
8 changed files with 564 additions and 91 deletions
+23 -8
View File
@@ -580,7 +580,7 @@ struct BoardView: View {
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
// **The session governs through the release**, not just the drag: after the release its
// frozen standard and the unit count it *wrote* keep answering here until the snapshot
// standard and the unit count it *wrote* keep answering here until the snapshot
// carries that width back (`LaneWidthHold`). Reading `lane.width` in that window would draw
// the pre-drag layout for a round trip.
let resizing = resize.governs(lane.id)
@@ -625,6 +625,7 @@ struct BoardView: View {
committedUnits: units,
standard: standard,
gap: spacing,
totalUnits: stripTotalUnits,
window: window
)
// The read-only lock disables every mutating gesture, not just the menu items
@@ -703,10 +704,14 @@ struct BoardView: View {
/// `laneDrops.stripFrame.width` instead, which is the same number read at event time exactly
/// the registry's purpose. The two agree because the padded container fills the reader.
///
/// During a resize session the standard is **frozen** at its drag-start value: the window is
/// animating mid-resize, so deriving the standard from the live width would feed that animation
/// back into every lane and pulse the whole strip. The window is sized on each tick so this frozen
/// value equals what the formula yields once the session ends the handoff is seamless (see
/// During a resize session the standard comes from the session and not from the viewport: the
/// window is animating mid-resize, so deriving it from the live width would feed that animation
/// back into every lane and pulse the whole strip. Within the screen's fit the session's answer is
/// its drag-start value, **frozen** the window is sized on each tick so it equals what the
/// formula yields once the session ends, and the siblings never move. Past the fit the window is
/// pinned and the session re-divides instead (settled 2026-08-08, 03-board-ui.md § Lane), so the
/// number returned here shrinks with each tick and every lane compresses which is exactly how
/// the drag borrows the stepper's mechanism. Either way the handoff at the end is seamless (see
/// `LaneResizeSession`).
///
/// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that
@@ -730,15 +735,25 @@ struct BoardView: View {
/// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
units += arrivingLaneUnits
return LaneLayoutMath.standardWidth(
stripWidth: stripWidth,
totalUnits: units,
totalUnits: stripTotalUnits + arrivingLaneUnits,
gap: spacing
)
}
/// The strip's resting divide: every lane's units plus the trash's fixed one while it is shown.
///
/// Named because it is asked twice for two purposes. The resting layout divides the viewport by
/// it (plus a cross-board arrival's units, which are a hover-only addition and no part of the
/// strip itself), and each lane's grab strip hands it to `LaneResizeSession.begin` as the total
/// the drag's re-divide works from once the window has spent the screen (03-board-ui.md § Lane).
/// The arrival is deliberately absent from the second: a resize refuses to start while a drag
/// session is in flight, so there is never an arriving lane at a drag's begin.
private var stripTotalUnits: Int {
LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
}
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
/// zero for a within-board drag, whose lanes are already counted.
///