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(
+30 -10
View File
@@ -24,9 +24,9 @@ import SwiftUI
///
/// The session owns the two things that must move together on each tick: the SwiftUI unit count
/// (`units`, which drives the shadow slot, the siblings' positions, and the resizing lane's masonry
/// column count) and the host window's width. They animate on matching 0.2s curves the lane-resize
/// entry in 03-board-ui.md § Motion's snappy-spring vocabulary so the window edge and the lanes to
/// its right travel as one.
/// column count) and the host window's width. They animate on matching curves `Motion.laneResize`
/// and `Motion.laneResizeWindowDuration`, the two faces of 03-board-ui.md § Motion's lane-resize
/// entry so the window edge and the lanes to its right travel as one.
@MainActor
@Observable
final class LaneResizeSession {
@@ -64,6 +64,17 @@ final class LaneResizeSession {
/// though a resize cannot outlive the gesture that drives it.
@ObservationIgnored private weak var window: NSWindow?
/// Reduce Motion, read once at `begin` and frozen for the gesture (10-accessibility.md's
/// lane-resize commitment "the lane-resize rubber-band feedback gets reduced variants").
///
/// Frozen rather than re-read per tick because the two halves of a tick the unit count and the
/// window's width must never disagree about which variant they are in: a setting flipped
/// between them would animate the lanes inside a window that jumped, which is the one thing this
/// session's whole matched-curve design exists to prevent. Read from AppKit rather than from the
/// SwiftUI environment because this type animates an `NSWindow` and has no environment to read
/// (`Motion.prefersReducedMotion`).
@ObservationIgnored private var reducedMotion = false
var isActive: Bool { laneID != nil }
func isResizing(_ id: ItemID) -> Bool { laneID == id }
@@ -103,6 +114,7 @@ final class LaneResizeSession {
self.standard = standard
self.gap = gap
self.window = window
self.reducedMotion = Motion.prefersReducedMotion
self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window)
}
@@ -149,7 +161,7 @@ final class LaneResizeSession {
func end(commit: (ItemID, Int) -> Void) {
guard let laneID else { return }
commit(laneID, units)
withAnimation(.snappy(duration: 0.2)) {
withAnimation(Motion.laneResize(reduced: reducedMotion)) {
self.laneID = nil
self.liveWidth = 0
}
@@ -159,18 +171,26 @@ final class LaneResizeSession {
/// A single snapped step: animate the unit count (which resizes the shadow slot, translates the
/// lanes to the right, and reflows the resizing lane's interior columns) and the window's width
/// on matching 0.2s curves. The window grows and shrinks at its RIGHT edge width changes by
/// ±step with `origin.x` and height held so everything to the left, including this lane's own
/// left edge and the drag's coordinate origin, stays put.
/// on the two matching lane-resize curves. The window grows and shrinks at its RIGHT edge
/// width changes by ±step with `origin.x` and height held so everything to the left, including
/// this lane's own left edge and the drag's coordinate origin, stays put.
private func tick(to newUnits: Int) {
let delta = CGFloat(newUnits - units) * (standard + gap)
withAnimation(.snappy(duration: 0.2)) { units = newUnits }
withAnimation(Motion.laneResize(reduced: reducedMotion)) { units = newUnits }
guard let window else { return }
var frame = window.frame
frame.size.width += delta // right-edge growth: origin and height unchanged
// Reduce Motion's variant of the rubber-band feedback is the *instant* one
// (10-accessibility.md): the window takes its new width outright, matching the unit count
// that just did the same. Spelled as the absence of an animation group rather than as a
// zero-duration one see `Motion.laneResizeWindowDuration` for why a zero is not trusted.
guard !reducedMotion else {
window.setFrame(frame, display: true)
return
}
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
context.duration = Motion.laneResizeWindowDuration
context.timingFunction = Motion.laneResizeWindowTiming
context.allowsImplicitAnimation = true
window.setFrame(frame, display: true)
}
+18 -5
View File
@@ -69,6 +69,10 @@ struct LaneView: View {
/// business knowing about `WindowGroup` keys.
let openCard: (ItemID) -> Void
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
/// and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8
@@ -305,12 +309,21 @@ struct LaneView: View {
// `units` columns of `standard` (03-board-ui.md § Layout full visibility).
MasonryLayout(columns: columns, spacing: cardSpacing) {
ForEach(slots) { slot in
switch slot {
case let .card(card):
CardFaceView(store: store, card: card, openCard: openCard)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
Group {
switch slot {
case let .card(card):
CardFaceView(store: store, card: card, openCard: openCard)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
}
}
// "Appear/disappear is scale + fade (cards scale from ~0.8 )"
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back and
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
// too: it is the card, one round trip early. Whether any of it *performs* is
// decided upstream at the reload for the real cards (`Motion.reloadAnimates`),
// at the gesture for the placeholder, which touches no disk.
.transition(Motion.cardTransition(reduced: reduceMotion))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+19 -3
View File
@@ -208,9 +208,25 @@ struct ShowTrashCommand: View {
get: { store?.transient.isTrashVisible ?? false },
set: { shown in
guard let store else { return }
store.transient.isTrashVisible = shown
if !shown, store.selection.liveness == .trashed {
store.clearSelection()
// The re-divide is a *user-initiated structural change* every lane compresses or
// relaxes as the trash's one unit joins or leaves the division so it animates in
// the structural voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a
// re-divide trigger", a lane add's behaviour exactly). It is also one of the few
// structural changes that never touches disk, which is why it wears its own
// transaction here instead of arriving through the reload seam like the rest.
//
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's
// content is built outside any rendered hierarchy, where the environment's
// accessibility values are not reliably populated (`Motion.prefersReducedMotion`).
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
store.transient.isTrashVisible = shown
// Dropped inside the same transaction: the rows it pointed at are leaving under
// this very animation, and a selection that cleared outside it would be the
// highlight easing on its own which 03 § Motion rules out ("the selection
// highlight rides whatever transaction is active").
if !shown, store.selection.liveness == .trashed {
store.clearSelection()
}
}
}
)
+9
View File
@@ -114,6 +114,10 @@ struct TrashLaneView: View {
/// session has.
let dragSession: TrashDragSession
/// Reduce Motion, for the row transition below 10-accessibility.md names the trash
/// specifically ("and trash animations all get reduced variants").
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The lane plate's corner radius matched to `LaneView`'s so the column reads as a sibling of
/// the lanes rather than as a different kind of object.
private let cornerRadius: CGFloat = 10
@@ -205,6 +209,11 @@ struct TrashLaneView: View {
drag: drag,
dragSession: dragSession
)
// A row is a tombstoned item, so it arrives and leaves in the card's dialect
// a delete files one in, a Put Back or a purge takes one out, and both halves of
// that pair should read alike from either side of the strip. The transaction is
// the reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion))
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)