|
|
@@ -37,6 +37,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
|
/// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
|
|
|
static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
|
|
|
+ /// Hover states: darker brand blue, deeper gradient top, stronger tints, and subtle neutral fills used across CTAs, toggles, and the sidebar.
|
|
|
+ static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
|
|
|
+ static let findJobsCTAHighlightHover = NSColor(srgbRed: 44 / 255, green: 94 / 255, blue: 178 / 255, alpha: 1)
|
|
|
+ static let selectionFillHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.2)
|
|
|
+ static let neutralHoverFill = NSColor(srgbRed: 240 / 255, green: 240 / 255, blue: 240 / 255, alpha: 1)
|
|
|
+ static let sidebarRowHoverFill = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.04)
|
|
|
}
|
|
|
|
|
|
/// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title.
|
|
|
@@ -64,9 +70,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private let searchCard = NSView()
|
|
|
private let jobSearchIcon = NSImageView()
|
|
|
private let jobKeywordsField = NSTextField()
|
|
|
- private let findJobsButton = NSButton()
|
|
|
+ private let findJobsButton = HoverableButton()
|
|
|
private let findJobsCTAHost = NSView()
|
|
|
- private let findJobsCTAChrome = NSView()
|
|
|
+ private let findJobsCTAChrome = HoverableView()
|
|
|
private var findJobsCTAGradientLayer: CAGradientLayer?
|
|
|
private let jobListingsScrollView = NSScrollView()
|
|
|
/// 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 {
|
|
|
applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
applyButton.contentTintColor = Theme.proCTAText
|
|
|
applyButton.focusRingType = .none
|
|
|
+ applyButton.pointerCursor = true
|
|
|
+ applyButton.hoverHandler = { [weak applyButton] hovering in
|
|
|
+ applyButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
|
|
|
+ }
|
|
|
applyButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
|
@@ -424,6 +434,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
savedButton.focusRingType = .none
|
|
|
savedButton.state = savedOn ? .on : .off
|
|
|
+ savedButton.pointerCursor = true
|
|
|
+ savedButton.hoverHandler = { [weak self, weak savedButton] _ in
|
|
|
+ guard let savedButton = savedButton else { return }
|
|
|
+ self?.styleJobSavedButton(savedButton)
|
|
|
+ }
|
|
|
styleJobSavedButton(savedButton)
|
|
|
savedButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
@@ -442,6 +457,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
dismissButton.action = #selector(didTapJobDismiss(_:))
|
|
|
dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
|
|
|
dismissButton.focusRingType = .none
|
|
|
+ dismissButton.wantsLayer = true
|
|
|
+ dismissButton.layer?.cornerRadius = 14
|
|
|
+ dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
+ dismissButton.pointerCursor = true
|
|
|
+ dismissButton.hoverHandler = { [weak dismissButton] hovering in
|
|
|
+ dismissButton?.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : NSColor.clear).cgColor
|
|
|
+ dismissButton?.contentTintColor = hovering ? Theme.primaryText : Theme.secondaryText
|
|
|
+ }
|
|
|
dismissButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
|
|
let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
|
|
|
@@ -500,13 +523,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
button.wantsLayer = true
|
|
|
button.layer?.cornerRadius = 6
|
|
|
let on = button.state == .on
|
|
|
+ let hovering = (button as? HoverableButton)?.isHovering ?? false
|
|
|
if on {
|
|
|
- button.layer?.backgroundColor = Theme.selectionFill.cgColor
|
|
|
+ button.layer?.backgroundColor = (hovering ? Theme.selectionFillHover : Theme.selectionFill).cgColor
|
|
|
button.layer?.borderWidth = 1
|
|
|
button.layer?.borderColor = Theme.brandBlue.cgColor
|
|
|
button.contentTintColor = Theme.brandBlue
|
|
|
} else {
|
|
|
- button.layer?.backgroundColor = Theme.cardBackground.cgColor
|
|
|
+ button.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
|
|
|
button.layer?.borderWidth = 1
|
|
|
button.layer?.borderColor = Theme.border.cgColor
|
|
|
button.contentTintColor = Theme.primaryText
|
|
|
@@ -626,6 +650,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
findJobsCTAChrome.layer?.addSublayer(gradient)
|
|
|
findJobsCTAGradientLayer = gradient
|
|
|
|
|
|
+ // 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.
|
|
|
+ findJobsCTAChrome.pointerCursor = true
|
|
|
+ findJobsCTAChrome.hoverHandler = { [weak self] hovering in
|
|
|
+ guard let layer = self?.findJobsCTAGradientLayer else { return }
|
|
|
+ CATransaction.begin()
|
|
|
+ CATransaction.setAnimationDuration(0.15)
|
|
|
+ layer.colors = hovering
|
|
|
+ ? [Theme.findJobsCTAHighlightHover.cgColor, Theme.brandBlueHover.cgColor]
|
|
|
+ : [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor]
|
|
|
+ CATransaction.commit()
|
|
|
+ }
|
|
|
+
|
|
|
findJobsButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
findJobsButton.title = ""
|
|
|
findJobsButton.attributedTitle = NSAttributedString(
|
|
|
@@ -977,9 +1013,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
rowHost.translatesAutoresizingMaskIntoConstraints = false
|
|
|
rowHost.wantsLayer = true
|
|
|
rowHost.layer?.cornerRadius = 8
|
|
|
- if isSelected {
|
|
|
- rowHost.layer?.backgroundColor = Theme.selectionFill.cgColor
|
|
|
- }
|
|
|
+ rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil
|
|
|
+ rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill
|
|
|
rowHost.setAccessibilityLabel(item.title)
|
|
|
rowHost.setAccessibilityRole(.button)
|
|
|
rowHost.setAccessibilitySelected(isSelected)
|
|
|
@@ -1089,7 +1124,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
let innerContentWidth = cardWidth - 28
|
|
|
upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
|
|
|
|
|
|
- let upgradeButton = NSButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
|
|
|
+ let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
|
|
|
upgradeButton.isBordered = false
|
|
|
upgradeButton.bezelStyle = .rounded
|
|
|
upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
@@ -1100,6 +1135,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
upgradeButton.layer?.cornerRadius = 20
|
|
|
upgradeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
|
|
|
+ upgradeButton.pointerCursor = true
|
|
|
+ upgradeButton.hoverHandler = { [weak upgradeButton] hovering in
|
|
|
+ upgradeButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.proCTABackground).cgColor
|
|
|
+ }
|
|
|
|
|
|
inner.addArrangedSubview(eyebrowRow)
|
|
|
inner.addArrangedSubview(headline)
|
|
|
@@ -1153,19 +1192,128 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
|
|
|
/// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
|
|
|
-private final class JobPayloadButton: NSButton {
|
|
|
+private final class JobPayloadButton: HoverableButton {
|
|
|
var jobPayload: JobListing?
|
|
|
var cardContext: JobListingCardContext = .homeSearchResults
|
|
|
}
|
|
|
|
|
|
+/// `NSButton` with a tracking area that reports hover transitions and (optionally) swaps in a pointing-hand cursor while hovered.
|
|
|
+private class HoverableButton: NSButton {
|
|
|
+ var hoverHandler: ((Bool) -> Void)?
|
|
|
+ var pointerCursor: Bool = false
|
|
|
+ private(set) var isHovering: Bool = false
|
|
|
+ private var trackingArea: NSTrackingArea?
|
|
|
+ private var didPushCursor: Bool = false
|
|
|
+
|
|
|
+ override func updateTrackingAreas() {
|
|
|
+ super.updateTrackingAreas()
|
|
|
+ if let area = trackingArea { removeTrackingArea(area) }
|
|
|
+ let area = NSTrackingArea(
|
|
|
+ rect: bounds,
|
|
|
+ options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
|
|
|
+ owner: self,
|
|
|
+ userInfo: nil
|
|
|
+ )
|
|
|
+ addTrackingArea(area)
|
|
|
+ trackingArea = area
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseEntered(with event: NSEvent) {
|
|
|
+ super.mouseEntered(with: event)
|
|
|
+ isHovering = true
|
|
|
+ hoverHandler?(true)
|
|
|
+ if pointerCursor, !didPushCursor {
|
|
|
+ NSCursor.pointingHand.push()
|
|
|
+ didPushCursor = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseExited(with event: NSEvent) {
|
|
|
+ super.mouseExited(with: event)
|
|
|
+ isHovering = false
|
|
|
+ hoverHandler?(false)
|
|
|
+ if didPushCursor {
|
|
|
+ NSCursor.pop()
|
|
|
+ didPushCursor = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
|
+ super.viewWillMove(toWindow: newWindow)
|
|
|
+ // Guard against an unbalanced cursor stack if the button is removed mid-hover (e.g. job card replaced after a search).
|
|
|
+ if newWindow == nil, didPushCursor {
|
|
|
+ NSCursor.pop()
|
|
|
+ didPushCursor = false
|
|
|
+ isHovering = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// `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.
|
|
|
+private class HoverableView: NSView {
|
|
|
+ var hoverHandler: ((Bool) -> Void)?
|
|
|
+ var pointerCursor: Bool = false
|
|
|
+ private(set) var isHovering: Bool = false
|
|
|
+ private var trackingArea: NSTrackingArea?
|
|
|
+ private var didPushCursor: Bool = false
|
|
|
+
|
|
|
+ override func updateTrackingAreas() {
|
|
|
+ super.updateTrackingAreas()
|
|
|
+ if let area = trackingArea { removeTrackingArea(area) }
|
|
|
+ let area = NSTrackingArea(
|
|
|
+ rect: bounds,
|
|
|
+ options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
|
|
|
+ owner: self,
|
|
|
+ userInfo: nil
|
|
|
+ )
|
|
|
+ addTrackingArea(area)
|
|
|
+ trackingArea = area
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseEntered(with event: NSEvent) {
|
|
|
+ super.mouseEntered(with: event)
|
|
|
+ isHovering = true
|
|
|
+ hoverHandler?(true)
|
|
|
+ if pointerCursor, !didPushCursor {
|
|
|
+ NSCursor.pointingHand.push()
|
|
|
+ didPushCursor = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseExited(with event: NSEvent) {
|
|
|
+ super.mouseExited(with: event)
|
|
|
+ isHovering = false
|
|
|
+ hoverHandler?(false)
|
|
|
+ if didPushCursor {
|
|
|
+ NSCursor.pop()
|
|
|
+ didPushCursor = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
|
+ super.viewWillMove(toWindow: newWindow)
|
|
|
+ if newWindow == nil, didPushCursor {
|
|
|
+ NSCursor.pop()
|
|
|
+ didPushCursor = false
|
|
|
+ isHovering = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/// 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 }
|
|
|
}
|
|
|
|
|
|
-/// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab.
|
|
|
+/// 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.
|
|
|
private final class SidebarNavRowView: NSView {
|
|
|
private let onSelect: () -> Void
|
|
|
+ var restingBackgroundColor: NSColor? {
|
|
|
+ didSet { applyBackground() }
|
|
|
+ }
|
|
|
+ var hoverBackgroundColor: NSColor?
|
|
|
+ private var isHovering: Bool = false
|
|
|
+ private var didPushCursor: Bool = false
|
|
|
|
|
|
init(onSelect: @escaping () -> Void) {
|
|
|
self.onSelect = onSelect
|
|
|
@@ -1200,11 +1348,35 @@ private final class SidebarNavRowView: NSView {
|
|
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
|
super.mouseEntered(with: event)
|
|
|
- NSCursor.pointingHand.push()
|
|
|
+ isHovering = true
|
|
|
+ applyBackground()
|
|
|
+ if !didPushCursor {
|
|
|
+ NSCursor.pointingHand.push()
|
|
|
+ didPushCursor = true
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
|
super.mouseExited(with: event)
|
|
|
- NSCursor.pop()
|
|
|
+ isHovering = false
|
|
|
+ applyBackground()
|
|
|
+ if didPushCursor {
|
|
|
+ NSCursor.pop()
|
|
|
+ didPushCursor = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
|
|
|
+ super.viewWillMove(toWindow: newWindow)
|
|
|
+ if newWindow == nil, didPushCursor {
|
|
|
+ NSCursor.pop()
|
|
|
+ didPushCursor = false
|
|
|
+ isHovering = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyBackground() {
|
|
|
+ let color = isHovering ? (hoverBackgroundColor ?? restingBackgroundColor) : restingBackgroundColor
|
|
|
+ layer?.backgroundColor = color?.cgColor
|
|
|
}
|
|
|
}
|