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:
2026-08-01 09:53:26 -04:00
parent ba1726fa77
commit 0933ac1b01
8 changed files with 795 additions and 41 deletions
+105
View File
@@ -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)
}
}