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