Onboarding Motion Handoff

Onboarding Motion Handoff cover showing three mobile motion states
实时预览Live preview
01

入场与助手问候

Success to assistant greeting

Mobile 预览iOS entrance motion
交付代码Onboarding motion
Single-file componentShared motion specBuild checked
import SwiftUI
#if canImport(SDWebImageSwiftUI)
import SDWebImageSwiftUI
#endif
#if canImport(SDWebImageWebPCoder)
import SDWebImage
import SDWebImageWebPCoder
#endif

/// Complete iOS 17+ onboarding motion.
/// Add assistant-wave-transparent-bright.webp as a Bundle Resource; add the
/// neutral avatar and provider icons to Assets.xcassets. Animated playback uses
/// SDWebImageSwiftUI 3.1.4 + SDWebImageWebPCoder 0.9.1.
struct OnboardingMotionView: View {
    fileprivate enum Scene: Hashable { case success, greeting, email }
    private struct PlaybackKey: Hashable { let scene: Scene; let id: Int }

    fileprivate enum Motion {
        static let teamAutoAdvance = Duration.milliseconds(850)
        static let greetingHold = Duration.milliseconds(4074)
        static let sceneEnter = Double(460) / 1000
        static let wordStagger = Duration.milliseconds(90)
        static let avatarMorph = Double(680) / 1000
        static let cardStagger = Double(120) / 1000
        static let enter = Animation.timingCurve(0.16, 1, 0.3, 1, duration: sceneEnter)
    }

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var scene: Scene = .success
    @State private var playbackID = 0

    init() {
        #if canImport(SDWebImageWebPCoder)
        _ = Self.webPCoderConfigured
        #endif
    }

    #if canImport(SDWebImageWebPCoder)
    private static let webPCoderConfigured: Void = {
        SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
    }()
    #endif

    var body: some View {
        ZStack {
            LinearGradient(
                colors: [Color(red: 0.91, green: 0.95, blue: 1), Color(red: 0.75, green: 0.85, blue: 1)],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()

            SuccessScreen(action: { scene = .greeting })
                .opacity(scene == .success ? 1 : 0)
                .offset(y: scene == .success || reduceMotion ? 0 : -14)
                .scaleEffect(scene == .success || reduceMotion ? 1 : 0.985)

            SharedAssistant(scene: scene, reduceMotion: reduceMotion)

            GreetingCopy(active: scene == .greeting, playbackID: playbackID)
                .opacity(scene == .greeting ? 1 : 0)

            EmailConnectScreen(active: scene == .email, playbackID: playbackID)
                .opacity(scene == .email ? 1 : 0)
        }
        .animation(reduceMotion ? nil : Motion.enter, value: scene)
        .task(id: PlaybackKey(scene: scene, id: playbackID)) {
            switch scene {
            case .success:
                try? await Task.sleep(for: reduceMotion ? .zero : Motion.teamAutoAdvance)
                guard !Task.isCancelled, scene == .success else { return }
                scene = .greeting
            case .greeting:
                try? await Task.sleep(for: reduceMotion ? .milliseconds(50) : Motion.greetingHold)
                guard !Task.isCancelled, scene == .greeting else { return }
                scene = .email
            case .email:
                break
            }
        }
        .accessibilityAction(named: "Replay") {
            playbackID += 1
            scene = .success
        }
    }
}

private struct SuccessScreen: View {
    let action: () -> Void

    var body: some View {
        VStack(spacing: 0) {
            Image(systemName: "checkmark")
                .font(.system(size: 26, weight: .semibold))
                .foregroundStyle(.white)
                .frame(width: 56, height: 56)
                .background(Color(red: 0.04, green: 0.77, blue: 0.42), in: Circle())
                .padding(.bottom, 18)
            Text("Team created")
                .font(.system(size: 20, weight: .semibold))
                .foregroundStyle(Color(red: 0.01, green: 0.03, blue: 0.12))
            Text("Welcome aboard — invite your team\nanytime from Settings.")
                .font(.system(size: 15))
                .foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
                .multilineTextAlignment(.center)
                .padding(.top, 10)
            Button("Continue", action: action)
                .font(.system(size: 16, weight: .semibold))
                .foregroundStyle(.white)
                .frame(width: 220, height: 50)
                .background(Color(red: 0.01, green: 0.03, blue: 0.12), in: Capsule())
                .padding(.top, 28)
        }
    }
}

private struct SharedAssistant: View {
    let scene: OnboardingMotionView.Scene
    let reduceMotion: Bool

    var body: some View {
        ZStack {
            Circle().fill(.white)
            if scene == .success {
                EmptyView()
            } else if scene == .greeting {
                AssistantWaveAsset().clipShape(Circle())
            } else {
                Image("assistant-neutral")
                    .resizable().scaledToFill().clipShape(Circle())
            }
        }
        .frame(width: 245, height: 245)
        .scaleEffect(scene == .success ? 0.001 : (scene == .email ? CGFloat(120) / 245 : 1))
        .offset(y: scene == .email ? -188 : -72)
        .animation(
            reduceMotion ? nil : .timingCurve(0.16, 1, 0.3, 1, duration: OnboardingMotionView.Motion.avatarMorph),
            value: scene
        )
        .allowsHitTesting(false)
    }
}

private struct AssistantWaveAsset: View {
    var body: some View {
        #if canImport(SDWebImageSwiftUI)
        AnimatedImage(name: "assistant-wave-transparent-bright.webp")
            .resizable().customLoopCount(1).scaledToFill()
        #else
        Image("assistant-wave-fallback")
            .resizable().scaledToFill()
        #endif
    }
}

private struct GreetingCopy: View {
    let active: Bool
    let playbackID: Int
    @State private var visibleWords = 0

    private let words = ["Hi,", "I am", "your", "AI", "Assistant"]

    var body: some View {
        VStack(spacing: 10) {
            HStack(spacing: 6) {
                ForEach(Array(words.prefix(3).enumerated()), id: \.offset) { index, word in
                    wordView(word, index: index, size: 24)
                }
            }
            HStack(spacing: 8) {
                ForEach(Array(words.suffix(2).enumerated()), id: \.offset) { index, word in
                    wordView(word, index: index + 3, size: 36)
                }
            }
        }
        .offset(y: 116)
        .task(id: "\(active)-\(playbackID)") {
            visibleWords = 0
            guard active else { return }
            for count in 1...words.count {
                withAnimation(OnboardingMotionView.Motion.enter) { visibleWords = count }
                try? await Task.sleep(for: OnboardingMotionView.Motion.wordStagger)
                if Task.isCancelled { return }
            }
        }
    }

    private func wordView(_ word: String, index: Int, size: CGFloat) -> some View {
        Text(word)
            .font(.system(size: size, weight: .medium))
            .foregroundStyle(Color(red: 0.01, green: 0.03, blue: 0.12))
            .opacity(visibleWords > index ? 1 : 0)
            .offset(y: visibleWords > index ? 0 : 12)
    }
}

private struct EmailConnectScreen: View {
    let active: Bool
    let playbackID: Int
    @State private var visibleWords = 0

    private let titleWords = ["Connect", "your", "work", "email"]

    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: 8) {
                ForEach(Array(titleWords.enumerated()), id: \.offset) { index, word in
                    Text(word)
                        .font(.system(size: 32, weight: .medium))
                        .opacity(visibleWords > index ? 1 : 0)
                        .offset(y: visibleWords > index ? 0 : 12)
                }
            }
            .frame(maxWidth: 350)
            .padding(.top, 214)

            Text("The more I understand your work,\nthe better I can help")
                .font(.system(size: 16))
                .foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
                .multilineTextAlignment(.center)
                .padding(.top, 12)

            VStack(spacing: 12) {
                ProviderCard(name: "Gmail", asset: "gmail", delay: 0, active: active)
                ProviderCard(name: "Outlook", asset: "outlook", delay: OnboardingMotionView.Motion.cardStagger, active: active)
            }
            .padding(.horizontal, 24)
            .padding(.top, 42)

            Spacer()
            Label("Your data stays private and secure", systemImage: "shield")
                .font(.system(size: 13))
                .foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
                .padding(.bottom, 38)
        }
        .task(id: "email-\(active)-\(playbackID)") {
            visibleWords = 0
            guard active else { return }
            for count in 1...titleWords.count {
                withAnimation(OnboardingMotionView.Motion.enter) { visibleWords = count }
                try? await Task.sleep(for: OnboardingMotionView.Motion.wordStagger)
                if Task.isCancelled { return }
            }
        }
    }
}

private struct ProviderCard: View {
    let name: String
    let asset: String
    let delay: Double
    let active: Bool

    var body: some View {
        HStack(spacing: 14) {
            Image(asset).resizable().scaledToFit().frame(width: 28, height: 28)
                .frame(width: 50, height: 50).background(.white, in: RoundedRectangle(cornerRadius: 14))
            Text(name).font(.system(size: 17, weight: .medium))
            Spacer()
            Text("Link").font(.system(size: 14, weight: .medium)).foregroundStyle(.white)
                .padding(.horizontal, 18).frame(height: 38).background(Color(red: 0.01, green: 0.03, blue: 0.12), in: Capsule())
        }
        .padding(.horizontal, 16).frame(height: 82).background(.white, in: RoundedRectangle(cornerRadius: 18))
        .opacity(active ? 1 : 0).offset(y: active ? 0 : 18)
        .animation(OnboardingMotionView.Motion.enter.delay(delay), value: active)
    }
}
02

邮件分析进度

Seven seconds of visible progress

Mobile 预览Email analysis motion
交付代码Email analysis motion
Single-file componentShared motion specBuild checked
import SwiftUI

/// Complete iOS 17+ seven-second email analysis motion.
struct EmailAnalysisView: View {
    private enum Motion {
        static let duration = Double(7000) / 1000
        static let cardsEnter = Double(300) / 1000
        static let shimmerCycle = Double(1600) / 1000
        static let targets = [214,49,21]
        static let enter = Animation.timingCurve(0.16, 1, 0.3, 1, duration: cardsEnter)
    }

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var progress = 0.0
    @State private var cardsVisible = false
    @State private var playbackID = 0

    private let rows = [
        ("Messages found", "envelope", 0),
        ("Sent by you", "paperplane", 1),
        ("Read Closely", "eyeglasses", 2)
    ]

    var body: some View {
        ZStack {
            LinearGradient(colors: [Color(red: 0.92, green: 0.96, blue: 1), .white], startPoint: .top, endPoint: .bottom)
                .ignoresSafeArea()
            VStack(spacing: 0) {
                HStack(spacing: 24) {
                    CircleAsset(name: "gmail", size: 92)
                    DataFlow(progress: progress)
                        .frame(width: 48, height: 46)
                    CircleAsset(name: "assistant-neutral", size: 92)
                }
                .padding(.top, 110)

                ShimmerText("Reading recent emails...", cycle: Motion.shimmerCycle)
                    .font(.system(size: 20, weight: .semibold))
                    .padding(.top, 30)
                Text("Your AI assistant is reading your work content")
                    .font(.system(size: 14))
                    .foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
                    .padding(.top, 8)

                ProgressView(value: progress)
                    .tint(Color(red: 0.46, green: 0.65, blue: 1))
                    .padding(.horizontal, 42)
                    .padding(.top, 18)

                VStack(spacing: 10) {
                    ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
                        MetricCard(title: row.0, symbol: row.1, value: Double(Motion.targets[row.2]) * progress)
                            .opacity(cardsVisible ? 1 : 0)
                            .offset(y: cardsVisible ? 0 : 10)
                            .animation(Motion.enter.delay(Double(rowIndex) * 0.06), value: cardsVisible)
                    }
                }
                .padding(.horizontal, 24)
                .padding(.top, 42)
                Spacer()
            }
        }
        .task(id: playbackID) {
            progress = 0
            cardsVisible = false
            if reduceMotion {
                cardsVisible = true
                progress = 1
                return
            }
            withAnimation(Motion.enter) { cardsVisible = true }
            withAnimation(.linear(duration: Motion.duration)) { progress = 1 }
        }
        .accessibilityAction(named: "Replay") { playbackID += 1 }
    }
}

private struct CircleAsset: View {
    let name: String
    let size: CGFloat
    var body: some View {
        Image(name).resizable().scaledToFit().padding(18)
            .frame(width: size, height: size).background(.white, in: Circle())
    }
}

private struct DataFlow: View {
    let progress: Double
    var body: some View {
        TimelineView(.animation) { timeline in
            Canvas { context, size in
                let phase = timeline.date.timeIntervalSinceReferenceDate * -12
                var paths = [Path]()
                var top = Path(); top.move(to: .zero); top.addCurve(to: CGPoint(x: size.width, y: 18), control1: CGPoint(x: 18, y: 14), control2: CGPoint(x: 30, y: 18)); paths.append(top)
                var middle = Path(); middle.move(to: CGPoint(x: 0, y: 23)); middle.addLine(to: CGPoint(x: size.width, y: 23)); paths.append(middle)
                var bottom = Path(); bottom.move(to: CGPoint(x: 0, y: size.height)); bottom.addCurve(to: CGPoint(x: size.width, y: 28), control1: CGPoint(x: 18, y: 32), control2: CGPoint(x: 30, y: 28)); paths.append(bottom)
                for path in paths {
                    context.stroke(path, with: .color(Color(red: 0, green: 0.37, blue: 1).opacity(0.2)), style: StrokeStyle(lineWidth: 1.5, dash: [4, 4], dashPhase: phase))
                }
            }
        }
        .accessibilityHidden(true)
    }
}

private struct MetricCard: View {
    let title: String
    let symbol: String
    let value: Double
    var body: some View {
        HStack(spacing: 14) {
            Image(systemName: symbol).frame(width: 44, height: 44)
                .background(Color(red: 0.94, green: 0.96, blue: 0.99), in: RoundedRectangle(cornerRadius: 12))
            Text(title).font(.system(size: 16)).foregroundStyle(.secondary)
            Spacer()
            CountingNumber(value: value).font(.system(size: 28, weight: .semibold).monospacedDigit())
        }
        .padding(.horizontal, 18).frame(height: 82).background(.white, in: RoundedRectangle(cornerRadius: 18))
    }
}

private struct CountingNumber: View, Animatable {
    var value: Double
    var animatableData: Double { get { value } set { value = newValue } }
    var body: some View { Text(Int(value.rounded()).formatted()) }
}

private struct ShimmerText: View {
    let text: String
    let cycle: Double
    init(_ text: String, cycle: Double) { self.text = text; self.cycle = cycle }
    var body: some View {
        TimelineView(.animation) { context in
            let phase = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycle) / cycle
            Text(text).foregroundStyle(Color.gray.opacity(0.45))
                .overlay {
                    LinearGradient(colors: [.clear, .white.opacity(0.9), .clear], startPoint: .leading, endPoint: .trailing)
                        .offset(x: CGFloat(phase * 320 - 160)).mask(Text(text))
                }
        }
    }
}
03

被邀请者气泡动画

Invitee context bubbles

Mobile 预览Invitee context bubble motion
交付代码Invitee bubble motion
Single-file componentShared motion specBuild checked
import SwiftUI
#if canImport(SDWebImageSwiftUI)
import SDWebImageSwiftUI
#endif
#if canImport(SDWebImageWebPCoder)
import SDWebImage
import SDWebImageWebPCoder
#endif

/// Complete iOS 17+ invitee sequence: joined → greeting → email → floating bubbles.
struct InviteeBubbleMotionView: View {
    fileprivate enum Scene: Hashable { case joined, greeting, email }
    fileprivate struct Bubble: Identifiable {
        let id: Int; let label: String; let value: String; let symbol: String
    }
    private enum Motion {
        static let teamAdvance = Duration.milliseconds(850)
        static let greetingHold = Duration.milliseconds(4300)
        static let firstBubbleDelay = Duration.milliseconds(900)
        static let bubbleHold = Duration.milliseconds(3600)
        static let bubbleExit = Double(1400) / 1000
        static let handoff = Duration.milliseconds(900)
        static let enter = Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.46)
    }

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var scene: Scene = .joined
    @State private var bubbleIndex = 0
    @State private var bubbleVisible = false
    @State private var bubbleLeaving = false
    @State private var playbackID = 0

    init() {
        #if canImport(SDWebImageWebPCoder)
        _ = Self.webPCoderConfigured
        #endif
    }

    #if canImport(SDWebImageWebPCoder)
    private static let webPCoderConfigured: Void = {
        SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
    }()
    #endif

    private let bubbles = [
        Bubble(id: 0, label: "Organization", value: "Tanka", symbol: "building.2"),
        Bubble(id: 1, label: "Chat Group", value: "Product Workspace", symbol: "bubble.left.and.bubble.right"),
        Bubble(id: 2, label: "Role", value: "Product Lead", symbol: "person.crop.circle")
    ]

    var body: some View {
        ZStack {
            LinearGradient(colors: [Color(red: 0.91, green: 0.95, blue: 1), .white], startPoint: .top, endPoint: .bottom).ignoresSafeArea()
            InviteeBaseContent(scene: scene)
            if scene == .email {
                ContextBubbleCard(item: bubbles[bubbleIndex])
                    .opacity(bubbleVisible && !bubbleLeaving ? 1 : 0)
                    .scaleEffect(bubbleVisible && !bubbleLeaving ? 1 : 0.985)
                    .offset(y: bubbleLeaving && !reduceMotion ? -56 : (bubbleVisible ? -310 : -300))
                    .animation(
                        reduceMotion ? .easeOut(duration: 0.15) : .timingCurve(0.16, 1, 0.3, 1, duration: bubbleLeaving ? Motion.bubbleExit : 0.42),
                        value: bubbleLeaving
                    )
                    .animation(Motion.enter, value: bubbleVisible)
            }
        }
        .animation(reduceMotion ? nil : Motion.enter, value: scene)
        .task(id: "\(scene)-\(playbackID)") {
            switch scene {
            case .joined:
                try? await Task.sleep(for: reduceMotion ? .zero : Motion.teamAdvance)
                guard !Task.isCancelled else { return }; scene = .greeting
            case .greeting:
                try? await Task.sleep(for: reduceMotion ? .milliseconds(50) : Motion.greetingHold)
                guard !Task.isCancelled else { return }; scene = .email
            case .email:
                try? await Task.sleep(for: reduceMotion ? .milliseconds(100) : Motion.firstBubbleDelay)
                while !Task.isCancelled {
                    bubbleLeaving = false
                    withAnimation(Motion.enter) { bubbleVisible = true }
                    try? await Task.sleep(for: reduceMotion ? .milliseconds(800) : Motion.bubbleHold)
                    withAnimation { bubbleLeaving = true }
                    try? await Task.sleep(for: reduceMotion ? .milliseconds(150) : Motion.handoff)
                    bubbleVisible = false
                    bubbleIndex = (bubbleIndex + 1) % bubbles.count
                }
            }
        }
        .accessibilityAction(named: "Replay") {
            bubbleVisible = false; bubbleLeaving = false; bubbleIndex = 0
            playbackID += 1; scene = .joined
        }
    }
}

private struct InviteeBaseContent: View {
    let scene: InviteeBubbleMotionView.Scene
    var body: some View {
        VStack(spacing: 0) {
            Spacer().frame(height: 170)
            ZStack {
                Circle().fill(.white)
                if scene == .joined {
                    Image(systemName: "checkmark").font(.system(size: 30, weight: .semibold)).foregroundStyle(.green)
                } else if scene == .greeting {
                    #if canImport(SDWebImageSwiftUI)
                    AnimatedImage(name: "assistant-wave-transparent-bright.webp").resizable().customLoopCount(1).scaledToFill()
                    #else
                    Image("assistant-wave-fallback").resizable().scaledToFill()
                    #endif
                } else {
                    Image("assistant-neutral").resizable().scaledToFill()
                }
            }
            .clipShape(Circle()).frame(width: scene == .greeting ? 245 : 120, height: scene == .greeting ? 245 : 120)

            if scene == .joined {
                Text("You’ve joined Tanka").font(.system(size: 22, weight: .semibold)).padding(.top, 20)
            } else if scene == .greeting {
                Text("Hi, I am your").font(.system(size: 24, weight: .medium)).padding(.top, 22)
                Text("AI Assistant").font(.system(size: 36, weight: .medium)).padding(.top, 8)
            } else {
                Text("Connect your work email").font(.system(size: 32, weight: .medium)).padding(.top, 24)
                Text("The more I understand your work, the better I can help")
                    .font(.system(size: 15)).foregroundStyle(.secondary).padding(.top, 10)
                VStack(spacing: 12) {
                    InviteeProvider(name: "Gmail", image: "gmail")
                    InviteeProvider(name: "Outlook", image: "outlook")
                }.padding(.horizontal, 24).padding(.top, 38)
            }
            Spacer()
        }
    }
}

private struct InviteeProvider: View {
    let name: String; let image: String
    var body: some View {
        HStack { Image(image).resizable().scaledToFit().frame(width: 28, height: 28); Text(name).fontWeight(.medium); Spacer(); Text("Link").foregroundStyle(.white).padding(.horizontal, 18).frame(height: 38).background(.black, in: Capsule()) }
            .padding(.horizontal, 18).frame(height: 82).background(.white, in: RoundedRectangle(cornerRadius: 18))
    }
}

private struct ContextBubbleCard: View {
    let item: InviteeBubbleMotionView.Bubble
    @State private var floating = false
    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: item.symbol).frame(width: 40, height: 40).background(.white.opacity(0.8), in: RoundedRectangle(cornerRadius: 12))
            VStack(alignment: .leading, spacing: 4) {
                Text(item.label.uppercased()).font(.system(size: 13, weight: .medium)).foregroundStyle(.secondary)
                Text(item.value).font(.system(size: 14, weight: .medium))
            }
        }
        .padding(.horizontal, 16).frame(width: 220, height: 72)
        .background(.white.opacity(0.44), in: RoundedRectangle(cornerRadius: 24))
        .offset(y: floating ? -3 : 3)
        .onAppear { withAnimation(.easeInOut(duration: 3.2).repeatForever(autoreverses: true)) { floating = true } }
    }
}
04

进入 Tanka 首页

From onboarding chat to Tanka home

Mobile 预览Enter Tanka home motion
全屏预览
交付代码Join Tanka home motion
Single-file componentShared motion specBuild checked
import SwiftUI

/// iOS 17+ handoff for the approved 393 × 852 Join Tanka sequence.
/// Add the exported `assistant-top`, `plus`, `mic`, `history`, and tab icons to Assets.xcassets.
struct JoinTankaMotionView: View {
    private enum Phase: Hashable {
        case answering
        case joinReady
        case enteringHome
        case home
    }

    private enum Motion {
        static let streamStart = Duration.milliseconds(350)
        static let wordInterval = Duration.milliseconds(45)
        static let answerHold = Duration.milliseconds(550)
        static let joinLead = Duration.milliseconds(70)
        static let footerHandoff = Duration.milliseconds(450)
        static let homeSettle = Duration.milliseconds(900)
        static let firstUpdate = Duration.milliseconds(1900)
        static let secondUpdate = Duration.milliseconds(1900)
        static let drawer = Animation.timingCurve(0.32, 0.72, 0, 1, duration: Double(360) / 1000)
        static let fade = Animation.timingCurve(0.23, 1, 0.32, 1, duration: 0.22)
    }

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var phase: Phase = .answering
    @State private var streamedWordCount = 0
    @State private var composerVisible = true
    @State private var joinVisible = false
    @State private var firstUpdateVisible = false
    @State private var secondUpdateVisible = false
    @State private var playbackID = 0

    private let answerWords = "every conversation with people inside and outside Tanka is aggregated there, and you can hand any of it off to me at any time."
        .split(separator: " ")
        .map(String.init)

    var body: some View {
        ZStack(alignment: .bottom) {
            Color(red: 0.953, green: 0.969, blue: 0.988)
                .ignoresSafeArea()

            VStack(spacing: 0) {
                statusBar
                navigationBar
                content
            }

            LinearGradient(
                colors: [.clear, Color(red: 0.953, green: 0.969, blue: 0.988)],
                startPoint: .top,
                endPoint: .bottom
            )
            .frame(height: phase == .home ? 216 : 150)
            .allowsHitTesting(false)

            if composerVisible {
                composer
                    .padding(.horizontal, 16)
                    .padding(.bottom, 50)
                    .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
            }

            if joinVisible {
                Button("Join Tanka", action: enterTanka)
                    .buttonStyle(JoinButtonStyle())
                    .padding(.horizontal, 16)
                    .padding(.bottom, 50)
                    .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
            }

            if phase == .enteringHome || phase == .home {
                homeFooter
                    .padding(.bottom, 28)
                    .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
            }
        }
        .frame(width: 393, height: 852)
        .clipShape(RoundedRectangle(cornerRadius: 50, style: .continuous))
        .task(id: playbackID) { await playSequence() }
        .accessibilityAction(named: "Replay") { replay() }
    }

    private var statusBar: some View {
        HStack {
            Text("9:41").font(.system(size: 17, weight: .medium))
            Spacer()
            HStack(spacing: 7) {
                Image(systemName: "cellularbars")
                Image(systemName: "wifi")
                Image(systemName: "battery.100")
            }
            .font(.system(size: 15, weight: .medium))
        }
        .padding(.horizontal, 34)
        .frame(height: 62)
    }

    private var navigationBar: some View {
        ZStack {
            HStack {
                if phase == .answering || phase == .joinReady {
                    Spacer()
                    Text("Skip").font(.system(size: 16))
                } else {
                    HStack(spacing: 4) {
                        Text("Shanda").font(.system(size: 17, weight: .medium))
                        Image(systemName: "chevron.down")
                            .font(.system(size: 12, weight: .medium))
                    }
                    Spacer()
                    Image("history").resizable().scaledToFit().frame(width: 82, height: 24)
                }
            }

            Image("assistant-top")
                .resizable()
                .scaledToFill()
                .frame(width: phase == .home ? 40 : 44, height: phase == .home ? 40 : 44)
                .clipShape(Circle())
                .background(.white, in: Circle())
        }
        .padding(.horizontal, 18)
        .frame(height: 64)
        .animation(reduceMotion ? Motion.fade : Motion.drawer, value: phase)
    }

    @ViewBuilder private var content: some View {
        ScrollViewReader { proxy in
            ScrollView {
                VStack(alignment: .leading, spacing: 24) {
                    if phase == .answering || phase == .joinReady {
                        onboardingAnswer
                    } else {
                        homeConversation
                    }
                }
                .padding(.horizontal, 16)
                .padding(.top, 14)
                .padding(.bottom, phase == .home ? 210 : 146)
            }
            .scrollIndicators(.hidden)
            .onChange(of: secondUpdateVisible) { _, visible in
                guard visible else { return }
                withAnimation(reduceMotion ? Motion.fade : Motion.drawer) {
                    proxy.scrollTo("latest-update", anchor: .bottom)
                }
            }
        }
    }

    private var onboardingAnswer: some View {
        VStack(alignment: .leading, spacing: 24) {
            Text("Chat — ").fontWeight(.medium) +
            Text(answerWords.prefix(streamedWordCount).joined(separator: " "))
        }
        .font(.system(size: 17, weight: .regular))
        .lineSpacing(5)
    }

    private var homeConversation: some View {
        VStack(alignment: .leading, spacing: 24) {
            Text("You’re inside Tanka. I’ll keep reporting back in this conversation.")

            if firstUpdateVisible {
                Text("The research Subflow has already found three active objects inside Onboarding 2.0 改版: a launch-plan document, a Jira delivery board and Marubishi’s unanswered rollout email.")
                    .transition(messageTransition)
            }

            if secondUpdateVisible {
                Text("That focused research surfaced two useful follow-ups, so I delegated a private reply draft and a weekly project watch. They’ll report progress back through me.")
                    .id("latest-update")
                    .transition(messageTransition)
            }
        }
        .font(.system(size: 17, weight: .regular))
        .lineSpacing(5)
    }

    private var messageTransition: AnyTransition {
        reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)
    }

    private var composer: some View {
        HStack(spacing: 12) {
            HStack(spacing: 6) {
                Image("plus").resizable().scaledToFit().frame(width: 24, height: 24)
                Text("Type a message")
                    .foregroundStyle(Color(red: 0.435, green: 0.498, blue: 0.58))
                Spacer()
            }
            Image("mic").resizable().scaledToFit().frame(width: 40, height: 40)
        }
        .padding(.horizontal, 12)
        .frame(height: 68)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().stroke(.white.opacity(0.8), lineWidth: 1))
    }

    private var homeFooter: some View {
        VStack(spacing: 20) {
            composer
                .padding(.horizontal, 16)

            HStack(spacing: 0) {
                TankaTab(title: "Flow", symbol: "square.grid.2x2.fill", selected: true)
                TankaTab(title: "Chat", symbol: "bubble.left", selected: false)
                TankaTab(title: "Link", symbol: "link", selected: false)
            }
            .padding(4)
            .frame(width: 294, height: 62)
            .background(.ultraThinMaterial, in: Capsule())
        }
    }

    private func playSequence() async {
        resetSequence()
        if reduceMotion {
            streamedWordCount = answerWords.count
        } else {
            try? await Task.sleep(for: Motion.streamStart)
            for index in answerWords.indices {
                guard !Task.isCancelled else { return }
                streamedWordCount = index + 1
                try? await Task.sleep(for: Motion.wordInterval)
            }
        }

        try? await Task.sleep(for: reduceMotion ? .milliseconds(120) : Motion.answerHold)
        guard !Task.isCancelled else { return }
        withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { composerVisible = false }
        try? await Task.sleep(for: reduceMotion ? .milliseconds(20) : Motion.joinLead)
        guard !Task.isCancelled else { return }
        withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { joinVisible = true }
        try? await Task.sleep(for: reduceMotion ? .milliseconds(200) : Motion.footerHandoff)
        phase = .joinReady
    }

    private func enterTanka() {
        guard phase == .joinReady else { return }
        withAnimation(reduceMotion ? Motion.fade : Motion.drawer) {
            joinVisible = false
            phase = .enteringHome
        }

        Task {
            try? await Task.sleep(for: reduceMotion ? .milliseconds(120) : Motion.homeSettle)
            guard !Task.isCancelled else { return }
            withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { phase = .home }
            try? await Task.sleep(for: reduceMotion ? .milliseconds(200) : Motion.firstUpdate)
            guard !Task.isCancelled else { return }
            withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { firstUpdateVisible = true }
            try? await Task.sleep(for: reduceMotion ? .milliseconds(300) : Motion.secondUpdate)
            guard !Task.isCancelled else { return }
            withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { secondUpdateVisible = true }
        }
    }

    private func resetSequence() {
        phase = .answering
        streamedWordCount = 0
        composerVisible = true
        joinVisible = false
        firstUpdateVisible = false
        secondUpdateVisible = false
    }

    private func replay() {
        playbackID += 1
    }
}

private struct JoinButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.system(size: 17, weight: .regular))
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .frame(height: 46)
            .background(Color(red: 0.008, green: 0.024, blue: 0.09), in: Capsule())
            .scaleEffect(configuration.isPressed ? 0.985 : 1)
            .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
    }
}

private struct TankaTab: View {
    let title: String
    let symbol: String
    let selected: Bool

    var body: some View {
        VStack(spacing: 3) {
            Image(systemName: symbol).font(.system(size: 22, weight: .medium))
            Text(title).font(.system(size: 11, weight: .medium))
        }
        .foregroundStyle(selected ? Color(red: 0.208, green: 0.224, blue: 0.298) : .secondary)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(selected ? Color(red: 0.925, green: 0.945, blue: 0.961) : .clear, in: Capsule())
    }
}