diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 9f3fac4..a6ee50c 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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 diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index eb52066..3174c9b 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -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( diff --git a/Kanban/UI/Board/LaneResizeSession.swift b/Kanban/UI/Board/LaneResizeSession.swift index 2bfb80e..d69a781 100644 --- a/Kanban/UI/Board/LaneResizeSession.swift +++ b/Kanban/UI/Board/LaneResizeSession.swift @@ -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) } diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 0f51da7..cd01fa0 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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) diff --git a/Kanban/UI/Board/TrashCommands.swift b/Kanban/UI/Board/TrashCommands.swift index b7b4e50..192906d 100644 --- a/Kanban/UI/Board/TrashCommands.swift +++ b/Kanban/UI/Board/TrashCommands.swift @@ -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() + } } } ) diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index 18a081a..b05aac6 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -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) diff --git a/Kanban/UI/Motion.swift b/Kanban/UI/Motion.swift new file mode 100644 index 0000000..8c177d7 --- /dev/null +++ b/Kanban/UI/Motion.swift @@ -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) + } +} diff --git a/KanbanTests/MotionTests.swift b/KanbanTests/MotionTests.swift new file mode 100644 index 0000000..686f5da --- /dev/null +++ b/KanbanTests/MotionTests.swift @@ -0,0 +1,142 @@ +import SwiftUI +import Testing +@testable import Kanban + +/// The motion language's testable half (03-board-ui.md § Motion, 10-accessibility.md ▸ Reduce +/// Motion). Animation itself is not unit-testable — nothing here renders — so what these pin is the +/// *contract* the surface makes to its call sites: +/// +/// 1. **Which reloads perform.** `Motion.reloadAnimates` is the whole of "user-initiated structural +/// changes animate; foreign changes snap" in Lanework's one-way flow, and it is a pure function +/// of an origin and a flag precisely so it can be pinned here rather than watched for. +/// 2. **Which variant Reduce Motion gets.** 10-accessibility.md's commitment is "crossfade or +/// instant", and both halves are assertable: an animation's reduced variant is `nil`, a +/// transition's is the opacity-only crossfade (`Motion.Appearance`, which exists so this claim is +/// testable at all — `AnyTransition` is opaque). +/// 3. **The named durations.** 03 fixes five figures by name; a silent drift in one of them would be +/// invisible in every other test in the suite. + +// MARK: - Which reloads perform + +/// The store seam's decision, over every origin the watcher can deliver. +struct ReloadVoiceTests { + + /// The rule's positive half: an app-mediated reload is the tail of something the user did, and + /// it is the *only* origin that performs. + @Test func anAppMediatedReloadAnimates() { + #expect(Motion.reloadAnimates(origin: .appMediated, endsBracketedOperation: false)) + } + + /// "Changes arriving through the watcher — agent edits, hand edits, sync, external git — apply + /// instantly with no transition: live-reload is the board becoming what's on disk, not an event + /// to perform." + @Test func aForeignReloadSnaps() { + #expect(!Motion.reloadAnimates(origin: .foreign, endsBracketedOperation: false)) + } + + /// A reconciling sweep — wake, activation, a missed-events flag — makes no claim to be anyone's + /// gesture. Usually it lands a value-equal snapshot and nothing moves at all; when it does not, + /// what it found is a change that already happened, not one to perform. + @Test func aReconcilingReloadSnaps() { + #expect(!Motion.reloadAnimates(origin: .reconciling, endsBracketedOperation: false)) + } + + /// A bracketed wholesale operation's closing reload snaps **whatever its origin** — a pull, a + /// branch switch, a wholesale rewrite can leave the board a different tree, and 10 gives those + /// one announcement at completion rather than a performance of their churn. + @Test func aBracketedWholesaleReloadSnapsWhateverItsOrigin() { + for origin in [WatchOrigin.appMediated, .foreign, .reconciling] { + #expect( + !Motion.reloadAnimates(origin: origin, endsBracketedOperation: true), + "a bracketed wholesale reload from \(origin.rawValue) should snap" + ) + } + } + + /// **The merged-origin case, pinned because it is not obvious.** Origins coalesce by precedence + /// and the merge is lossy: a foreign edit folding into an app-mediated span yields + /// `.appMediated`, so a window that mixes both is indistinguishable from a pure echo and + /// animates. This test exists to make that a decision rather than a surprise — if the merge rule + /// ever grows a "mixed" case, this is what should fail. + @Test func aWindowMixingForeignIntoAnAppMediatedSpanStillAnimates() { + let merged = WatchOrigin.merged(.appMediated, .foreign) + #expect(merged == .appMediated) + #expect(Motion.reloadAnimates(origin: merged, endsBracketedOperation: false)) + } + + /// The other merge, the other way: reconciling outranks app-mediated, so a sweep folding over an + /// echo snaps. The stronger claim wins, and the stronger claim here is "assume nothing". + @Test func aReconcilingSweepFoldingOverAnEchoSnaps() { + let merged = WatchOrigin.merged(.appMediated, .reconciling) + #expect(merged == .reconciling) + #expect(!Motion.reloadAnimates(origin: merged, endsBracketedOperation: false)) + } + + /// The wrapper the store actually calls: the same decision, plus the voice and the Reduce Motion + /// variant. Every snapping path is `nil`, and so is the reduced form of the animating one — which + /// is what makes `withAnimation` at the seam need no branch of its own. + @Test func theStoreSeamsWrapperYieldsTheStructuralVoiceOnlyWhereItPerforms() { + #expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: false, reduced: false) + == Motion.structural(reduced: false)) + #expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: false, reduced: true) == nil) + #expect(Motion.reloadAnimation(origin: .foreign, endsBracketedOperation: false, reduced: false) == nil) + #expect(Motion.reloadAnimation(origin: .reconciling, endsBracketedOperation: false, reduced: false) == nil) + #expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: true, reduced: false) == nil) + } +} + +// MARK: - The named curves + +/// The five durations 03-board-ui.md § Motion fixes, and the two voices they are spoken in. +struct MotionCurveTests { + + @Test func theStructuralVoiceIsTheSnappySpringAtItsNamedDurations() { + #expect(Motion.structural(reduced: false) == .snappy(duration: 0.2)) + #expect(Motion.dragReflow(reduced: false) == .snappy(duration: 0.18)) + #expect(Motion.delete(reduced: false) == .snappy(duration: 0.25)) + #expect(Motion.laneResize(reduced: false) == .snappy(duration: 0.2)) + } + + /// The content-reflow voice is a *different preset*, not the structural one slowed down — that + /// is the whole of "two curves, semantically split", and it is why search filtering and an undo + /// restore read alike and neither reads like a drop. + @Test func theContentReflowVoiceIsTheSmoothSpring() { + #expect(Motion.contentReflow(reduced: false) == .smooth(duration: 0.28)) + #expect(Motion.contentReflow(reduced: false) != Motion.structural(reduced: false)) + } + + /// The window's half of the lane resize has to be the same figure as the units' half, or the + /// window edge and the lanes inside it stop travelling as one (`LaneResizeSession`). + @Test func theWindowResizeMatchesTheLaneResizeDuration() { + #expect(Motion.laneResizeWindowDuration == 0.2) + #expect(Motion.laneResize(reduced: false) == .snappy(duration: Motion.laneResizeWindowDuration)) + } +} + +// MARK: - Reduce Motion + +/// 10-accessibility.md's "crossfade or instant", by kind: an animation has no crossfade available, +/// so its reduced variant is instant; a transition does, so its reduced variant is the crossfade. +struct ReduceMotionVariantTests { + + @Test func everyAnimationsReducedVariantIsInstant() { + #expect(Motion.structural(reduced: true) == nil) + #expect(Motion.dragReflow(reduced: true) == nil) + #expect(Motion.delete(reduced: true) == nil) + #expect(Motion.laneResize(reduced: true) == nil) + #expect(Motion.contentReflow(reduced: true) == nil) + } + + /// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)." + @Test func appearanceIsScaleAndFadeAtTheTwoNamedScales() { + #expect(Motion.cardAppearance(reduced: false) == .scaleAndFade(from: 0.8)) + #expect(Motion.laneAppearance(reduced: false) == .scaleAndFade(from: 0.9)) + } + + /// The scale is what drops under Reduce Motion; the fade is what stays. A reduced user still + /// sees the item leave — it just does not travel to do it. + @Test func everyTransitionsReducedVariantIsTheCrossfade() { + #expect(Motion.cardAppearance(reduced: true) == .crossfade) + #expect(Motion.laneAppearance(reduced: true) == .crossfade) + } +}