Quellcode durchsuchen

Add dark theme support and fix paywall styling for dark mode.

Introduce ThemeManager with theme-aware AppTheme colors, update tool views and window chrome for dark mode, and fix paywall title, plan labels, footer links, and hover states.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 vor 1 Monat
Ursprung
Commit
e9fdc42f75

+ 3 - 1
gramora/App/GramoraApp.swift

@@ -3,15 +3,17 @@ import SwiftUI
 @main
 struct GramoraApp: App {
     @StateObject private var subscriptions = SubscriptionManager()
+    @StateObject private var themeManager = ThemeManager()
 
     var body: some Scene {
         WindowGroup {
             MainView()
                 .environmentObject(subscriptions)
+                .environmentObject(themeManager)
                 .frame(minWidth: AppTheme.windowMinWidth, minHeight: AppTheme.windowMinHeight)
                 .initialWindowSize(width: AppTheme.windowWidth, height: AppTheme.windowHeight)
                 .configureWindowChrome()
-                .preferredColorScheme(.light)
+                .preferredColorScheme(themeManager.preferredColorScheme)
                 .task {
                     await NetworkClient.prepare()
                 }

+ 31 - 0
gramora/Managers/ThemeManager.swift

@@ -0,0 +1,31 @@
+import Combine
+import Foundation
+import SwiftUI
+
+extension Notification.Name {
+    static let themeDidChange = Notification.Name("themeDidChange")
+}
+
+@MainActor
+final class ThemeManager: ObservableObject {
+    static let storageKey = "isDarkMode"
+
+    @Published var isDarkMode: Bool {
+        didSet {
+            guard isDarkMode != oldValue else { return }
+            defaults.set(isDarkMode, forKey: Self.storageKey)
+            NotificationCenter.default.post(name: .themeDidChange, object: nil)
+        }
+    }
+
+    private let defaults: UserDefaults
+
+    init(defaults: UserDefaults = .standard) {
+        self.defaults = defaults
+        self.isDarkMode = defaults.bool(forKey: Self.storageKey)
+    }
+
+    var preferredColorScheme: ColorScheme? {
+        isDarkMode ? .dark : .light
+    }
+}

+ 133 - 22
gramora/Utilities/AppTheme.swift

@@ -37,47 +37,158 @@ enum AppTheme {
     static let textEditorVerticalInset: CGFloat = 20
     static let compactTextEditorHeight: CGFloat = 240
 
-    static let background = Color(red: 0.973, green: 0.980, blue: 0.984)
-    static let sidebarBackground = Color.white
-    static let cardBackground = Color.white
-    static let textPrimary = Color(red: 0.10, green: 0.14, blue: 0.22)
-    static let textSecondary = Color(red: 0.28, green: 0.32, blue: 0.38)
-    static let textMuted = Color(red: 0.42, green: 0.46, blue: 0.50)
-    static let border = Color(red: 0.78, green: 0.82, blue: 0.87)
-    static let inputBorder = border
+    private static var isDark: Bool {
+        UserDefaults.standard.bool(forKey: ThemeManager.storageKey)
+    }
+
+    static var background: Color {
+        isDark
+            ? Color(red: 0.08, green: 0.09, blue: 0.11)
+            : Color(red: 0.973, green: 0.980, blue: 0.984)
+    }
+
+    static var sidebarBackground: Color {
+        isDark
+            ? Color(red: 0.11, green: 0.12, blue: 0.15)
+            : Color.white
+    }
+
+    static var cardBackground: Color {
+        isDark
+            ? Color(red: 0.14, green: 0.15, blue: 0.18)
+            : Color.white
+    }
+
+    static var surface: Color { cardBackground }
+
+    static var sectionBackground: Color {
+        isDark
+            ? Color(red: 0.12, green: 0.13, blue: 0.16)
+            : Color(red: 0.98, green: 0.99, blue: 0.99)
+    }
+
+    static var panelBackground: Color {
+        isDark
+            ? Color(red: 0.10, green: 0.11, blue: 0.13)
+            : Color(red: 0.96, green: 0.97, blue: 0.98)
+    }
+
+    static var textPrimary: Color {
+        isDark
+            ? Color(red: 0.94, green: 0.95, blue: 0.97)
+            : Color(red: 0.10, green: 0.14, blue: 0.22)
+    }
+
+    static var textSecondary: Color {
+        isDark
+            ? Color(red: 0.74, green: 0.77, blue: 0.82)
+            : Color(red: 0.28, green: 0.32, blue: 0.38)
+    }
+
+    static var textMuted: Color {
+        isDark
+            ? Color(red: 0.54, green: 0.57, blue: 0.62)
+            : Color(red: 0.42, green: 0.46, blue: 0.50)
+    }
+
+    static var border: Color {
+        isDark
+            ? Color(red: 0.24, green: 0.26, blue: 0.30)
+            : Color(red: 0.78, green: 0.82, blue: 0.87)
+    }
+
+    static var inputBorder: Color { border }
 
     static let teal = Color(red: 0.0, green: 0.651, blue: 0.494)
-    static let tealLight = Color(red: 0.90, green: 0.97, blue: 0.94)
     static let tealDark = Color(red: 0.0, green: 0.55, blue: 0.42)
+
+    static var tealLight: Color {
+        isDark
+            ? Color(red: 0.0, green: 0.651, blue: 0.494).opacity(0.16)
+            : Color(red: 0.90, green: 0.97, blue: 0.94)
+    }
+
     static let gradientStart = Color(red: 0.0, green: 0.62, blue: 0.47)
     static let gradientEnd = Color(red: 0.20, green: 0.78, blue: 0.60)
 
-    static let premiumBackground = Color(red: 0.91, green: 0.97, blue: 0.95)
-    static let premiumBorder = Color(red: 0.82, green: 0.93, blue: 0.88)
-    static let blobColor = Color(red: 0.0, green: 0.651, blue: 0.494).opacity(0.07)
-    static let dotColor = Color(red: 0.0, green: 0.651, blue: 0.494).opacity(0.14)
+    static var premiumBackground: Color {
+        isDark
+            ? Color(red: 0.0, green: 0.651, blue: 0.494).opacity(0.12)
+            : Color(red: 0.91, green: 0.97, blue: 0.95)
+    }
 
-    static let cardShadow = Color.black.opacity(0.06)
+    static var premiumBorder: Color {
+        isDark
+            ? Color(red: 0.0, green: 0.651, blue: 0.494).opacity(0.28)
+            : Color(red: 0.82, green: 0.93, blue: 0.88)
+    }
+
+    static var blobColor: Color {
+        Color(red: 0.0, green: 0.651, blue: 0.494).opacity(isDark ? 0.10 : 0.07)
+    }
+
+    static var dotColor: Color {
+        Color(red: 0.0, green: 0.651, blue: 0.494).opacity(isDark ? 0.18 : 0.14)
+    }
+
+    static var cardShadow: Color {
+        Color.black.opacity(isDark ? 0.24 : 0.06)
+    }
+
+    static var clearBackground: Color {
+        isDark
+            ? Color(red: 0.28, green: 0.12, blue: 0.14)
+            : Color(red: 1.0, green: 0.95, blue: 0.94)
+    }
+
+    static var clearBackgroundHover: Color {
+        isDark
+            ? Color(red: 0.34, green: 0.14, blue: 0.16)
+            : Color(red: 1.0, green: 0.91, blue: 0.90)
+    }
 
-    static let clearBackground = Color(red: 1.0, green: 0.95, blue: 0.94)
-    static let clearBackgroundHover = Color(red: 1.0, green: 0.91, blue: 0.90)
     static let clearForeground = Color(red: 0.82, green: 0.32, blue: 0.36)
     static let clearForegroundHover = Color(red: 0.72, green: 0.22, blue: 0.28)
-    static let clearBorder = Color(red: 0.94, green: 0.80, blue: 0.80)
+
+    static var clearBorder: Color {
+        isDark
+            ? Color(red: 0.82, green: 0.32, blue: 0.36).opacity(0.35)
+            : 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 var toolbarBackgroundHover: Color {
+        isDark
+            ? Color(red: 0.18, green: 0.19, blue: 0.22)
+            : 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 var toolbarBorderHover: Color {
+        teal.opacity(isDark ? 0.45 : 0.35)
+    }
+
+    static var toolbarShadow: Color {
+        Color.black.opacity(isDark ? 0.18 : 0.03)
+    }
+
+    static var toolbarShadowHover: Color {
+        Color.black.opacity(isDark ? 0.28 : 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 var navBackgroundHover: Color {
+        isDark
+            ? Color(red: 0.0, green: 0.651, blue: 0.494).opacity(0.14)
+            : Color(red: 0.88, green: 0.96, blue: 0.93)
+    }
+
     static let navForegroundHover = teal
 
     static var primaryGradient: LinearGradient {

+ 8 - 0
gramora/Utilities/View+ClearHostingBackground.swift

@@ -0,0 +1,8 @@
+import SwiftUI
+
+extension View {
+    /// Prevents root `GeometryReader` views from painting an opaque system background on macOS.
+    func clearHostingBackground() -> some View {
+        background(Color.clear)
+    }
+}

+ 15 - 3
gramora/Utilities/WindowChromeConfigurator.swift

@@ -43,19 +43,21 @@ private struct WindowChromeAccessor: NSViewRepresentable {
         }
 
         private func applyChrome(to window: NSWindow) {
+            let isDark = UserDefaults.standard.bool(forKey: ThemeManager.storageKey)
+
             window.styleMask.insert(.fullSizeContentView)
             window.titlebarAppearsTransparent = true
             window.titleVisibility = .hidden
             window.isMovableByWindowBackground = true
             window.titlebarSeparatorStyle = .none
             window.title = ""
-            window.appearance = NSAppearance(named: .aqua)
+            window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua)
             window.backgroundColor = NSColor(AppTheme.background)
         }
 
         private func setupObservers(for window: NSWindow) {
             let center = NotificationCenter.default
-            let names: [Notification.Name] = [
+            let windowNames: [Notification.Name] = [
                 NSWindow.didBecomeKeyNotification,
                 NSWindow.didEnterFullScreenNotification,
                 NSWindow.willExitFullScreenNotification,
@@ -63,7 +65,7 @@ private struct WindowChromeAccessor: NSViewRepresentable {
                 NSWindow.didResizeNotification,
             ]
 
-            for name in names {
+            for name in windowNames {
                 let observer = center.addObserver(
                     forName: name,
                     object: window,
@@ -75,6 +77,16 @@ private struct WindowChromeAccessor: NSViewRepresentable {
                 }
                 observers.append(observer)
             }
+
+            let themeObserver = center.addObserver(
+                forName: .themeDidChange,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                guard let self, let window = self.observedWindow else { return }
+                self.applyChrome(to: window)
+            }
+            observers.append(themeObserver)
         }
 
         private func teardown() {

+ 18 - 0
gramora/ViewModels/SettingsViewModel.swift

@@ -9,19 +9,33 @@ final class SettingsViewModel: ObservableObject {
     @Published var isShowingClearSearchesConfirmation = false
 
     private let historyStorage: HistoryStoring
+    private let themeManager: ThemeManager
     private let defaults: UserDefaults
     private var cancellables = Set<AnyCancellable>()
 
     private static let dictionaryRecentSearchesKey = "dictionaryRecentSearches"
 
+    var isDarkModeEnabled: Bool {
+        themeManager.isDarkMode
+    }
+
     init(
         historyStorage: HistoryStoring? = nil,
+        themeManager: ThemeManager,
         defaults: UserDefaults = .standard
     ) {
         self.historyStorage = historyStorage ?? HistoryStorageService.shared
+        self.themeManager = themeManager
         self.defaults = defaults
         refreshDataCounts()
 
+        themeManager.objectWillChange
+            .receive(on: RunLoop.main)
+            .sink { [weak self] _ in
+                self?.objectWillChange.send()
+            }
+            .store(in: &cancellables)
+
         NotificationCenter.default.publisher(for: .historyDidChange)
             .receive(on: RunLoop.main)
             .sink { [weak self] _ in
@@ -30,6 +44,10 @@ final class SettingsViewModel: ObservableObject {
             .store(in: &cancellables)
     }
 
+    func setDarkModeEnabled(_ isEnabled: Bool) {
+        themeManager.isDarkMode = isEnabled
+    }
+
     func refreshDataCounts() {
         historyEntryCount = historyStorage.loadEntries().count
         dictionarySearchCount = defaults.stringArray(forKey: Self.dictionaryRecentSearchesKey)?.count ?? 0

+ 3 - 3
gramora/Views/Components/GradientButton.swift

@@ -64,7 +64,7 @@ struct ToolbarButton: View {
             .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
             .padding(.horizontal, horizontalPadding)
             .padding(.vertical, verticalPadding)
-            .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
+            .background(isHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
             .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
             .overlay(
                 RoundedRectangle(cornerRadius: cornerRadius)
@@ -109,7 +109,7 @@ struct ToolbarMenuButton<MenuContent: View>: View {
             .padding(.vertical, 7)
             .background(
                 RoundedRectangle(cornerRadius: 8, style: .continuous)
-                    .fill(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
+                    .fill(isHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
             )
             .overlay(
                 RoundedRectangle(cornerRadius: 8, style: .continuous)
@@ -298,7 +298,7 @@ struct ChipButton: View {
         if isSelected {
             return AppTheme.tealLight
         }
-        return isHovered ? AppTheme.toolbarBackgroundHover : Color.white
+        return isHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface
     }
 
     private var chipBorder: Color {

+ 1 - 1
gramora/Views/Components/PremiumCardView.swift

@@ -39,7 +39,7 @@ struct PremiumCardView: View {
                 .foregroundStyle(isUpgradeHovered ? AppTheme.toolbarForegroundHover : AppTheme.textPrimary)
                 .frame(maxWidth: .infinity)
                 .padding(.vertical, 9)
-                .background(isUpgradeHovered ? AppTheme.toolbarBackgroundHover : Color.white)
+                .background(isUpgradeHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: 8))
                 .overlay(
                     RoundedRectangle(cornerRadius: 8)

+ 18 - 0
gramora/Views/Components/ThinCaretTextEditor.swift

@@ -181,6 +181,24 @@ struct ThinCaretTextEditor: NSViewRepresentable {
 
         init(parent: ThinCaretTextEditor) {
             self.parent = parent
+            super.init()
+            NotificationCenter.default.addObserver(
+                self,
+                selector: #selector(applyThemeColors),
+                name: .themeDidChange,
+                object: nil
+            )
+        }
+
+        deinit {
+            NotificationCenter.default.removeObserver(self)
+        }
+
+        @objc private func applyThemeColors() {
+            guard let textView = textView as? ThinCaretTextView else { return }
+            textView.textColor = NSColor(AppTheme.textPrimary)
+            textView.placeholderColor = NSColor(AppTheme.textMuted)
+            textView.needsDisplay = true
         }
 
         func textDidChange(_ notification: Notification) {

+ 5 - 6
gramora/Views/DictionaryView.swift

@@ -4,8 +4,6 @@ struct DictionaryView: View {
     @StateObject private var viewModel = DictionaryViewModel()
     @Environment(\.onSettingsTapped) private var onSettingsTapped
 
-    private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
-
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -34,6 +32,7 @@ struct DictionaryView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.query) { newValue in
             if newValue.count > DictionaryViewModel.maxQueryLength {
                 viewModel.query = String(newValue.prefix(DictionaryViewModel.maxQueryLength))
@@ -129,7 +128,7 @@ struct DictionaryView: View {
                 }
                 .padding(.horizontal, 16)
                 .padding(.vertical, 14)
-                .background(Color.white)
+                .background(AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 .overlay(
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -173,7 +172,7 @@ struct DictionaryView: View {
         .frame(maxWidth: .infinity)
         .padding(.vertical, 28)
         .padding(.horizontal, 20)
-        .background(panelBackground)
+        .background(AppTheme.panelBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
         .overlay(
             RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -192,7 +191,7 @@ struct DictionaryView: View {
         }
         .frame(maxWidth: .infinity, alignment: .leading)
         .padding(18)
-        .background(panelBackground)
+        .background(AppTheme.panelBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
     }
 
@@ -296,7 +295,7 @@ struct DictionaryView: View {
         }
         .padding(18)
         .frame(maxWidth: .infinity, alignment: .leading)
-        .background(panelBackground)
+        .background(AppTheme.panelBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .overlay(
             RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)

+ 6 - 5
gramora/Views/EmailWriterView.swift

@@ -44,6 +44,7 @@ struct EmailWriterView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.prompt) { _ in
             viewModel.handlePromptChange()
         }
@@ -145,7 +146,7 @@ struct EmailWriterView: View {
                 .foregroundStyle(AppTheme.textPrimary)
                 .padding(.horizontal, 14)
                 .padding(.vertical, 8)
-                .background(Color.white)
+                .background(AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 .overlay(
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -184,7 +185,7 @@ struct EmailWriterView: View {
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
                     .frame(minHeight: 72)
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -234,7 +235,7 @@ struct EmailWriterView: View {
                 .foregroundStyle(AppTheme.textPrimary)
                 .frame(maxWidth: .infinity, maxHeight: .infinity)
                 .frame(minHeight: 72)
-                .background(Color.white)
+                .background(AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 .overlay(
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -255,7 +256,7 @@ struct EmailWriterView: View {
                             .foregroundStyle(AppTheme.textSecondary)
                     }
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
-                    .background(Color.white.opacity(0.92))
+                    .background(AppTheme.surface.opacity(0.92))
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 }
             }
@@ -302,7 +303,7 @@ private struct EmailOptionMenu<Option: Identifiable & Equatable>: View {
             .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
             .padding(.horizontal, 24)
             .padding(.vertical, 7)
-            .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
+            .background(isHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
             .clipShape(RoundedRectangle(cornerRadius: 8))
             .overlay(
                 RoundedRectangle(cornerRadius: 8)

+ 5 - 6
gramora/Views/GrammarCheckerView.swift

@@ -12,8 +12,6 @@ struct GrammarCheckerView: View {
         self.onRestoreHandled = onRestoreHandled
     }
 
-    private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
-
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -68,6 +66,7 @@ struct GrammarCheckerView: View {
                 }
             }
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.text) { _ in
             viewModel.handleTextChange()
         }
@@ -161,7 +160,7 @@ struct GrammarCheckerView: View {
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -237,7 +236,7 @@ struct GrammarCheckerView: View {
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
             }
             .frame(maxHeight: .infinity)
-            .background(panelBackground)
+            .background(AppTheme.panelBackground)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
             .overlay(
                 RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
@@ -278,7 +277,7 @@ struct GrammarCheckerView: View {
             .padding(16)
             .frame(width: 300)
             .frame(maxHeight: .infinity, alignment: .topLeading)
-            .background(panelBackground)
+            .background(AppTheme.panelBackground)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
             .overlay(
                 RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
@@ -311,7 +310,7 @@ struct GrammarCheckerView: View {
         .frame(maxWidth: .infinity, alignment: .leading)
         .padding(.horizontal, 14)
         .padding(.vertical, 12)
-        .background(Color.white)
+        .background(AppTheme.surface)
         .clipShape(RoundedRectangle(cornerRadius: 10))
         .overlay(
             RoundedRectangle(cornerRadius: 10)

+ 1 - 0
gramora/Views/HistoryView.swift

@@ -34,6 +34,7 @@ struct HistoryView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
+        .clearHostingBackground()
         .confirmationDialog(
             "Clear all history?",
             isPresented: $viewModel.isShowingClearConfirmation,

+ 8 - 12
gramora/Views/JournalFinderView.swift

@@ -12,8 +12,6 @@ struct JournalFinderView: View {
         self.onRestoreHandled = onRestoreHandled
     }
 
-    private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
-
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -43,6 +41,7 @@ struct JournalFinderView: View {
                 }
             }
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.title) { newValue in
             if newValue.count > JournalFinderViewModel.maxTitleLength {
                 viewModel.title = String(newValue.prefix(JournalFinderViewModel.maxTitleLength))
@@ -267,7 +266,7 @@ struct JournalFinderView: View {
                 .foregroundStyle(AppTheme.textPrimary)
                 .padding(.horizontal, 16)
                 .padding(.vertical, 13)
-                .background(Color.white)
+                .background(AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 .overlay(
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -290,7 +289,7 @@ struct JournalFinderView: View {
                     .frame(maxWidth: .infinity, minHeight: minHeight)
             }
             .frame(maxWidth: .infinity, minHeight: minHeight)
-            .background(Color.white)
+            .background(AppTheme.surface)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
             .overlay(
                 RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -314,7 +313,7 @@ struct JournalFinderView: View {
                 .foregroundStyle(AppTheme.textPrimary)
                 .padding(.horizontal, 16)
                 .padding(.vertical, 13)
-                .background(Color.white)
+                .background(AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 .overlay(
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -449,7 +448,7 @@ struct JournalFinderView: View {
             }
             .padding(14)
             .frame(maxWidth: .infinity, alignment: .leading)
-            .background(panelBackground)
+            .background(AppTheme.panelBackground)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
             .overlay(
                 RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -556,7 +555,7 @@ private struct ResearchFieldPicker: View {
         .buttonStyle(.plain)
         .padding(.horizontal, 16)
         .padding(.vertical, 10)
-        .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
+        .background(isHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
         .overlay(
             RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -609,13 +608,10 @@ private struct RankBadge: View {
             .foregroundStyle(AppTheme.textMuted)
             .padding(.horizontal, 8)
             .padding(.vertical, 4)
-            .background(panelBackgroundColor)
+            .background(AppTheme.panelBackground)
             .clipShape(RoundedRectangle(cornerRadius: 6))
     }
 
-    private var panelBackgroundColor: Color {
-        Color(red: 0.96, green: 0.97, blue: 0.98)
-    }
 }
 
 private struct StatusBadge: View {
@@ -653,7 +649,7 @@ private struct StatusBadge: View {
     private var backgroundColor: Color {
         switch style {
         case .teal: AppTheme.tealLight
-        case .neutral: Color.white
+        case .neutral: AppTheme.surface
         }
     }
 }

+ 4 - 3
gramora/Views/LanguageTranslatorView.swift

@@ -30,6 +30,7 @@ struct LanguageTranslatorView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.sourceText) { _ in
             viewModel.handleSourceTextChange()
         }
@@ -154,7 +155,7 @@ struct LanguageTranslatorView: View {
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
                     .frame(minHeight: 96)
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -207,7 +208,7 @@ struct LanguageTranslatorView: View {
                 .foregroundStyle(AppTheme.textPrimary)
                 .frame(maxWidth: .infinity, maxHeight: .infinity)
                 .frame(minHeight: 96)
-                .background(Color.white)
+                .background(AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 .overlay(
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -228,7 +229,7 @@ struct LanguageTranslatorView: View {
                             .foregroundStyle(AppTheme.textSecondary)
                     }
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
-                    .background(Color.white.opacity(0.92))
+                    .background(AppTheme.surface.opacity(0.92))
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                 }
             }

+ 7 - 1
gramora/Views/MainView.swift

@@ -3,6 +3,7 @@ import SwiftUI
 struct MainView: View {
     @StateObject private var viewModel = MainViewModel()
     @EnvironmentObject private var subscriptions: SubscriptionManager
+    @EnvironmentObject private var themeManager: ThemeManager
 
     var body: some View {
         Group {
@@ -30,7 +31,10 @@ struct MainView: View {
 
                         Group {
                             if viewModel.isShowingSettings {
-                                SettingsView(onClose: viewModel.hideSettings)
+                                SettingsView(
+                                    onClose: viewModel.hideSettings,
+                                    themeManager: themeManager
+                                )
                                 .transition(.opacity)
                             } else {
                                 contentView
@@ -40,6 +44,7 @@ struct MainView: View {
                         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                     }
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .background(AppTheme.background)
                     .ignoresSafeArea(edges: .top)
                     .environment(\.onSettingsTapped, viewModel.showSettings)
                 }
@@ -49,6 +54,7 @@ struct MainView: View {
                 }
             }
         }
+        .id(themeManager.isDarkMode)
         .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingPaywall)
         .animation(.easeInOut(duration: 0.2), value: viewModel.isShowingSettings)
         .onChange(of: subscriptions.hasPremiumAccess) { hasPremium in

+ 2 - 1
gramora/Views/ParaphrasingView.swift

@@ -58,6 +58,7 @@ struct ParaphrasingView: View {
                 }
             }
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.text) { _ in
             viewModel.handleTextChange()
         }
@@ -133,7 +134,7 @@ struct ParaphrasingView: View {
 
             VStack(alignment: .leading, spacing: 6) {
                 textEditor
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)

+ 14 - 15
gramora/Views/PaywallView.swift

@@ -49,7 +49,7 @@ struct PaywallView: View {
                     .padding(.bottom, topPadding)
                 }
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
-                .background(Color.white.ignoresSafeArea())
+                .background(AppTheme.background.ignoresSafeArea())
 
                 if isPremium {
                     closeButton
@@ -64,6 +64,7 @@ struct PaywallView: View {
                 }
             }
         }
+        .clearHostingBackground()
         .task {
             await subscriptions.loadProducts()
         }
@@ -94,7 +95,7 @@ struct PaywallView: View {
             HStack(spacing: 10) {
                 Text("Upgrade to")
                     .font(.system(size: 38, weight: .heavy, design: .rounded))
-                    .foregroundStyle(Color(red: 0.11, green: 0.17, blue: 0.26))
+                    .foregroundStyle(AppTheme.textPrimary)
 
                 Text("PRO")
                     .font(.system(size: 32, weight: .heavy, design: .rounded))
@@ -212,8 +213,10 @@ struct PaywallView: View {
                     title: isPremium ? "Manage Subscription" : "Continue with free plan",
                     action: isPremium ? subscriptions.openSubscriptionManagement : onClose
                 )
-                PaywallFooterLinkView(title: "Website") {
-                    AppLinks.open(AppLinks.website)
+                PaywallFooterLinkView(title: "Restore Purchase") {
+                    Task {
+                        await subscriptions.restorePurchases()
+                    }
                 }
                 PaywallFooterLinkView(title: "Privacy") {
                     AppLinks.open(AppLinks.privacy)
@@ -227,8 +230,6 @@ struct PaywallView: View {
             }
             .frame(height: 44)
             .frame(maxWidth: .infinity)
-            .background(Color.white)
-            .clipShape(RoundedRectangle(cornerRadius: 0))
         }
     }
 
@@ -244,7 +245,7 @@ private struct PaywallCloseButton: View {
                 .font(.system(size: 13, weight: .medium))
                 .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
                 .frame(width: 32, height: 32)
-                .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
+                .background(isHovered ? AppTheme.toolbarBackgroundHover : AppTheme.surface)
                 .clipShape(RoundedRectangle(cornerRadius: 8))
                 .overlay(
                     RoundedRectangle(cornerRadius: 8)
@@ -351,13 +352,13 @@ private struct PaywallPlanCardView: View {
 
                 Text(plan.title)
                     .font(.system(size: 15, weight: .semibold))
-                    .foregroundStyle(Color(red: 0.16, green: 0.20, blue: 0.26))
+                    .foregroundStyle(AppTheme.textPrimary)
                     .padding(.horizontal, 14)
                     .padding(.top, 14)
 
                 Text(mainPrice)
                     .font(.system(size: 21, weight: .heavy, design: .rounded))
-                    .foregroundStyle(Color(red: 0.03, green: 0.62, blue: 0.48))
+                    .foregroundStyle(AppTheme.teal)
                     .lineLimit(1)
                     .minimumScaleFactor(0.75)
                     .padding(.horizontal, 14)
@@ -368,11 +369,11 @@ private struct PaywallPlanCardView: View {
                     HStack {
                         Text(secondaryPrice)
                             .font(.system(size: 14, weight: .semibold))
-                            .foregroundStyle(Color(red: 0.45, green: 0.48, blue: 0.54))
+                            .foregroundStyle(AppTheme.textSecondary)
                             .overlay(alignment: .center) {
                                 if plan == .weekly || plan == .lifetime {
                                     Rectangle()
-                                        .fill(Color(red: 0.45, green: 0.48, blue: 0.54))
+                                        .fill(AppTheme.textSecondary)
                                         .frame(height: 1)
                                 }
                             }
@@ -382,7 +383,7 @@ private struct PaywallPlanCardView: View {
                     .padding(.vertical, 12)
                     .frame(maxWidth: .infinity, alignment: .leading)
                 }
-                .background(Color(red: 0.95, green: 0.96, blue: 0.97))
+                .background(AppTheme.sectionBackground)
                 .clipShape(
                     RoundedRectangle(cornerRadius: 0)
                 )
@@ -425,15 +426,13 @@ private struct PaywallFooterLinkView: View {
         Button(action: action) {
             Text(title)
                 .font(.system(size: 14, weight: .semibold))
-                .foregroundStyle(isHovered ? Color(red: 0.47, green: 0.51, blue: 0.57) : Color(red: 0.59, green: 0.62, blue: 0.68))
+                .foregroundStyle(isHovered ? AppTheme.teal : AppTheme.textMuted)
                 .lineLimit(1)
                 .minimumScaleFactor(0.75)
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                 .contentShape(Rectangle())
         }
         .frame(maxWidth: .infinity, maxHeight: .infinity)
-        .background(isHovered ? AppTheme.cardBackground : Color.clear)
-        .scaleEffect(isHovered ? 1.01 : 1.0)
         .animation(.easeOut(duration: 0.15), value: isHovered)
         .buttonStyle(.plain)
         .onHover { isHovered = $0 }

+ 2 - 1
gramora/Views/PunctuationCheckerView.swift

@@ -58,6 +58,7 @@ struct PunctuationCheckerView: View {
                 }
             }
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.text) { _ in
             viewModel.handleTextChange()
         }
@@ -133,7 +134,7 @@ struct PunctuationCheckerView: View {
 
             VStack(alignment: .leading, spacing: 6) {
                 textEditor
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)

+ 55 - 3
gramora/Views/SettingsView.swift

@@ -1,10 +1,15 @@
 import SwiftUI
 
 struct SettingsView: View {
-    @StateObject private var viewModel = SettingsViewModel()
+    @StateObject private var viewModel: SettingsViewModel
 
     let onClose: () -> Void
 
+    init(onClose: @escaping () -> Void, themeManager: ThemeManager) {
+        self.onClose = onClose
+        _viewModel = StateObject(wrappedValue: SettingsViewModel(themeManager: themeManager))
+    }
+
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -23,6 +28,7 @@ struct SettingsView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
+        .clearHostingBackground()
         .confirmationDialog(
             "Clear all history?",
             isPresented: $viewModel.isShowingClearHistoryConfirmation,
@@ -69,6 +75,7 @@ struct SettingsView: View {
     private var settingsCard: some View {
         ScrollView(.vertical, showsIndicators: false) {
             VStack(alignment: .leading, spacing: 24) {
+                appearanceSection
                 dataSection
                 aboutSection
             }
@@ -85,6 +92,20 @@ struct SettingsView: View {
         )
     }
 
+    private var appearanceSection: some View {
+        SettingsSectionCard(title: "Appearance", iconName: "paintbrush.fill") {
+            SettingsToggleRow(
+                iconName: "moon.fill",
+                title: "Dark Theme",
+                subtitle: "Use a darker color scheme across the app",
+                isOn: Binding(
+                    get: { viewModel.isDarkModeEnabled },
+                    set: { viewModel.setDarkModeEnabled($0) }
+                )
+            )
+        }
+    }
+
     private var dataSection: some View {
         SettingsSectionCard(title: "Data & Privacy", iconName: "externaldrive.fill") {
             VStack(spacing: 0) {
@@ -168,7 +189,7 @@ private struct SettingsSectionCard<Content: View>: View {
             content()
                 .padding(16)
                 .frame(maxWidth: .infinity, alignment: .leading)
-                .background(Color(red: 0.98, green: 0.99, blue: 0.99))
+                .background(AppTheme.sectionBackground)
                 .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
                 .overlay(
                     RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -237,7 +258,7 @@ private struct SettingsActionRow: View {
     }
 
     private var actionBackground: Color {
-        guard isActionEnabled else { return Color.white }
+        guard isActionEnabled else { return AppTheme.surface }
         if isDestructive {
             return isActionHovered ? AppTheme.clearBackgroundHover : AppTheme.clearBackground
         }
@@ -300,6 +321,37 @@ private struct SettingsRowIcon: View {
     }
 }
 
+private struct SettingsToggleRow: View {
+    let iconName: String
+    let title: String
+    let subtitle: String
+    @Binding var isOn: Bool
+
+    var body: some View {
+        HStack(spacing: 12) {
+            SettingsRowIcon(systemName: iconName)
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(title)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text(subtitle)
+                    .font(.system(size: 12))
+                    .foregroundStyle(AppTheme.textMuted)
+            }
+
+            Spacer(minLength: 8)
+
+            Toggle("", isOn: $isOn)
+                .labelsHidden()
+                .toggleStyle(.switch)
+                .tint(AppTheme.teal)
+        }
+        .padding(.vertical, 4)
+    }
+}
+
 private struct SettingsRowDivider: View {
     var body: some View {
         Rectangle()

+ 5 - 6
gramora/Views/SpellCheckerView.swift

@@ -12,8 +12,6 @@ struct SpellCheckerView: View {
         self.onRestoreHandled = onRestoreHandled
     }
 
-    private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
-
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
@@ -68,6 +66,7 @@ struct SpellCheckerView: View {
                 }
             }
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.text) { _ in
             viewModel.handleTextChange()
         }
@@ -161,7 +160,7 @@ struct SpellCheckerView: View {
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
@@ -237,7 +236,7 @@ struct SpellCheckerView: View {
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
             }
             .frame(maxHeight: .infinity)
-            .background(panelBackground)
+            .background(AppTheme.panelBackground)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
             .overlay(
                 RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
@@ -278,7 +277,7 @@ struct SpellCheckerView: View {
             .padding(16)
             .frame(width: 300)
             .frame(maxHeight: .infinity, alignment: .topLeading)
-            .background(panelBackground)
+            .background(AppTheme.panelBackground)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
             .overlay(
                 RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
@@ -307,7 +306,7 @@ struct SpellCheckerView: View {
         .frame(maxWidth: .infinity, alignment: .leading)
         .padding(.horizontal, 14)
         .padding(.vertical, 12)
-        .background(Color.white)
+        .background(AppTheme.surface)
         .clipShape(RoundedRectangle(cornerRadius: 10))
         .overlay(
             RoundedRectangle(cornerRadius: 10)

+ 2 - 1
gramora/Views/SummarizerView.swift

@@ -58,6 +58,7 @@ struct SummarizerView: View {
                 }
             }
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.text) { _ in
             viewModel.handleTextChange()
         }
@@ -133,7 +134,7 @@ struct SummarizerView: View {
 
             VStack(alignment: .leading, spacing: 6) {
                 textEditor
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)

+ 2 - 1
gramora/Views/WordsCountView.swift

@@ -29,6 +29,7 @@ struct WordsCountView: View {
             .padding(.top, AppTheme.brandLabelTopInset)
             .padding(.bottom, dynamicBottomPadding)
         }
+        .clearHostingBackground()
         .onChange(of: viewModel.text) { _ in
             viewModel.handleTextChange()
         }
@@ -86,7 +87,7 @@ struct WordsCountView: View {
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
                     .frame(minHeight: 96)
-                    .background(Color.white)
+                    .background(AppTheme.surface)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)