Переглянути джерело

Redesign welcome hero to match reference layout

Restack sparkle badge above Welcome title with pastel well and layered
sparkles; add drawn wave and sparkle decorations; align typography and
spacing (#0052CC / #334155). Remove subtitle breathing animation; match
feature card primary blue to hero.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 місяців тому
батько
коміт
dcb606d9ed
1 змінених файлів з 181 додано та 55 видалено
  1. 181 55
      App for Indeed/Views/DashboardView.swift

+ 181 - 55
App for Indeed/Views/DashboardView.swift

@@ -20,6 +20,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         static let chromeBackground = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
         static let sidebarBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
         static let mainHostBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
+        /// Welcome hero (matches reference: `#0052CC` heading, `#334155` subline, `#EFF6FF` icon well).
+        static let welcomeHeroHeadingBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
+        static let welcomeHeroSubtitleText = NSColor(srgbRed: 51 / 255, green: 65 / 255, blue: 85 / 255, alpha: 1)
+        static let welcomeHeroIconWell = NSColor(srgbRed: 239 / 255, green: 246 / 255, blue: 255 / 255, alpha: 1)
+        /// Light decorative strokes / sparkles behind the welcome hero.
+        static let welcomeHeroWaveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
         /// Subtitle on the welcome hero: dark neutral gray to match the reference layout.
         static let welcomeSubtitleText = NSColor(srgbRed: 64 / 255, green: 64 / 255, blue: 64 / 255, alpha: 1)
         static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12)
@@ -82,7 +88,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private let findJobsCTAHost = NSView()
     private let findJobsCTAChrome = HoverableView()
     private var findJobsCTAGradientLayer: CAGradientLayer?
-    private let welcomeSparkleIcon = NSImageView()
+    private let welcomeHeroHost = NSView()
+    private let welcomeHeroBackgroundView = WelcomeHeroBackgroundView()
+    private lazy var welcomeSparkleCluster: WelcomeSparkleClusterView = {
+        WelcomeSparkleClusterView(iconWell: Theme.welcomeHeroIconWell, tint: Theme.welcomeHeroHeadingBlue)
+    }()
     private let featureCardsRow = NSStackView()
     private let chatScrollView = NSScrollView()
     private let chatDocumentView = JobListingsDocumentView()
@@ -125,8 +135,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         min(jobsPerSearchMaxCap, max(jobsPerSearchMin, requested))
     }
 
-    private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
-
     override init(frame frameRect: NSRect) {
         super.init(frame: frameRect)
         setupLayout()
@@ -207,36 +215,54 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         mainOverlay.translatesAutoresizingMaskIntoConstraints = false
         mainOverlay.setContentHuggingPriority(.defaultLow, for: .vertical)
 
-        greetingLabel.font = .systemFont(ofSize: 32, weight: .bold)
-        greetingLabel.textColor = Theme.brandBlue
+        greetingLabel.font = .systemFont(ofSize: 34, weight: .bold)
+        greetingLabel.textColor = Theme.welcomeHeroHeadingBlue
         greetingLabel.alignment = .center
         greetingLabel.maximumNumberOfLines = 1
 
         subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
-        subtitleLabel.textColor = Theme.welcomeSubtitleText
+        subtitleLabel.textColor = Theme.welcomeHeroSubtitleText
         subtitleLabel.alignment = .center
         subtitleLabel.maximumNumberOfLines = 2
-        subtitleLabel.wantsLayer = true
 
         let topInset = NSView()
         topInset.translatesAutoresizingMaskIntoConstraints = false
-        topInset.heightAnchor.constraint(equalToConstant: 18).isActive = true
+        topInset.heightAnchor.constraint(equalToConstant: 24).isActive = true
 
         configureSearchBar()
         configureChatViews()
 
-        welcomeSparkleIcon.translatesAutoresizingMaskIntoConstraints = false
-        welcomeSparkleIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
-        welcomeSparkleIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
-        welcomeSparkleIcon.contentTintColor = Theme.brandBlue
+        welcomeHeroHost.translatesAutoresizingMaskIntoConstraints = false
+        welcomeHeroBackgroundView.translatesAutoresizingMaskIntoConstraints = false
+        welcomeHeroBackgroundView.waveTint = Theme.welcomeHeroWaveTint
 
-        let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel, welcomeSparkleIcon])
-        titleBlock.orientation = .vertical
-        titleBlock.spacing = 10
-        titleBlock.alignment = .centerX
+        let welcomeHeroContent = NSStackView(views: [welcomeSparkleCluster, greetingLabel, subtitleLabel])
+        welcomeHeroContent.orientation = .vertical
+        welcomeHeroContent.spacing = 14
+        welcomeHeroContent.alignment = .centerX
+        welcomeHeroContent.translatesAutoresizingMaskIntoConstraints = false
+
+        welcomeHeroHost.addSubview(welcomeHeroBackgroundView)
+        welcomeHeroHost.addSubview(welcomeHeroContent)
+        NSLayoutConstraint.activate([
+            welcomeHeroBackgroundView.leadingAnchor.constraint(equalTo: welcomeHeroHost.leadingAnchor),
+            welcomeHeroBackgroundView.trailingAnchor.constraint(equalTo: welcomeHeroHost.trailingAnchor),
+            welcomeHeroBackgroundView.topAnchor.constraint(equalTo: welcomeHeroHost.topAnchor),
+            welcomeHeroBackgroundView.bottomAnchor.constraint(equalTo: welcomeHeroHost.bottomAnchor),
+
+            welcomeHeroContent.centerXAnchor.constraint(equalTo: welcomeHeroHost.centerXAnchor),
+            welcomeHeroContent.leadingAnchor.constraint(greaterThanOrEqualTo: welcomeHeroHost.leadingAnchor, constant: 16),
+            welcomeHeroContent.trailingAnchor.constraint(lessThanOrEqualTo: welcomeHeroHost.trailingAnchor, constant: -16),
+            welcomeHeroContent.topAnchor.constraint(equalTo: welcomeHeroHost.topAnchor, constant: 8),
+            welcomeHeroContent.bottomAnchor.constraint(equalTo: welcomeHeroHost.bottomAnchor, constant: -4)
+        ])
 
         configureFeatureShortcutCards()
 
+        let heroCardsSpacer = NSView()
+        heroCardsSpacer.translatesAutoresizingMaskIntoConstraints = false
+        heroCardsSpacer.heightAnchor.constraint(equalToConstant: 36).isActive = true
+
         let midSpacer = NSView()
         midSpacer.translatesAutoresizingMaskIntoConstraints = false
         midSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
@@ -250,7 +276,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         chatBottomSpacer.heightAnchor.constraint(equalToConstant: 14).isActive = true
 
         mainOverlay.addArrangedSubview(topInset)
-        mainOverlay.addArrangedSubview(titleBlock)
+        mainOverlay.addArrangedSubview(welcomeHeroHost)
+        mainOverlay.addArrangedSubview(heroCardsSpacer)
         mainOverlay.addArrangedSubview(featureCardsRow)
         mainOverlay.addArrangedSubview(midSpacer)
         mainOverlay.addArrangedSubview(chatTopSpacer)
@@ -289,10 +316,15 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
             chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
 
+            welcomeHeroHost.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor),
+            welcomeHeroHost.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor),
+
             greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
             greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -16),
             subtitleLabel.leadingAnchor.constraint(equalTo: greetingLabel.leadingAnchor),
-            subtitleLabel.trailingAnchor.constraint(equalTo: greetingLabel.trailingAnchor)
+            subtitleLabel.trailingAnchor.constraint(equalTo: greetingLabel.trailingAnchor),
+
+            welcomeHeroContent.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, constant: -32)
         ])
     }
 
@@ -355,40 +387,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         ])
     }
 
-    private func isWelcomeHeroVisible() -> Bool {
-        !mainOverlay.isHidden
-    }
-
     private var prefersReducedMotion: Bool {
         NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
     }
 
-    private func syncWelcomeSubtitleBreathingAnimation() {
-        guard !prefersReducedMotion else {
-            stopWelcomeSubtitleBreathingAnimation()
-            return
-        }
-        guard isWelcomeHeroVisible(), !subtitleLabel.stringValue.isEmpty else {
-            stopWelcomeSubtitleBreathingAnimation()
-            return
-        }
-        guard let layer = subtitleLabel.layer, layer.animation(forKey: Self.welcomeSubtitleBreathKey) == nil else { return }
-
-        let pulse = CABasicAnimation(keyPath: "opacity")
-        pulse.fromValue = 1.0
-        pulse.toValue = 0.86
-        pulse.duration = 2.4
-        pulse.autoreverses = true
-        pulse.repeatCount = .greatestFiniteMagnitude
-        pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
-        layer.add(pulse, forKey: Self.welcomeSubtitleBreathKey)
-    }
-
-    private func stopWelcomeSubtitleBreathingAnimation() {
-        subtitleLabel.layer?.removeAnimation(forKey: Self.welcomeSubtitleBreathKey)
-        subtitleLabel.layer?.opacity = 1
-    }
-
     private func updateJobListingDescriptionWidths() {
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
         walkChatJobStacks { stack in
@@ -1303,11 +1305,6 @@ final class DashboardView: NSView, NSTextFieldDelegate {
                 nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
             }
         }
-        if home {
-            syncWelcomeSubtitleBreathingAnimation()
-        } else {
-            stopWelcomeSubtitleBreathingAnimation()
-        }
     }
 
     /// Restores the main job-search experience: cleared query and a fresh chat history.
@@ -2467,6 +2464,135 @@ private struct OpenAIAPIErrorResponse: Codable {
     }
 }
 
+/// Decorative waves and faint sparkles behind the welcome hero (reference layout).
+private final class WelcomeHeroBackgroundView: NSView {
+    /// Stroke color for side waves (pastel blue).
+    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)
+    }
+
+    private func drawSideWaves(in bounds: NSRect, isLeft: Bool) {
+        for i in 0..<9 {
+            let path = NSBezierPath()
+            path.lineWidth = 1
+            path.lineCapStyle = .round
+            let phase = CGFloat(i) * 0.88
+            let base = CGFloat(i + 1) * 11 + 4
+            var first = true
+            for y in stride(from: CGFloat(0), through: bounds.height, by: 2.8) {
+                let wobble = sin(y * 0.048 + phase) * (4 + 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) / 10
+            waveTint.withAlphaComponent((0.09 + CGFloat(i % 3) * 0.022) * 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.14, 9, 0.28),
+            (0.94, 0.12, 12, 0.34),
+            (0.18, 0.42, 5, 0.18),
+            (0.86, 0.44, 6, 0.2),
+            (0.5, 0.06, 7, 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 x = center.x + cos(angle) * radius
+            let y = center.y + sin(angle) * radius
+            let point = NSPoint(x: x, y: y)
+            if i == 0 {
+                path.move(to: point)
+            } else {
+                path.line(to: point)
+            }
+        }
+        path.close()
+        color.setFill()
+        path.fill()
+    }
+}
+
+/// Circular pastel well with three sparkle symbols (reference: layered stars of different sizes).
+private final class WelcomeSparkleClusterView: NSView {
+    private let diameter: CGFloat = 72
+
+    override var intrinsicContentSize: NSSize {
+        NSSize(width: diameter, height: diameter)
+    }
+
+    init(iconWell: NSColor, tint: NSColor) {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.cornerRadius = diameter / 2
+        if #available(macOS 11.0, *) {
+            layer?.cornerCurve = .continuous
+        }
+        layer?.backgroundColor = iconWell.cgColor
+
+        let configs: [(CGFloat, NSFont.Weight, CGFloat, CGFloat)] = [
+            (22, .medium, 0, 0),
+            (14, .regular, -14, -11),
+            (11, .regular, 15, 13)
+        ]
+
+        let symbolName = Self.sparkleSymbolName()
+        for (size, weight, ox, oy) in configs {
+            let iv = NSImageView()
+            iv.translatesAutoresizingMaskIntoConstraints = false
+            iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: size, weight: weight)
+            iv.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
+            iv.contentTintColor = tint
+            addSubview(iv)
+            NSLayoutConstraint.activate([
+                iv.centerXAnchor.constraint(equalTo: centerXAnchor, constant: ox),
+                iv.centerYAnchor.constraint(equalTo: centerYAnchor, constant: oy)
+            ])
+        }
+    }
+
+    private static func sparkleSymbolName() -> String {
+        if NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil) != nil {
+            return "sparkle"
+        }
+        return "sparkles"
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+}
+
 /// Home welcome row: three tappable shortcuts that seed the main search field (reference: white cards, pastel icon well, arrow at bottom trailing).
 private final class FeatureShortcutCardView: NSView {
     private static let cardCornerRadius: CGFloat = 14
@@ -2493,8 +2619,8 @@ private final class FeatureShortcutCardView: NSView {
         layer?.shadowRadius = 12
         layer?.shadowOpacity = 1
 
-        // `#0047AB` — primary title / icons / arrow.
-        let primaryBlue = NSColor(srgbRed: 0 / 255, green: 71 / 255, blue: 171 / 255, alpha: 1)
+        // `#0052CC` — primary title / icons / arrow (matches welcome hero reference).
+        let primaryBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
         // `#EBF2FF` — circular icon well.
         let iconWellColor = NSColor(srgbRed: 235 / 255, green: 242 / 255, blue: 255 / 255, alpha: 1)
         // `#5D6D7E` — muted description.