Browse Source

Add consistent hover feedback across all buttons to improve macOS affordance.

Extend the Clear button interaction pattern to primary, toolbar, icon, sidebar, and premium buttons with shared theme tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 month ago
parent
commit
7cd1958891

+ 22 - 0
gramora/Utilities/AppTheme.swift

@@ -42,6 +42,20 @@ enum AppTheme {
     static let clearBorder = Color(red: 0.94, green: 0.80, blue: 0.80)
     static let clearShadow = Color(red: 0.82, green: 0.32, blue: 0.36).opacity(0.18)
 
+    static let toolbarBackgroundHover = Color(red: 0.96, green: 0.99, blue: 0.98)
+    static let toolbarForegroundHover = teal
+    static let toolbarBorderHover = teal.opacity(0.35)
+    static let toolbarShadow = Color.black.opacity(0.03)
+    static let toolbarShadowHover = Color.black.opacity(0.08)
+
+    static let gradientStartHover = Color(red: 0.0, green: 0.68, blue: 0.52)
+    static let gradientEndHover = Color(red: 0.25, green: 0.84, blue: 0.66)
+    static let primaryShadow = teal.opacity(0.30)
+    static let primaryShadowHover = teal.opacity(0.42)
+
+    static let navBackgroundHover = Color(red: 0.88, green: 0.96, blue: 0.93)
+    static let navForegroundHover = teal
+
     static var primaryGradient: LinearGradient {
         LinearGradient(
             colors: [gradientStart, gradientEnd],
@@ -49,4 +63,12 @@ enum AppTheme {
             endPoint: .trailing
         )
     }
+
+    static var primaryGradientHover: LinearGradient {
+        LinearGradient(
+            colors: [gradientStartHover, gradientEndHover],
+            startPoint: .leading,
+            endPoint: .trailing
+        )
+    }
 }

+ 37 - 10
gramora/Views/Components/GradientButton.swift

@@ -4,6 +4,8 @@ struct GradientButton: View {
     let title: String
     let action: () -> Void
 
+    @State private var isHovered = false
+
     var body: some View {
         Button(action: action) {
             HStack(spacing: 10) {
@@ -21,11 +23,18 @@ struct GradientButton: View {
             .padding(.horizontal, 28)
             .padding(.vertical, 15)
             .frame(maxWidth: .infinity)
-            .background(AppTheme.primaryGradient)
+            .background(isHovered ? AppTheme.primaryGradientHover : AppTheme.primaryGradient)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.buttonCornerRadius))
-            .shadow(color: AppTheme.teal.opacity(0.30), radius: 10, y: 4)
+            .shadow(
+                color: isHovered ? AppTheme.primaryShadowHover : AppTheme.primaryShadow,
+                radius: isHovered ? 14 : 10,
+                y: isHovered ? 6 : 4
+            )
+            .scaleEffect(isHovered ? 1.02 : 1.0)
+            .animation(.easeOut(duration: 0.15), value: isHovered)
         }
         .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
     }
 }
 
@@ -34,6 +43,8 @@ struct ToolbarButton: View {
     let iconName: String
     let action: () -> Void
 
+    @State private var isHovered = false
+
     var body: some View {
         Button(action: action) {
             HStack(spacing: 6) {
@@ -43,18 +54,25 @@ struct ToolbarButton: View {
                 Text(title)
                     .font(.system(size: 12, weight: .medium))
             }
-            .foregroundStyle(AppTheme.textSecondary)
+            .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
             .padding(.horizontal, 12)
             .padding(.vertical, 7)
-            .background(Color.white)
+            .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
             .clipShape(RoundedRectangle(cornerRadius: 8))
             .overlay(
                 RoundedRectangle(cornerRadius: 8)
-                    .stroke(AppTheme.border, lineWidth: 1)
+                    .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
             )
-            .shadow(color: Color.black.opacity(0.03), radius: 2, y: 1)
+            .shadow(
+                color: isHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
+                radius: isHovered ? 4 : 2,
+                y: isHovered ? 2 : 1
+            )
+            .scaleEffect(isHovered ? 1.02 : 1.0)
+            .animation(.easeOut(duration: 0.15), value: isHovered)
         }
         .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
     }
 }
 
@@ -62,21 +80,30 @@ struct IconButton: View {
     let iconName: String
     let action: () -> Void
 
+    @State private var isHovered = false
+
     var body: some View {
         Button(action: action) {
             Image(systemName: iconName)
                 .font(.system(size: 14))
-                .foregroundStyle(AppTheme.textSecondary)
+                .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
                 .frame(width: 36, height: 36)
-                .background(Color.white)
+                .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
                 .clipShape(RoundedRectangle(cornerRadius: 8))
                 .overlay(
                     RoundedRectangle(cornerRadius: 8)
-                        .stroke(AppTheme.border, lineWidth: 1)
+                        .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
                 )
-                .shadow(color: Color.black.opacity(0.04), radius: 3, y: 1)
+                .shadow(
+                    color: isHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
+                    radius: isHovered ? 5 : 3,
+                    y: isHovered ? 2 : 1
+                )
+                .scaleEffect(isHovered ? 1.02 : 1.0)
+                .animation(.easeOut(duration: 0.15), value: isHovered)
         }
         .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
     }
 }
 

+ 13 - 3
gramora/Views/Components/PremiumCardView.swift

@@ -1,6 +1,8 @@
 import SwiftUI
 
 struct PremiumCardView: View {
+    @State private var isUpgradeHovered = false
+
     var body: some View {
         VStack(alignment: .leading, spacing: 10) {
             HStack(spacing: 8) {
@@ -27,17 +29,25 @@ struct PremiumCardView: View {
                     Image(systemName: "arrow.up.right")
                         .font(.system(size: 10, weight: .semibold))
                 }
-                .foregroundStyle(AppTheme.textPrimary)
+                .foregroundStyle(isUpgradeHovered ? AppTheme.toolbarForegroundHover : AppTheme.textPrimary)
                 .frame(maxWidth: .infinity)
                 .padding(.vertical, 9)
-                .background(Color.white)
+                .background(isUpgradeHovered ? AppTheme.toolbarBackgroundHover : Color.white)
                 .clipShape(RoundedRectangle(cornerRadius: 8))
                 .overlay(
                     RoundedRectangle(cornerRadius: 8)
-                        .stroke(AppTheme.border, lineWidth: 1)
+                        .stroke(isUpgradeHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
+                )
+                .shadow(
+                    color: isUpgradeHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
+                    radius: isUpgradeHovered ? 4 : 2,
+                    y: isUpgradeHovered ? 2 : 1
                 )
+                .scaleEffect(isUpgradeHovered ? 1.02 : 1.0)
+                .animation(.easeOut(duration: 0.15), value: isUpgradeHovered)
             }
             .buttonStyle(.plain)
+            .onHover { isUpgradeHovered = $0 }
         }
         .padding(14)
         .background(AppTheme.premiumBackground)

+ 21 - 2
gramora/Views/SidebarView.swift

@@ -61,6 +61,8 @@ private struct SidebarNavItem: View {
     let isSelected: Bool
     let action: () -> Void
 
+    @State private var isHovered = false
+
     var body: some View {
         Button(action: action) {
             HStack(spacing: 10) {
@@ -74,14 +76,31 @@ private struct SidebarNavItem: View {
 
                 Spacer()
             }
-            .foregroundStyle(isSelected ? AppTheme.teal : AppTheme.textSecondary)
+            .foregroundStyle(foregroundColor)
             .padding(.horizontal, 12)
             .padding(.vertical, 9)
             .background(
                 RoundedRectangle(cornerRadius: AppTheme.navItemCornerRadius)
-                    .fill(isSelected ? AppTheme.tealLight : Color.clear)
+                    .fill(backgroundColor)
             )
+            .scaleEffect(isHovered ? 1.02 : 1.0)
+            .animation(.easeOut(duration: 0.15), value: isHovered)
         }
         .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
+    }
+
+    private var foregroundColor: Color {
+        if isSelected {
+            return AppTheme.teal
+        }
+        return isHovered ? AppTheme.navForegroundHover : AppTheme.textSecondary
+    }
+
+    private var backgroundColor: Color {
+        if isSelected {
+            return isHovered ? AppTheme.navBackgroundHover : AppTheme.tealLight
+        }
+        return isHovered ? AppTheme.tealLight : Color.clear
     }
 }