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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user