Преглед изворни кода

Add full-page launch loading screen with startup progress.

Shows subscription and StoreKit setup before the dashboard, reuses the chat thinking animation, and moves initial entitlement refresh off AppDelegate.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 пре 3 недеља
родитељ
комит
25e4a389ea

+ 1 - 8
App for Indeed/AppDelegate.swift

@@ -69,14 +69,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
69 69
             }
70 70
         }
71 71
 
72
-        Task { @MainActor in
73
-            // Do not call `AppStore.sync()` here — it prompts "Sign in with Apple Account" in Xcode / StoreKit
74
-            // testing and can repeat when the app re-activates after dismissing the sheet. Sync only from
75
-            // explicit "Restore purchases" in `SubscriptionStore.restorePurchases()`.
76
-            lastSubscriptionRefreshAt = Date()
77
-            await SubscriptionStore.shared.refreshEntitlements(deep: true)
78
-            NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
79
-        }
72
+        // Initial StoreKit refresh runs on `LoadingViewController` before the dashboard is shown.
80 73
         NSApp.activate(ignoringOtherApps: true)
81 74
         applyDefaultWindowSize()
82 75
     }

+ 14 - 1
App for Indeed/Base.lproj/Main.storyboard

@@ -693,13 +693,26 @@
693 693
                         </connections>
694 694
                     </window>
695 695
                     <connections>
696
-                        <segue destination="XfG-lQ-9wD" kind="relationship" relationship="window.shadowedContentViewController" id="cq2-FE-JQM"/>
696
+                        <segue destination="LdG-lQ-9wD" kind="relationship" relationship="window.shadowedContentViewController" id="cq2-FE-JQM"/>
697 697
                     </connections>
698 698
                 </windowController>
699 699
                 <customObject id="Oky-zY-oP4" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
700 700
             </objects>
701 701
             <point key="canvasLocation" x="75" y="250"/>
702 702
         </scene>
703
+        <!--Loading View Controller-->
704
+        <scene sceneID="LdG-AP-VOD">
705
+            <objects>
706
+                <viewController id="LdG-lQ-9wD" customClass="LoadingViewController" customModuleProvider="target" sceneMemberID="viewController">
707
+                    <view key="view" id="LdG-Jp-Qdl">
708
+                        <rect key="frame" x="0.0" y="0.0" width="1120" height="800"/>
709
+                        <autoresizingMask key="autoresizingMask"/>
710
+                    </view>
711
+                </viewController>
712
+                <customObject id="LdG-NT-nkU" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
713
+            </objects>
714
+            <point key="canvasLocation" x="75" y="450"/>
715
+        </scene>
703 716
         <!--View Controller-->
704 717
         <scene sceneID="hIz-AP-VOD">
705 718
             <objects>

+ 62 - 0
App for Indeed/Controllers/LoadingViewController.swift

@@ -0,0 +1,62 @@
1
+//
2
+//  LoadingViewController.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Cocoa
7
+
8
+final class LoadingViewController: NSViewController {
9
+    private let loadingView = LoadingView(frame: .zero)
10
+    private var didStartLaunch = false
11
+    private var didTransitionToDashboard = false
12
+
13
+    override func loadView() {
14
+        view = loadingView
15
+    }
16
+
17
+    override func viewDidAppear() {
18
+        super.viewDidAppear()
19
+        guard !didStartLaunch else { return }
20
+        didStartLaunch = true
21
+
22
+        if let window = view.window {
23
+            AppWindowConfiguration.apply(to: window)
24
+        }
25
+
26
+        Task { @MainActor in
27
+            await runLaunchSequence()
28
+            transitionToDashboard()
29
+        }
30
+    }
31
+
32
+    @MainActor
33
+    private func runLaunchSequence() async {
34
+        loadingView.setStatus("Starting up…", progress: 0.05)
35
+
36
+        async let startup: Void = AppLaunchCoordinator.performStartup { [weak self] message, progress in
37
+            self?.loadingView.setStatus(message, progress: progress)
38
+        }
39
+        async let minimumDisplay: Void = {
40
+            try? await Task.sleep(nanoseconds: AppLaunchCoordinator.minimumDisplayNanoseconds)
41
+        }()
42
+        _ = await (startup, minimumDisplay)
43
+    }
44
+
45
+    @MainActor
46
+    private func transitionToDashboard() {
47
+        guard !didTransitionToDashboard, let window = view.window else { return }
48
+        didTransitionToDashboard = true
49
+        loadingView.stopAnimating()
50
+
51
+        let dashboard = ViewController()
52
+        NSAnimationContext.runAnimationGroup { context in
53
+            context.duration = 0.32
54
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
55
+            window.animator().contentViewController = dashboard
56
+        } completionHandler: { [weak window] in
57
+            guard let window else { return }
58
+            AppWindowConfiguration.apply(to: window)
59
+            window.center()
60
+        }
61
+    }
62
+}

+ 32 - 0
App for Indeed/Services/AppLaunchCoordinator.swift

@@ -0,0 +1,32 @@
1
+//
2
+//  AppLaunchCoordinator.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Foundation
7
+
8
+@MainActor
9
+enum AppLaunchCoordinator {
10
+    /// Minimum time the loading screen stays visible so the UI does not flash.
11
+    static let minimumDisplayNanoseconds: UInt64 = 1_200_000_000
12
+
13
+    typealias StatusUpdate = (_ message: String, _ progress: CGFloat) -> Void
14
+
15
+    /// Subscription refresh and product catalog load before the dashboard appears.
16
+    static func performStartup(update: StatusUpdate) async {
17
+        update("Starting App for Indeed…", 0.12)
18
+        try? await Task.sleep(nanoseconds: 180_000_000)
19
+
20
+        update("Checking your Pro subscription…", 0.38)
21
+        await SubscriptionStore.shared.refreshEntitlements(deep: true)
22
+
23
+        update("Loading premium plans from the App Store…", 0.62)
24
+        await SubscriptionStore.shared.loadProducts()
25
+
26
+        update("Preparing your job search workspace…", 0.86)
27
+        NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
28
+        try? await Task.sleep(nanoseconds: 220_000_000)
29
+
30
+        update("Almost ready…", 1.0)
31
+    }
32
+}

+ 139 - 0
App for Indeed/Views/ChatThinkingIndicatorView.swift

@@ -0,0 +1,139 @@
1
+//
2
+//  ChatThinkingIndicatorView.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Cocoa
7
+import QuartzCore
8
+
9
+/// Single sparkle with a soft brand-blue glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
10
+final class ChatThinkingIndicatorView: NSView {
11
+    private enum AnimationKey {
12
+        static let dotOpacity = "thinkingDotOpacity"
13
+        static let sparklePulse = "thinkingSparklePulse"
14
+    }
15
+
16
+    /// Matches `DashboardView.Theme.brandBlue` — Indeed-style `#2557a7`.
17
+    private static let accentBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
18
+    /// Slightly lighter blue for the sparkle glow (reads clearly on light banner/chat surfaces).
19
+    private static let accentBlueGlow = NSColor(srgbRed: 54 / 255, green: 130 / 255, blue: 220 / 255, alpha: 1)
20
+
21
+    private let sparkleView = NSImageView()
22
+    private let dotStack = NSStackView()
23
+    private var dotViews: [NSView] = []
24
+
25
+    init(compact: Bool, accessibilityLabel: String = "Assistant is searching") {
26
+        super.init(frame: .zero)
27
+        translatesAutoresizingMaskIntoConstraints = false
28
+
29
+        let sparklePoint: CGFloat = compact ? 11 : 14
30
+        let dotSize: CGFloat = compact ? 4 : 5
31
+        let sparkleDotGap: CGFloat = compact ? 7 : 10
32
+        let dotSpacing: CGFloat = compact ? 4 : 5
33
+
34
+        sparkleView.translatesAutoresizingMaskIntoConstraints = false
35
+        sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
36
+        sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium)
37
+        sparkleView.contentTintColor = Self.accentBlue
38
+        sparkleView.wantsLayer = true
39
+        sparkleView.layer?.masksToBounds = false
40
+        sparkleView.layer?.shadowColor = Self.accentBlueGlow.cgColor
41
+        sparkleView.layer?.shadowRadius = compact ? 5 : 9
42
+        sparkleView.layer?.shadowOpacity = 0.55
43
+        sparkleView.layer?.shadowOffset = .zero
44
+
45
+        NSLayoutConstraint.activate([
46
+            sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6),
47
+            sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6)
48
+        ])
49
+
50
+        dotStack.orientation = .horizontal
51
+        dotStack.spacing = dotSpacing
52
+        dotStack.alignment = .centerY
53
+        dotStack.translatesAutoresizingMaskIntoConstraints = false
54
+
55
+        let dotFill = Self.accentBlue
56
+        for _ in 0..<3 {
57
+            let dot = NSView()
58
+            dot.translatesAutoresizingMaskIntoConstraints = false
59
+            dot.wantsLayer = true
60
+            dot.layer?.cornerRadius = dotSize / 2
61
+            dot.layer?.backgroundColor = dotFill.cgColor
62
+            NSLayoutConstraint.activate([
63
+                dot.widthAnchor.constraint(equalToConstant: dotSize),
64
+                dot.heightAnchor.constraint(equalToConstant: dotSize)
65
+            ])
66
+            dotStack.addArrangedSubview(dot)
67
+            dotViews.append(dot)
68
+        }
69
+
70
+        let row = NSStackView(views: [sparkleView, dotStack])
71
+        row.orientation = .horizontal
72
+        row.spacing = sparkleDotGap
73
+        row.alignment = .centerY
74
+        row.translatesAutoresizingMaskIntoConstraints = false
75
+
76
+        addSubview(row)
77
+        NSLayoutConstraint.activate([
78
+            row.leadingAnchor.constraint(equalTo: leadingAnchor),
79
+            row.trailingAnchor.constraint(equalTo: trailingAnchor),
80
+            row.topAnchor.constraint(equalTo: topAnchor),
81
+            row.bottomAnchor.constraint(equalTo: bottomAnchor)
82
+        ])
83
+
84
+        setAccessibilityElement(true)
85
+        setAccessibilityRole(.group)
86
+        setAccessibilityLabel(accessibilityLabel)
87
+    }
88
+
89
+    @available(*, unavailable)
90
+    required init?(coder: NSCoder) {
91
+        fatalError("init(coder:) has not been implemented")
92
+    }
93
+
94
+    private static var reducedMotion: Bool {
95
+        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
96
+    }
97
+
98
+    func startAnimatingIfNeeded() {
99
+        stopAnimating()
100
+        guard !Self.reducedMotion else {
101
+            dotViews.forEach { $0.layer?.opacity = 0.78 }
102
+            return
103
+        }
104
+        guard let sparkleLayer = sparkleView.layer else { return }
105
+        let waveDuration: CFTimeInterval = 0.52
106
+        let n = max(1, dotViews.count)
107
+        for (i, dot) in dotViews.enumerated() {
108
+            guard let layer = dot.layer else { continue }
109
+            layer.opacity = 1
110
+            let pulse = CABasicAnimation(keyPath: "opacity")
111
+            pulse.fromValue = 0.2
112
+            pulse.toValue = 1.0
113
+            pulse.duration = waveDuration
114
+            pulse.autoreverses = true
115
+            pulse.repeatCount = .greatestFiniteMagnitude
116
+            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
117
+            pulse.timeOffset = waveDuration * Double(i) / Double(n)
118
+            layer.add(pulse, forKey: AnimationKey.dotOpacity)
119
+        }
120
+
121
+        let scale = CABasicAnimation(keyPath: "transform.scale")
122
+        scale.fromValue = 1.0
123
+        scale.toValue = 1.07
124
+        scale.duration = 1.15
125
+        scale.autoreverses = true
126
+        scale.repeatCount = .greatestFiniteMagnitude
127
+        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
128
+        sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse)
129
+    }
130
+
131
+    func stopAnimating() {
132
+        sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse)
133
+        sparkleView.layer?.transform = CATransform3DIdentity
134
+        for dot in dotViews {
135
+            dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity)
136
+            dot.layer?.opacity = 1
137
+        }
138
+    }
139
+}

+ 0 - 132
App for Indeed/Views/DashboardView.swift

@@ -3462,138 +3462,6 @@ private class HoverableView: NSView {
3462 3462
     }
3463 3463
 }
3464 3464
 
3465
-/// Single sparkle with a soft brand-blue glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
3466
-private final class ChatThinkingIndicatorView: NSView {
3467
-    private enum AnimationKey {
3468
-        static let dotOpacity = "thinkingDotOpacity"
3469
-        static let sparklePulse = "thinkingSparklePulse"
3470
-    }
3471
-
3472
-    /// Matches `DashboardView.Theme.brandBlue` — Indeed-style `#2557a7`.
3473
-    private static let accentBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
3474
-    /// Slightly lighter blue for the sparkle glow (reads clearly on light banner/chat surfaces).
3475
-    private static let accentBlueGlow = NSColor(srgbRed: 54 / 255, green: 130 / 255, blue: 220 / 255, alpha: 1)
3476
-
3477
-    private let sparkleView = NSImageView()
3478
-    private let dotStack = NSStackView()
3479
-    private var dotViews: [NSView] = []
3480
-
3481
-    init(compact: Bool) {
3482
-        super.init(frame: .zero)
3483
-        translatesAutoresizingMaskIntoConstraints = false
3484
-
3485
-        let sparklePoint: CGFloat = compact ? 11 : 14
3486
-        let dotSize: CGFloat = compact ? 4 : 5
3487
-        let sparkleDotGap: CGFloat = compact ? 7 : 10
3488
-        let dotSpacing: CGFloat = compact ? 4 : 5
3489
-
3490
-        sparkleView.translatesAutoresizingMaskIntoConstraints = false
3491
-        sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
3492
-        sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium)
3493
-        sparkleView.contentTintColor = Self.accentBlue
3494
-        sparkleView.wantsLayer = true
3495
-        sparkleView.layer?.masksToBounds = false
3496
-        sparkleView.layer?.shadowColor = Self.accentBlueGlow.cgColor
3497
-        sparkleView.layer?.shadowRadius = compact ? 5 : 9
3498
-        sparkleView.layer?.shadowOpacity = 0.55
3499
-        sparkleView.layer?.shadowOffset = .zero
3500
-
3501
-        NSLayoutConstraint.activate([
3502
-            sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6),
3503
-            sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6)
3504
-        ])
3505
-
3506
-        dotStack.orientation = .horizontal
3507
-        dotStack.spacing = dotSpacing
3508
-        dotStack.alignment = .centerY
3509
-        dotStack.translatesAutoresizingMaskIntoConstraints = false
3510
-
3511
-        let dotFill = Self.accentBlue
3512
-        for _ in 0..<3 {
3513
-            let dot = NSView()
3514
-            dot.translatesAutoresizingMaskIntoConstraints = false
3515
-            dot.wantsLayer = true
3516
-            dot.layer?.cornerRadius = dotSize / 2
3517
-            dot.layer?.backgroundColor = dotFill.cgColor
3518
-            NSLayoutConstraint.activate([
3519
-                dot.widthAnchor.constraint(equalToConstant: dotSize),
3520
-                dot.heightAnchor.constraint(equalToConstant: dotSize)
3521
-            ])
3522
-            dotStack.addArrangedSubview(dot)
3523
-            dotViews.append(dot)
3524
-        }
3525
-
3526
-        let row = NSStackView(views: [sparkleView, dotStack])
3527
-        row.orientation = .horizontal
3528
-        row.spacing = sparkleDotGap
3529
-        row.alignment = .centerY
3530
-        row.translatesAutoresizingMaskIntoConstraints = false
3531
-
3532
-        addSubview(row)
3533
-        NSLayoutConstraint.activate([
3534
-            row.leadingAnchor.constraint(equalTo: leadingAnchor),
3535
-            row.trailingAnchor.constraint(equalTo: trailingAnchor),
3536
-            row.topAnchor.constraint(equalTo: topAnchor),
3537
-            row.bottomAnchor.constraint(equalTo: bottomAnchor)
3538
-        ])
3539
-
3540
-        setAccessibilityElement(true)
3541
-        setAccessibilityRole(.group)
3542
-        setAccessibilityLabel("Assistant is searching")
3543
-    }
3544
-
3545
-    @available(*, unavailable)
3546
-    required init?(coder: NSCoder) {
3547
-        fatalError("init(coder:) has not been implemented")
3548
-    }
3549
-
3550
-    private static var reducedMotion: Bool {
3551
-        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
3552
-    }
3553
-
3554
-    func startAnimatingIfNeeded() {
3555
-        stopAnimating()
3556
-        guard !Self.reducedMotion else {
3557
-            dotViews.forEach { $0.layer?.opacity = 0.78 }
3558
-            return
3559
-        }
3560
-        guard let sparkleLayer = sparkleView.layer else { return }
3561
-        let waveDuration: CFTimeInterval = 0.52
3562
-        let n = max(1, dotViews.count)
3563
-        for (i, dot) in dotViews.enumerated() {
3564
-            guard let layer = dot.layer else { continue }
3565
-            layer.opacity = 1
3566
-            let pulse = CABasicAnimation(keyPath: "opacity")
3567
-            pulse.fromValue = 0.2
3568
-            pulse.toValue = 1.0
3569
-            pulse.duration = waveDuration
3570
-            pulse.autoreverses = true
3571
-            pulse.repeatCount = .greatestFiniteMagnitude
3572
-            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
3573
-            pulse.timeOffset = waveDuration * Double(i) / Double(n)
3574
-            layer.add(pulse, forKey: AnimationKey.dotOpacity)
3575
-        }
3576
-
3577
-        let scale = CABasicAnimation(keyPath: "transform.scale")
3578
-        scale.fromValue = 1.0
3579
-        scale.toValue = 1.07
3580
-        scale.duration = 1.15
3581
-        scale.autoreverses = true
3582
-        scale.repeatCount = .greatestFiniteMagnitude
3583
-        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
3584
-        sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse)
3585
-    }
3586
-
3587
-    func stopAnimating() {
3588
-        sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse)
3589
-        sparkleView.layer?.transform = CATransform3DIdentity
3590
-        for dot in dotViews {
3591
-            dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity)
3592
-            dot.layer?.opacity = 1
3593
-        }
3594
-    }
3595
-}
3596
-
3597 3465
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
3598 3466
 private final class JobListingsDocumentView: NSView {
3599 3467
     override var isFlipped: Bool { true }

+ 507 - 0
App for Indeed/Views/LoadingView.swift

@@ -0,0 +1,507 @@
1
+//
2
+//  LoadingView.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Cocoa
7
+import QuartzCore
8
+
9
+final class LoadingView: NSView {
10
+    private enum Theme {
11
+        static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
12
+        static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
13
+        static let pageBackgroundTop = NSColor(srgbRed: 252 / 255, green: 253 / 255, blue: 255 / 255, alpha: 1)
14
+        static let pageBackgroundBottom = NSColor(srgbRed: 241 / 255, green: 245 / 255, blue: 252 / 255, alpha: 1)
15
+        static let iconWell = NSColor(srgbRed: 239 / 255, green: 246 / 255, blue: 255 / 255, alpha: 1)
16
+        static let headingBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
17
+        static let subtitleText = NSColor(srgbRed: 51 / 255, green: 65 / 255, blue: 85 / 255, alpha: 1)
18
+        static let statusText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
19
+        static let waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
20
+        static let badgeFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.1)
21
+        static let badgeText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
22
+    }
23
+
24
+    private let backgroundGradientHost = NSView()
25
+    private let heroBackground = LoadingSplashBackgroundView()
26
+    private let iconWell = NSView()
27
+    private let iconView = NSImageView()
28
+    private let aiBadgeHost = NSView()
29
+    private let aiBadgeLabel = NSTextField(labelWithString: "AI-POWERED")
30
+    private let titleLabel = NSTextField(labelWithString: "App for Indeed")
31
+    private let subtitleLabel = NSTextField(labelWithString: "Find your perfect job with the power of AI.")
32
+    private let statusLabel = NSTextField(labelWithString: "Starting up…")
33
+    private let progressBar = LoadingProgressBarView()
34
+    private let thinkingIndicator = ChatThinkingIndicatorView(
35
+        compact: false,
36
+        accessibilityLabel: "Loading App for Indeed"
37
+    )
38
+
39
+    override init(frame frameRect: NSRect) {
40
+        super.init(frame: frameRect)
41
+        setUp()
42
+    }
43
+
44
+    @available(*, unavailable)
45
+    required init?(coder: NSCoder) {
46
+        fatalError("init(coder:) has not been implemented")
47
+    }
48
+
49
+    override func layout() {
50
+        super.layout()
51
+        backgroundGradientHost.layer?.sublayers?
52
+            .filter { $0.name == "pageGradient" }
53
+            .forEach { $0.frame = backgroundGradientHost.bounds }
54
+        progressBar.layoutProgressFill(animated: false)
55
+    }
56
+
57
+    override func viewDidMoveToWindow() {
58
+        super.viewDidMoveToWindow()
59
+        if window != nil {
60
+            startAnimating()
61
+        } else {
62
+            stopAnimating()
63
+        }
64
+    }
65
+
66
+    func setStatus(_ message: String, progress: CGFloat) {
67
+        statusLabel.stringValue = message
68
+        progressBar.setProgress(progress, animated: true)
69
+        setAccessibilityLabel("Loading App for Indeed. \(message)")
70
+    }
71
+
72
+    func startAnimating() {
73
+        thinkingIndicator.startAnimatingIfNeeded()
74
+        progressBar.startShimmerIfNeeded()
75
+        heroBackground.startAmbientAnimationIfNeeded()
76
+    }
77
+
78
+    func stopAnimating() {
79
+        thinkingIndicator.stopAnimating()
80
+        progressBar.stopShimmer()
81
+        heroBackground.stopAmbientAnimation()
82
+    }
83
+
84
+    private func setUp() {
85
+        wantsLayer = true
86
+
87
+        backgroundGradientHost.translatesAutoresizingMaskIntoConstraints = false
88
+        backgroundGradientHost.wantsLayer = true
89
+
90
+        heroBackground.translatesAutoresizingMaskIntoConstraints = false
91
+        heroBackground.waveTint = Theme.waveTint
92
+
93
+        iconWell.translatesAutoresizingMaskIntoConstraints = false
94
+        iconWell.wantsLayer = true
95
+        iconWell.layer?.backgroundColor = Theme.iconWell.cgColor
96
+        iconWell.layer?.cornerRadius = 28
97
+        if #available(macOS 11.0, *) {
98
+            iconWell.layer?.cornerCurve = .continuous
99
+        }
100
+        iconWell.layer?.masksToBounds = true
101
+        iconWell.layer?.borderColor = NSColor.white.cgColor
102
+        iconWell.layer?.borderWidth = 3
103
+        iconWell.layer?.shadowColor = NSColor.black.cgColor
104
+        iconWell.layer?.shadowOpacity = 0.08
105
+        iconWell.layer?.shadowRadius = 16
106
+        iconWell.layer?.shadowOffset = CGSize(width: 0, height: -4)
107
+
108
+        iconView.translatesAutoresizingMaskIntoConstraints = false
109
+        iconView.imageScaling = .scaleProportionallyUpOrDown
110
+        iconView.image = NSApp.applicationIconImage
111
+
112
+        aiBadgeHost.translatesAutoresizingMaskIntoConstraints = false
113
+        aiBadgeHost.wantsLayer = true
114
+        aiBadgeHost.layer?.backgroundColor = Theme.badgeFill.cgColor
115
+        aiBadgeHost.layer?.cornerRadius = 10
116
+        if #available(macOS 11.0, *) {
117
+            aiBadgeHost.layer?.cornerCurve = .continuous
118
+        }
119
+
120
+        aiBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
121
+        aiBadgeLabel.font = .systemFont(ofSize: 11, weight: .bold)
122
+        aiBadgeLabel.textColor = Theme.badgeText
123
+        aiBadgeLabel.alignment = .center
124
+        aiBadgeLabel.isEditable = false
125
+        aiBadgeLabel.isSelectable = false
126
+        aiBadgeLabel.isBezeled = false
127
+        aiBadgeLabel.drawsBackground = false
128
+
129
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
130
+        titleLabel.font = .systemFont(ofSize: 44, weight: .bold)
131
+        titleLabel.textColor = Theme.headingBlue
132
+        titleLabel.alignment = .center
133
+
134
+        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
135
+        subtitleLabel.font = .systemFont(ofSize: 18, weight: .regular)
136
+        subtitleLabel.textColor = Theme.subtitleText
137
+        subtitleLabel.alignment = .center
138
+        subtitleLabel.maximumNumberOfLines = 2
139
+        subtitleLabel.lineBreakMode = .byWordWrapping
140
+
141
+        statusLabel.translatesAutoresizingMaskIntoConstraints = false
142
+        statusLabel.font = .systemFont(ofSize: 15, weight: .medium)
143
+        statusLabel.textColor = Theme.statusText
144
+        statusLabel.alignment = .center
145
+        statusLabel.maximumNumberOfLines = 2
146
+        statusLabel.lineBreakMode = .byTruncatingTail
147
+
148
+        progressBar.translatesAutoresizingMaskIntoConstraints = false
149
+
150
+        thinkingIndicator.translatesAutoresizingMaskIntoConstraints = false
151
+
152
+        iconWell.addSubview(iconView)
153
+        aiBadgeHost.addSubview(aiBadgeLabel)
154
+
155
+        let heroStack = NSStackView(views: [
156
+            iconWell,
157
+            aiBadgeHost,
158
+            titleLabel,
159
+            subtitleLabel,
160
+            statusLabel
161
+        ])
162
+        heroStack.orientation = .vertical
163
+        heroStack.alignment = .centerX
164
+        heroStack.spacing = 18
165
+        heroStack.setCustomSpacing(28, after: iconWell)
166
+        heroStack.setCustomSpacing(12, after: aiBadgeHost)
167
+        heroStack.setCustomSpacing(10, after: titleLabel)
168
+        heroStack.setCustomSpacing(28, after: subtitleLabel)
169
+        heroStack.translatesAutoresizingMaskIntoConstraints = false
170
+
171
+        addSubview(backgroundGradientHost)
172
+        addSubview(heroBackground)
173
+        addSubview(heroStack)
174
+        addSubview(thinkingIndicator)
175
+        addSubview(progressBar)
176
+
177
+        NSLayoutConstraint.activate([
178
+            backgroundGradientHost.leadingAnchor.constraint(equalTo: leadingAnchor),
179
+            backgroundGradientHost.trailingAnchor.constraint(equalTo: trailingAnchor),
180
+            backgroundGradientHost.topAnchor.constraint(equalTo: topAnchor),
181
+            backgroundGradientHost.bottomAnchor.constraint(equalTo: bottomAnchor),
182
+
183
+            heroBackground.leadingAnchor.constraint(equalTo: leadingAnchor),
184
+            heroBackground.trailingAnchor.constraint(equalTo: trailingAnchor),
185
+            heroBackground.topAnchor.constraint(equalTo: topAnchor),
186
+            heroBackground.bottomAnchor.constraint(equalTo: bottomAnchor),
187
+
188
+            iconWell.widthAnchor.constraint(equalToConstant: 128),
189
+            iconWell.heightAnchor.constraint(equalToConstant: 128),
190
+
191
+            iconView.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
192
+            iconView.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor),
193
+            iconView.widthAnchor.constraint(equalToConstant: 88),
194
+            iconView.heightAnchor.constraint(equalToConstant: 88),
195
+
196
+            aiBadgeHost.heightAnchor.constraint(equalToConstant: 26),
197
+            aiBadgeLabel.leadingAnchor.constraint(equalTo: aiBadgeHost.leadingAnchor, constant: 14),
198
+            aiBadgeLabel.trailingAnchor.constraint(equalTo: aiBadgeHost.trailingAnchor, constant: -14),
199
+            aiBadgeLabel.centerYAnchor.constraint(equalTo: aiBadgeHost.centerYAnchor),
200
+
201
+            heroStack.centerXAnchor.constraint(equalTo: centerXAnchor),
202
+            heroStack.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -48),
203
+            heroStack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48),
204
+            heroStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -48),
205
+            heroStack.widthAnchor.constraint(lessThanOrEqualToConstant: 720),
206
+
207
+            subtitleLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor),
208
+            statusLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor),
209
+
210
+            progressBar.centerXAnchor.constraint(equalTo: centerXAnchor),
211
+            progressBar.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 72),
212
+            progressBar.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -72),
213
+            progressBar.widthAnchor.constraint(lessThanOrEqualToConstant: 560),
214
+            progressBar.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.52),
215
+            progressBar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -64),
216
+
217
+            thinkingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
218
+            thinkingIndicator.bottomAnchor.constraint(equalTo: progressBar.topAnchor, constant: -24)
219
+        ])
220
+
221
+        installPageGradient()
222
+        setAccessibilityElement(true)
223
+        setAccessibilityRole(.group)
224
+        setAccessibilityLabel("Loading App for Indeed")
225
+    }
226
+
227
+    private func installPageGradient() {
228
+        let gradient = CAGradientLayer()
229
+        gradient.name = "pageGradient"
230
+        gradient.colors = [
231
+            Theme.pageBackgroundTop.cgColor,
232
+            Theme.pageBackgroundBottom.cgColor
233
+        ]
234
+        gradient.startPoint = CGPoint(x: 0.5, y: 1)
235
+        gradient.endPoint = CGPoint(x: 0.5, y: 0)
236
+        gradient.frame = backgroundGradientHost.bounds
237
+        backgroundGradientHost.layer?.addSublayer(gradient)
238
+    }
239
+
240
+}
241
+
242
+// MARK: - Loading progress bar
243
+
244
+private final class LoadingProgressBarView: NSView {
245
+    private enum Theme {
246
+        static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
247
+        static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
248
+        static let trackFill = NSColor(srgbRed: 228 / 255, green: 233 / 255, blue: 242 / 255, alpha: 1)
249
+        static let trackBorder = NSColor(srgbRed: 212 / 255, green: 218 / 255, blue: 230 / 255, alpha: 1)
250
+        static let percentText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
251
+    }
252
+
253
+    private let track = NSView()
254
+    private let fill = NSView()
255
+    private let percentLabel = NSTextField(labelWithString: "0%")
256
+    private var fillWidthConstraint: NSLayoutConstraint?
257
+    private var fillGradientLayer: CAGradientLayer?
258
+    private var shimmerLayer: CAGradientLayer?
259
+    private var targetProgress: CGFloat = 0.04
260
+
261
+    override var intrinsicContentSize: NSSize {
262
+        NSSize(width: 420, height: 44)
263
+    }
264
+
265
+    override init(frame frameRect: NSRect) {
266
+        super.init(frame: frameRect)
267
+        setUp()
268
+    }
269
+
270
+    @available(*, unavailable)
271
+    required init?(coder: NSCoder) {
272
+        fatalError("init(coder:) has not been implemented")
273
+    }
274
+
275
+    override func layout() {
276
+        super.layout()
277
+        layoutProgressFill(animated: false)
278
+    }
279
+
280
+    func setProgress(_ progress: CGFloat, animated: Bool) {
281
+        targetProgress = min(max(progress, 0), 1)
282
+        let percent = Int((targetProgress * 100).rounded())
283
+        percentLabel.stringValue = "\(percent)%"
284
+        layoutProgressFill(animated: animated)
285
+    }
286
+
287
+    func layoutProgressFill(animated: Bool) {
288
+        layoutSubtreeIfNeeded()
289
+        let trackWidth = track.bounds.width
290
+        guard trackWidth > 1 else { return }
291
+        let width = max(trackWidth * max(targetProgress, 0.03), 12)
292
+        fillWidthConstraint?.constant = width
293
+
294
+        guard animated else {
295
+            fillGradientLayer?.frame = fill.bounds
296
+            shimmerLayer?.frame = fill.bounds
297
+            return
298
+        }
299
+        NSAnimationContext.runAnimationGroup { context in
300
+            context.duration = 0.45
301
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
302
+            context.allowsImplicitAnimation = true
303
+            fill.animator().layoutSubtreeIfNeeded()
304
+        } completionHandler: { [weak self] in
305
+            guard let self else { return }
306
+            self.fillGradientLayer?.frame = self.fill.bounds
307
+            self.shimmerLayer?.frame = self.fill.bounds
308
+        }
309
+    }
310
+
311
+    func startShimmerIfNeeded() {
312
+        guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
313
+              let shimmerLayer else { return }
314
+        shimmerLayer.removeAnimation(forKey: "shimmer")
315
+        let move = CABasicAnimation(keyPath: "transform.translation.x")
316
+        move.fromValue = -fill.bounds.width * 0.6
317
+        move.toValue = fill.bounds.width * 0.6
318
+        move.duration = 1.35
319
+        move.repeatCount = .greatestFiniteMagnitude
320
+        move.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
321
+        shimmerLayer.add(move, forKey: "shimmer")
322
+    }
323
+
324
+    func stopShimmer() {
325
+        shimmerLayer?.removeAnimation(forKey: "shimmer")
326
+    }
327
+
328
+    private func setUp() {
329
+        translatesAutoresizingMaskIntoConstraints = false
330
+
331
+        track.translatesAutoresizingMaskIntoConstraints = false
332
+        track.wantsLayer = true
333
+        track.layer?.backgroundColor = Theme.trackFill.cgColor
334
+        track.layer?.borderColor = Theme.trackBorder.cgColor
335
+        track.layer?.borderWidth = 1
336
+        track.layer?.cornerRadius = 7
337
+        if #available(macOS 11.0, *) {
338
+            track.layer?.cornerCurve = .continuous
339
+        }
340
+        track.layer?.masksToBounds = true
341
+        track.setContentHuggingPriority(.defaultLow, for: .horizontal)
342
+        track.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
343
+
344
+        fill.translatesAutoresizingMaskIntoConstraints = false
345
+        fill.wantsLayer = true
346
+        fill.layer?.cornerRadius = 7
347
+        if #available(macOS 11.0, *) {
348
+            fill.layer?.cornerCurve = .continuous
349
+        }
350
+        fill.layer?.masksToBounds = true
351
+
352
+        let fillGradient = CAGradientLayer()
353
+        fillGradient.colors = [
354
+            Theme.brandBlueLight.cgColor,
355
+            Theme.brandBlue.cgColor
356
+        ]
357
+        fillGradient.startPoint = CGPoint(x: 0, y: 0.5)
358
+        fillGradient.endPoint = CGPoint(x: 1, y: 0.5)
359
+        fillGradient.cornerRadius = 7
360
+        fill.layer?.addSublayer(fillGradient)
361
+        fillGradientLayer = fillGradient
362
+
363
+        let shimmer = CAGradientLayer()
364
+        shimmer.colors = [
365
+            NSColor.white.withAlphaComponent(0).cgColor,
366
+            NSColor.white.withAlphaComponent(0.45).cgColor,
367
+            NSColor.white.withAlphaComponent(0).cgColor
368
+        ]
369
+        shimmer.startPoint = CGPoint(x: 0, y: 0.5)
370
+        shimmer.endPoint = CGPoint(x: 1, y: 0.5)
371
+        shimmer.locations = [0, 0.5, 1]
372
+        fill.layer?.addSublayer(shimmer)
373
+        shimmerLayer = shimmer
374
+
375
+        percentLabel.translatesAutoresizingMaskIntoConstraints = false
376
+        percentLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
377
+        percentLabel.textColor = Theme.percentText
378
+        percentLabel.alignment = .right
379
+        percentLabel.setContentHuggingPriority(.required, for: .horizontal)
380
+
381
+        track.addSubview(fill)
382
+
383
+        let row = NSStackView(views: [track, percentLabel])
384
+        row.orientation = .horizontal
385
+        row.spacing = 14
386
+        row.alignment = .centerY
387
+        row.translatesAutoresizingMaskIntoConstraints = false
388
+        addSubview(row)
389
+
390
+        fillWidthConstraint = fill.widthAnchor.constraint(equalToConstant: 12)
391
+        fillWidthConstraint?.isActive = true
392
+
393
+        NSLayoutConstraint.activate([
394
+            row.leadingAnchor.constraint(equalTo: leadingAnchor),
395
+            row.trailingAnchor.constraint(equalTo: trailingAnchor),
396
+            row.topAnchor.constraint(equalTo: topAnchor),
397
+            row.bottomAnchor.constraint(equalTo: bottomAnchor),
398
+
399
+            track.heightAnchor.constraint(equalToConstant: 14),
400
+
401
+            fill.leadingAnchor.constraint(equalTo: track.leadingAnchor),
402
+            fill.topAnchor.constraint(equalTo: track.topAnchor),
403
+            fill.bottomAnchor.constraint(equalTo: track.bottomAnchor),
404
+
405
+            percentLabel.widthAnchor.constraint(equalToConstant: 44)
406
+        ])
407
+
408
+        setAccessibilityElement(true)
409
+        setAccessibilityRole(.progressIndicator)
410
+        setAccessibilityLabel("Loading progress")
411
+    }
412
+}
413
+
414
+// MARK: - Full-page decorative background (matches dashboard welcome hero)
415
+
416
+private final class LoadingSplashBackgroundView: NSView {
417
+    var waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
418
+
419
+    override var isFlipped: Bool { true }
420
+
421
+    override func draw(_ dirtyRect: NSRect) {
422
+        NSColor.clear.setFill()
423
+        bounds.fill()
424
+        guard bounds.width > 24, bounds.height > 24 else { return }
425
+        drawSideWaves(in: bounds, isLeft: true)
426
+        drawSideWaves(in: bounds, isLeft: false)
427
+        drawAmbientSparkles(in: bounds)
428
+    }
429
+
430
+    func startAmbientAnimationIfNeeded() {
431
+        guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return }
432
+        let anim = CABasicAnimation(keyPath: "opacity")
433
+        anim.fromValue = 0.92
434
+        anim.toValue = 1
435
+        anim.duration = 2.4
436
+        anim.autoreverses = true
437
+        anim.repeatCount = .greatestFiniteMagnitude
438
+        layer?.add(anim, forKey: "ambientPulse")
439
+    }
440
+
441
+    func stopAmbientAnimation() {
442
+        layer?.removeAnimation(forKey: "ambientPulse")
443
+    }
444
+
445
+    private func drawSideWaves(in bounds: NSRect, isLeft: Bool) {
446
+        for i in 0..<12 {
447
+            let path = NSBezierPath()
448
+            path.lineWidth = 1.2
449
+            path.lineCapStyle = .round
450
+            let phase = CGFloat(i) * 0.88
451
+            let base = CGFloat(i + 1) * 14 + 8
452
+            var first = true
453
+            for y in stride(from: CGFloat(0), through: bounds.height, by: 2.4) {
454
+                let wobble = sin(y * 0.042 + phase) * (5 + CGFloat(i % 5))
455
+                let x = isLeft ? (base + wobble) : (bounds.width - base - wobble)
456
+                let point = NSPoint(x: x, y: y)
457
+                if first {
458
+                    path.move(to: point)
459
+                    first = false
460
+                } else {
461
+                    path.line(to: point)
462
+                }
463
+            }
464
+            let fade = 1 - CGFloat(i) / 13
465
+            waveTint.withAlphaComponent((0.1 + CGFloat(i % 3) * 0.024) * fade).setStroke()
466
+            path.stroke()
467
+        }
468
+    }
469
+
470
+    private func drawAmbientSparkles(in bounds: NSRect) {
471
+        let accent = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
472
+        let specs: [(CGFloat, CGFloat, CGFloat, CGFloat)] = [
473
+            (0.06, 0.1, 12, 0.32),
474
+            (0.94, 0.08, 14, 0.36),
475
+            (0.12, 0.38, 7, 0.22),
476
+            (0.88, 0.36, 8, 0.24),
477
+            (0.5, 0.05, 10, 0.18),
478
+            (0.22, 0.72, 6, 0.16),
479
+            (0.78, 0.68, 9, 0.2),
480
+            (0.04, 0.55, 5, 0.14),
481
+            (0.96, 0.52, 6, 0.15)
482
+        ]
483
+        for (nx, ny, size, a) in specs {
484
+            let center = NSPoint(x: bounds.width * nx, y: bounds.height * ny)
485
+            fillFourPointStar(center: center, radius: size, color: accent.withAlphaComponent(a))
486
+        }
487
+    }
488
+
489
+    private func fillFourPointStar(center: NSPoint, radius: CGFloat, color: NSColor) {
490
+        let path = NSBezierPath()
491
+        for i in 0..<4 {
492
+            let angle = CGFloat(i) * .pi / 2 - .pi / 2
493
+            let point = NSPoint(
494
+                x: center.x + cos(angle) * radius,
495
+                y: center.y + sin(angle) * radius
496
+            )
497
+            if i == 0 {
498
+                path.move(to: point)
499
+            } else {
500
+                path.line(to: point)
501
+            }
502
+        }
503
+        path.close()
504
+        color.setFill()
505
+        path.fill()
506
+    }
507
+}