Browse Source

Add hover feedback to premium plan CTAs and close button

Introduce PlanPurchaseHoverButton and PremiumCloseHoverButton with
NSTrackingArea-driven hover styles, shadows, and pointing-hand cursor.
Use AnyObject? for button targets to satisfy NSButton weak target typing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 months ago
parent
commit
6a8ab9919e
1 changed files with 216 additions and 26 deletions
  1. 216 26
      App for Indeed/Controllers/PremiumPlansWindowController.swift

+ 216 - 26
App for Indeed/Controllers/PremiumPlansWindowController.swift

@@ -151,6 +151,214 @@ private final class PremiumPlansViewController: NSViewController {
         }
     }
 
+    /// Purchase CTAs: fill/border/shadow + pointing hand on hover.
+    private final class PlanPurchaseHoverButton: NSButton {
+        private var trackingAreaRef: NSTrackingArea?
+        private var didPushCursor = false
+        private let isPrimaryStyle: Bool
+
+        private static let primaryFill = NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
+        private static let primaryFillHover = NSColor(srgbRed: 205 / 255, green: 88 / 255, blue: 255 / 255, alpha: 1)
+
+        init(planId: String, title: String, isPrimaryStyle: Bool, target: AnyObject?, action: Selector) {
+            self.isPrimaryStyle = isPrimaryStyle
+            super.init(frame: .zero)
+            identifier = NSUserInterfaceItemIdentifier(planId)
+            self.title = title
+            self.target = target
+            self.action = action
+            isBordered = false
+            bezelStyle = .rounded
+            font = .systemFont(ofSize: 14, weight: .semibold)
+            wantsLayer = true
+            layer?.cornerRadius = 12
+            focusRingType = .none
+            translatesAutoresizingMaskIntoConstraints = false
+            applyBaseStyle(hovered: false)
+        }
+
+        @available(*, unavailable)
+        required init?(coder: NSCoder) {
+            nil
+        }
+
+        override var isEnabled: Bool {
+            didSet {
+                if !isEnabled {
+                    applyBaseStyle(hovered: false, animated: true)
+                    if didPushCursor {
+                        NSCursor.pop()
+                        didPushCursor = false
+                    }
+                }
+            }
+        }
+
+        override func updateTrackingAreas() {
+            super.updateTrackingAreas()
+            if let trackingAreaRef {
+                removeTrackingArea(trackingAreaRef)
+            }
+            let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
+            let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
+            addTrackingArea(area)
+            trackingAreaRef = area
+        }
+
+        override func mouseEntered(with event: NSEvent) {
+            super.mouseEntered(with: event)
+            guard isEnabled else { return }
+            applyBaseStyle(hovered: true, animated: true)
+            if !didPushCursor {
+                NSCursor.pointingHand.push()
+                didPushCursor = true
+            }
+        }
+
+        override func mouseExited(with event: NSEvent) {
+            super.mouseExited(with: event)
+            applyBaseStyle(hovered: false, animated: true)
+            if didPushCursor {
+                NSCursor.pop()
+                didPushCursor = false
+            }
+        }
+
+        override func viewWillMove(toWindow newWindow: NSWindow?) {
+            super.viewWillMove(toWindow: newWindow)
+            if newWindow == nil, didPushCursor {
+                NSCursor.pop()
+                didPushCursor = false
+            }
+            if newWindow == nil {
+                applyBaseStyle(hovered: false, animated: false)
+            }
+        }
+
+        private func applyBaseStyle(hovered: Bool, animated: Bool = true) {
+            let updates = {
+                if self.isPrimaryStyle {
+                    self.layer?.backgroundColor = (hovered ? Self.primaryFillHover : Self.primaryFill).cgColor
+                    self.layer?.borderColor = Theme.accent.cgColor
+                    self.layer?.borderWidth = hovered ? 2 : 1
+                    self.contentTintColor = .white
+                    self.layer?.shadowColor = Self.primaryFill.cgColor
+                    self.layer?.shadowOpacity = hovered ? 0.28 : 0
+                    self.layer?.shadowRadius = hovered ? 12 : 0
+                    self.layer?.shadowOffset = CGSize(width: 0, height: -2)
+                } else {
+                    let baseFill = Theme.mutedButtonFill
+                    let hoverFill = baseFill.blended(withFraction: 0.22, of: Theme.accent) ?? baseFill
+                    self.layer?.backgroundColor = (hovered ? hoverFill : baseFill).cgColor
+                    self.layer?.borderColor = (hovered ? Theme.accent : Theme.divider).cgColor
+                    self.layer?.borderWidth = hovered ? 2 : 1
+                    self.contentTintColor = Theme.primaryText
+                    self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.35).cgColor
+                    self.layer?.shadowOpacity = hovered ? 0.18 : 0
+                    self.layer?.shadowRadius = hovered ? 10 : 0
+                    self.layer?.shadowOffset = CGSize(width: 0, height: -2)
+                }
+            }
+            if animated {
+                NSAnimationContext.runAnimationGroup { context in
+                    context.duration = 0.16
+                    updates()
+                }
+            } else {
+                updates()
+            }
+        }
+    }
+
+    /// Close control: subtle lift + accent tint on hover.
+    private final class PremiumCloseHoverButton: NSButton {
+        private var trackingAreaRef: NSTrackingArea?
+        private var didPushCursor = false
+
+        init(target: AnyObject?, action: Selector) {
+            super.init(frame: .zero)
+            self.target = target
+            self.action = action
+            isBordered = false
+            wantsLayer = true
+            layer?.cornerRadius = 15
+            bezelStyle = .regularSquare
+            image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
+            imageScaling = .scaleProportionallyDown
+            focusRingType = .none
+            translatesAutoresizingMaskIntoConstraints = false
+            applyStyle(hovered: false, animated: false)
+        }
+
+        @available(*, unavailable)
+        required init?(coder: NSCoder) {
+            nil
+        }
+
+        override func updateTrackingAreas() {
+            super.updateTrackingAreas()
+            if let trackingAreaRef {
+                removeTrackingArea(trackingAreaRef)
+            }
+            let options: NSTrackingArea.Options = [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect]
+            let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
+            addTrackingArea(area)
+            trackingAreaRef = area
+        }
+
+        override func mouseEntered(with event: NSEvent) {
+            super.mouseEntered(with: event)
+            applyStyle(hovered: true, animated: true)
+            if !didPushCursor {
+                NSCursor.pointingHand.push()
+                didPushCursor = true
+            }
+        }
+
+        override func mouseExited(with event: NSEvent) {
+            super.mouseExited(with: event)
+            applyStyle(hovered: false, animated: true)
+            if didPushCursor {
+                NSCursor.pop()
+                didPushCursor = false
+            }
+        }
+
+        override func viewWillMove(toWindow newWindow: NSWindow?) {
+            super.viewWillMove(toWindow: newWindow)
+            if newWindow == nil, didPushCursor {
+                NSCursor.pop()
+                didPushCursor = false
+            }
+            if newWindow == nil {
+                applyStyle(hovered: false, animated: false)
+            }
+        }
+
+        private func applyStyle(hovered: Bool, animated: Bool) {
+            let updates = {
+                self.layer?.backgroundColor = (hovered
+                    ? NSColor.white.withAlphaComponent(0.98)
+                    : NSColor.white.withAlphaComponent(0.92)).cgColor
+                self.layer?.borderColor = (hovered ? Theme.accent.withAlphaComponent(0.45) : Theme.divider).cgColor
+                self.layer?.borderWidth = hovered ? 1.5 : 1
+                self.contentTintColor = hovered ? Theme.accent : Theme.secondaryText
+                self.layer?.shadowColor = Theme.accent.withAlphaComponent(0.25).cgColor
+                self.layer?.shadowOpacity = hovered ? 0.2 : 0
+                self.layer?.shadowRadius = hovered ? 8 : 0
+                self.layer?.shadowOffset = CGSize(width: 0, height: -1)
+            }
+            if animated {
+                NSAnimationContext.runAnimationGroup { context in
+                    context.duration = 0.15
+                    updates()
+                }
+            } else {
+                updates()
+            }
+        }
+    }
+
     private struct Plan {
         let id: String
         let title: String
@@ -300,18 +508,7 @@ private final class PremiumPlansViewController: NSViewController {
     }
 
     private func setupLayout() {
-        let closeButton = NSButton(title: "", target: self, action: #selector(didTapClose))
-        closeButton.translatesAutoresizingMaskIntoConstraints = false
-        closeButton.isBordered = false
-        closeButton.wantsLayer = true
-        closeButton.layer?.cornerRadius = 15
-        closeButton.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor
-        closeButton.layer?.borderWidth = 1
-        closeButton.layer?.borderColor = Theme.divider.cgColor
-        closeButton.contentTintColor = Theme.secondaryText
-        closeButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")
-        closeButton.imageScaling = .scaleProportionallyDown
-        closeButton.bezelStyle = .regularSquare
+        let closeButton = PremiumCloseHoverButton(target: self, action: #selector(didTapClose))
 
         let crownIcon = NSImageView()
         crownIcon.translatesAutoresizingMaskIntoConstraints = false
@@ -441,22 +638,15 @@ private final class PremiumPlansViewController: NSViewController {
         featuresStack.alignment = .leading
         featuresStack.edgeInsets = FeatureListMetrics.edgeInsets
 
-        let selectButton = NSButton(title: "Get \(plan.title)", target: self, action: #selector(didTapSelectPlan))
-        selectButton.identifier = NSUserInterfaceItemIdentifier(plan.id)
+        let selectButton = PlanPurchaseHoverButton(
+            planId: plan.id,
+            title: "Get \(plan.title)",
+            isPrimaryStyle: plan.highlight,
+            target: self,
+            action: #selector(didTapSelectPlan)
+        )
         planPurchaseButtons[plan.id] = selectButton
         planPriceFields[plan.id] = (priceLabel, periodLabel)
-        selectButton.isBordered = false
-        selectButton.bezelStyle = .rounded
-        selectButton.font = .systemFont(ofSize: 14, weight: .semibold)
-        selectButton.contentTintColor = plan.highlight ? .white : Theme.primaryText
-        selectButton.wantsLayer = true
-        selectButton.layer?.cornerRadius = 12
-        selectButton.layer?.borderWidth = 1
-        selectButton.layer?.borderColor = (plan.highlight ? Theme.accent : Theme.divider).cgColor
-        selectButton.layer?.backgroundColor = (plan.highlight
-            ? NSColor(srgbRed: 189 / 255, green: 52 / 255, blue: 255 / 255, alpha: 1)
-            : Theme.mutedButtonFill).cgColor
-        selectButton.translatesAutoresizingMaskIntoConstraints = false
         selectButton.heightAnchor.constraint(equalToConstant: 50).isActive = true
 
         var contentViews: [NSView] = [iconWell, titleLabel, subtitleLabel, priceRow]