ios: switching spaces fades through the smoke-and-motes scene (#564)
* ios: switching spaces fades through the smoke-and-motes scene Switching spaces from the switcher no longer flashes a bare loading spinner. The outgoing chat list fades into the brand's smoke-and-motes scene (~0.3s), the store swap happens at full cover, the smoke plays while the new space's chats load — holding a minimum beat so even an instant switch reads as one crossfade rather than a flash — and the new list fades back in as the scene dissolves. A small spinner joins over the scene only past ~2s of loading, and a hard cap guarantees a hung or failed load still reveals today's loading/empty state instead of trapping anyone in smoke. Reduce Motion keeps the plain crossfade, no drifting particles. The scene is the one that already ships: SmokeSceneView (new, in LChatCore) wraps the create-space fader's AmbientField/AmbientCanvas — the native twin of web's bootscene.js — with just a standalone 60Hz driver, so the particle system stays defined in one place and the Mac can adopt it later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: switch scene tuned on device — brisker fades, shorter beat, smoke at half strength Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: switch scene covers only the list — chrome holds still — and the beat drops to a blink Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: switch blink ~250ms, smoke at 0.2, spinner at 500ms — third device-tuning pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: switch blink ~200ms; a spinner that appears holds a beat instead of flashing off Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+207
−3
ios/LChat/LChatApp.swift+18 −3ios/LChat/Views/SpaceSwitchScene.swift+121 −0ios/LChatCore/Sources/LChatCore/SmokeScene.swift+68 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 78fce4c..8b4d2f2 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -264,6 +264,9 @@ struct RootView: View { |
| // (item-based presentation — no present-before-content race). |
| @State private var spacesOffered = false |
| @State private var createModel: CreateSpaceModel? |
| + // The space-switch transition: the outgoing list fades into the brand's |
| + // smoke-and-motes scene while the next space loads (SpaceSwitchScene.swift). |
| + @StateObject private var switchScene = SpaceSwitchModel() |
| var body: some View { |
| NavigationStack(path: $path) { |
| @@ -278,6 +281,10 @@ struct RootView: View { |
| noServers |
| } |
| } |
| + // The switch scene covers only the LIST — inside the stack, under |
| + // the navigation chrome, so the header, search and toolbar hold |
| + // still while the chats crossfade through smoke. |
| + .overlay { SpaceSwitchOverlay(model: switchScene) } |
| } |
| // Load server photos once at launch (and when the server set changes) so both |
| // the toolbar space button and the switcher show them without opening anything. |
| @@ -411,13 +418,21 @@ struct RootView: View { |
| } |
| private func select(_ inst: SavedInstance) { |
| - path = [] // pushed chats belong to the previous server |
| - nav.selectInstance(inst) |
| + showSwitcher = false |
| + // Re-picking the space you're already in loads nothing — no transition. |
| + guard inst.id != nav.instanceID else { return } |
| // First join included: ask for notification permission the moment there's |
| // a chat worth hearing about, and (re)register this instance. |
| push.enablePush() |
| push.subscribeNewInstance(inst) |
| - showSwitcher = false |
| + // The current chat list fades into the smoke-and-motes scene; the real |
| + // swap happens at full cover, and the reveal waits for the new space's |
| + // chats plus a minimum beat (SpaceSwitchModel has the choreography). |
| + switchScene.begin { |
| + path = [] // pushed chats belong to the previous server |
| + nav.selectInstance(inst) |
| + return nav.store |
| + } |
| } |
| // Sign out = clear this device (the spaces live in the member's Eleven |
diff --git a/ios/LChat/Views/SpaceSwitchScene.swift b/ios/LChat/Views/SpaceSwitchScene.swift| new file mode 100644 |
| index 0000000..1c18a32 |
| --- /dev/null |
| +++ b/ios/LChat/Views/SpaceSwitchScene.swift |
| @@ -0,0 +1,121 @@ |
| +import LChatCore |
| +import SwiftUI |
| + |
| +// 🤖 Switching spaces fades through the brand's smoke-and-motes scene instead |
| +// of flashing a spinner: the current chat list fades out under the scene, the |
| +// store swap happens at full cover, the smoke plays while the new space's |
| +// chats load (a small spinner joins only if that takes a while), and the new |
| +// list fades back in as the scene dissolves. The scene itself is |
| +// SmokeSceneView (LChatCore) — web bootscene.js's native twin. |
| +@MainActor |
| +final class SpaceSwitchModel: ObservableObject { |
| + @Published private(set) var active = false // scene mounted at all |
| + @Published private(set) var covered = false // overlay at full opacity |
| + @Published private(set) var spinner = false // the slow-load spinner, over the scene |
| + |
| + // The choreography: a quick fade in, hold the smoke at least the minimum |
| + // beat once covered (so an instant load still reads as one crossfade |
| + // through smoke, not a flash), spinner only past ~2s, and a hard cap so a |
| + // hung load can never trap anyone in smoke — past it the reveal happens |
| + // regardless and whatever loading/empty state exists today shows beneath. |
| + // Timings tuned on device with Paul, four passes down from 0.3/0.35/0.75: |
| + // the whole switch should read ~200ms — a barely-there blink of smoke — |
| + // and a load slower than half a second earns the spinner. A spinner that |
| + // DID appear stays a comfortable beat (spinnerHold): flashing one off the |
| + // instant the load lands reads as a glitch, not feedback. |
| + static let cover = 0.08 |
| + static let reveal = 0.12 |
| + static let minBeat = 0.0 |
| + static let spinnerAfter = 0.5 |
| + static let spinnerHold = 0.7 |
| + static let maxWait = 10.0 |
| + |
| + private var run: Task<Void, Never>? |
| + |
| + /// Start the transition. `swap` performs the actual instance switch and |
| + /// returns the fresh store to watch — called only once the scene fully |
| + /// covers the old list, so the outgoing space visibly fades into smoke |
| + /// rather than being yanked out from under the fade. |
| + func begin(swap: @escaping @MainActor () -> ThreadStore?) { |
| + run?.cancel() |
| + spinner = false |
| + // A re-select while a transition is still up keeps the cover it has |
| + // (resetting to transparent would flash the half-loaded list). |
| + let alreadyCovered = active && covered |
| + active = true |
| + run = Task { @MainActor [weak self] in |
| + guard let self else { return } |
| + if !alreadyCovered { |
| + // Mount at opacity 0 for a tick first, or the fade-in is skipped. |
| + await Task.yield() |
| + guard !Task.isCancelled else { return } |
| + withAnimation(.easeInOut(duration: Self.cover)) { self.covered = true } |
| + try? await Task.sleep(for: .seconds(Self.cover)) |
| + guard !Task.isCancelled else { return } |
| + } |
| + |
| + let began = Date() |
| + let store = swap() |
| + // "Settled" = the new store's refresh ran and finished (loading |
| + // rose then fell), errored before it could start, or rows landed — |
| + // failure settles too, so the empty/error state is reachable. |
| + var sawLoading = false |
| + var spinnerAt: Date? |
| + while !Task.isCancelled { |
| + let elapsed = Date().timeIntervalSince(began) |
| + if let store { |
| + if store.loading { sawLoading = true } |
| + } |
| + let settled = store == nil |
| + || store!.loadError != nil |
| + || (sawLoading && !store!.loading) |
| + || !store!.threads.isEmpty |
| + if !self.spinner, !settled, elapsed >= Self.spinnerAfter { |
| + spinnerAt = Date() |
| + withAnimation(.easeInOut(duration: 0.25)) { self.spinner = true } |
| + } |
| + let spinnerDone = spinnerAt.map { Date().timeIntervalSince($0) >= Self.spinnerHold } ?? true |
| + if (settled && elapsed >= Self.minBeat && spinnerDone) || elapsed >= Self.maxWait { break } |
| + try? await Task.sleep(for: .milliseconds(40)) |
| + } |
| + guard !Task.isCancelled else { return } |
| + |
| + withAnimation(.easeInOut(duration: Self.reveal)) { |
| + self.covered = false |
| + self.spinner = false |
| + } |
| + try? await Task.sleep(for: .seconds(Self.reveal)) |
| + if !Task.isCancelled { self.active = false } |
| + } |
| + } |
| +} |
| + |
| +/// The full-screen transition surface RootView overlays on the chat list: an |
| +/// opaque background (so nothing beneath flashes) carrying the smoke scene, |
| +/// plus the sanctioned late-load spinner — it sits on a transition surface, |
| +/// never over content someone is reading. Reduce Motion drops the drifting |
| +/// particles and keeps the plain crossfade. |
| +struct SpaceSwitchOverlay: View { |
| + @ObservedObject var model: SpaceSwitchModel |
| + @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| + |
| + var body: some View { |
| + if model.active { |
| + ZStack { |
| + Color(.systemBackground).ignoresSafeArea() |
| + // A fraction of full strength: the shared AmbientField is |
| + // tuned for the create-space stage, where the scene IS the |
| + // show — in a transition it's barely noticeable, by design. |
| + if !reduceMotion { SmokeSceneView().opacity(0.2) } |
| + if model.spinner { |
| + ProgressView() |
| + .accessibilityLabel("Loading chats") |
| + } |
| + } |
| + .opacity(model.covered ? 1 : 0) |
| + // Inert the moment the reveal starts, so the incoming list is |
| + // immediately tappable under the dissolving smoke. |
| + .allowsHitTesting(model.covered) |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/SmokeScene.swift b/ios/LChatCore/Sources/LChatCore/SmokeScene.swift| new file mode 100644 |
| index 0000000..e1a244d |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/SmokeScene.swift |
| @@ -0,0 +1,68 @@ |
| +import SwiftUI |
| + |
| +// 🤖 The brand smoke-and-motes scene as a standalone, self-driving surface — |
| +// the same scene as web's bootscene.js (the home loader / ambient haze). The |
| +// particle system itself lives in CreateSpaceView.swift (AmbientField + |
| +// AmbientCanvas, "home's bootscene, the quiet sibling"); this wrapper adds |
| +// only a driver so any transition can drop the scene in without borrowing the |
| +// create-space model. First use: iOS's space switch fades through it while |
| +// the next space's chats load. The Mac could adopt it later (its sidebar + |
| +// detail switch is a different shape). |
| +public struct SmokeSceneView: View { |
| + @StateObject private var driver = SmokeSceneDriver() |
| + |
| + public init() {} |
| + |
| + public var body: some View { |
| + // Measured full-bleed so motes genuinely rise from below the physical |
| + // screen edge (the same lesson as CreateSpaceView's stage sizing). |
| + GeometryReader { geo in |
| + AmbientCanvas(field: driver.field, tick: driver.tick) |
| + .onAppear { |
| + driver.size = geo.size |
| + driver.start() |
| + } |
| + .onChange(of: geo.size) { _, s in driver.size = s } |
| + .onDisappear { driver.stop() } |
| + } |
| + .allowsHitTesting(false) |
| + .ignoresSafeArea() |
| + } |
| +} |
| + |
| +/// The scene's 60Hz heartbeat. An explicit Timer, not TimelineView(.animation), |
| +/// for the same reason CreateSpaceModel drives its sim with one: TimelineView |
| +/// silently stopped ticking on device and the smoke froze. The published tick |
| +/// is what forces the Canvas to redraw (the field is a class — an identical |
| +/// reference frame-to-frame would skip the redraw). |
| +@MainActor |
| +final class SmokeSceneDriver: ObservableObject { |
| + @Published private(set) var tick = 0 |
| + let field = AmbientField() |
| + var size: CGSize = .zero |
| + private var timer: Timer? |
| + private var lastT: TimeInterval? |
| + |
| + func start() { |
| + guard timer == nil else { return } |
| + let t = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in |
| + MainActor.assumeIsolated { |
| + guard let self else { return } |
| + let now = Date().timeIntervalSinceReferenceDate |
| + let dt = min(0.1, max(0, now - (self.lastT ?? now))) |
| + self.lastT = now |
| + self.field.step(dt: dt, size: self.size) |
| + self.tick &+= 1 |
| + } |
| + } |
| + // .common so the scene keeps drifting through any gesture tracking. |
| + RunLoop.main.add(t, forMode: .common) |
| + timer = t |
| + } |
| + |
| + func stop() { |
| + timer?.invalidate() |
| + timer = nil |
| + lastT = nil |
| + } |
| +} |