浏览代码

Add proper System/Light/Dark appearance with a wider sidebar.

Make AppColors adaptive, sync AppKit appearance so Light→System updates correctly, and enlarge the sidebar brand title.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 15 小时之前
父节点
当前提交
5cda0e1bab

+ 7 - 0
indeed_ai_job_search/App/RootShellView.swift

@@ -56,7 +56,14 @@ struct RootShellView: View {
             }
             .background(AppColors.backgroundPrimary)
         }
+        // Concrete scheme (never nil) — nil after Light was sticky and broke Light → System.
         .preferredColorScheme(settingsVM.preferredColorScheme)
+        .onAppear {
+            settingsVM.applyAppearanceToApp()
+        }
+        .onChange(of: settingsVM.appearanceMode) { _, _ in
+            settingsVM.applyAppearanceToApp()
+        }
         .sheet(isPresented: $showFiltersSheet) {
             NavigationStack {
                 FiltersPanelView(

+ 2 - 0
indeed_ai_job_search/App/indeed_ai_job_searchApp.swift

@@ -29,6 +29,8 @@ struct indeed_ai_job_searchApp: App {
 /// Keeps exactly one running process of this app; terminates older duplicates on launch.
 final class AppDelegate: NSObject, NSApplicationDelegate {
     func applicationWillFinishLaunching(_ notification: Notification) {
+        SettingsViewModel.applyPersistedAppearance()
+
         guard let bundleID = Bundle.main.bundleIdentifier else { return }
         let current = NSRunningApplication.current
         for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)

+ 18 - 0
indeed_ai_job_search/Assets.xcassets/AccentColor.colorset/Contents.json

@@ -11,6 +11,24 @@
         }
       },
       "idiom" : "universal"
+    },
+    {
+      "appearances" : [
+        {
+          "appearance" : "luminosity",
+          "value" : "dark"
+        }
+      ],
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0.980",
+          "green" : "0.549",
+          "red" : "0.357"
+        }
+      },
+      "idiom" : "universal"
     }
   ],
   "info" : {

+ 1 - 1
indeed_ai_job_search/DesignSystem/Components/EmptyStateView.swift

@@ -110,7 +110,7 @@ private struct ShimmerModifier: ViewModifier {
             content
                 .overlay(
                     LinearGradient(
-                        colors: [.clear, Color.white.opacity(0.35), .clear],
+                        colors: [.clear, AppColors.textPrimary.opacity(0.12), .clear],
                         startPoint: .leading,
                         endPoint: .trailing
                     )

+ 2 - 6
indeed_ai_job_search/DesignSystem/Components/FilterControls.swift

@@ -5,8 +5,7 @@
 
 import SwiftUI
 
-/// Styles plain `TextField`s that sit on light custom surfaces.
-/// macOS otherwise uses the system (often white) text color when Dark appearance is on.
+/// Styles plain `TextField`s on adaptive `AppColors` surfaces.
 struct AppPlainTextFieldModifier: ViewModifier {
     func body(content: Content) -> some View {
         content
@@ -14,8 +13,6 @@ struct AppPlainTextFieldModifier: ViewModifier {
             .font(AppTypography.body)
             .foregroundStyle(AppColors.textPrimary)
             .tint(AppColors.brand)
-            // Keep control chrome/text readable on fixed light input backgrounds.
-            .environment(\.colorScheme, .light)
     }
 }
 
@@ -52,7 +49,6 @@ struct FilterTextField: View {
                     RoundedRectangle(cornerRadius: 8, style: .continuous)
                         .stroke(AppColors.borderStrong, lineWidth: 1)
                 )
-                .environment(\.colorScheme, .light)
         }
     }
 }
@@ -65,7 +61,7 @@ struct FilterCheckboxToggleStyle: ToggleStyle {
             HStack(alignment: .center, spacing: 10) {
                 ZStack {
                     RoundedRectangle(cornerRadius: 4, style: .continuous)
-                        .fill(Color.white)
+                        .fill(AppColors.surface)
                         .frame(width: 16, height: 16)
                         .overlay(
                             RoundedRectangle(cornerRadius: 4, style: .continuous)

+ 0 - 1
indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift

@@ -132,7 +132,6 @@ struct SearchComposerView: View {
                 RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous)
                     .stroke(isFocused ? AppColors.brand : AppColors.borderStrong, lineWidth: isFocused ? 1.5 : 1)
             )
-            .environment(\.colorScheme, .light)
 
             Button {
                 onSubmit()

+ 111 - 25
indeed_ai_job_search/DesignSystem/Theme/AppColors.swift

@@ -3,42 +3,128 @@
 //  indeed_ai_job_search
 //
 
+import AppKit
 import SwiftUI
 
 enum AppColors {
     // Brand — Indeed-inspired blue from design
-    static let brand = Color(red: 0.145, green: 0.388, blue: 0.922) // #2557EB
-    static let brandDark = Color(red: 0.082, green: 0.255, blue: 0.651) // #1541A6
-    static let brandSoft = Color(red: 0.898, green: 0.933, blue: 0.988) // #E5EEFC
-    static let brandMuted = Color(red: 0.820, green: 0.886, blue: 0.976) // #D1E2F9
+    static let brand = Color.dynamic(
+        light: NSColor(srgbRed: 0.145, green: 0.388, blue: 0.922, alpha: 1), // #2557EB
+        dark: NSColor(srgbRed: 0.357, green: 0.549, blue: 0.980, alpha: 1) // #5B8CFA
+    )
+    static let brandDark = Color.dynamic(
+        light: NSColor(srgbRed: 0.082, green: 0.255, blue: 0.651, alpha: 1), // #1541A6
+        dark: NSColor(srgbRed: 0.255, green: 0.455, blue: 0.922, alpha: 1) // #4174EB
+    )
+    static let brandSoft = Color.dynamic(
+        light: NSColor(srgbRed: 0.898, green: 0.933, blue: 0.988, alpha: 1), // #E5EEFC
+        dark: NSColor(srgbRed: 0.145, green: 0.200, blue: 0.320, alpha: 1)
+    )
+    static let brandMuted = Color.dynamic(
+        light: NSColor(srgbRed: 0.820, green: 0.886, blue: 0.976, alpha: 1), // #D1E2F9
+        dark: NSColor(srgbRed: 0.180, green: 0.255, blue: 0.400, alpha: 1)
+    )
 
     // Surfaces
-    static let backgroundPrimary = Color(red: 0.973, green: 0.976, blue: 0.980) // #F8F9FA
-    static let backgroundSidebar = Color(red: 0.953, green: 0.961, blue: 0.973) // #F3F5F8
-    static let surface = Color.white
-    static let surfaceElevated = Color.white
-    static let surfaceCard = Color.white
-    static let border = Color(red: 0.890, green: 0.906, blue: 0.925) // #E3E7EC
-    static let borderStrong = Color(red: 0.820, green: 0.843, blue: 0.875) // #D1D7DF
+    static let backgroundPrimary = Color.dynamic(
+        light: NSColor(srgbRed: 0.973, green: 0.976, blue: 0.980, alpha: 1), // #F8F9FA
+        dark: NSColor(srgbRed: 0.086, green: 0.098, blue: 0.118, alpha: 1) // #161920
+    )
+    static let backgroundSidebar = Color.dynamic(
+        light: NSColor(srgbRed: 0.953, green: 0.961, blue: 0.973, alpha: 1), // #F3F5F8
+        dark: NSColor(srgbRed: 0.110, green: 0.125, blue: 0.149, alpha: 1) // #1C2026
+    )
+    static let surface = Color.dynamic(
+        light: .white,
+        dark: NSColor(srgbRed: 0.145, green: 0.165, blue: 0.196, alpha: 1) // #252A32
+    )
+    static let surfaceElevated = Color.dynamic(
+        light: .white,
+        dark: NSColor(srgbRed: 0.173, green: 0.196, blue: 0.231, alpha: 1) // #2C323B
+    )
+    static let surfaceCard = Color.dynamic(
+        light: .white,
+        dark: NSColor(srgbRed: 0.145, green: 0.165, blue: 0.196, alpha: 1) // #252A32
+    )
+    static let border = Color.dynamic(
+        light: NSColor(srgbRed: 0.890, green: 0.906, blue: 0.925, alpha: 1), // #E3E7EC
+        dark: NSColor(srgbRed: 0.255, green: 0.282, blue: 0.322, alpha: 1) // #414852
+    )
+    static let borderStrong = Color.dynamic(
+        light: NSColor(srgbRed: 0.820, green: 0.843, blue: 0.875, alpha: 1), // #D1D7DF
+        dark: NSColor(srgbRed: 0.322, green: 0.353, blue: 0.400, alpha: 1) // #525A66
+    )
 
     // Text
-    static let textPrimary = Color(red: 0.102, green: 0.122, blue: 0.153) // #1A1F27
-    static let textSecondary = Color(red: 0.420, green: 0.455, blue: 0.510) // #6B7482
-    static let textTertiary = Color(red: 0.580, green: 0.620, blue: 0.670) // #949EAB
+    static let textPrimary = Color.dynamic(
+        light: NSColor(srgbRed: 0.102, green: 0.122, blue: 0.153, alpha: 1), // #1A1F27
+        dark: NSColor(srgbRed: 0.937, green: 0.945, blue: 0.957, alpha: 1) // #EFF1F4
+    )
+    static let textSecondary = Color.dynamic(
+        light: NSColor(srgbRed: 0.420, green: 0.455, blue: 0.510, alpha: 1), // #6B7482
+        dark: NSColor(srgbRed: 0.655, green: 0.690, blue: 0.737, alpha: 1) // #A7B0BC
+    )
+    static let textTertiary = Color.dynamic(
+        light: NSColor(srgbRed: 0.580, green: 0.620, blue: 0.670, alpha: 1), // #949EAB
+        dark: NSColor(srgbRed: 0.510, green: 0.545, blue: 0.600, alpha: 1) // #828B99
+    )
     static let textOnBrand = Color.white
 
     // Semantic
-    static let salary = Color(red: 0.086, green: 0.580, blue: 0.373) // #16945F
-    static let featured = Color(red: 0.898, green: 0.933, blue: 0.988)
-    static let featuredText = Color(red: 0.145, green: 0.388, blue: 0.922)
-    static let urgent = Color(red: 1.0, green: 0.910, blue: 0.910) // #FFE8E8
-    static let urgentText = Color(red: 0.780, green: 0.220, blue: 0.250) // #C73840
-    static let easyApply = Color(red: 0.145, green: 0.388, blue: 0.922)
-    static let destructive = Color(red: 0.780, green: 0.220, blue: 0.250)
-    static let unavailable = Color(red: 0.580, green: 0.620, blue: 0.670)
-    static let success = Color(red: 0.086, green: 0.580, blue: 0.373)
-    static let warning = Color(red: 0.851, green: 0.549, blue: 0.118)
+    static let salary = Color.dynamic(
+        light: NSColor(srgbRed: 0.086, green: 0.580, blue: 0.373, alpha: 1), // #16945F
+        dark: NSColor(srgbRed: 0.290, green: 0.780, blue: 0.550, alpha: 1)
+    )
+    static let featured = Color.dynamic(
+        light: NSColor(srgbRed: 0.898, green: 0.933, blue: 0.988, alpha: 1),
+        dark: NSColor(srgbRed: 0.145, green: 0.200, blue: 0.320, alpha: 1)
+    )
+    static let featuredText = Color.dynamic(
+        light: NSColor(srgbRed: 0.145, green: 0.388, blue: 0.922, alpha: 1),
+        dark: NSColor(srgbRed: 0.557, green: 0.710, blue: 1.0, alpha: 1)
+    )
+    static let urgent = Color.dynamic(
+        light: NSColor(srgbRed: 1.0, green: 0.910, blue: 0.910, alpha: 1), // #FFE8E8
+        dark: NSColor(srgbRed: 0.320, green: 0.145, blue: 0.160, alpha: 1)
+    )
+    static let urgentText = Color.dynamic(
+        light: NSColor(srgbRed: 0.780, green: 0.220, blue: 0.250, alpha: 1), // #C73840
+        dark: NSColor(srgbRed: 1.0, green: 0.580, blue: 0.600, alpha: 1)
+    )
+    static let easyApply = Color.dynamic(
+        light: NSColor(srgbRed: 0.145, green: 0.388, blue: 0.922, alpha: 1),
+        dark: NSColor(srgbRed: 0.557, green: 0.710, blue: 1.0, alpha: 1)
+    )
+    static let destructive = Color.dynamic(
+        light: NSColor(srgbRed: 0.780, green: 0.220, blue: 0.250, alpha: 1),
+        dark: NSColor(srgbRed: 1.0, green: 0.420, blue: 0.450, alpha: 1)
+    )
+    static let unavailable = Color.dynamic(
+        light: NSColor(srgbRed: 0.580, green: 0.620, blue: 0.670, alpha: 1),
+        dark: NSColor(srgbRed: 0.510, green: 0.545, blue: 0.600, alpha: 1)
+    )
+    static let success = Color.dynamic(
+        light: NSColor(srgbRed: 0.086, green: 0.580, blue: 0.373, alpha: 1),
+        dark: NSColor(srgbRed: 0.290, green: 0.780, blue: 0.550, alpha: 1)
+    )
+    static let warning = Color.dynamic(
+        light: NSColor(srgbRed: 0.851, green: 0.549, blue: 0.118, alpha: 1),
+        dark: NSColor(srgbRed: 0.960, green: 0.690, blue: 0.290, alpha: 1)
+    )
 
     // Promo card
-    static let promoBackground = Color(red: 0.898, green: 0.933, blue: 0.988)
+    static let promoBackground = Color.dynamic(
+        light: NSColor(srgbRed: 0.898, green: 0.933, blue: 0.988, alpha: 1),
+        dark: NSColor(srgbRed: 0.145, green: 0.200, blue: 0.320, alpha: 1)
+    )
+}
+
+private extension Color {
+    static func dynamic(light: NSColor, dark: NSColor) -> Color {
+        Color(
+            nsColor: NSColor(name: nil) { appearance in
+                appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
+            }
+        )
+    }
 }

+ 1 - 1
indeed_ai_job_search/DesignSystem/Theme/AppSpacing.swift

@@ -15,7 +15,7 @@ enum AppSpacing {
     static let xxl: CGFloat = 32
     static let xxxl: CGFloat = 40
 
-    static let sidebarWidth: CGFloat = 220
+    static let sidebarWidth: CGFloat = 248
     static let filtersWidth: CGFloat = 280
     /// Clears traffic lights / top utility row (bell + profile).
     static let windowChromeTop: CGFloat = 52

+ 1 - 1
indeed_ai_job_search/DesignSystem/Theme/AppTypography.swift

@@ -7,7 +7,7 @@ import SwiftUI
 
 /// Semantic text styles that scale with Dynamic Type / Large Text.
 enum AppTypography {
-    static let brandTitle = Font.subheadline.weight(.semibold)
+    static let brandTitle = Font.headline.weight(.semibold)
     static let sidebarItem = Font.callout.weight(.medium)
     static let sectionTitle = Font.headline.weight(.semibold)
     static let jobTitle = Font.headline.weight(.semibold)

+ 0 - 2
indeed_ai_job_search/Features/JobAlerts/Views/JobAlertsView.swift

@@ -187,8 +187,6 @@ struct AlertEditorSheet: View {
             }
             .formStyle(.grouped)
             .padding(.horizontal, AppSpacing.sm)
-            // Form fields use system text color; keep them readable on light sheet surfaces.
-            .environment(\.colorScheme, .light)
             .foregroundStyle(AppColors.textPrimary)
 
             HStack {

+ 1 - 2
indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift

@@ -161,7 +161,6 @@ struct SavedJobsView: View {
             RoundedRectangle(cornerRadius: 8, style: .continuous)
                 .stroke(AppColors.borderStrong, lineWidth: 1)
         )
-        .environment(\.colorScheme, .light)
     }
 
     private var workTypeDropdown: some View {
@@ -200,7 +199,7 @@ struct SavedJobsView: View {
             .padding(.vertical, 8)
             .background(
                 Capsule()
-                    .fill(isSelected ? AppColors.brandSoft : Color.white)
+                    .fill(isSelected ? AppColors.brandSoft : AppColors.surface)
             )
             .overlay(
                 Capsule()

+ 73 - 4
indeed_ai_job_search/Features/Settings/ViewModels/SettingsViewModel.swift

@@ -30,23 +30,88 @@ final class SettingsViewModel {
     var appearanceMode: AppearanceMode {
         didSet {
             UserDefaults.standard.set(appearanceMode.rawValue, forKey: Keys.appearanceMode)
+            applyAppearanceToApp()
+            // Force SwiftUI to re-read `preferredColorScheme` even when System resolves
+            // to the same concrete scheme as the previous Light/Dark choice.
+            appearanceEpoch &+= 1
         }
     }
 
+    /// Bumped on mode changes and system theme changes so appearance bindings refresh.
+    private(set) var appearanceEpoch: UInt = 0
+
     var appVersionLabel: String {
         let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
         let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
         return "Version \(version) (\(build))"
     }
 
-    var preferredColorScheme: ColorScheme? {
+    /// Always returns a concrete scheme. Passing `nil` after `.light`/`.dark` is sticky in SwiftUI
+    /// and is why Light → System failed to update.
+    var preferredColorScheme: ColorScheme {
+        _ = appearanceEpoch
         switch appearanceMode {
-        case .system: return nil
-        case .light: return .light
-        case .dark: return .dark
+        case .system:
+            return Self.systemColorScheme
+        case .light:
+            return .light
+        case .dark:
+            return .dark
+        }
+    }
+
+    /// Keeps AppKit chrome and dynamic `AppColors` aligned with the selected mode.
+    func applyAppearanceToApp() {
+        Self.apply(appearanceMode)
+    }
+
+    /// Apply a persisted preference before the first window paints (avoids a theme flash).
+    static func applyPersistedAppearance() {
+        let raw = UserDefaults.standard.string(forKey: Keys.appearanceMode) ?? AppearanceMode.system.rawValue
+        apply(AppearanceMode(rawValue: raw) ?? .system)
+    }
+
+    /// True system appearance from System Settings (ignores any `NSApp.appearance` override).
+    private static var systemColorScheme: ColorScheme {
+        UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" ? .dark : .light
+    }
+
+    private static func apply(_ mode: AppearanceMode) {
+        let appearance: NSAppearance?
+        switch mode {
+        case .system:
+            appearance = nil
+        case .light:
+            appearance = NSAppearance(named: .aqua)
+        case .dark:
+            appearance = NSAppearance(named: .darkAqua)
+        }
+
+        NSApp.appearance = appearance
+        // Window-level overrides can linger after Light/Dark; clear them for System.
+        for window in NSApp.windows {
+            window.appearance = appearance
         }
     }
 
+    private func startObservingSystemAppearanceChanges() {
+        systemAppearanceObserver = DistributedNotificationCenter.default().addObserver(
+            forName: Notification.Name("AppleInterfaceThemeChangedNotification"),
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            Task { @MainActor [weak self] in
+                self?.handleSystemAppearanceChanged()
+            }
+        }
+    }
+
+    private func handleSystemAppearanceChanged() {
+        guard appearanceMode == .system else { return }
+        applyAppearanceToApp()
+        appearanceEpoch &+= 1
+    }
+
     var savedJobCount: Int {
         _ = dataVersion
         return savedJobsStore.savedJobs.count
@@ -54,6 +119,7 @@ final class SettingsViewModel {
 
     private let savedJobsStore: any SavedJobsStore
     private let onLocalDataCleared: (() -> Void)?
+    private var systemAppearanceObserver: NSObjectProtocol?
 
     init(
         savedJobsStore: any SavedJobsStore,
@@ -66,6 +132,9 @@ final class SettingsViewModel {
         let raw = defaults.string(forKey: Keys.appearanceMode) ?? AppearanceMode.system.rawValue
         appearanceMode = AppearanceMode(rawValue: raw) ?? .system
         quietHoursEnabled = defaults.bool(forKey: Keys.quietHoursEnabled)
+        // didSet does not run during init — apply the persisted preference immediately.
+        applyAppearanceToApp()
+        startObservingSystemAppearanceChanges()
     }
 
     convenience init(dependencies: AppDependencies, onLocalDataCleared: (() -> Void)? = nil) {