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