Implement the motion language

One named home for every curve and duration — the two-voice split 03
fixes (snappy structural: drag reflow 0.18, delete 0.25, lane resize
0.2; smooth content-reflow 0.28), the scale-and-fade appear/disappear
transitions (cards 0.8, lanes 0.9), and a Reduce Motion variant on
every accessor (animations go instant, transitions go crossfade). A
post-migration grep holds the invariant: zero motion literals outside
Motion.swift. The animate-vs-snap split lands where Lanework's
one-way flow puts it: the store's reload seam. An app-mediated echo
applies its snapshot inside the structural transaction; foreign and
reconciling reloads — and every bracket-ending wholesale reload —
assign bare, because live-reload is the board becoming what's on
disk, not an event to perform. A window that merged foreign events
into an app-mediated span animates, deliberately: the ratified merge
rule makes it indistinguishable from a pure echo, and a test pins
that reading so a future mixed case fails loudly. The lane-reorder
reflow keys on the drop proposal alone (pointer tracking stays 1:1),
the resize session freezes its Reduce Motion answer at drag start so
the unit tick and the window resize can never disagree, and the m5
drag / m5 search / m7 undo voices are named seams waiting for their
call sites. 13 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 15:59:59 -04:00
parent 3be38fcb47
commit 5ebf9fb90c
8 changed files with 548 additions and 37 deletions
+36 -12
View File
@@ -69,6 +69,10 @@ struct BoardView: View {
/// included, and no global or lane-local space is that.
static let stripSpace = "board-strip"
/// Reduce Motion, for the transitions and the reflow curve below (10-accessibility.md). Read
/// from the environment here and handed to `Motion`, which owns what "reduced" means for each.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@@ -96,15 +100,25 @@ struct BoardView: View {
// unit and every lane compresses a lane add's behaviour, exactly.
totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0),
gap: spacing)
// The drag's drop proposal, computed once because two things read it: the order the
// strip shows, and the key its reflow animates on. Recomputed on every render, so a
// foreign reload mid-drag simply moves the zones (rule 1 of 04-interactions.md Drag
// and drop's re-grounding trio).
let move = proposal(among: lanes, standard: standard)
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
// the drag's would-be order while a reorder is in flight which is how the siblings
// reflow to make room (04-interactions.md Drag and drop). The proposal is recomputed
// here on every render, so a foreign reload mid-drag simply moves the zones (rule 1 of
// that section's re-grounding trio).
let shown = shownLanes(lanes, standard: standard)
// reflow to make room. A drag whose lane has vanished from the snapshot proposes nothing
// and shows the plain order; its release then cancels ("an emptied drag cancels itself").
let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes
HStack(alignment: .top, spacing: spacing) {
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
laneSlot(lane, at: position, among: shown, standard: standard)
// "Appear/disappear is scale + fade lanes ~0.9" (03-board-ui.md § Motion).
// A create, a delete and a Put Back all reach the strip as a lane arriving in
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
// the reload that carried it (`Motion.reloadAnimates`) a transition with no
// animated transaction around it is simply an appearance.
.transition(Motion.laneTransition(reduced: reduceMotion))
}
if isTrashVisible {
// Trailing, always the quasi-lane has no position of its own to lose, which is
@@ -118,8 +132,21 @@ struct BoardView: View {
)
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
.frame(maxHeight: .infinity, alignment: .top)
// It arrives and leaves like a lane, because that is what it looks like the
// column scales and fades while every real lane compresses to make room for its
// unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the
// menu toggle's (`ShowTrashCommand`).
.transition(Motion.laneTransition(reduced: reduceMotion))
}
}
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
// proposal never on broad state"). The travelling lane's own offset changes on every
// pointer sample and none of those samples touch this value, so the replica keeps
// tracking the cursor 1:1 which is the same bullet's other half. At the instant the
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
// animating both under one curve is what keeps it pinned under the cursor.
.animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to)
.padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The space a drop out of the trash is resolved in see `BoardView.stripSpace`. It goes
@@ -292,16 +319,13 @@ struct BoardView: View {
// MARK: - Reorder
/// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in
/// flight. A drag whose lane has vanished from the snapshot shows the plain order and proposes
/// nothing its release then cancels (04 Drag and drop, "an emptied drag cancels itself").
private func shownLanes(_ lanes: [Lane], standard: CGFloat) -> [Lane] {
guard let (from, to) = proposal(among: lanes, standard: standard) else { return lanes }
return LaneReorderMath.reordered(lanes, from: from, to: to)
}
/// Where the dragged lane sits in `lanes` and where it would land `nil` when no reorder is in
/// flight, or when the lane it is carrying is no longer on the board.
///
/// Called **once** per render, because a proposal is two things at once and they must be the
/// same answer: the order the strip shows, and the narrow key its reflow animates on (see
/// `body`). It is asked again at release, against the snapshot as it is by then
/// (`commitReorder`).
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
let to = LaneReorderMath.proposedIndex(