فهرست منبع

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 2 ماه پیش
والد
کامیت
25e4a389ea

+ 1 - 8
App for Indeed/AppDelegate.swift

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

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

@@ -693,13 +693,26 @@
                         </connections>
                     </window>
                     <connections>
-                        <segue destination="XfG-lQ-9wD" kind="relationship" relationship="window.shadowedContentViewController" id="cq2-FE-JQM"/>
+                        <segue destination="LdG-lQ-9wD" kind="relationship" relationship="window.shadowedContentViewController" id="cq2-FE-JQM"/>
                     </connections>
                 </windowController>
                 <customObject id="Oky-zY-oP4" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
             </objects>
             <point key="canvasLocation" x="75" y="250"/>
         </scene>
+        <!--Loading View Controller-->
+        <scene sceneID="LdG-AP-VOD">
+            <objects>
+                <viewController id="LdG-lQ-9wD" customClass="LoadingViewController" customModuleProvider="target" sceneMemberID="viewController">
+                    <view key="view" id="LdG-Jp-Qdl">
+                        <rect key="frame" x="0.0" y="0.0" width="1120" height="800"/>
+                        <autoresizingMask key="autoresizingMask"/>
+                    </view>
+                </viewController>
+                <customObject id="LdG-NT-nkU" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="75" y="450"/>
+        </scene>
         <!--View Controller-->
         <scene sceneID="hIz-AP-VOD">
             <objects>

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

@@ -0,0 +1,62 @@
+//
+//  LoadingViewController.swift
+//  App for Indeed
+//
+
+import Cocoa
+
+final class LoadingViewController: NSViewController {
+    private let loadingView = LoadingView(frame: .zero)
+    private var didStartLaunch = false
+    private var didTransitionToDashboard = false
+
+    override func loadView() {
+        view = loadingView
+    }
+
+    override func viewDidAppear() {
+        super.viewDidAppear()
+        guard !didStartLaunch else { return }
+        didStartLaunch = true
+
+        if let window = view.window {
+            AppWindowConfiguration.apply(to: window)
+        }
+
+        Task { @MainActor in
+            await runLaunchSequence()
+            transitionToDashboard()
+        }
+    }
+
+    @MainActor
+    private func runLaunchSequence() async {
+        loadingView.setStatus("Starting up…", progress: 0.05)
+
+        async let startup: Void = AppLaunchCoordinator.performStartup { [weak self] message, progress in
+            self?.loadingView.setStatus(message, progress: progress)
+        }
+        async let minimumDisplay: Void = {
+            try? await Task.sleep(nanoseconds: AppLaunchCoordinator.minimumDisplayNanoseconds)
+        }()
+        _ = await (startup, minimumDisplay)
+    }
+
+    @MainActor
+    private func transitionToDashboard() {
+        guard !didTransitionToDashboard, let window = view.window else { return }
+        didTransitionToDashboard = true
+        loadingView.stopAnimating()
+
+        let dashboard = ViewController()
+        NSAnimationContext.runAnimationGroup { context in
+            context.duration = 0.32
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+            window.animator().contentViewController = dashboard
+        } completionHandler: { [weak window] in
+            guard let window else { return }
+            AppWindowConfiguration.apply(to: window)
+            window.center()
+        }
+    }
+}

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

@@ -0,0 +1,32 @@
+//
+//  AppLaunchCoordinator.swift
+//  App for Indeed
+//
+
+import Foundation
+
+@MainActor
+enum AppLaunchCoordinator {
+    /// Minimum time the loading screen stays visible so the UI does not flash.
+    static let minimumDisplayNanoseconds: UInt64 = 1_200_000_000
+
+    typealias StatusUpdate = (_ message: String, _ progress: CGFloat) -> Void
+
+    /// Subscription refresh and product catalog load before the dashboard appears.
+    static func performStartup(update: StatusUpdate) async {
+        update("Starting App for Indeed…", 0.12)
+        try? await Task.sleep(nanoseconds: 180_000_000)
+
+        update("Checking your Pro subscription…", 0.38)
+        await SubscriptionStore.shared.refreshEntitlements(deep: true)
+
+        update("Loading premium plans from the App Store…", 0.62)
+        await SubscriptionStore.shared.loadProducts()
+
+        update("Preparing your job search workspace…", 0.86)
+        NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
+        try? await Task.sleep(nanoseconds: 220_000_000)
+
+        update("Almost ready…", 1.0)
+    }
+}

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

@@ -0,0 +1,139 @@
+//
+//  ChatThinkingIndicatorView.swift
+//  App for Indeed
+//
+
+import Cocoa
+import QuartzCore
+
+/// Single sparkle with a soft brand-blue glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
+final class ChatThinkingIndicatorView: NSView {
+    private enum AnimationKey {
+        static let dotOpacity = "thinkingDotOpacity"
+        static let sparklePulse = "thinkingSparklePulse"
+    }
+
+    /// Matches `DashboardView.Theme.brandBlue` — Indeed-style `#2557a7`.
+    private static let accentBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
+    /// Slightly lighter blue for the sparkle glow (reads clearly on light banner/chat surfaces).
+    private static let accentBlueGlow = NSColor(srgbRed: 54 / 255, green: 130 / 255, blue: 220 / 255, alpha: 1)
+
+    private let sparkleView = NSImageView()
+    private let dotStack = NSStackView()
+    private var dotViews: [NSView] = []
+
+    init(compact: Bool, accessibilityLabel: String = "Assistant is searching") {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+
+        let sparklePoint: CGFloat = compact ? 11 : 14
+        let dotSize: CGFloat = compact ? 4 : 5
+        let sparkleDotGap: CGFloat = compact ? 7 : 10
+        let dotSpacing: CGFloat = compact ? 4 : 5
+
+        sparkleView.translatesAutoresizingMaskIntoConstraints = false
+        sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
+        sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium)
+        sparkleView.contentTintColor = Self.accentBlue
+        sparkleView.wantsLayer = true
+        sparkleView.layer?.masksToBounds = false
+        sparkleView.layer?.shadowColor = Self.accentBlueGlow.cgColor
+        sparkleView.layer?.shadowRadius = compact ? 5 : 9
+        sparkleView.layer?.shadowOpacity = 0.55
+        sparkleView.layer?.shadowOffset = .zero
+
+        NSLayoutConstraint.activate([
+            sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6),
+            sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6)
+        ])
+
+        dotStack.orientation = .horizontal
+        dotStack.spacing = dotSpacing
+        dotStack.alignment = .centerY
+        dotStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let dotFill = Self.accentBlue
+        for _ in 0..<3 {
+            let dot = NSView()
+            dot.translatesAutoresizingMaskIntoConstraints = false
+            dot.wantsLayer = true
+            dot.layer?.cornerRadius = dotSize / 2
+            dot.layer?.backgroundColor = dotFill.cgColor
+            NSLayoutConstraint.activate([
+                dot.widthAnchor.constraint(equalToConstant: dotSize),
+                dot.heightAnchor.constraint(equalToConstant: dotSize)
+            ])
+            dotStack.addArrangedSubview(dot)
+            dotViews.append(dot)
+        }
+
+        let row = NSStackView(views: [sparkleView, dotStack])
+        row.orientation = .horizontal
+        row.spacing = sparkleDotGap
+        row.alignment = .centerY
+        row.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(row)
+        NSLayoutConstraint.activate([
+            row.leadingAnchor.constraint(equalTo: leadingAnchor),
+            row.trailingAnchor.constraint(equalTo: trailingAnchor),
+            row.topAnchor.constraint(equalTo: topAnchor),
+            row.bottomAnchor.constraint(equalTo: bottomAnchor)
+        ])
+
+        setAccessibilityElement(true)
+        setAccessibilityRole(.group)
+        setAccessibilityLabel(accessibilityLabel)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    private static var reducedMotion: Bool {
+        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+    }
+
+    func startAnimatingIfNeeded() {
+        stopAnimating()
+        guard !Self.reducedMotion else {
+            dotViews.forEach { $0.layer?.opacity = 0.78 }
+            return
+        }
+        guard let sparkleLayer = sparkleView.layer else { return }
+        let waveDuration: CFTimeInterval = 0.52
+        let n = max(1, dotViews.count)
+        for (i, dot) in dotViews.enumerated() {
+            guard let layer = dot.layer else { continue }
+            layer.opacity = 1
+            let pulse = CABasicAnimation(keyPath: "opacity")
+            pulse.fromValue = 0.2
+            pulse.toValue = 1.0
+            pulse.duration = waveDuration
+            pulse.autoreverses = true
+            pulse.repeatCount = .greatestFiniteMagnitude
+            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+            pulse.timeOffset = waveDuration * Double(i) / Double(n)
+            layer.add(pulse, forKey: AnimationKey.dotOpacity)
+        }
+
+        let scale = CABasicAnimation(keyPath: "transform.scale")
+        scale.fromValue = 1.0
+        scale.toValue = 1.07
+        scale.duration = 1.15
+        scale.autoreverses = true
+        scale.repeatCount = .greatestFiniteMagnitude
+        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+        sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse)
+    }
+
+    func stopAnimating() {
+        sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse)
+        sparkleView.layer?.transform = CATransform3DIdentity
+        for dot in dotViews {
+            dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity)
+            dot.layer?.opacity = 1
+        }
+    }
+}

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

@@ -3462,138 +3462,6 @@ private class HoverableView: NSView {
     }
 }
 
-/// Single sparkle with a soft brand-blue glow and three dots whose opacity animates in a staggered wave (typing-style “thinking” affordance).
-private final class ChatThinkingIndicatorView: NSView {
-    private enum AnimationKey {
-        static let dotOpacity = "thinkingDotOpacity"
-        static let sparklePulse = "thinkingSparklePulse"
-    }
-
-    /// Matches `DashboardView.Theme.brandBlue` — Indeed-style `#2557a7`.
-    private static let accentBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
-    /// Slightly lighter blue for the sparkle glow (reads clearly on light banner/chat surfaces).
-    private static let accentBlueGlow = NSColor(srgbRed: 54 / 255, green: 130 / 255, blue: 220 / 255, alpha: 1)
-
-    private let sparkleView = NSImageView()
-    private let dotStack = NSStackView()
-    private var dotViews: [NSView] = []
-
-    init(compact: Bool) {
-        super.init(frame: .zero)
-        translatesAutoresizingMaskIntoConstraints = false
-
-        let sparklePoint: CGFloat = compact ? 11 : 14
-        let dotSize: CGFloat = compact ? 4 : 5
-        let sparkleDotGap: CGFloat = compact ? 7 : 10
-        let dotSpacing: CGFloat = compact ? 4 : 5
-
-        sparkleView.translatesAutoresizingMaskIntoConstraints = false
-        sparkleView.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
-        sparkleView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: sparklePoint, weight: .medium)
-        sparkleView.contentTintColor = Self.accentBlue
-        sparkleView.wantsLayer = true
-        sparkleView.layer?.masksToBounds = false
-        sparkleView.layer?.shadowColor = Self.accentBlueGlow.cgColor
-        sparkleView.layer?.shadowRadius = compact ? 5 : 9
-        sparkleView.layer?.shadowOpacity = 0.55
-        sparkleView.layer?.shadowOffset = .zero
-
-        NSLayoutConstraint.activate([
-            sparkleView.widthAnchor.constraint(equalToConstant: sparklePoint + 6),
-            sparkleView.heightAnchor.constraint(equalToConstant: sparklePoint + 6)
-        ])
-
-        dotStack.orientation = .horizontal
-        dotStack.spacing = dotSpacing
-        dotStack.alignment = .centerY
-        dotStack.translatesAutoresizingMaskIntoConstraints = false
-
-        let dotFill = Self.accentBlue
-        for _ in 0..<3 {
-            let dot = NSView()
-            dot.translatesAutoresizingMaskIntoConstraints = false
-            dot.wantsLayer = true
-            dot.layer?.cornerRadius = dotSize / 2
-            dot.layer?.backgroundColor = dotFill.cgColor
-            NSLayoutConstraint.activate([
-                dot.widthAnchor.constraint(equalToConstant: dotSize),
-                dot.heightAnchor.constraint(equalToConstant: dotSize)
-            ])
-            dotStack.addArrangedSubview(dot)
-            dotViews.append(dot)
-        }
-
-        let row = NSStackView(views: [sparkleView, dotStack])
-        row.orientation = .horizontal
-        row.spacing = sparkleDotGap
-        row.alignment = .centerY
-        row.translatesAutoresizingMaskIntoConstraints = false
-
-        addSubview(row)
-        NSLayoutConstraint.activate([
-            row.leadingAnchor.constraint(equalTo: leadingAnchor),
-            row.trailingAnchor.constraint(equalTo: trailingAnchor),
-            row.topAnchor.constraint(equalTo: topAnchor),
-            row.bottomAnchor.constraint(equalTo: bottomAnchor)
-        ])
-
-        setAccessibilityElement(true)
-        setAccessibilityRole(.group)
-        setAccessibilityLabel("Assistant is searching")
-    }
-
-    @available(*, unavailable)
-    required init?(coder: NSCoder) {
-        fatalError("init(coder:) has not been implemented")
-    }
-
-    private static var reducedMotion: Bool {
-        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
-    }
-
-    func startAnimatingIfNeeded() {
-        stopAnimating()
-        guard !Self.reducedMotion else {
-            dotViews.forEach { $0.layer?.opacity = 0.78 }
-            return
-        }
-        guard let sparkleLayer = sparkleView.layer else { return }
-        let waveDuration: CFTimeInterval = 0.52
-        let n = max(1, dotViews.count)
-        for (i, dot) in dotViews.enumerated() {
-            guard let layer = dot.layer else { continue }
-            layer.opacity = 1
-            let pulse = CABasicAnimation(keyPath: "opacity")
-            pulse.fromValue = 0.2
-            pulse.toValue = 1.0
-            pulse.duration = waveDuration
-            pulse.autoreverses = true
-            pulse.repeatCount = .greatestFiniteMagnitude
-            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
-            pulse.timeOffset = waveDuration * Double(i) / Double(n)
-            layer.add(pulse, forKey: AnimationKey.dotOpacity)
-        }
-
-        let scale = CABasicAnimation(keyPath: "transform.scale")
-        scale.fromValue = 1.0
-        scale.toValue = 1.07
-        scale.duration = 1.15
-        scale.autoreverses = true
-        scale.repeatCount = .greatestFiniteMagnitude
-        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
-        sparkleLayer.add(scale, forKey: AnimationKey.sparklePulse)
-    }
-
-    func stopAnimating() {
-        sparkleView.layer?.removeAnimation(forKey: AnimationKey.sparklePulse)
-        sparkleView.layer?.transform = CATransform3DIdentity
-        for dot in dotViews {
-            dot.layer?.removeAnimation(forKey: AnimationKey.dotOpacity)
-            dot.layer?.opacity = 1
-        }
-    }
-}
-
 /// 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).
 private final class JobListingsDocumentView: NSView {
     override var isFlipped: Bool { true }

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

@@ -0,0 +1,507 @@
+//
+//  LoadingView.swift
+//  App for Indeed
+//
+
+import Cocoa
+import QuartzCore
+
+final class LoadingView: NSView {
+    private enum Theme {
+        static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
+        static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
+        static let pageBackgroundTop = NSColor(srgbRed: 252 / 255, green: 253 / 255, blue: 255 / 255, alpha: 1)
+        static let pageBackgroundBottom = NSColor(srgbRed: 241 / 255, green: 245 / 255, blue: 252 / 255, alpha: 1)
+        static let iconWell = NSColor(srgbRed: 239 / 255, green: 246 / 255, blue: 255 / 255, alpha: 1)
+        static let headingBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
+        static let subtitleText = NSColor(srgbRed: 51 / 255, green: 65 / 255, blue: 85 / 255, alpha: 1)
+        static let statusText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
+        static let waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
+        static let badgeFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.1)
+        static let badgeText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
+    }
+
+    private let backgroundGradientHost = NSView()
+    private let heroBackground = LoadingSplashBackgroundView()
+    private let iconWell = NSView()
+    private let iconView = NSImageView()
+    private let aiBadgeHost = NSView()
+    private let aiBadgeLabel = NSTextField(labelWithString: "AI-POWERED")
+    private let titleLabel = NSTextField(labelWithString: "App for Indeed")
+    private let subtitleLabel = NSTextField(labelWithString: "Find your perfect job with the power of AI.")
+    private let statusLabel = NSTextField(labelWithString: "Starting up…")
+    private let progressBar = LoadingProgressBarView()
+    private let thinkingIndicator = ChatThinkingIndicatorView(
+        compact: false,
+        accessibilityLabel: "Loading App for Indeed"
+    )
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setUp()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func layout() {
+        super.layout()
+        backgroundGradientHost.layer?.sublayers?
+            .filter { $0.name == "pageGradient" }
+            .forEach { $0.frame = backgroundGradientHost.bounds }
+        progressBar.layoutProgressFill(animated: false)
+    }
+
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        if window != nil {
+            startAnimating()
+        } else {
+            stopAnimating()
+        }
+    }
+
+    func setStatus(_ message: String, progress: CGFloat) {
+        statusLabel.stringValue = message
+        progressBar.setProgress(progress, animated: true)
+        setAccessibilityLabel("Loading App for Indeed. \(message)")
+    }
+
+    func startAnimating() {
+        thinkingIndicator.startAnimatingIfNeeded()
+        progressBar.startShimmerIfNeeded()
+        heroBackground.startAmbientAnimationIfNeeded()
+    }
+
+    func stopAnimating() {
+        thinkingIndicator.stopAnimating()
+        progressBar.stopShimmer()
+        heroBackground.stopAmbientAnimation()
+    }
+
+    private func setUp() {
+        wantsLayer = true
+
+        backgroundGradientHost.translatesAutoresizingMaskIntoConstraints = false
+        backgroundGradientHost.wantsLayer = true
+
+        heroBackground.translatesAutoresizingMaskIntoConstraints = false
+        heroBackground.waveTint = Theme.waveTint
+
+        iconWell.translatesAutoresizingMaskIntoConstraints = false
+        iconWell.wantsLayer = true
+        iconWell.layer?.backgroundColor = Theme.iconWell.cgColor
+        iconWell.layer?.cornerRadius = 28
+        if #available(macOS 11.0, *) {
+            iconWell.layer?.cornerCurve = .continuous
+        }
+        iconWell.layer?.masksToBounds = true
+        iconWell.layer?.borderColor = NSColor.white.cgColor
+        iconWell.layer?.borderWidth = 3
+        iconWell.layer?.shadowColor = NSColor.black.cgColor
+        iconWell.layer?.shadowOpacity = 0.08
+        iconWell.layer?.shadowRadius = 16
+        iconWell.layer?.shadowOffset = CGSize(width: 0, height: -4)
+
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+        iconView.imageScaling = .scaleProportionallyUpOrDown
+        iconView.image = NSApp.applicationIconImage
+
+        aiBadgeHost.translatesAutoresizingMaskIntoConstraints = false
+        aiBadgeHost.wantsLayer = true
+        aiBadgeHost.layer?.backgroundColor = Theme.badgeFill.cgColor
+        aiBadgeHost.layer?.cornerRadius = 10
+        if #available(macOS 11.0, *) {
+            aiBadgeHost.layer?.cornerCurve = .continuous
+        }
+
+        aiBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
+        aiBadgeLabel.font = .systemFont(ofSize: 11, weight: .bold)
+        aiBadgeLabel.textColor = Theme.badgeText
+        aiBadgeLabel.alignment = .center
+        aiBadgeLabel.isEditable = false
+        aiBadgeLabel.isSelectable = false
+        aiBadgeLabel.isBezeled = false
+        aiBadgeLabel.drawsBackground = false
+
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+        titleLabel.font = .systemFont(ofSize: 44, weight: .bold)
+        titleLabel.textColor = Theme.headingBlue
+        titleLabel.alignment = .center
+
+        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
+        subtitleLabel.font = .systemFont(ofSize: 18, weight: .regular)
+        subtitleLabel.textColor = Theme.subtitleText
+        subtitleLabel.alignment = .center
+        subtitleLabel.maximumNumberOfLines = 2
+        subtitleLabel.lineBreakMode = .byWordWrapping
+
+        statusLabel.translatesAutoresizingMaskIntoConstraints = false
+        statusLabel.font = .systemFont(ofSize: 15, weight: .medium)
+        statusLabel.textColor = Theme.statusText
+        statusLabel.alignment = .center
+        statusLabel.maximumNumberOfLines = 2
+        statusLabel.lineBreakMode = .byTruncatingTail
+
+        progressBar.translatesAutoresizingMaskIntoConstraints = false
+
+        thinkingIndicator.translatesAutoresizingMaskIntoConstraints = false
+
+        iconWell.addSubview(iconView)
+        aiBadgeHost.addSubview(aiBadgeLabel)
+
+        let heroStack = NSStackView(views: [
+            iconWell,
+            aiBadgeHost,
+            titleLabel,
+            subtitleLabel,
+            statusLabel
+        ])
+        heroStack.orientation = .vertical
+        heroStack.alignment = .centerX
+        heroStack.spacing = 18
+        heroStack.setCustomSpacing(28, after: iconWell)
+        heroStack.setCustomSpacing(12, after: aiBadgeHost)
+        heroStack.setCustomSpacing(10, after: titleLabel)
+        heroStack.setCustomSpacing(28, after: subtitleLabel)
+        heroStack.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(backgroundGradientHost)
+        addSubview(heroBackground)
+        addSubview(heroStack)
+        addSubview(thinkingIndicator)
+        addSubview(progressBar)
+
+        NSLayoutConstraint.activate([
+            backgroundGradientHost.leadingAnchor.constraint(equalTo: leadingAnchor),
+            backgroundGradientHost.trailingAnchor.constraint(equalTo: trailingAnchor),
+            backgroundGradientHost.topAnchor.constraint(equalTo: topAnchor),
+            backgroundGradientHost.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            heroBackground.leadingAnchor.constraint(equalTo: leadingAnchor),
+            heroBackground.trailingAnchor.constraint(equalTo: trailingAnchor),
+            heroBackground.topAnchor.constraint(equalTo: topAnchor),
+            heroBackground.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            iconWell.widthAnchor.constraint(equalToConstant: 128),
+            iconWell.heightAnchor.constraint(equalToConstant: 128),
+
+            iconView.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
+            iconView.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor),
+            iconView.widthAnchor.constraint(equalToConstant: 88),
+            iconView.heightAnchor.constraint(equalToConstant: 88),
+
+            aiBadgeHost.heightAnchor.constraint(equalToConstant: 26),
+            aiBadgeLabel.leadingAnchor.constraint(equalTo: aiBadgeHost.leadingAnchor, constant: 14),
+            aiBadgeLabel.trailingAnchor.constraint(equalTo: aiBadgeHost.trailingAnchor, constant: -14),
+            aiBadgeLabel.centerYAnchor.constraint(equalTo: aiBadgeHost.centerYAnchor),
+
+            heroStack.centerXAnchor.constraint(equalTo: centerXAnchor),
+            heroStack.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -48),
+            heroStack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48),
+            heroStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -48),
+            heroStack.widthAnchor.constraint(lessThanOrEqualToConstant: 720),
+
+            subtitleLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor),
+            statusLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor),
+
+            progressBar.centerXAnchor.constraint(equalTo: centerXAnchor),
+            progressBar.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 72),
+            progressBar.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -72),
+            progressBar.widthAnchor.constraint(lessThanOrEqualToConstant: 560),
+            progressBar.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.52),
+            progressBar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -64),
+
+            thinkingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
+            thinkingIndicator.bottomAnchor.constraint(equalTo: progressBar.topAnchor, constant: -24)
+        ])
+
+        installPageGradient()
+        setAccessibilityElement(true)
+        setAccessibilityRole(.group)
+        setAccessibilityLabel("Loading App for Indeed")
+    }
+
+    private func installPageGradient() {
+        let gradient = CAGradientLayer()
+        gradient.name = "pageGradient"
+        gradient.colors = [
+            Theme.pageBackgroundTop.cgColor,
+            Theme.pageBackgroundBottom.cgColor
+        ]
+        gradient.startPoint = CGPoint(x: 0.5, y: 1)
+        gradient.endPoint = CGPoint(x: 0.5, y: 0)
+        gradient.frame = backgroundGradientHost.bounds
+        backgroundGradientHost.layer?.addSublayer(gradient)
+    }
+
+}
+
+// MARK: - Loading progress bar
+
+private final class LoadingProgressBarView: NSView {
+    private enum Theme {
+        static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
+        static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
+        static let trackFill = NSColor(srgbRed: 228 / 255, green: 233 / 255, blue: 242 / 255, alpha: 1)
+        static let trackBorder = NSColor(srgbRed: 212 / 255, green: 218 / 255, blue: 230 / 255, alpha: 1)
+        static let percentText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
+    }
+
+    private let track = NSView()
+    private let fill = NSView()
+    private let percentLabel = NSTextField(labelWithString: "0%")
+    private var fillWidthConstraint: NSLayoutConstraint?
+    private var fillGradientLayer: CAGradientLayer?
+    private var shimmerLayer: CAGradientLayer?
+    private var targetProgress: CGFloat = 0.04
+
+    override var intrinsicContentSize: NSSize {
+        NSSize(width: 420, height: 44)
+    }
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setUp()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func layout() {
+        super.layout()
+        layoutProgressFill(animated: false)
+    }
+
+    func setProgress(_ progress: CGFloat, animated: Bool) {
+        targetProgress = min(max(progress, 0), 1)
+        let percent = Int((targetProgress * 100).rounded())
+        percentLabel.stringValue = "\(percent)%"
+        layoutProgressFill(animated: animated)
+    }
+
+    func layoutProgressFill(animated: Bool) {
+        layoutSubtreeIfNeeded()
+        let trackWidth = track.bounds.width
+        guard trackWidth > 1 else { return }
+        let width = max(trackWidth * max(targetProgress, 0.03), 12)
+        fillWidthConstraint?.constant = width
+
+        guard animated else {
+            fillGradientLayer?.frame = fill.bounds
+            shimmerLayer?.frame = fill.bounds
+            return
+        }
+        NSAnimationContext.runAnimationGroup { context in
+            context.duration = 0.45
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+            context.allowsImplicitAnimation = true
+            fill.animator().layoutSubtreeIfNeeded()
+        } completionHandler: { [weak self] in
+            guard let self else { return }
+            self.fillGradientLayer?.frame = self.fill.bounds
+            self.shimmerLayer?.frame = self.fill.bounds
+        }
+    }
+
+    func startShimmerIfNeeded() {
+        guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
+              let shimmerLayer else { return }
+        shimmerLayer.removeAnimation(forKey: "shimmer")
+        let move = CABasicAnimation(keyPath: "transform.translation.x")
+        move.fromValue = -fill.bounds.width * 0.6
+        move.toValue = fill.bounds.width * 0.6
+        move.duration = 1.35
+        move.repeatCount = .greatestFiniteMagnitude
+        move.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+        shimmerLayer.add(move, forKey: "shimmer")
+    }
+
+    func stopShimmer() {
+        shimmerLayer?.removeAnimation(forKey: "shimmer")
+    }
+
+    private func setUp() {
+        translatesAutoresizingMaskIntoConstraints = false
+
+        track.translatesAutoresizingMaskIntoConstraints = false
+        track.wantsLayer = true
+        track.layer?.backgroundColor = Theme.trackFill.cgColor
+        track.layer?.borderColor = Theme.trackBorder.cgColor
+        track.layer?.borderWidth = 1
+        track.layer?.cornerRadius = 7
+        if #available(macOS 11.0, *) {
+            track.layer?.cornerCurve = .continuous
+        }
+        track.layer?.masksToBounds = true
+        track.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        track.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+        fill.translatesAutoresizingMaskIntoConstraints = false
+        fill.wantsLayer = true
+        fill.layer?.cornerRadius = 7
+        if #available(macOS 11.0, *) {
+            fill.layer?.cornerCurve = .continuous
+        }
+        fill.layer?.masksToBounds = true
+
+        let fillGradient = CAGradientLayer()
+        fillGradient.colors = [
+            Theme.brandBlueLight.cgColor,
+            Theme.brandBlue.cgColor
+        ]
+        fillGradient.startPoint = CGPoint(x: 0, y: 0.5)
+        fillGradient.endPoint = CGPoint(x: 1, y: 0.5)
+        fillGradient.cornerRadius = 7
+        fill.layer?.addSublayer(fillGradient)
+        fillGradientLayer = fillGradient
+
+        let shimmer = CAGradientLayer()
+        shimmer.colors = [
+            NSColor.white.withAlphaComponent(0).cgColor,
+            NSColor.white.withAlphaComponent(0.45).cgColor,
+            NSColor.white.withAlphaComponent(0).cgColor
+        ]
+        shimmer.startPoint = CGPoint(x: 0, y: 0.5)
+        shimmer.endPoint = CGPoint(x: 1, y: 0.5)
+        shimmer.locations = [0, 0.5, 1]
+        fill.layer?.addSublayer(shimmer)
+        shimmerLayer = shimmer
+
+        percentLabel.translatesAutoresizingMaskIntoConstraints = false
+        percentLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
+        percentLabel.textColor = Theme.percentText
+        percentLabel.alignment = .right
+        percentLabel.setContentHuggingPriority(.required, for: .horizontal)
+
+        track.addSubview(fill)
+
+        let row = NSStackView(views: [track, percentLabel])
+        row.orientation = .horizontal
+        row.spacing = 14
+        row.alignment = .centerY
+        row.translatesAutoresizingMaskIntoConstraints = false
+        addSubview(row)
+
+        fillWidthConstraint = fill.widthAnchor.constraint(equalToConstant: 12)
+        fillWidthConstraint?.isActive = true
+
+        NSLayoutConstraint.activate([
+            row.leadingAnchor.constraint(equalTo: leadingAnchor),
+            row.trailingAnchor.constraint(equalTo: trailingAnchor),
+            row.topAnchor.constraint(equalTo: topAnchor),
+            row.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            track.heightAnchor.constraint(equalToConstant: 14),
+
+            fill.leadingAnchor.constraint(equalTo: track.leadingAnchor),
+            fill.topAnchor.constraint(equalTo: track.topAnchor),
+            fill.bottomAnchor.constraint(equalTo: track.bottomAnchor),
+
+            percentLabel.widthAnchor.constraint(equalToConstant: 44)
+        ])
+
+        setAccessibilityElement(true)
+        setAccessibilityRole(.progressIndicator)
+        setAccessibilityLabel("Loading progress")
+    }
+}
+
+// MARK: - Full-page decorative background (matches dashboard welcome hero)
+
+private final class LoadingSplashBackgroundView: NSView {
+    var waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
+
+    override var isFlipped: Bool { true }
+
+    override func draw(_ dirtyRect: NSRect) {
+        NSColor.clear.setFill()
+        bounds.fill()
+        guard bounds.width > 24, bounds.height > 24 else { return }
+        drawSideWaves(in: bounds, isLeft: true)
+        drawSideWaves(in: bounds, isLeft: false)
+        drawAmbientSparkles(in: bounds)
+    }
+
+    func startAmbientAnimationIfNeeded() {
+        guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return }
+        let anim = CABasicAnimation(keyPath: "opacity")
+        anim.fromValue = 0.92
+        anim.toValue = 1
+        anim.duration = 2.4
+        anim.autoreverses = true
+        anim.repeatCount = .greatestFiniteMagnitude
+        layer?.add(anim, forKey: "ambientPulse")
+    }
+
+    func stopAmbientAnimation() {
+        layer?.removeAnimation(forKey: "ambientPulse")
+    }
+
+    private func drawSideWaves(in bounds: NSRect, isLeft: Bool) {
+        for i in 0..<12 {
+            let path = NSBezierPath()
+            path.lineWidth = 1.2
+            path.lineCapStyle = .round
+            let phase = CGFloat(i) * 0.88
+            let base = CGFloat(i + 1) * 14 + 8
+            var first = true
+            for y in stride(from: CGFloat(0), through: bounds.height, by: 2.4) {
+                let wobble = sin(y * 0.042 + phase) * (5 + CGFloat(i % 5))
+                let x = isLeft ? (base + wobble) : (bounds.width - base - wobble)
+                let point = NSPoint(x: x, y: y)
+                if first {
+                    path.move(to: point)
+                    first = false
+                } else {
+                    path.line(to: point)
+                }
+            }
+            let fade = 1 - CGFloat(i) / 13
+            waveTint.withAlphaComponent((0.1 + CGFloat(i % 3) * 0.024) * fade).setStroke()
+            path.stroke()
+        }
+    }
+
+    private func drawAmbientSparkles(in bounds: NSRect) {
+        let accent = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
+        let specs: [(CGFloat, CGFloat, CGFloat, CGFloat)] = [
+            (0.06, 0.1, 12, 0.32),
+            (0.94, 0.08, 14, 0.36),
+            (0.12, 0.38, 7, 0.22),
+            (0.88, 0.36, 8, 0.24),
+            (0.5, 0.05, 10, 0.18),
+            (0.22, 0.72, 6, 0.16),
+            (0.78, 0.68, 9, 0.2),
+            (0.04, 0.55, 5, 0.14),
+            (0.96, 0.52, 6, 0.15)
+        ]
+        for (nx, ny, size, a) in specs {
+            let center = NSPoint(x: bounds.width * nx, y: bounds.height * ny)
+            fillFourPointStar(center: center, radius: size, color: accent.withAlphaComponent(a))
+        }
+    }
+
+    private func fillFourPointStar(center: NSPoint, radius: CGFloat, color: NSColor) {
+        let path = NSBezierPath()
+        for i in 0..<4 {
+            let angle = CGFloat(i) * .pi / 2 - .pi / 2
+            let point = NSPoint(
+                x: center.x + cos(angle) * radius,
+                y: center.y + sin(angle) * radius
+            )
+            if i == 0 {
+                path.move(to: point)
+            } else {
+                path.line(to: point)
+            }
+        }
+        path.close()
+        color.setFill()
+        path.fill()
+    }
+}