Every open passes through a real loading window — the walk moves off the main actor
Phase 2 of the decision surface: the pre-snapshot loading state ruled 2026-07-29 (02 § Launch and window lifecycle), built. The board window appears immediately at its saved frame, titled with the registry record's cached name, its content a centered spinner behind an injectable ~200ms grace — no skeletons, and the first snapshot snaps in place. The tree walk runs off-main via BoardStoreRegistry.acquireOffMain (per-board single-flight keyed by file identity — concurrent opens of one root share a walk, restoration of many boards is genuinely parallel), landing in BoardStore's new designated init(rootURL:loaded:); the self-walking init survives as a convenience for its ~470 callers. ⌘W during the walk is real: configureWindow split into a loading half (frame restore, frame tracking, close interception — installed before the walk) and a store half (toolbar, widget, hideTitle, undo — installed at the snap), and the walk lives in an explicitly held BoardOpenWalk so the user's close and SwiftUI's teardown end in one cancel(). Cancellation is discard-on-completion: nil from acquireOffMain means nothing was built, nothing retained, and no open-now flag was ever set. Failure keeps today's sequence exactly: record the launch failure, welcome's row carries it, the window retires. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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<Void, Never>?
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user