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". /// /// **The search filter is its call site** (`BoardView.laneStrip`), where it wraps a transaction /// keyed on the query and nothing else — 03's own narrow key for this reflow. The leavers and /// arrivers it talks about are `cardTransition`, already attached to every card slot and trash /// row, so the two halves of the sentence are two modifiers rather than one bespoke animation. /// // m7-undo: the restore is the other half of the pair. It arrives as a bracketed wholesale // reload, so it reaches this voice through `reloadAnimation` rather than through a transaction // of its own — see `reloadAnimates(origin:endsBracketedOperation:)`. 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) /// Slides in from `edge` while fading in, and leaves the way it came. case slideAndFade(from: Edge) /// 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 let .slideAndFade(edge): .move(edge: edge).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 the search filter's leavers /// and arrivers ("Search-hiding rides the same structural transition — hiding is removal, not a /// special fade"). 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 } /// The transient search bar arriving and leaving — ⌘F's fallback when the search field has been /// removed from the toolbar (03-board-ui.md ▸ Toolbar: "with the field removed from the toolbar, /// invoking it surfaces the field transiently until the search clears"). /// /// It comes from the top edge because that is where the field lives when it is installed: the /// bar is the toolbar's item arriving one row lower, not a new kind of surface. static func transientSearchAppearance(reduced: Bool) -> Appearance { reduced ? .crossfade : .slideAndFade(from: .top) } static func transientSearchTransition(reduced: Bool) -> AnyTransition { transientSearchAppearance(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: 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; this seam stays the floor under them. // // The search filter needed none of that and never reaches here: a query change is not a reload // at all — it is transient state, so its transaction is wrapped where it happens // (`BoardView.laneStrip`, `contentReflow`). static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? { guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil } return structural(reduced: reduced) } }