import SwiftUI // MARK: - BoardLoadingIndicator /// **The grace before the spinner** — the whole of the pre-snapshot loading state's timing /// (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29): /// /// > The content area holds a quiet loading surface: a centered system spinner appearing only after /// > a short grace (~200 ms) so ordinary fast opens never flash it — no skeleton lanes, the motion /// > language animates real data only. /// /// ### Why the grace is an object rather than a `task` modifier /// /// Two reasons, and they are the same two `DragSession.holdTimeout` has. It is a **state machine** /// worth pinning — nothing showing before the grace, the spinner after, nothing showing again once /// the snapshot lands — and a rule about *when* something appears is only checkable if there is /// something to ask. And the figure has to be injectable: a suite that had to wait the real 200 ms /// out, once per case, would be paying wall-clock time to assert a constant. /// /// The body the sleep runs is `graceElapsed()`, spelled as a method rather than inlined in the task, /// so the "after" half can be pinned directly as well as through the clock — again the drag /// session's shape (`DragSession.expire`). /// /// ### It is not a progress report /// /// The walk has no progress to report — `BoardLoader.load` is one call that either lands or throws /// — so this is indeterminate by construction and carries no percentage, no phase, and no cancel /// affordance of its own. ⌘W is the cancel (`BoardWindowHost`), which is 02's own answer. @MainActor @Observable final class BoardLoadingIndicator { /// ~200 ms, 02's figure. Named rather than inlined so the one place that states it is the one /// place a future ruling would change. static let standardGrace: Duration = .milliseconds(200) /// Whether the spinner is on screen. False until the grace elapses, and false again the moment /// the surface ends — a board that loaded inside the grace never sets it at all, which is the /// "ordinary fast opens never flash it" clause. private(set) var showsSpinner = false /// How long the surface stays empty before the spinner appears. A `var` for `holdTimeout`'s /// reason exactly: the app never writes it, and a test that had to wait the real figure out /// would be a wall clock standing in for a rule. Not observed — changing it mid-grace is not a /// thing that happens. @ObservationIgnored var grace: Duration = BoardLoadingIndicator.standardGrace @ObservationIgnored private var graceTask: Task? init() {} /// Arms the grace. Idempotent: a body that evaluates twice, or a host that configures itself /// more than once, must not restart the clock a board has already been waiting on. func begin() { guard graceTask == nil else { return } let grace = self.grace graceTask = Task { @MainActor [weak self] in try? await Task.sleep(for: grace) guard !Task.isCancelled, let self else { return } graceElapsed() } } /// The grace's own body: the open is taking long enough to be worth explaining. func graceElapsed() { showsSpinner = true } /// The surface is over — the snapshot landed, the walk failed, or ⌘W cancelled it. Disarms the /// grace and clears the spinner, so a host that reuses the indicator starts from rest. func end() { graceTask?.cancel() graceTask = nil showsSpinner = false } } // MARK: - BoardLoadingView /// The board window's content area **before its first snapshot** — quiet by design. /// /// Nothing but a centered system `ProgressView`, and only once the grace has elapsed. No skeleton /// lanes (02 rules them out explicitly), no board background — the board's own background colour is /// a fact of the snapshot that has not arrived, so painting a guess here would be a colour that /// changed at the snap. /// /// **The snap is the absence of an animation.** `BoardWindowHost` replaces this view with the board /// by assigning its phase outside any `withAnimation`, per 02's "the first snapshot replaces the /// surface in place (a snap — there is no prior arrangement to animate from)" and the Motion /// vocabulary's reload seam. There is deliberately no transition on this view. struct BoardLoadingView: View { let indicator: BoardLoadingIndicator var body: some View { ZStack { if indicator.showsSpinner { ProgressView() .controlSize(.large) .accessibilityLabel(AccessibilityPhrases.boardLoading) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } }