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
+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)
}