|
|
@@ -37,6 +37,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
37
|
37
|
static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
38
|
38
|
/// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
|
|
39
|
39
|
static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
|
|
|
40
|
+ /// Hover states: darker brand blue, deeper gradient top, stronger tints, and subtle neutral fills used across CTAs, toggles, and the sidebar.
|
|
|
41
|
+ static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
|
|
|
42
|
+ static let findJobsCTAHighlightHover = NSColor(srgbRed: 44 / 255, green: 94 / 255, blue: 178 / 255, alpha: 1)
|
|
|
43
|
+ static let selectionFillHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.2)
|
|
|
44
|
+ static let neutralHoverFill = NSColor(srgbRed: 240 / 255, green: 240 / 255, blue: 240 / 255, alpha: 1)
|
|
|
45
|
+ static let sidebarRowHoverFill = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.04)
|
|
40
|
46
|
}
|
|
41
|
47
|
|
|
42
|
48
|
/// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title.
|
|
|
@@ -64,9 +70,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
64
|
70
|
private let searchCard = NSView()
|
|
65
|
71
|
private let jobSearchIcon = NSImageView()
|
|
66
|
72
|
private let jobKeywordsField = NSTextField()
|
|
67
|
|
- private let findJobsButton = NSButton()
|
|
|
73
|
+ private let findJobsButton = HoverableButton()
|
|
68
|
74
|
private let findJobsCTAHost = NSView()
|
|
69
|
|
- private let findJobsCTAChrome = NSView()
|
|
|
75
|
+ private let findJobsCTAChrome = HoverableView()
|
|
70
|
76
|
private var findJobsCTAGradientLayer: CAGradientLayer?
|
|
71
|
77
|
private let jobListingsScrollView = NSScrollView()
|
|
72
|
78
|
/// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
|
|
|
@@ -411,6 +417,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
411
|
417
|
applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
412
|
418
|
applyButton.contentTintColor = Theme.proCTAText
|
|
413
|
419
|
applyButton.focusRingType = .none
|
|
|
420
|
+ applyButton.pointerCursor = true
|
|
|
421
|
+ applyButton.hoverHandler = { [weak applyButton] hovering in
|
|
|
422
|
+ applyButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
|
|
|
423
|
+ }
|
|
414
|
424
|
applyButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
415
|
425
|
applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
416
|
426
|
|
|
|
@@ -424,6 +434,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
424
|
434
|
savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
425
|
435
|
savedButton.focusRingType = .none
|
|
426
|
436
|
savedButton.state = savedOn ? .on : .off
|
|
|
437
|
+ savedButton.pointerCursor = true
|
|
|
438
|
+ savedButton.hoverHandler = { [weak self, weak savedButton] _ in
|
|
|
439
|
+ guard let savedButton = savedButton else { return }
|
|
|
440
|
+ self?.styleJobSavedButton(savedButton)
|
|
|
441
|
+ }
|
|
427
|
442
|
styleJobSavedButton(savedButton)
|
|
428
|
443
|
savedButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
429
|
444
|
savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
@@ -442,6 +457,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
442
|
457
|
dismissButton.action = #selector(didTapJobDismiss(_:))
|
|
443
|
458
|
dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
|
|
444
|
459
|
dismissButton.focusRingType = .none
|
|
|
460
|
+ dismissButton.wantsLayer = true
|
|
|
461
|
+ dismissButton.layer?.cornerRadius = 14
|
|
|
462
|
+ dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
463
|
+ dismissButton.pointerCursor = true
|
|
|
464
|
+ dismissButton.hoverHandler = { [weak dismissButton] hovering in
|
|
|
465
|
+ dismissButton?.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : NSColor.clear).cgColor
|
|
|
466
|
+ dismissButton?.contentTintColor = hovering ? Theme.primaryText : Theme.secondaryText
|
|
|
467
|
+ }
|
|
445
|
468
|
dismissButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
446
|
469
|
|
|
447
|
470
|
let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
|
|
|
@@ -500,13 +523,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
500
|
523
|
button.wantsLayer = true
|
|
501
|
524
|
button.layer?.cornerRadius = 6
|
|
502
|
525
|
let on = button.state == .on
|
|
|
526
|
+ let hovering = (button as? HoverableButton)?.isHovering ?? false
|
|
503
|
527
|
if on {
|
|
504
|
|
- button.layer?.backgroundColor = Theme.selectionFill.cgColor
|
|
|
528
|
+ button.layer?.backgroundColor = (hovering ? Theme.selectionFillHover : Theme.selectionFill).cgColor
|
|
505
|
529
|
button.layer?.borderWidth = 1
|
|
506
|
530
|
button.layer?.borderColor = Theme.brandBlue.cgColor
|
|
507
|
531
|
button.contentTintColor = Theme.brandBlue
|
|
508
|
532
|
} else {
|
|
509
|
|
- button.layer?.backgroundColor = Theme.cardBackground.cgColor
|
|
|
533
|
+ button.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
|
|
510
|
534
|
button.layer?.borderWidth = 1
|
|
511
|
535
|
button.layer?.borderColor = Theme.border.cgColor
|
|
512
|
536
|
button.contentTintColor = Theme.primaryText
|
|
|
@@ -626,6 +650,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
626
|
650
|
findJobsCTAChrome.layer?.addSublayer(gradient)
|
|
627
|
651
|
findJobsCTAGradientLayer = gradient
|
|
628
|
652
|
|
|
|
653
|
+ // Tracks hover over the full pill (the button only covers an inset area), so the gradient darkens whenever the mouse is anywhere over the CTA.
|
|
|
654
|
+ findJobsCTAChrome.pointerCursor = true
|
|
|
655
|
+ findJobsCTAChrome.hoverHandler = { [weak self] hovering in
|
|
|
656
|
+ guard let layer = self?.findJobsCTAGradientLayer else { return }
|
|
|
657
|
+ CATransaction.begin()
|
|
|
658
|
+ CATransaction.setAnimationDuration(0.15)
|
|
|
659
|
+ layer.colors = hovering
|
|
|
660
|
+ ? [Theme.findJobsCTAHighlightHover.cgColor, Theme.brandBlueHover.cgColor]
|
|
|
661
|
+ : [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor]
|
|
|
662
|
+ CATransaction.commit()
|
|
|
663
|
+ }
|
|
|
664
|
+
|
|
629
|
665
|
findJobsButton.translatesAutoresizingMaskIntoConstraints = false
|
|
630
|
666
|
findJobsButton.title = ""
|
|
631
|
667
|
findJobsButton.attributedTitle = NSAttributedString(
|
|
|
@@ -977,9 +1013,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
977
|
1013
|
rowHost.translatesAutoresizingMaskIntoConstraints = false
|
|
978
|
1014
|
rowHost.wantsLayer = true
|
|
979
|
1015
|
rowHost.layer?.cornerRadius = 8
|
|
980
|
|
- if isSelected {
|
|
981
|
|
- rowHost.layer?.backgroundColor = Theme.selectionFill.cgColor
|
|
982
|
|
- }
|
|
|
1016
|
+ rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil
|
|
|
1017
|
+ rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill
|
|
983
|
1018
|
rowHost.setAccessibilityLabel(item.title)
|
|
984
|
1019
|
rowHost.setAccessibilityRole(.button)
|
|
985
|
1020
|
rowHost.setAccessibilitySelected(isSelected)
|
|
|
@@ -1089,7 +1124,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1089
|
1124
|
let innerContentWidth = cardWidth - 28
|
|
1090
|
1125
|
upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
|
|
1091
|
1126
|
|
|
1092
|
|
- let upgradeButton = NSButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
|
|
|
1127
|
+ let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
|
|
1093
|
1128
|
upgradeButton.isBordered = false
|
|
1094
|
1129
|
upgradeButton.bezelStyle = .rounded
|
|
1095
|
1130
|
upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
@@ -1100,6 +1135,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1100
|
1135
|
upgradeButton.layer?.cornerRadius = 20
|
|
1101
|
1136
|
upgradeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
1102
|
1137
|
upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
|
|
|
1138
|
+ upgradeButton.pointerCursor = true
|
|
|
1139
|
+ upgradeButton.hoverHandler = { [weak upgradeButton] hovering in
|
|
|
1140
|
+ upgradeButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.proCTABackground).cgColor
|
|
|
1141
|
+ }
|
|
1103
|
1142
|
|
|
1104
|
1143
|
inner.addArrangedSubview(eyebrowRow)
|
|
1105
|
1144
|
inner.addArrangedSubview(headline)
|
|
|
@@ -1153,19 +1192,128 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
1153
|
1192
|
}
|
|
1154
|
1193
|
|
|
1155
|
1194
|
/// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
|
|
1156
|
|
-private final class JobPayloadButton: NSButton {
|
|
|
1195
|
+private final class JobPayloadButton: HoverableButton {
|
|
1157
|
1196
|
var jobPayload: JobListing?
|
|
1158
|
1197
|
var cardContext: JobListingCardContext = .homeSearchResults
|
|
1159
|
1198
|
}
|
|
1160
|
1199
|
|
|
|
1200
|
+/// `NSButton` with a tracking area that reports hover transitions and (optionally) swaps in a pointing-hand cursor while hovered.
|
|
|
1201
|
+private class HoverableButton: NSButton {
|
|
|
1202
|
+ var hoverHandler: ((Bool) -> Void)?
|
|
|
1203
|
+ var pointerCursor: Bool = false
|
|
|
1204
|
+ private(set) var isHovering: Bool = false
|
|
|
1205
|
+ private var trackingArea: NSTrackingArea?
|
|
|
1206
|
+ private var didPushCursor: Bool = false
|
|
|
1207
|
+
|
|
|
1208
|
+ override func updateTrackingAreas() {
|
|
|
1209
|
+ super.updateTrackingAreas()
|
|
|
1210
|
+ if let area = trackingArea { removeTrackingArea(area) }
|
|
|
1211
|
+ let area = NSTrackingArea(
|
|
|
1212
|
+ rect: bounds,
|
|
|
1213
|
+ options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
|
|
|
1214
|
+ owner: self,
|
|
|
1215
|
+ userInfo: nil
|
|
|
1216
|
+ )
|
|
|
1217
|
+ addTrackingArea(area)
|
|
|
1218
|
+ trackingArea = area
|
|
|
1219
|
+ }
|
|
|
1220
|
+
|
|
|
1221
|
+ override func mouseEntered(with event: NSEvent) {
|
|
|
1222
|
+ super.mouseEntered(with: event)
|
|
|
1223
|
+ isHovering = true
|
|
|
1224
|
+ hoverHandler?(true)
|
|
|
1225
|
+ if pointerCursor, !didPushCursor {
|
|
|
1226
|
+ NSCursor.pointingHand.push()
|
|
|
1227
|
+ didPushCursor = true
|
|
|
1228
|
+ }
|
|
|
1229
|
+ }
|
|
|
1230
|
+
|
|
|
1231
|
+ override func mouseExited(with event: NSEvent) {
|
|
|
1232
|
+ super.mouseExited(with: event)
|
|
|
1233
|
+ isHovering = false
|
|
|
1234
|
+ hoverHandler?(false)
|
|
|
1235
|
+ if didPushCursor {
|
|
|
1236
|
+ NSCursor.pop()
|
|
|
1237
|
+ didPushCursor = false
|
|
|
1238
|
+ }
|
|
|
1239
|
+ }
|
|
|
1240
|
+
|
|
|
1241
|
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
|
1242
|
+ super.viewWillMove(toWindow: newWindow)
|
|
|
1243
|
+ // Guard against an unbalanced cursor stack if the button is removed mid-hover (e.g. job card replaced after a search).
|
|
|
1244
|
+ if newWindow == nil, didPushCursor {
|
|
|
1245
|
+ NSCursor.pop()
|
|
|
1246
|
+ didPushCursor = false
|
|
|
1247
|
+ isHovering = false
|
|
|
1248
|
+ }
|
|
|
1249
|
+ }
|
|
|
1250
|
+}
|
|
|
1251
|
+
|
|
|
1252
|
+/// `NSView` companion to `HoverableButton`: emits hover transitions and can manage a pointing-hand cursor. Used to track hover over composite controls like the gradient "Find jobs" pill.
|
|
|
1253
|
+private class HoverableView: NSView {
|
|
|
1254
|
+ var hoverHandler: ((Bool) -> Void)?
|
|
|
1255
|
+ var pointerCursor: Bool = false
|
|
|
1256
|
+ private(set) var isHovering: Bool = false
|
|
|
1257
|
+ private var trackingArea: NSTrackingArea?
|
|
|
1258
|
+ private var didPushCursor: Bool = false
|
|
|
1259
|
+
|
|
|
1260
|
+ override func updateTrackingAreas() {
|
|
|
1261
|
+ super.updateTrackingAreas()
|
|
|
1262
|
+ if let area = trackingArea { removeTrackingArea(area) }
|
|
|
1263
|
+ let area = NSTrackingArea(
|
|
|
1264
|
+ rect: bounds,
|
|
|
1265
|
+ options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
|
|
|
1266
|
+ owner: self,
|
|
|
1267
|
+ userInfo: nil
|
|
|
1268
|
+ )
|
|
|
1269
|
+ addTrackingArea(area)
|
|
|
1270
|
+ trackingArea = area
|
|
|
1271
|
+ }
|
|
|
1272
|
+
|
|
|
1273
|
+ override func mouseEntered(with event: NSEvent) {
|
|
|
1274
|
+ super.mouseEntered(with: event)
|
|
|
1275
|
+ isHovering = true
|
|
|
1276
|
+ hoverHandler?(true)
|
|
|
1277
|
+ if pointerCursor, !didPushCursor {
|
|
|
1278
|
+ NSCursor.pointingHand.push()
|
|
|
1279
|
+ didPushCursor = true
|
|
|
1280
|
+ }
|
|
|
1281
|
+ }
|
|
|
1282
|
+
|
|
|
1283
|
+ override func mouseExited(with event: NSEvent) {
|
|
|
1284
|
+ super.mouseExited(with: event)
|
|
|
1285
|
+ isHovering = false
|
|
|
1286
|
+ hoverHandler?(false)
|
|
|
1287
|
+ if didPushCursor {
|
|
|
1288
|
+ NSCursor.pop()
|
|
|
1289
|
+ didPushCursor = false
|
|
|
1290
|
+ }
|
|
|
1291
|
+ }
|
|
|
1292
|
+
|
|
|
1293
|
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
|
1294
|
+ super.viewWillMove(toWindow: newWindow)
|
|
|
1295
|
+ if newWindow == nil, didPushCursor {
|
|
|
1296
|
+ NSCursor.pop()
|
|
|
1297
|
+ didPushCursor = false
|
|
|
1298
|
+ isHovering = false
|
|
|
1299
|
+ }
|
|
|
1300
|
+ }
|
|
|
1301
|
+}
|
|
|
1302
|
+
|
|
1161
|
1303
|
/// 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).
|
|
1162
|
1304
|
private final class JobListingsDocumentView: NSView {
|
|
1163
|
1305
|
override var isFlipped: Bool { true }
|
|
1164
|
1306
|
}
|
|
1165
|
1307
|
|
|
1166
|
|
-/// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab.
|
|
|
1308
|
+/// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab. Manages its own hover background so non-selected rows highlight subtly on hover without disturbing the selected-row fill.
|
|
1167
|
1309
|
private final class SidebarNavRowView: NSView {
|
|
1168
|
1310
|
private let onSelect: () -> Void
|
|
|
1311
|
+ var restingBackgroundColor: NSColor? {
|
|
|
1312
|
+ didSet { applyBackground() }
|
|
|
1313
|
+ }
|
|
|
1314
|
+ var hoverBackgroundColor: NSColor?
|
|
|
1315
|
+ private var isHovering: Bool = false
|
|
|
1316
|
+ private var didPushCursor: Bool = false
|
|
1169
|
1317
|
|
|
1170
|
1318
|
init(onSelect: @escaping () -> Void) {
|
|
1171
|
1319
|
self.onSelect = onSelect
|
|
|
@@ -1200,11 +1348,35 @@ private final class SidebarNavRowView: NSView {
|
|
1200
|
1348
|
|
|
1201
|
1349
|
override func mouseEntered(with event: NSEvent) {
|
|
1202
|
1350
|
super.mouseEntered(with: event)
|
|
1203
|
|
- NSCursor.pointingHand.push()
|
|
|
1351
|
+ isHovering = true
|
|
|
1352
|
+ applyBackground()
|
|
|
1353
|
+ if !didPushCursor {
|
|
|
1354
|
+ NSCursor.pointingHand.push()
|
|
|
1355
|
+ didPushCursor = true
|
|
|
1356
|
+ }
|
|
1204
|
1357
|
}
|
|
1205
|
1358
|
|
|
1206
|
1359
|
override func mouseExited(with event: NSEvent) {
|
|
1207
|
1360
|
super.mouseExited(with: event)
|
|
1208
|
|
- NSCursor.pop()
|
|
|
1361
|
+ isHovering = false
|
|
|
1362
|
+ applyBackground()
|
|
|
1363
|
+ if didPushCursor {
|
|
|
1364
|
+ NSCursor.pop()
|
|
|
1365
|
+ didPushCursor = false
|
|
|
1366
|
+ }
|
|
|
1367
|
+ }
|
|
|
1368
|
+
|
|
|
1369
|
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
|
1370
|
+ super.viewWillMove(toWindow: newWindow)
|
|
|
1371
|
+ if newWindow == nil, didPushCursor {
|
|
|
1372
|
+ NSCursor.pop()
|
|
|
1373
|
+ didPushCursor = false
|
|
|
1374
|
+ isHovering = false
|
|
|
1375
|
+ }
|
|
|
1376
|
+ }
|
|
|
1377
|
+
|
|
|
1378
|
+ private func applyBackground() {
|
|
|
1379
|
+ let color = isHovering ? (hoverBackgroundColor ?? restingBackgroundColor) : restingBackgroundColor
|
|
|
1380
|
+ layer?.backgroundColor = color?.cgColor
|
|
1209
|
1381
|
}
|
|
1210
|
1382
|
}
|