Quellcode durchsuchen

Polish dashboard welcome hero and chat with motion-aware animations.

Add subtle pulse on the status sparkles and breathing opacity on the welcome subtitle when the home overlay is visible, respecting Reduce Motion. Route chat status updates through a helper so animations stay in sync. Fade new chat bubbles in unless reduced motion is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 vor 3 Wochen
Ursprung
Commit
17c35b12a0
1 geänderte Dateien mit 106 neuen und 8 gelöschten Zeilen
  1. 106 8
      App for Indeed/Views/DashboardView.swift

+ 106 - 8
App for Indeed/Views/DashboardView.swift

@@ -111,6 +111,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
111 111
     private var isAwaitingResponse = false
112 112
     private let jobSearchService = OpenAIJobSearchService()
113 113
 
114
+    private static let chatStatusSparklePulseKey = "chatStatusSparklePulse"
115
+    private static let welcomeSubtitleBreathKey = "welcomeSubtitleBreath"
116
+
114 117
     override init(frame frameRect: NSRect) {
115 118
         super.init(frame: frameRect)
116 119
         setupLayout()
@@ -200,6 +203,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
200 203
         subtitleLabel.textColor = Theme.welcomeSubtitleText
201 204
         subtitleLabel.alignment = .center
202 205
         subtitleLabel.maximumNumberOfLines = 2
206
+        subtitleLabel.wantsLayer = true
203 207
 
204 208
         let topInset = NSView()
205 209
         topInset.translatesAutoresizingMaskIntoConstraints = false
@@ -279,6 +283,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
279 283
         chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
280 284
 
281 285
         chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
286
+        chatStatusIcon.wantsLayer = true
282 287
         chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
283 288
         chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
284 289
         chatStatusIcon.contentTintColor = Theme.brandBlue
@@ -325,6 +330,76 @@ final class DashboardView: NSView, NSTextFieldDelegate {
325 330
         ])
326 331
     }
327 332
 
333
+    private func setChatStatusLabel(_ text: String) {
334
+        chatStatusLabel.stringValue = text
335
+        syncChatStatusSparkleAnimation()
336
+    }
337
+
338
+    private func isWelcomeHeroVisible() -> Bool {
339
+        !mainOverlay.isHidden
340
+    }
341
+
342
+    private var prefersReducedMotion: Bool {
343
+        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
344
+    }
345
+
346
+    private func shouldAnimateChatStatusSparkles(for statusText: String) -> Bool {
347
+        statusText == "Ask me to find jobs"
348
+            || statusText == "Ask for another role, company, or skill match"
349
+    }
350
+
351
+    private func syncChatStatusSparkleAnimation() {
352
+        guard !prefersReducedMotion else {
353
+            stopChatStatusSparkleAnimation()
354
+            return
355
+        }
356
+        guard isWelcomeHeroVisible(), shouldAnimateChatStatusSparkles(for: chatStatusLabel.stringValue) else {
357
+            stopChatStatusSparkleAnimation()
358
+            return
359
+        }
360
+        guard let layer = chatStatusIcon.layer, layer.animation(forKey: Self.chatStatusSparklePulseKey) == nil else { return }
361
+
362
+        let scale = CABasicAnimation(keyPath: "transform.scale")
363
+        scale.fromValue = 1.0
364
+        scale.toValue = 1.1
365
+        scale.duration = 1.25
366
+        scale.autoreverses = true
367
+        scale.repeatCount = .greatestFiniteMagnitude
368
+        scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
369
+        layer.add(scale, forKey: Self.chatStatusSparklePulseKey)
370
+    }
371
+
372
+    private func stopChatStatusSparkleAnimation() {
373
+        chatStatusIcon.layer?.removeAnimation(forKey: Self.chatStatusSparklePulseKey)
374
+        chatStatusIcon.layer?.transform = CATransform3DIdentity
375
+    }
376
+
377
+    private func syncWelcomeSubtitleBreathingAnimation() {
378
+        guard !prefersReducedMotion else {
379
+            stopWelcomeSubtitleBreathingAnimation()
380
+            return
381
+        }
382
+        guard isWelcomeHeroVisible(), !subtitleLabel.stringValue.isEmpty else {
383
+            stopWelcomeSubtitleBreathingAnimation()
384
+            return
385
+        }
386
+        guard let layer = subtitleLabel.layer, layer.animation(forKey: Self.welcomeSubtitleBreathKey) == nil else { return }
387
+
388
+        let pulse = CABasicAnimation(keyPath: "opacity")
389
+        pulse.fromValue = 1.0
390
+        pulse.toValue = 0.86
391
+        pulse.duration = 2.4
392
+        pulse.autoreverses = true
393
+        pulse.repeatCount = .greatestFiniteMagnitude
394
+        pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
395
+        layer.add(pulse, forKey: Self.welcomeSubtitleBreathKey)
396
+    }
397
+
398
+    private func stopWelcomeSubtitleBreathingAnimation() {
399
+        subtitleLabel.layer?.removeAnimation(forKey: Self.welcomeSubtitleBreathKey)
400
+        subtitleLabel.layer?.opacity = 1
401
+    }
402
+
328 403
     private func updateJobListingDescriptionWidths() {
329 404
         updateDescriptionColumnWidths(in: savedJobsStack, containerWidth: savedJobsDocumentView.bounds.width)
330 405
         walkChatJobStacks { stack in
@@ -1155,6 +1230,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1155 1230
                 nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title
1156 1231
             }
1157 1232
         }
1233
+        if home {
1234
+            syncWelcomeSubtitleBreathingAnimation()
1235
+            syncChatStatusSparkleAnimation()
1236
+        } else {
1237
+            stopWelcomeSubtitleBreathingAnimation()
1238
+            stopChatStatusSparkleAnimation()
1239
+        }
1158 1240
     }
1159 1241
 
1160 1242
     /// Restores the main job-search experience: cleared query and a fresh chat history.
@@ -1185,7 +1267,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1185 1267
         chatMessages.append(ChatMessage(role: "user", content: prompt))
1186 1268
         jobKeywordsField.stringValue = ""
1187 1269
         isAwaitingResponse = true
1188
-        chatStatusLabel.stringValue = "Thinking..."
1270
+        setChatStatusLabel("Thinking...")
1189 1271
         setInputEnabled(false)
1190 1272
         let contextMessages = chatMessages
1191 1273
         jobSearchService.searchJobs(query: effectiveQuery, conversation: contextMessages) { [weak self] result in
@@ -1212,13 +1294,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1212 1294
                     )
1213 1295
                     self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
1214 1296
                     self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
1215
-                    self.chatStatusLabel.stringValue = "Ask for another role, company, or skill match"
1297
+                    self.setChatStatusLabel("Ask for another role, company, or skill match")
1216 1298
                 case .failure(let error):
1217 1299
                     self.appendChatBubble(text: error.localizedDescription, isUser: false)
1218 1300
                     if error is URLError {
1219
-                        self.chatStatusLabel.stringValue = "Could not reach API. Try again."
1301
+                        self.setChatStatusLabel("Could not reach API. Try again.")
1220 1302
                     } else {
1221
-                        self.chatStatusLabel.stringValue = "Search did not finish. Try again."
1303
+                        self.setChatStatusLabel("Search did not finish. Try again.")
1222 1304
                     }
1223 1305
                 }
1224 1306
             }
@@ -1327,7 +1409,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1327 1409
     func controlTextDidBeginEditing(_ obj: Notification) {
1328 1410
         applySearchFieldInsertionPoint(obj.object)
1329 1411
         if (obj.object as? NSTextField) === jobKeywordsField {
1330
-            chatStatusLabel.stringValue = "Opening the vault..."
1412
+            setChatStatusLabel("Opening the vault...")
1331 1413
         }
1332 1414
     }
1333 1415
 
@@ -1360,7 +1442,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1360 1442
         let welcome = "Tell me what role you want and I will return job descriptions, key skills, and a quick fit summary."
1361 1443
         chatMessages.append(ChatMessage(role: "assistant", content: welcome))
1362 1444
         appendChatBubble(text: welcome, isUser: false)
1363
-        chatStatusLabel.stringValue = "Ask me to find jobs"
1445
+        setChatStatusLabel("Ask me to find jobs")
1364 1446
     }
1365 1447
 
1366 1448
     private func appendChatBubble(text: String, isUser: Bool, jobs: [JobListing]? = nil) {
@@ -1376,9 +1458,25 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1376 1458
         chatStack.addArrangedSubview(host)
1377 1459
         host.widthAnchor.constraint(equalTo: chatStack.widthAnchor).isActive = true
1378 1460
 
1461
+        if prefersReducedMotion {
1462
+            host.alphaValue = 1
1463
+        } else {
1464
+            host.alphaValue = 0
1465
+        }
1379 1466
         DispatchQueue.main.async { [weak self] in
1380
-            self?.updateChatBubbleWidths()
1381
-            self?.scrollChatToBottom()
1467
+            guard let self else { return }
1468
+            if self.prefersReducedMotion {
1469
+                self.updateChatBubbleWidths()
1470
+                self.scrollChatToBottom()
1471
+                return
1472
+            }
1473
+            NSAnimationContext.runAnimationGroup { ctx in
1474
+                ctx.duration = 0.3
1475
+                ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
1476
+                host.animator().alphaValue = 1
1477
+            }
1478
+            self.updateChatBubbleWidths()
1479
+            self.scrollChatToBottom()
1382 1480
         }
1383 1481
     }
1384 1482