eleven / messenger Public

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>
Paul Campbell d78de06058b5907e0f27386cc4267dbb2157e806 parent 7447de2
3 files changed, +207 −3
  • ios/LChat/LChatApp.swift +18 −3
  • ios/LChat/Views/SpaceSwitchScene.swift +121 −0
  • ios/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
+ }
+}