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
+38 -7
View File
@@ -1,6 +1,10 @@
import Foundation
import Observation
import os
// The one UI import in the store layer, and it earns its place: 03-board-ui.md § Motion puts the
// animate-or-snap split on the *reload*, and a reload lands here. `Motion` owns the decision and
// every curve; this file owns nothing but the `withAnimation` around the assignment (see `land`).
import SwiftUI
// MARK: - Vocabulary
@@ -395,21 +399,48 @@ public final class BoardStore {
switch outcome {
case let .success(result):
snapshot = result.model
loadWarnings = result.warnings
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`).
// "User-initiated structural changes animate; foreign changes snap" cannot live at the
// call sites here the way it did in the pathfinder the one-way flow means the user's
// own delete arrives back through the watcher exactly like an agent's edit so it lives
// at the *reload*, whose origin is already classified. `Motion` owns which origins
// perform and in which voice; this store owns only the assignment they wrap.
//
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare assignment,
// which is what the snapping origins and the Reduce Motion variant both want.
withAnimation(Motion.reloadAnimation(
origin: origin,
endsBracketedOperation: endsWholesaleOperation,
reduced: Motion.prefersReducedMotion
)) {
snapshot = result.model
loadWarnings = result.warnings
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree.
//
// Inside the transaction deliberately: 03 § Motion has the selection highlight
// "ride whatever transaction is active rather than easing on its own", and a
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
transient.resolve(against: result.model)
}
// Outside it, equally deliberately 03 keys transactions narrowly, and the banner
// conditions are not board structure. A lock clearing is not a thing the strip should
// slide out on the back of a card being deleted.
//
// Breakage always heals on a success it *is* the claim "the last reload failed", and
// this one did not.
reloadFailure = nil
clearLockIfDisproved(by: origin)
// The one place transient state is re-grounded. It goes last, after `snapshot` is the
// new one, because a view woken by the snapshot's change must never observe a selection
// still pointing at the old tree.
transient.resolve(against: result.model)
case let .failure(error):
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
// never replaces a good snapshot, and state over a snapshot that did not change has
// nothing to re-resolve against.
// nothing to re-resolve against. Nothing is performed here either, for the same reason
// and neither is anything on the lock paths below (`enterVanishedRootLock`,
// `enterUnwritableLock`, `relocate`), none of which touch the snapshot at all. A board
// that did not change has no motion to show.
reloadFailure = error
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
// mid-bracket already raised its own, truer lock, and 02-architecture.md is explicit
+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)
+256
View File
@@ -0,0 +1,256 @@
import AppKit
import QuartzCore
import SwiftUI
/// The app's one animation surface every curve, duration and appear/disappear transition it uses
/// is named here (03-board-ui.md § Motion: "The rewrite gives the vocabulary the one thing the
/// pathfinder lacked: a single named home (one animation-constants surface), so curves and durations
/// stop being per-site literals").
///
/// **The rule this type exists to enforce: no call site anywhere spells a duration, a spring, a
/// timing function, or a transition of its own.** A site that needs motion asks for it by *meaning*
/// `Motion.laneResize`, `Motion.cardTransition` and the meaning is defined once, here.
///
/// ### Two voices, semantically split
///
/// - **Structural** (snappy springs) is the positional voice: drag reflow (~0.18 s), drop commit,
/// paste, delete (~0.25 s), keyboard nudges, scroll-into-view, lane resize (~0.2 s). It is what
/// the board wears when *things move*.
/// - **Content reflow** (a smooth ~0.28 s spring) is the filtering voice: search filtering and
/// undo/redo restore, "deliberately paired so a restore reads like the search filter".
///
/// Named system presets only (`.snappy`, `.smooth`); 03 rules out hand-tuned spring parameters, so
/// there is not a `response:` or a `dampingFraction:` anywhere below.
///
/// ### Which changes animate at all
///
/// "User-initiated structural changes animate; foreign changes snap." Lanework's one-way flow means
/// a user's own action arrives back as a watcher reload like any other, so the split cannot live at
/// the call sites the pathfinder put it at it lives at the *reload*, whose origin the store
/// already classifies. `reloadAnimates(origin:endsBracketedOperation:)` is that decision, kept pure
/// so it can be tested without a filesystem, and `BoardStore.land` is its one caller.
///
/// ### Reduce Motion
///
/// Every accessor comes in a Reduce Motion-aware form, because 10-accessibility.md makes the reduced
/// variants an obligation rather than an inheritance ("the pathfinder ships zero reduced variants").
/// The mapping is 10's own "crossfade or instant", applied by kind:
///
/// - an **animation** has no crossfade available a reflow either eases or it does not so its
/// reduced variant is `nil`, which `withAnimation` and `.animation(_:value:)` both read as
/// "apply instantly";
/// - a **transition** does, so its reduced variant is the opacity-only crossfade, with the scale
/// dropped.
///
/// Views read `@Environment(\.accessibilityReduceMotion)` and pass it in. Code with no environment
/// to read the store's reload seam, the lane-resize session's window animation, a menu command's
/// action reads `prefersReducedMotion` instead, which asks AppKit the same question.
enum Motion {
// MARK: - The numbers
/// The durations 03-board-ui.md § Motion fixes by name. Private: the whole point of this type is
/// that a duration is never read as a number except by the accessors below.
///
/// `structural` and `laneResize` are the same 0.2 s today and are still two entries, because 03
/// names them separately one is the voice's general figure and the other is one operation's.
/// Collapsing them would make a later "lane resize is a touch slower than the rest" a change to
/// every structural site.
private enum Duration {
static let structural: TimeInterval = 0.2
static let dragReflow: TimeInterval = 0.18
static let delete: TimeInterval = 0.25
static let laneResize: TimeInterval = 0.2
static let contentReflow: TimeInterval = 0.28
}
/// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)."
private enum AppearScale {
static let card: CGFloat = 0.8
static let lane: CGFloat = 0.9
}
// MARK: - The structural voice
/// The general snappy spring the voice every positional change wears unless 03 gives that
/// operation a figure of its own. The store's reload seam and the trash re-divide are its call
/// sites today.
static func structural(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.structural)
}
/// The drag's reflow-to-make-room the siblings opening a slot under a drop proposal
/// (03 § Motion: "on the drag's **drop proposal** (the reflow-to-make-room above animates under
/// it, ~0.18 s)"). The lane reorder's sibling reflow is its call site today; m5's card drag joins
/// it, and the replica's own tracking deliberately never comes near it.
static func dragReflow(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.dragReflow)
}
/// Delete's slightly longer settle the survivors' reflow as an item leaves.
///
/// **No call site yet, and that is a known gap rather than an oversight.** Delete lands through
/// the Writer and comes back as an app-mediated reload, and the reload seam cannot know *which*
/// operation echoed see `reloadAnimates(origin:endsBracketedOperation:)`. It is named here
/// because 03 fixes the figure and because the alternative is a literal at whichever site first
/// needs it.
static func delete(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.delete)
}
/// The lane resize's snap tick the SwiftUI half. The window's half is
/// `laneResizeWindowDuration`, and the two must stay on matching curves or the window edge and
/// the lanes to its right stop travelling as one (`LaneResizeSession`).
static func laneResize(reduced: Bool) -> Animation? {
reduced ? nil : .snappy(duration: Duration.laneResize)
}
/// The content-reflow voice: search filtering and undo/redo restore, "deliberately paired so a
/// restore reads like the search filter leavers and arrivers run their transition, survivors
/// reflow under one gentle spring".
///
/// Named ahead of both its call sites (m5's search, m7's undo) for the same reason `delete` is:
/// the figure is settled, and a duration that has nowhere to live gets spelled at a call site.
static func contentReflow(reduced: Bool) -> Animation? {
reduced ? nil : .smooth(duration: Duration.contentReflow)
}
// MARK: - Appear and disappear
/// The shape an appear/disappear transition takes the *testable* half of `cardTransition` and
/// `laneTransition`.
///
/// It exists because `AnyTransition` is opaque and not `Equatable`: without this, "the reduced
/// variant is a crossfade" would be a claim no test could make, and the one thing
/// 10-accessibility.md actually commits to here is which variant a reduced-motion user gets.
enum Appearance: Equatable {
/// Scales up from `from` while fading in (and the reverse on the way out).
case scaleAndFade(from: CGFloat)
/// Opacity only 10-accessibility.md's "crossfade" variant.
case crossfade
var transition: AnyTransition {
switch self {
case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
case .crossfade: .opacity
}
}
}
static func cardAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .scaleAndFade(from: AppearScale.card)
}
static func laneAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
}
/// A card arriving or leaving a create, a delete, a Put Back, and (m5) a search filter's
/// leavers and arrivers. The trash's rows wear it too: they are cards, and 10 requires the trash
/// animations to have a reduced variant like everything else.
static func cardTransition(reduced: Bool) -> AnyTransition {
cardAppearance(reduced: reduced).transition
}
/// A lane arriving or leaving and the trash quasi-lane joining or leaving the width division,
/// which is lane-shaped and reads as one.
static func laneTransition(reduced: Bool) -> AnyTransition {
laneAppearance(reduced: reduced).transition
}
// MARK: - The AppKit face
/// The lane-resize window animation's duration, for `NSAnimationContext` which takes a number
/// of seconds and not an `Animation`, and so cannot be handed `laneResize(reduced:)`.
///
/// No `reduced:` parameter, deliberately: the reduced variant of a window resize is **no
/// animation group at all** (`LaneResizeSession.tick`), not a zero-duration one. Core Animation
/// treats a zero duration as "use the transaction's default" often enough that trusting it to
/// mean *instant* would make 10-accessibility.md's lane-resize commitment a coin toss.
static var laneResizeWindowDuration: TimeInterval { Duration.laneResize }
/// The window animation's timing function. `.easeOut` is the closest AppKit curve to the snappy
/// spring the units tick on the two are travelling the same distance in the same time, and a
/// visible disagreement between the window's right edge and the lanes inside it is exactly what
/// `LaneResizeSession`'s frozen standard exists to prevent.
static var laneResizeWindowTiming: CAMediaTimingFunction {
CAMediaTimingFunction(name: .easeOut)
}
/// Reduce Motion, asked of AppKit rather than of the SwiftUI environment.
///
/// For the three kinds of caller that have no environment to read: the store's reload seam (a
/// model type), `LaneResizeSession` (which animates an `NSWindow`), and the menu commands (whose
/// content is built outside any rendered hierarchy, so the environment's accessibility values are
/// not reliably populated there). It is the same system setting SwiftUI's
/// `accessibilityReduceMotion` reports, asked at the other end.
@MainActor
static var prefersReducedMotion: Bool {
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
// MARK: - Which reloads perform
/// **The two-voice split, as one pure decision**: does the snapshot this reload landed get
/// applied in an animated transaction, or does it simply appear?
///
/// 03-board-ui.md § Motion states the rule at the level of user intent "User-initiated
/// structural changes animate; foreign changes snap" and notes the pathfinder enforced it by
/// routing every user operation through animated store methods. Lanework cannot: the one-way
/// flow means a delete the user just asked for arrives back through the watcher exactly like an
/// agent's edit would. What the app has instead is the reload's **origin**, which the watcher
/// already classifies, and that is enough:
///
/// - `.appMediated` the tail of an operation this app ran. The user did something and this is
/// it landing, so it performs.
/// - `.foreign` an editor, an agent, `git` in a terminal. It snaps: "live-reload is the board
/// becoming what's on disk, not an event to perform."
/// - `.reconciling` a sweep after a wake, an activation, or a missed-events flag. It makes no
/// claim to be anyone's gesture, so it snaps too; a board that animated every lane on wake
/// would be performing the *absence* of a change.
///
/// **A bracketed wholesale operation snaps whatever its origin.** Its reload is a pull-rebase, a
/// branch switch, a wholesale rewrite the board can be a different tree afterwards, and
/// animating every lane and card out and back in would be theatre over a change the user asked
/// for in one gesture and 10-accessibility.md announces in one sentence ("Bracketed operations
/// announce once, at completion").
///
/// ### The merged-origin case, stated because it is not what it looks like
///
/// Origins coalesce by precedence (`reconciling > appMediated > foreign`, `WatchOrigin.merged`),
/// and that merge is **lossy on purpose**: a foreign edit landing inside an app-mediated span
/// does not downgrade it, so the store is handed `.appMediated` and cannot tell a pure echo from
/// a mixed one. The mixed window therefore animates. That is the honest reading rather than a
/// concession the merged label is defined as "the strongest claim either half made", the app's
/// own operation *is* in that window, and it is the thing the user is waiting to see land. The
/// alternative would be to split deliveries to keep origins pure, which `FolderWatcher.schedule`
/// rejects for the coalescing it would cost.
///
// m7-undo: undo/redo restore is 03's "one deliberate crossover" app-initiated, so it animates,
// but in the *content-reflow* voice rather than the structural one ("a git checkout, but ours").
// It arrives as a bracketed wholesale reload, so it lands on the `endsBracketedOperation` branch
// below: m7 gives this function a way to know the operation was a restore (an origin case, or a
// restore flag threaded through `performWholesale`) and returns `contentReflow` for it instead of
// snapping. Everything else on that branch keeps snapping.
static func reloadAnimates(origin: WatchOrigin, endsBracketedOperation: Bool) -> Bool {
guard !endsBracketedOperation else { return false }
switch origin {
case .appMediated: return true
case .foreign, .reconciling: return false
}
}
/// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is
/// applied in, or `nil` for the reloads that snap.
///
// m5-drag, m5-search: the voice is the *general* structural spring for every app-mediated
// reload, because the reload seam knows an operation echoed but not which one 03 gives delete
// 0.25 s and a drop commit its own dialect, and neither is reachable from an origin tag. The
// per-operation figures (`delete`, `dragReflow`) become reachable when the operations that own
// them run their own transactions around the gesture, which is m5's card; this seam stays the
// floor under them.
static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? {
guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil }
return structural(reduced: reduced)
}
}