diff --git a/Shared/LifecycleKit/Sources/LifecycleRunner.swift b/Shared/LifecycleKit/Sources/LifecycleRunner.swift index a16df78d1..b5a56db58 100644 --- a/Shared/LifecycleKit/Sources/LifecycleRunner.swift +++ b/Shared/LifecycleKit/Sources/LifecycleRunner.swift @@ -89,6 +89,18 @@ public final class LifecycleRunner { state.reason } + /// Changes only the observed launch reason, modeling a promotion whose + /// intermediate drive phases coalesced between SwiftUI renders. + @_spi(Testing) + public func promoteReasonToForegroundForTesting() { + switch state { + case .notStarted: + state = .notStarted(.userForeground) + case let .running(_, task): + state = .running(reason: .userForeground, task: task) + } + } + @ObservationIgnored private let launchNodes: [LaunchPlanNode] /// The output of every node that ran to completion during the current diff --git a/Shared/LifecycleKitUI/AGENTS.md b/Shared/LifecycleKitUI/AGENTS.md index 2925efa49..62aa5e698 100644 --- a/Shared/LifecycleKitUI/AGENTS.md +++ b/Shared/LifecycleKitUI/AGENTS.md @@ -29,14 +29,19 @@ build system, formatting, and global conventions. Read that first. - **Every splash-showing state resolves to one `LaunchOverlay.splash` case** — never per-phase `switch` arms, which remount the splash at each boundary and reset its animations and caption timers. -- **`minimumSplashDuration` only holds a splash that was actually shown** — - armed when the splash *appears*, so an already-`.ready` mount reveals - immediately. Guard: `minimumSplashDurationDoesNotHoldWhenNoSplashWasShown` - (the timing half is device-verified, not host-testable). Assert "revealed" - via the *absent splash*, not via `content` (content is built during a hold - too); `isShowingSplash` must read the runner's own surface, never - `displayedSurfaceIdentity`, which reports `.splash` for a held `.ready` and - would re-arm the hold from its own release. +- **`minimumSplashDuration` holds only an observed splash by default** — + `.phaseDriven` keeps an already-`.ready` mount immediate, while + `.splashBeforeFirstReveal` may establish the first hold from a visible + `.ready` when foreground promotion coalesced past the splash. Keep that hold + keyed on readiness *and* visibility, retain an observed splash's original + deadline, and never arm it headlessly. Guards: + `minimumSplashDurationDoesNotHoldWhenNoSplashWasShown`, + `splashBeforeFirstRevealKeepsBackgroundReadyHeadless`, + `promotedBackgroundReadyForcesTheFirstRevealSplash`. Assert + "revealed" via the *absent splash*, not via `content` (content is built + during a hold too); `isShowingSplash` must read the runner's own surface, + never `displayedSurfaceIdentity`, which reports `.splash` for a held + `.ready` and would re-arm the hold from its own release. - **Gate views resolve only their own handle** — a superseded drive's handle no-ops; don't route gate resolution through anything else. - **One registration per gate type** (construction `precondition`); a parked diff --git a/Shared/LifecycleKitUI/README.md b/Shared/LifecycleKitUI/README.md index 06496d999..8e19c3bdf 100644 --- a/Shared/LifecycleKitUI/README.md +++ b/Shared/LifecycleKitUI/README.md @@ -88,6 +88,28 @@ reveal animation starts. It stays gated on the launch's output either way (that value is only readable from `.ready`), so nothing is built speculatively: the hold just stops being a stall and starts being a warm-up. +By default, an already-`.ready` container reveals immediately because SwiftUI +never displayed a splash for the minimum to hold. An app that must always show +the splash before its first visible main-UI reveal can opt in: + +```swift +LifecycleContainer( + runner, + minimumSplashDuration: .milliseconds(800), + readyRevealPolicy: .splashBeforeFirstReveal, +) { session in + MainTabs(session: session) +} +``` + +This covers the background-launch edge case where the runner is already ready, +foreground promotion starts and finishes, and SwiftUI observes only the final +`.ready` state. The first visible ready presentation establishes the hold in +that case. A rendered splash keeps its original deadline, so reaching `.ready` +does not start a second hold. Headless phases remain viewless, gate and failure +surfaces are unaffected, and once content is revealed an ordinary foreground +resume does not replay the forced splash. + ## Reaching the runner from nested views `LifecycleContainer` publishes a `LifecycleProxy` under diff --git a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift index 1534cdc01..9fe80c750 100644 --- a/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift +++ b/Shared/LifecycleKitUI/Sources/LifecycleContainer.swift @@ -50,10 +50,10 @@ public struct LifecycleContainer< private let gates: [GateRegistration] private let content: (Launch) -> Content - /// When the splash may be dismissed: the deadline the current appearance's - /// `minimumSplashDuration` set. `nil` means nothing is holding the reveal — - /// no minimum was requested, no splash was shown, or the hold has elapsed. - @State private var splashHoldUntil: ContinuousClock.Instant? + /// One value tracks whether the first visible ready presentation still + /// owes the user a splash, a rendered splash is being held, or content has + /// already been revealed. Kept scene-local by SwiftUI's state lifetime. + @State private var readyRevealState: LifecycleReadyRevealState /// - Parameters: /// - transition: how each surface enters/leaves. Defaults to a crossfade. @@ -65,6 +65,9 @@ public struct LifecycleContainer< /// as soon as the runner is ready. The hold isn't dead time: `content` /// is already built beneath the splash, so the destination warms up /// during it rather than in the frame the reveal starts. + /// - readyRevealPolicy: whether an already-ready first visible presentation + /// reveals immediately or synthesizes the splash hold when SwiftUI never + /// observed a preceding splash phase. /// - splash: the waiting surface; receives the running step's context /// (nil between steps) so it can show a caption/progress. /// - failure: the (terminal) error surface, given the failure. There is @@ -76,6 +79,7 @@ public struct LifecycleContainer< transition: AnyTransition = .opacity, animation: Animation? = .default, minimumSplashDuration: Duration = .zero, + readyRevealPolicy: LifecycleReadyRevealPolicy = .phaseDriven, @ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash, @ViewBuilder failure: @escaping (LifecycleFailure) -> Failure, @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, @@ -89,6 +93,12 @@ public struct LifecycleContainer< failureView = failure self.gates = gates() self.content = content + _readyRevealState = State( + initialValue: LifecycleReadyRevealState( + policy: readyRevealPolicy, + minimumSplashDuration: minimumSplashDuration, + ), + ) Self.assertUniqueGateTypes(self.gates) } @@ -117,13 +127,22 @@ public struct LifecycleContainer< // Arm the hold each time the splash appears, so every episode (a reset // relaunch, the return from a gate) gets its own minimum. .onChange(of: isShowingSplash, initial: true) { _, showing in - guard showing, minimumSplashDuration > .zero else { return } - splashHoldUntil = ContinuousClock.now.advanced(by: minimumSplashDuration) + guard showing else { return } + readyRevealState.splashAppeared( + at: .now, + minimumSplashDuration: minimumSplashDuration, + ) } - // Once the runner is ready, wait out whatever is left of the hold, then - // release the reveal. - .task(id: runner.phase.isReady) { - guard runner.phase.isReady, let deadline = splashHoldUntil else { return } + // Include visibility in the identity: a background runner can remain + // `.ready` across foreground promotion, and that false → true transition + // is what must start an opt-in first-reveal hold. + .task(id: isReadyAndVisible) { + guard isReadyAndVisible else { return } + readyRevealState.readyBecameVisible( + at: .now, + minimumSplashDuration: minimumSplashDuration, + ) + guard let deadline = readyRevealState.splashHoldDeadline else { return } do { // Returns immediately once the deadline has passed, so a launch // slower than the minimum reveals without waiting. @@ -131,10 +150,14 @@ public struct LifecycleContainer< } catch { return // Superseded — a new appearance re-armed the hold. } + guard + Task.isCancelled == false, + readyRevealState.splashHoldDeadline == deadline + else { return } // Drive the reveal in an explicit transaction: `.animation(_:value:)` // doesn't reliably animate this async, `.task`-driven flip, so the // splash would be removed without its reveal transition. - withAnimation(animation) { splashHoldUntil = nil } + withAnimation(animation) { readyRevealState = .revealed } } } @@ -149,10 +172,17 @@ public struct LifecycleContainer< !runner.reason.buildsNoViewTree && runner.phase.surfaceIdentity == .splash } - /// Whether the app content may be revealed: nothing is holding it — no - /// minimum was requested, no splash was shown, or the hold has elapsed. + /// Ready and eligible to render a tree. Unlike readiness alone, this flips + /// when a headless-ready runner is promoted without publishing a different + /// terminal phase. + private var isReadyAndVisible: Bool { + runner.phase.isReady && !runner.reason.buildsNoViewTree + } + + /// Whether presentation history permits the ready content to show: the + /// policy owes no first splash and no rendered-splash hold remains. private var canRevealReady: Bool { - splashHoldUntil == nil + readyRevealState.canRevealReady } /// The surface actually on screen, for `.animation(_:value:)`. While the @@ -316,12 +346,14 @@ extension LifecycleContainer where Splash == LifecycleSplash, Failure == Lifecyc public init( _ runner: LifecycleRunner, minimumSplashDuration: Duration = .zero, + readyRevealPolicy: LifecycleReadyRevealPolicy = .phaseDriven, @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, @ViewBuilder content: @escaping (Launch) -> Content, ) { self.init( runner, minimumSplashDuration: minimumSplashDuration, + readyRevealPolicy: readyRevealPolicy, splash: { _ in LifecycleSplash() }, failure: { LifecycleFailureView(failure: $0) }, gates: gates, @@ -336,6 +368,7 @@ extension LifecycleContainer where Failure == LifecycleFailureView { public init( _ runner: LifecycleRunner, minimumSplashDuration: Duration = .zero, + readyRevealPolicy: LifecycleReadyRevealPolicy = .phaseDriven, @ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash, @GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] }, @ViewBuilder content: @escaping (Launch) -> Content, @@ -343,6 +376,7 @@ extension LifecycleContainer where Failure == LifecycleFailureView { self.init( runner, minimumSplashDuration: minimumSplashDuration, + readyRevealPolicy: readyRevealPolicy, splash: splash, failure: { LifecycleFailureView(failure: $0) }, gates: gates, diff --git a/Shared/LifecycleKitUI/Sources/LifecycleReadyRevealPolicy.swift b/Shared/LifecycleKitUI/Sources/LifecycleReadyRevealPolicy.swift new file mode 100644 index 000000000..80bdde1af --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/LifecycleReadyRevealPolicy.swift @@ -0,0 +1,13 @@ +/// Controls how `LifecycleContainer` presents its first foreground-visible +/// `.ready` value. +public enum LifecycleReadyRevealPolicy: Sendable, Hashable { + /// Let the runner's rendered phases drive presentation. An already-ready + /// container reveals immediately when no splash appearance established a + /// minimum-duration hold. + case phaseDriven + + /// Show the splash before the first visible ready-content reveal, even when + /// a headless-to-foreground drive completes between SwiftUI render passes. + /// The splash uses the container's `minimumSplashDuration`. + case splashBeforeFirstReveal +} diff --git a/Shared/LifecycleKitUI/Sources/LifecycleReadyRevealState.swift b/Shared/LifecycleKitUI/Sources/LifecycleReadyRevealState.swift new file mode 100644 index 000000000..43031c629 --- /dev/null +++ b/Shared/LifecycleKitUI/Sources/LifecycleReadyRevealState.swift @@ -0,0 +1,54 @@ +/// The presentation history that decides whether ready content is covered by +/// the splash. The deadline belongs to a rendered splash appearance—or, for +/// the opt-in policy, the first visible ready presentation when SwiftUI never +/// observed the runner's intervening splash phase. +enum LifecycleReadyRevealState: Equatable { + case awaitingFirstVisibleReady + case holdingSplash(until: ContinuousClock.Instant) + case revealed + + init( + policy: LifecycleReadyRevealPolicy, + minimumSplashDuration: Duration, + ) { + switch policy { + case .phaseDriven: + self = .revealed + case .splashBeforeFirstReveal: + self = minimumSplashDuration > .zero ? .awaitingFirstVisibleReady : .revealed + } + } + + var canRevealReady: Bool { + switch self { + case .awaitingFirstVisibleReady, .holdingSplash: false + case .revealed: true + } + } + + var splashHoldDeadline: ContinuousClock.Instant? { + switch self { + case let .holdingSplash(until: deadline): deadline + case .awaitingFirstVisibleReady, .revealed: nil + } + } + + mutating func splashAppeared( + at instant: ContinuousClock.Instant, + minimumSplashDuration: Duration, + ) { + guard minimumSplashDuration > .zero else { + self = .revealed + return + } + self = .holdingSplash(until: instant.advanced(by: minimumSplashDuration)) + } + + mutating func readyBecameVisible( + at instant: ContinuousClock.Instant, + minimumSplashDuration: Duration, + ) { + guard case .awaitingFirstVisibleReady = self else { return } + splashAppeared(at: instant, minimumSplashDuration: minimumSplashDuration) + } +} diff --git a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift index 7c3ef130e..aa3b58377 100644 --- a/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift +++ b/Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift @@ -1,4 +1,4 @@ -import LifecycleKit +@_spi(Testing) @testable import LifecycleKit @testable import LifecycleKitUI import SwiftUI import TestHostSupport @@ -93,6 +93,52 @@ struct LifecycleContainerTests { #expect(!splashShown) } + @Test func splashBeforeFirstRevealCoversAlreadyReadyContent() async throws { + var content = false + var splashShown = false + let runner = await makeReadyRunner() + #expect(runner.phase.isReady) + + let container = LifecycleContainer( + runner, + minimumSplashDuration: .seconds(60), + readyRevealPolicy: .splashBeforeFirstReveal, + splash: { _ in ProbeView { splashShown = true } }, + failure: { _ in EmptyView() }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { content && splashShown } + } + + // Ready content warms beneath the covering splash. + #expect(content) + #expect(splashShown) + } + + @Test func zeroMinimumDoesNotForceAFirstRevealSplash() async throws { + var content = false + var splashShown = false + let runner = await makeReadyRunner() + + let container = LifecycleContainer( + runner, + minimumSplashDuration: .zero, + readyRevealPolicy: .splashBeforeFirstReveal, + splash: { _ in ProbeView { splashShown = true } }, + failure: { _ in EmptyView() }, + ) { _ in + ProbeView { content = true } + } + try show(UIHostingController(rootView: container)) { _ in + try waitFor { content } + } + + #expect(content) + #expect(splashShown == false) + } + @Test func aCoveringSurfaceHidesTheContentBeneathIt() { // `content` is built as soon as the launch produces its value — including // while a surface still covers it, so the hold warms it up — which means @@ -165,7 +211,7 @@ struct LifecycleContainerTests { await task.value } - @Test func backgroundLaunchShowsNothing() async throws { + @Test func splashBeforeFirstRevealKeepsBackgroundReadyHeadless() async throws { var content = false var splash = false let runner = await makeReadyRunner(reason: .background(.location)) @@ -173,6 +219,8 @@ struct LifecycleContainerTests { let container = LifecycleContainer( runner, + minimumSplashDuration: .seconds(60), + readyRevealPolicy: .splashBeforeFirstReveal, splash: { _ in ProbeView { splash = true } }, ) { _ in ProbeView { content = true } @@ -203,22 +251,44 @@ struct LifecycleContainerTests { } } - @Test func backgroundReadyThenEnterForegroundShowsContent() async throws { + @Test func promotedBackgroundReadyForcesTheFirstRevealSplash() async throws { var content = false + var splashWasShown = false + var splashIsVisible = false let runner = await makeReadyRunner(reason: .background(.location)) #expect(runner.reason.buildsNoViewTree) - - await runner.enterForeground() - #expect(!runner.reason.buildsNoViewTree) #expect(runner.phase.isReady) - let container = LifecycleContainer(runner) { _ in + let container = LifecycleContainer( + runner, + minimumSplashDuration: .milliseconds(200), + readyRevealPolicy: .splashBeforeFirstReveal, + splash: { _ in + ProbeView { + splashWasShown = true + splashIsVisible = true + } + .onDisappear { splashIsVisible = false } + }, + ) { _ in ProbeView { content = true } } - try show(UIHostingController(rootView: container)) { _ in - try waitFor { content } + try await show(UIHostingController(rootView: container)) { _ in + // Mount the ready runner while it is still headless. Promotion must + // invalidate the container's readiness-and-visibility task ID; a + // readiness-only ID would remain `true` and never release the + // synthesized splash. + #expect(!renders { content || splashWasShown }) + + runner.promoteReasonToForegroundForTesting() + #expect(!runner.reason.buildsNoViewTree) + #expect(runner.phase.isReady) + try await waitUntil { content && splashIsVisible } + try await waitUntil { !splashIsVisible } } #expect(content) + #expect(splashWasShown) + #expect(!splashIsVisible) } @Test func awaitingGateShowsTheRegisteredGateViewWithTheTrunkValue() async throws { diff --git a/Shared/LifecycleKitUI/Tests/LifecycleReadyRevealPolicyTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleReadyRevealPolicyTests.swift new file mode 100644 index 000000000..e33e1c708 --- /dev/null +++ b/Shared/LifecycleKitUI/Tests/LifecycleReadyRevealPolicyTests.swift @@ -0,0 +1,32 @@ +@testable import LifecycleKitUI +import Testing + +struct LifecycleReadyRevealPolicyTests { + @Test func phaseDrivenStartsRevealedWithoutAnObservedSplash() { + let state = LifecycleReadyRevealState( + policy: .phaseDriven, + minimumSplashDuration: .seconds(60), + ) + + #expect(state.canRevealReady) + } + + @Test func splashBeforeFirstRevealWithZeroMinimumStartsRevealed() { + let state = LifecycleReadyRevealState( + policy: .splashBeforeFirstReveal, + minimumSplashDuration: .zero, + ) + + #expect(state.canRevealReady) + } + + @Test func splashBeforeFirstRevealWithPositiveMinimumAwaitsPresentation() { + let state = LifecycleReadyRevealState( + policy: .splashBeforeFirstReveal, + minimumSplashDuration: .milliseconds(800), + ) + + #expect(state.canRevealReady == false) + #expect(state.splashHoldDeadline == nil) + } +} diff --git a/Shared/LifecycleKitUI/Tests/LifecycleReadyRevealStateTests.swift b/Shared/LifecycleKitUI/Tests/LifecycleReadyRevealStateTests.swift new file mode 100644 index 000000000..df925aa2e --- /dev/null +++ b/Shared/LifecycleKitUI/Tests/LifecycleReadyRevealStateTests.swift @@ -0,0 +1,83 @@ +@testable import LifecycleKitUI +import Testing + +struct LifecycleReadyRevealStateTests { + private let minimumSplashDuration = Duration.milliseconds(800) + + @Test func firstVisibleReadyStartsAHoldWhenNoSplashRendered() { + let readyInstant = ContinuousClock.now + var state = LifecycleReadyRevealState( + policy: .splashBeforeFirstReveal, + minimumSplashDuration: minimumSplashDuration, + ) + + state.readyBecameVisible( + at: readyInstant, + minimumSplashDuration: minimumSplashDuration, + ) + + #expect( + state.splashHoldDeadline + == readyInstant.advanced(by: minimumSplashDuration), + ) + #expect(state.canRevealReady == false) + } + + @Test func visibleReadyKeepsTheRenderedSplashDeadline() { + let splashInstant = ContinuousClock.now + let readyInstant = splashInstant.advanced(by: .milliseconds(400)) + var state = LifecycleReadyRevealState( + policy: .splashBeforeFirstReveal, + minimumSplashDuration: minimumSplashDuration, + ) + state.splashAppeared( + at: splashInstant, + minimumSplashDuration: minimumSplashDuration, + ) + let originalDeadline = state.splashHoldDeadline + + state.readyBecameVisible( + at: readyInstant, + minimumSplashDuration: minimumSplashDuration, + ) + + #expect(state.splashHoldDeadline == originalDeadline) + } + + @Test func visibleReadyDoesNotReplayAfterContentWasRevealed() { + let readyInstant = ContinuousClock.now + var state = LifecycleReadyRevealState( + policy: .splashBeforeFirstReveal, + minimumSplashDuration: minimumSplashDuration, + ) + state = .revealed + + state.readyBecameVisible( + at: readyInstant, + minimumSplashDuration: minimumSplashDuration, + ) + + #expect(state.canRevealReady) + #expect(state.splashHoldDeadline == nil) + } + + @Test func aLaterRenderedSplashRearmsTheMinimum() { + let splashInstant = ContinuousClock.now + var state = LifecycleReadyRevealState( + policy: .phaseDriven, + minimumSplashDuration: minimumSplashDuration, + ) + #expect(state.canRevealReady) + + state.splashAppeared( + at: splashInstant, + minimumSplashDuration: minimumSplashDuration, + ) + + #expect( + state.splashHoldDeadline + == splashInstant.advanced(by: minimumSplashDuration), + ) + #expect(state.canRevealReady == false) + } +} diff --git a/Shared/TestHostSupport/AGENTS.md b/Shared/TestHostSupport/AGENTS.md index 1721637f0..51a2f6aab 100644 --- a/Shared/TestHostSupport/AGENTS.md +++ b/Shared/TestHostSupport/AGENTS.md @@ -26,10 +26,9 @@ Complements the root [`AGENTS.md`](../../AGENTS.md) — read that first. associated-object key must resolve to the same pointer in every image. A per-image `static var key: UInt8` would not match across the host↔bundle boundary and would silently read `nil` — the exact flake this replaces. -- **`show` waits for readiness.** It pumps the run loop for the host window + root - VC before hosting, so a test running before the scene connects doesn't fail - spuriously; it follows Apple's parent/child VC order and always restores - `layer.speed` via a `defer` at entry. +- **Both `show` overloads wait for readiness.** They pump the run loop for the + host window + root VC before hosting, follow Apple's parent/child VC order, + and always restore `layer.speed` via a `defer` at entry. ## Testing diff --git a/Shared/TestHostSupport/README.md b/Shared/TestHostSupport/README.md index 8f60cfeed..2c78f8692 100644 --- a/Shared/TestHostSupport/README.md +++ b/Shared/TestHostSupport/README.md @@ -24,10 +24,10 @@ import Testing import TestHostSupport @MainActor -@Test func rendersContent() throws { +@Test func rendersContent() async throws { let vc = MyViewController() - try show(vc) { vc in - try waitFor { vc.isFullyLoaded } + try await show(vc) { vc in + await vc.loadContent() #expect(vc.titleLabel.text == "Hello") } } @@ -38,7 +38,8 @@ import TestHostSupport - `show(_:loadAndPlaceView:timeout:perform:)` — hosts a `UIViewController` in the test host's main window for the duration of `perform`, driving the real UIKit appearance lifecycle (`addChild` → attach → `didMove(toParent:)`, reversed on - teardown) and restoring `layer.speed` even if the body throws. + teardown) and restoring `layer.speed` even if the body throws. Synchronous and + async-body overloads keep the view hosted while their body runs. - `hostKeyWindow()` — the host's designated window (see below), or `nil` before the scene connects. - `waitFor(timeout:predicate:)` — pump the run loop until a predicate holds. diff --git a/Shared/TestHostSupport/Sources/TestHostSupport.swift b/Shared/TestHostSupport/Sources/TestHostSupport.swift index 8b00c7d97..355191e27 100644 --- a/Shared/TestHostSupport/Sources/TestHostSupport.swift +++ b/Shared/TestHostSupport/Sources/TestHostSupport.swift @@ -113,6 +113,43 @@ public func show( } } +/// Async-body overload of ``show(_:loadAndPlaceView:timeout:perform:)``. +/// +/// Use this form when a hosted view must remain in the hierarchy while the +/// test awaits application or SwiftUI task work. +@MainActor +public func show( + _ viewController: ViewController, + loadAndPlaceView: Bool = true, + timeout: TimeInterval = 10.0, + perform test: (ViewController) async throws -> Void, +) async throws { + let rootVC = try waitForHostRootViewController(timeout: timeout) + + defer { rootVC.view.window?.layer.speed = 1 } + rootVC.view.window?.layer.speed = 100 + + rootVC.addChild(viewController) + + if loadAndPlaceView { + viewController.view.frame = rootVC.view.bounds + rootVC.view.addSubview(viewController.view) + viewController.view.layoutIfNeeded() + } + + viewController.didMove(toParent: rootVC) + + defer { + viewController.willMove(toParent: nil) + if loadAndPlaceView { + viewController.view.removeFromSuperview() + } + viewController.removeFromParent() + } + + try await test(viewController) +} + @MainActor private func waitForHostRootViewController(timeout: TimeInterval) throws -> UIViewController { let deadline = Date(timeIntervalSinceNow: timeout) diff --git a/Where/Specifications/FirstForegroundReveal/BrokenReadinessOnly.cfg b/Where/Specifications/FirstForegroundReveal/BrokenReadinessOnly.cfg new file mode 100644 index 000000000..c5da5ac65 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/BrokenReadinessOnly.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "readinessOnly" + ResumeLimit = 0 + ArmLimit = 2 + +INVARIANTS + TypeOK + NoStrandedReady + +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/FirstForegroundReveal/BrokenSelfRearming.cfg b/Where/Specifications/FirstForegroundReveal/BrokenSelfRearming.cfg new file mode 100644 index 000000000..e8527c081 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/BrokenSelfRearming.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "selfRearming" + ResumeLimit = 0 + ArmLimit = 2 + +INVARIANTS + TypeOK + OneHoldPerFirstReveal + +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/FirstForegroundReveal/Current.cfg b/Where/Specifications/FirstForegroundReveal/Current.cfg new file mode 100644 index 000000000..fb8eb1964 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/Current.cfg @@ -0,0 +1,20 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + ResumeLimit = 1 + ArmLimit = 2 + +INVARIANTS + TypeOK + HeadlessBuildsNoTree + ContentRequiresReadyReveal + FirstRevealWasCovered + CoveredReadyBuildsContent + OneHoldPerFirstReveal + NoStrandedReady + NoResumeReplay + +PROPERTY EventuallyFirstReveal + +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/FirstForegroundReveal/CurrentRepeated.cfg b/Where/Specifications/FirstForegroundReveal/CurrentRepeated.cfg new file mode 100644 index 000000000..5874f0fa3 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/CurrentRepeated.cfg @@ -0,0 +1,20 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + ResumeLimit = 2 + ArmLimit = 2 + +INVARIANTS + TypeOK + HeadlessBuildsNoTree + ContentRequiresReadyReveal + FirstRevealWasCovered + CoveredReadyBuildsContent + OneHoldPerFirstReveal + NoStrandedReady + NoResumeReplay + +PROPERTY EventuallyFirstReveal + +CHECK_DEADLOCK TRUE diff --git a/Where/Specifications/FirstForegroundReveal/FirstForegroundReveal.tla b/Where/Specifications/FirstForegroundReveal/FirstForegroundReveal.tla new file mode 100644 index 000000000..fa7e20798 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/FirstForegroundReveal.tla @@ -0,0 +1,293 @@ +---- MODULE FirstForegroundReveal ---- +EXTENDS Integers + +CONSTANTS Implementation, ResumeLimit, ArmLimit + +ASSUME /\ Implementation \in {"current", "readinessOnly", "selfRearming"} + /\ ResumeLimit \in Nat + /\ ArmLimit \in Nat + /\ ArmLimit >= 2 + +Reasons == {"headless", "foreground"} +RunnerPhases == {"splash", "ready"} +PromotionStages == {"notStarted", "promoting", "complete"} +RevealStates == {"awaiting", "holding", "revealed"} +Surfaces == {"none", "splash", "content"} + +VARIABLES + reason, + runnerPhase, + promotionStage, + revealState, + renderedSurface, + dirty, + installedTaskKey, + readyTaskPending, + splashObserved, + sleepingEpoch, + holdEpoch, + splashSeen, + contentBuilt, + contentRevealCount, + splashPhaseRendered, + sawCoalescedPromotion, + resumeCount + +vars == <> + +ReadyVisible == reason = "foreground" /\ runnerPhase = "ready" + +TaskIdentity == + IF Implementation = "readinessOnly" + THEN runnerPhase = "ready" + ELSE ReadyVisible + +RunnerShowsSplash == reason = "foreground" /\ runnerPhase = "splash" + +SurfaceFor(state) == + IF reason = "headless" + THEN "none" + ELSE IF runnerPhase = "splash" \/ state # "revealed" + THEN "splash" + ELSE "content" + +Init == + /\ reason = "headless" + /\ runnerPhase = "ready" + /\ promotionStage = "notStarted" + /\ revealState = "awaiting" + /\ renderedSurface = "none" + /\ dirty = FALSE + /\ installedTaskKey = TaskIdentity + /\ readyTaskPending = FALSE + /\ splashObserved = FALSE + /\ sleepingEpoch = 0 + /\ holdEpoch = 0 + /\ splashSeen = FALSE + /\ contentBuilt = FALSE + /\ contentRevealCount = 0 + /\ splashPhaseRendered = FALSE + /\ sawCoalescedPromotion = FALSE + /\ resumeCount = 0 + +\* LifecycleRunner.enterForeground() synchronously publishes its foreground +\* reason and launching surface before awaiting the replacement drive. +BeginPromotion == + /\ promotionStage = "notStarted" + /\ reason' = "foreground" + /\ runnerPhase' = "splash" + /\ promotionStage' = "promoting" + /\ dirty' = TRUE + /\ UNCHANGED <> + +\* The foreground drive completes at its next suspension boundary. SwiftUI may +\* render the launching surface before this action, or coalesce both mutations +\* and observe only ready. +CompletePromotion == + /\ promotionStage = "promoting" + /\ runnerPhase' = "ready" + /\ promotionStage' = "complete" + /\ dirty' = TRUE + /\ sawCoalescedPromotion' = + (sawCoalescedPromotion \/ ~splashPhaseRendered) + /\ UNCHANGED <> + +\* A SwiftUI update reads the runner atomically on the main actor. The +\* onChange body is synchronous, so a newly observed runner splash establishes +\* its hold in this same action. A task identity change schedules, but does not +\* synchronously execute, the ready callback. +Render == + /\ dirty + /\ LET newTaskKey == TaskIdentity + runnerSplash == RunnerShowsSplash + splashAppeared == runnerSplash /\ ~splashObserved + nextReveal == IF splashAppeared THEN "holding" ELSE revealState + nextHoldEpoch == IF splashAppeared THEN holdEpoch + 1 ELSE holdEpoch + nextSurface == SurfaceFor(nextReveal) + IN /\ ~splashAppeared \/ holdEpoch < ArmLimit + /\ installedTaskKey' = newTaskKey + /\ readyTaskPending' = + IF newTaskKey # installedTaskKey + THEN ReadyVisible + ELSE readyTaskPending + /\ splashObserved' = runnerSplash + /\ revealState' = nextReveal + /\ holdEpoch' = nextHoldEpoch + /\ renderedSurface' = nextSurface + /\ splashSeen' = (splashSeen \/ nextSurface = "splash") + /\ contentBuilt' = (contentBuilt \/ ReadyVisible) + /\ contentRevealCount' = + IF nextSurface = "content" /\ renderedSurface # "content" + THEN contentRevealCount + 1 + ELSE contentRevealCount + /\ splashPhaseRendered' = + (splashPhaseRendered + \/ (reason = "foreground" /\ runnerPhase = "splash")) + /\ dirty' = FALSE + /\ UNCHANGED <> + +\* LifecycleContainer's .task body begins after the render that scheduled it. +\* readyBecameVisible arms only the awaiting state; an observed runner splash's +\* earlier deadline is retained. +StartReadyTask == + /\ readyTaskPending + /\ ReadyVisible + /\ LET armsFirstVisibleReady == revealState = "awaiting" + nextReveal == + IF armsFirstVisibleReady THEN "holding" ELSE revealState + nextHoldEpoch == + IF armsFirstVisibleReady THEN holdEpoch + 1 ELSE holdEpoch + IN /\ ~armsFirstVisibleReady \/ holdEpoch < ArmLimit + /\ revealState' = nextReveal + /\ holdEpoch' = nextHoldEpoch + /\ sleepingEpoch' = + IF nextReveal = "holding" THEN nextHoldEpoch ELSE 0 + /\ dirty' = (dirty \/ armsFirstVisibleReady) + /\ readyTaskPending' = FALSE + /\ UNCHANGED <> + +\* The positive minimum duration is abstracted to one eventual timer action. +\* The production deadline equality guard rejects a completion whose captured +\* epoch was superseded. +TimerExpires == + /\ sleepingEpoch > 0 + /\ IF sleepingEpoch = holdEpoch + THEN /\ revealState' = "revealed" + /\ dirty' = TRUE + ELSE /\ revealState' = revealState + /\ dirty' = dirty + /\ sleepingEpoch' = 0 + /\ UNCHANGED <> + +\* Negative control: treating the held overlay as a fresh splash appearance +\* lets the overlay renew its own deadline. The sleeping task then carries a +\* stale epoch and no task-identity transition exists to start another timer. +OverlayRearmsItself == + /\ Implementation = "selfRearming" + /\ revealState = "holding" + /\ renderedSurface = "splash" + /\ sleepingEpoch > 0 + /\ holdEpoch < ArmLimit + /\ holdEpoch' = holdEpoch + 1 + /\ dirty' = TRUE + /\ UNCHANGED <> + +\* Once promoted, ordinary scene background/active cycles do not change the +\* launch reason or runner phase. They may request another render, but the +\* scene-local reveal state must remain revealed. +OrdinaryResume == + /\ promotionStage = "complete" + /\ renderedSurface = "content" + /\ resumeCount < ResumeLimit + /\ resumeCount' = resumeCount + 1 + /\ dirty' = TRUE + /\ UNCHANGED <> + +Idle == + /\ promotionStage = "complete" + /\ ~dirty + /\ ~readyTaskPending + /\ sleepingEpoch = 0 + /\ UNCHANGED vars + +Next == + \/ BeginPromotion + \/ CompletePromotion + \/ Render + \/ StartReadyTask + \/ TimerExpires + \/ OverlayRearmsItself + \/ OrdinaryResume + \/ Idle + +Fairness == + /\ WF_vars(CompletePromotion) + /\ WF_vars(Render) + /\ WF_vars(StartReadyTask) + /\ WF_vars(TimerExpires) + +Spec == Init /\ [][Next]_vars /\ Fairness + +TypeOK == + /\ reason \in Reasons + /\ runnerPhase \in RunnerPhases + /\ promotionStage \in PromotionStages + /\ revealState \in RevealStates + /\ renderedSurface \in Surfaces + /\ dirty \in BOOLEAN + /\ installedTaskKey \in BOOLEAN + /\ readyTaskPending \in BOOLEAN + /\ splashObserved \in BOOLEAN + /\ sleepingEpoch \in 0..ArmLimit + /\ holdEpoch \in 0..ArmLimit + /\ splashSeen \in BOOLEAN + /\ contentBuilt \in BOOLEAN + /\ contentRevealCount \in 0..1 + /\ splashPhaseRendered \in BOOLEAN + /\ sawCoalescedPromotion \in BOOLEAN + /\ resumeCount \in 0..ResumeLimit + +HeadlessBuildsNoTree == + reason = "headless" => renderedSurface = "none" + +ContentRequiresReadyReveal == + renderedSurface = "content" => ReadyVisible /\ revealState = "revealed" + +FirstRevealWasCovered == + contentRevealCount > 0 => splashSeen + +CoveredReadyBuildsContent == + promotionStage = "complete" /\ renderedSurface = "splash" /\ ~dirty + => contentBuilt + +OneHoldPerFirstReveal == + holdEpoch <= 1 + +NoStrandedReady == + ~(promotionStage = "complete" + /\ renderedSurface = "splash" + /\ ~dirty + /\ ~readyTaskPending + /\ sleepingEpoch = 0) + +NoResumeReplay == + resumeCount > 0 + => /\ revealState = "revealed" + /\ contentRevealCount = 1 + /\ holdEpoch = 1 + +EventuallyFirstReveal == + promotionStage = "complete" ~> renderedSurface = "content" + +CoalescedPromotionNotReached == ~sawCoalescedPromotion + +RenderedRunnerSplashNotReached == ~splashPhaseRendered + +RepeatedResumeNotReached == resumeCount < ResumeLimit + +==== diff --git a/Where/Specifications/FirstForegroundReveal/README.md b/Where/Specifications/FirstForegroundReveal/README.md new file mode 100644 index 000000000..d90bdf7ea --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/README.md @@ -0,0 +1,136 @@ +# First foreground reveal + +This model checks one narrow question: when a mounted, ready, headless Where +launch becomes foreground-visible, must the splash cover the first main-UI +reveal and then release, whether SwiftUI renders the runner's intervening +launching phase or coalesces directly to ready, without replaying on ordinary +scene resumes? + +The model represents production source at commit +`9587d79f10af43a0a31fa61a235682845680a794`. It is design evidence for the +stated bounds and assumptions, not proof that SwiftUI or the implementation is +correct. Changes to `RootView` foreground promotion, `LifecycleRunner` phase +publication, `LifecycleContainer`'s observation/task identities, or +`LifecycleReadyRevealState` invalidate the result until this mapping is checked +again. + +## Source correspondence + +| Model state or action | Production counterpart | +| --- | --- | +| `reason` | `LifecycleReason.buildsNoViewTree`; headless until `enterForeground()` changes the reason to `.userForeground` | +| `runnerPhase` | The `.launching` and `.ready` surfaces published by `LifecycleRunner.drive(reason:)` | +| `BeginPromotion` | `RootView`'s active-scene entry into `LifecycleRunner.enterForeground()`, through the synchronous reason/launching publication before its first suspension | +| `CompletePromotion` | The replacement foreground drive publishing `.ready`; `Render` may or may not interleave before it | +| `dirty` / `Render` | A pending SwiftUI update and one evaluation of `LifecycleContainer.body` | +| `installedTaskKey` | The identity installed by `.task(id: isReadyAndVisible)` | +| `readyTaskPending` / `StartReadyTask` | The scheduled `.task` body through `readyBecameVisible` and capture of the current hold deadline | +| `splashObserved` | The last value observed by `.onChange(of: isShowingSplash)`; the callback reads the runner surface, not the displayed overlay | +| `revealState` | `LifecycleReadyRevealState` (`awaitingFirstVisibleReady`, `holdingSplash`, `revealed`) | +| `holdEpoch` / `sleepingEpoch` | Abstract identities for `splashHoldDeadline` and the deadline captured across `Task.sleep` | +| `TimerExpires` | Return from `Task.sleep` plus the cancellation/deadline-equality guards before setting `.revealed` | +| `renderedSurface` | No tree, the splash overlay, or revealed ready content selected by `LifecycleContainer` | +| `contentBuilt` | Ready content's single call site, built under the covering splash before reveal | +| `OrdinaryResume` | Later scene background/active cycles after promotion; the launch reason and ready phase stay unchanged | + +The source entry points represented are `RootView.body`'s initial active-scene +task and `scenePhase` change handler, `LifecycleRunner.enterForeground()`, and +`LifecycleContainer.body`'s `onChange` and keyed task. The runner and reveal +state execute on the main actor. `enterForeground()` splits where it awaits its +replacement drive; the reveal task splits at `Task.sleep`. The synchronous +`onChange` callback is atomic with its modeled render because it has no +suspension at which another main-actor action can interleave. + +## Properties + +- `TypeOK` checks every model variable. +- `HeadlessBuildsNoTree` keeps a ready background launch viewless. +- `ContentRequiresReadyReveal` prevents content from becoming visible without + both a foreground-ready runner and a released presentation state. +- `FirstRevealWasCovered` requires an observed splash before the first content + reveal, including on a coalesced promotion. +- `CoveredReadyBuildsContent` requires ready content to warm beneath the splash. +- `OneHoldPerFirstReveal` prevents the ready transition or displayed overlay + from replacing the first episode's original deadline. +- `NoStrandedReady` rejects a foreground-ready splash with no pending callback + or timer capable of releasing it. +- `NoResumeReplay` keeps the revealed state and single content reveal across + ordinary scene resumes. +- `EventuallyFirstReveal` requires a completed foreground promotion to finish + its first reveal. + +Weak fairness assumes an admitted foreground drive eventually completes, +SwiftUI eventually renders a dirty mounted view, an installed task eventually +begins, and a non-cancelled positive-duration timer eventually returns. No +fairness forces a background launch to become foreground-visible or a user to +perform an ordinary resume. These are the runtime progress guarantees needed +for `EventuallyFirstReveal`; the safety invariants do not depend on fairness. + +## Bounds and exclusions + +The model begins after a background-safe drive has reached `.ready` while its +container is mounted headlessly. The positive 800 ms minimum is abstracted to +one timer completion; the model preserves deadline identity and supersession, +not elapsed time. The current configurations cover one and two ordinary resume +cycles with an arm bound of two. Promotion ordering is nondeterministic, so TLC +explores both a rendered launching splash and a coalesced direct-to-ready +render. Explicit reachability cases make those branches and the two-resume path +non-vacuous. + +Zero-duration policy, `.phaseDriven`, gates, failures, teardown/reset splash +episodes, animation frames, accessibility/hit testing, scene destruction and +recreation, multiple windows, process termination, and actual SwiftUI runtime +scheduling are excluded. The hosted Swift test remains the implementation +guard for the scheduling behavior represented by the model. + +## Controls and result + +The `readinessOnly` negative control keys the task only on the runner's ready +phase. TLC violates `NoStrandedReady`: the headless runner begins ready, the +foreground drive publishes splash and ready before a render, and the ready-only +identity therefore stays true. The coalesced ready render warms content beneath +the splash but schedules no callback or timer that can release it. The trace is +5 generated / 5 distinct states at depth 4. + +The `selfRearming` negative control lets a held displayed overlay count as a +new splash appearance. TLC violates `OneHoldPerFirstReveal`: after a coalesced +promotion schedules the ready task, that task arms epoch 1, then the overlay +replaces it with epoch 2 while the only sleeping task still carries epoch 1. +The trace is 14 generated / 11 distinct states at depth 6. + +The render-order reachability controls also fail as expected: TLC reaches a +coalesced promotion after 3 generated / 3 distinct states at depth 3, and a +rendered runner splash after 4 generated / 4 distinct states at depth 3. A +third control requires the configured two-resume path; its exact trace result +reaches both resumes after 26 generated / 17 distinct states at depth 9. These +traces demonstrate that the named paths are present rather than vacuously +hidden from the checked properties. + +**Verified for these model bounds and assumptions.** TLC exhausts both current +configurations without an invariant, temporal-property, or deadlock error: + +| Configuration | Ordinary resumes | Generated / distinct states | Depth | +| --- | ---: | ---: | ---: | +| `Current.cfg` | 1 | 31 / 18 | 10 | +| `CurrentRepeated.cfg` | 2 | 41 / 22 | 11 | + +The deterministic software guard is +[`LifecycleContainerTests.promotedBackgroundReadyForcesTheFirstRevealSplash`](../../../Shared/LifecycleKitUI/Tests/LifecycleContainerTests.swift). +It mounts a ready runner headlessly, changes only its observed reason, then +asserts that content warms beneath a splash which eventually releases. Its +mutation control fails when the production task identity is reduced to +readiness alone. + +## Run it + +From the repository root: + +```sh +./tla-check FirstForegroundReveal +``` + +The checker pins the tla2tools 1.7.4 release (TLC2 2.19, revision `5a47802`) at +SHA-256 +`936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88` +and Eclipse Temurin 21.0.8+9 through `mise`. It keeps downloads and run +artifacts under ignored `.build/tla/` and is not wired into CI. diff --git a/Where/Specifications/FirstForegroundReveal/ReachCoalesced.cfg b/Where/Specifications/FirstForegroundReveal/ReachCoalesced.cfg new file mode 100644 index 000000000..5595f5364 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/ReachCoalesced.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + ResumeLimit = 0 + ArmLimit = 2 + +INVARIANTS + TypeOK + CoalescedPromotionNotReached + +CHECK_DEADLOCK FALSE diff --git a/Where/Specifications/FirstForegroundReveal/ReachRenderedSplash.cfg b/Where/Specifications/FirstForegroundReveal/ReachRenderedSplash.cfg new file mode 100644 index 000000000..2db819d49 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/ReachRenderedSplash.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + ResumeLimit = 0 + ArmLimit = 2 + +INVARIANTS + TypeOK + RenderedRunnerSplashNotReached + +CHECK_DEADLOCK FALSE diff --git a/Where/Specifications/FirstForegroundReveal/ReachRepeatedResume.cfg b/Where/Specifications/FirstForegroundReveal/ReachRepeatedResume.cfg new file mode 100644 index 000000000..9d16c2f15 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/ReachRepeatedResume.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + ResumeLimit = 2 + ArmLimit = 2 + +INVARIANTS + TypeOK + RepeatedResumeNotReached + +CHECK_DEADLOCK FALSE diff --git a/Where/Specifications/FirstForegroundReveal/manifest.json b/Where/Specifications/FirstForegroundReveal/manifest.json new file mode 100644 index 000000000..2b5797731 --- /dev/null +++ b/Where/Specifications/FirstForegroundReveal/manifest.json @@ -0,0 +1,45 @@ +{ + "module": "FirstForegroundReveal.tla", + "cases": [ + { + "name": "broken-readiness-only", + "config": "BrokenReadinessOnly.cfg", + "expect": "fail", + "outputContains": "Invariant NoStrandedReady is violated." + }, + { + "name": "broken-self-rearming", + "config": "BrokenSelfRearming.cfg", + "expect": "fail", + "outputContains": "Invariant OneHoldPerFirstReveal is violated." + }, + { + "name": "coalesced-reachability", + "config": "ReachCoalesced.cfg", + "expect": "fail", + "outputContains": "Invariant CoalescedPromotionNotReached is violated." + }, + { + "name": "rendered-splash-reachability", + "config": "ReachRenderedSplash.cfg", + "expect": "fail", + "outputContains": "Invariant RenderedRunnerSplashNotReached is violated." + }, + { + "name": "repeated-resume-reachability", + "config": "ReachRepeatedResume.cfg", + "expect": "fail", + "outputContains": "Invariant RepeatedResumeNotReached is violated." + }, + { + "name": "current", + "config": "Current.cfg", + "expect": "pass" + }, + { + "name": "current-repeated", + "config": "CurrentRepeated.cfg", + "expect": "pass" + } + ] +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 7965f0d81..2782f16f7 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -72,6 +72,9 @@ Layering, localization, preview, and testing conventions live in the feature project, simplify, or spatially reduce artwork in a card's `body`. - Keep Locations-card points on `YearReportModel`'s loaded `YearReportDetails`. +- Keep `RootView` opted into LifecycleKitUI's first-ready splash policy: the + first foreground-visible `MainTabs` reveal gets the stylesheet minimum even + when headless promotion coalesces, while warm resumes never replay it. - Continuous/looping motion (repeat-forever pulses, `TimelineView(.animation)`, typewriter reveals) must consult the shared `@MotionIsStatic` helper ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index a0e1bf9ac..0701a1605 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -47,8 +47,11 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's vended by whoever owns it rather than listed in the view; it renders an explicit "no report" state, since only the app bundle carries one, and ends with a passport-style link to the project's public source on GitHub. `MainTabs` - is built from the `WhereSession` the launch's `.ready` carries. The app - injects the launch-built model + runner + is built from the `WhereSession` the launch's `.ready` carries. Its first + visible reveal is covered by the launch splash for the stylesheet's minimum + duration even when a headless-ready launch completes foreground promotion + between SwiftUI renders; the tabs warm beneath it, and later foreground + resumes do not replay it. The app injects the launch-built model + runner (`init(model:launcher:)`); a no-arg `init()` builds its own for previews and the hosted UI test. - **Developer tools** — DEBUG-only logging, span, region-map, Flyover, and diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 11d651848..6a627754a 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -16,9 +16,11 @@ import SwiftUI /// `LifecycleContainer` renders the splash / onboarding UI while the /// `LifecycleRunner` runs, then the `TabView` (the real "logged-in" UI — the /// launch *destination*, not a step) once it reaches `.ready`, built from the -/// session the launch's trunk produced. The model is built at launch (so -/// CoreLocation is wired for background relaunch) and shared down through the -/// environment. +/// session the launch's trunk produced. The first visible ready reveal is +/// always covered by the splash minimum, including after a headless launch +/// whose foreground drive coalesces between renders. The model is built at +/// launch (so CoreLocation is wired for background relaunch) and shared down +/// through the environment. public struct RootView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -112,6 +114,7 @@ public struct RootView: View { transition: revealTransition, animation: revealAnimation, minimumSplashDuration: stylesheet.launch.minimumSplashDuration, + readyRevealPolicy: .splashBeforeFirstReveal, splash: { _ in LaunchSplashView() }, failure: { WhereLifecycleFailureView(failure: $0) }, gates: {