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
257 lines
14 KiB
Swift
257 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|