Просмотр исходного кода

Apply dark mode across the app on launch and when toggled.

Add dynamic theme colors, live appearance refresh, premium page dark styling, and preserve Quick Start card colors in both modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 месяц назад
Родитель
Сommit
0ca58b3cd0

+ 4 - 1
smart_printer/AppDelegate.swift

@@ -10,8 +10,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
 
     private weak var mainWindowController: MainWindowController?
 
-    func applicationDidFinishLaunching(_ notification: Notification) {
+    func applicationWillFinishLaunching(_ notification: Notification) {
         AppSettings.applyAppearance()
+    }
+
+    func applicationDidFinishLaunching(_ notification: Notification) {
         resolveMainWindowController()
         configureMainWindow()
         configurePreferencesMenu()

+ 5 - 0
smart_printer/AppSettings.swift

@@ -148,6 +148,7 @@ enum AppSettings {
 
     static func applyAppearance() {
         NSApp.appearance = darkModeEnabled ? NSAppearance(named: .darkAqua) : NSAppearance(named: .aqua)
+        NotificationCenter.default.post(name: .appearanceDidChange, object: nil)
     }
 
     static var appLanguage: String {
@@ -155,3 +156,7 @@ enum AppSettings {
         set { defaults.set(newValue, forKey: Key.appLanguage) }
     }
 }
+
+extension Notification.Name {
+    static let appearanceDidChange = Notification.Name("appearanceDidChange")
+}

+ 204 - 12
smart_printer/AppTheme.swift

@@ -1,4 +1,5 @@
 import Cocoa
+import ObjectiveC
 
 enum AppTheme {
     static let windowWidth: CGFloat = 760
@@ -29,19 +30,93 @@ enum AppTheme {
     static let cardCornerRadius: CGFloat = 20
     static let featureCardCornerRadius: CGFloat = 16
 
-    static let background = NSColor(calibratedWhite: 0.965, alpha: 1)
-    static let sidebarBackground = NSColor.white
-    static let cardBackground = NSColor.white
-    static let textPrimary = NSColor(calibratedWhite: 0.12, alpha: 1)
-    static let textSecondary = NSColor(calibratedWhite: 0.48, alpha: 1)
+    private static var isDark: Bool { AppSettings.darkModeEnabled }
 
-    static let homeActiveBackground = NSColor(red: 0.91, green: 0.94, blue: 1.0, alpha: 1)
-    static let homeActiveForeground = NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
-    static let premiumBackground = NSColor(red: 0.95, green: 0.93, blue: 1.0, alpha: 1)
-    static let premiumForeground = NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
+    static var background: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.11, alpha: 1)
+            : NSColor(calibratedWhite: 0.965, alpha: 1)
+    }
+
+    static var sidebarBackground: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.15, alpha: 1)
+            : NSColor.white
+    }
+
+    static var cardBackground: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.18, alpha: 1)
+            : NSColor.white
+    }
+
+    static var groupCardBackground: NSColor { cardBackground }
+
+    static var elevatedBackground: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.22, alpha: 1)
+            : NSColor.white
+    }
+
+    static var textPrimary: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.92, alpha: 1)
+            : NSColor(calibratedWhite: 0.12, alpha: 1)
+    }
+
+    static var textSecondary: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.55, alpha: 1)
+            : NSColor(calibratedWhite: 0.48, alpha: 1)
+    }
+
+    static var border: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.28, alpha: 1)
+            : NSColor(calibratedWhite: 0.92, alpha: 1)
+    }
+
+    static var waveDotColor: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.35, alpha: 0.45)
+            : NSColor(calibratedWhite: 0.82, alpha: 0.55)
+    }
+
+    static var homeActiveBackground: NSColor {
+        isDark
+            ? NSColor(red: 0.18, green: 0.28, blue: 0.48, alpha: 1)
+            : NSColor(red: 0.91, green: 0.94, blue: 1.0, alpha: 1)
+    }
+
+    static var homeActiveForeground: NSColor {
+        isDark
+            ? NSColor(red: 0.45, green: 0.65, blue: 1.0, alpha: 1)
+            : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
+    }
+
+    static var premiumBackground: NSColor {
+        isDark
+            ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
+            : NSColor(red: 0.95, green: 0.93, blue: 1.0, alpha: 1)
+    }
+
+    static var premiumForeground: NSColor {
+        isDark
+            ? NSColor(red: 0.72, green: 0.58, blue: 1.0, alpha: 1)
+            : NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
+    }
 
     static let blue = NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
-    static let blueLight = NSColor(red: 0.88, green: 0.93, blue: 1.0, alpha: 1)
+    static let quickStartBlueLight = NSColor(red: 0.88, green: 0.93, blue: 1.0, alpha: 1)
+    static let quickStartCardSubtitle = NSColor(calibratedWhite: 0.48, alpha: 1)
+    static let quickStartWaveDot = NSColor(calibratedWhite: 0.82, alpha: 0.55)
+
+    static var blueLight: NSColor {
+        isDark
+            ? NSColor(red: 0.20, green: 0.28, blue: 0.42, alpha: 1)
+            : quickStartBlueLight
+    }
+
     static let green = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
     static let greenLight = NSColor(red: 0.88, green: 0.97, blue: 0.91, alpha: 1)
     static let orange = NSColor(red: 0.96, green: 0.52, blue: 0.18, alpha: 1)
@@ -53,7 +128,58 @@ enum AppTheme {
     static let paywallPinkText = NSColor(red: 0.75, green: 0.30, blue: 0.45, alpha: 1)
     static let paywallGold = NSColor(red: 0.96, green: 0.90, blue: 0.78, alpha: 1)
     static let paywallGoldText = NSColor(red: 0.65, green: 0.48, blue: 0.22, alpha: 1)
-    static let paywallBorder = NSColor(calibratedWhite: 0.88, alpha: 1)
+
+    static var paywallBorder: NSColor { border }
+
+    static var paywallBackground: NSColor { cardBackground }
+
+    static var paywallAccent: NSColor {
+        isDark ? textPrimary : navy
+    }
+
+    static var paywallLeftGradientColors: [NSColor] {
+        isDark
+            ? [
+                NSColor(calibratedWhite: 0.17, alpha: 1),
+                NSColor(calibratedWhite: 0.13, alpha: 1),
+            ]
+            : [
+                NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1),
+                NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1),
+            ]
+    }
+
+    static var paywallCTABackground: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.96, alpha: 1)
+            : navy
+    }
+
+    static var paywallCTAForeground: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.10, alpha: 1)
+            : NSColor.white
+    }
+
+    static var paywallTrustIconBackground: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.24, alpha: 1)
+            : quickStartBlueLight
+    }
+
+    static var paywallIconAccent: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.78, alpha: 1)
+            : navy
+    }
+
+    static var paywallTrustBackground: NSColor { elevatedBackground }
+
+    static var paywallOverlayBackdrop: NSColor {
+        isDark
+            ? NSColor(calibratedWhite: 0.05, alpha: 0.55)
+            : NSColor(calibratedWhite: 0.15, alpha: 0.22)
+    }
 
     static func semiboldFont(size: CGFloat) -> NSFont {
         .systemFont(ofSize: size, weight: .semibold)
@@ -68,11 +194,15 @@ enum AppTheme {
     }
 }
 
+protocol AppearanceRefreshable: AnyObject {
+    func refreshAppearance()
+}
+
 extension NSView {
     func applyCardShadow() {
         wantsLayer = true
         layer?.shadowColor = NSColor.black.cgColor
-        layer?.shadowOpacity = 0.07
+        layer?.shadowOpacity = AppSettings.darkModeEnabled ? 0.25 : 0.07
         layer?.shadowOffset = NSSize(width: 0, height: -3)
         layer?.shadowRadius = 14
         layer?.masksToBounds = false
@@ -83,4 +213,66 @@ extension NSView {
         layer?.cornerRadius = radius
         layer?.masksToBounds = true
     }
+
+    func refreshAppearanceRecursively() {
+        if let refreshable = self as? AppearanceRefreshable {
+            refreshable.refreshAppearance()
+        }
+        if let label = self as? NSTextField {
+            label.refreshThemeLabelColor()
+        }
+        if let imageView = self as? NSImageView, imageView.usesThemeTint {
+            imageView.contentTintColor = AppTheme.textPrimary
+        }
+        subviews.forEach { $0.refreshAppearanceRecursively() }
+    }
+}
+
+private enum ThemeAssociatedKeys {
+    static var labelStyle = 0
+    static var usesThemeTint = 0
+}
+
+enum ThemeLabelStyle: String {
+    case primary
+    case secondary
+}
+
+extension NSTextField {
+    static func themeLabel(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
+        let label = NSTextField(labelWithString: string)
+        label.font = font
+        label.themeLabelStyle = style
+        label.textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
+        return label
+    }
+
+    var themeLabelStyle: ThemeLabelStyle? {
+        get {
+            guard let rawValue = objc_getAssociatedObject(self, &ThemeAssociatedKeys.labelStyle) as? String else {
+                return nil
+            }
+            return ThemeLabelStyle(rawValue: rawValue)
+        }
+        set {
+            objc_setAssociatedObject(
+                self,
+                &ThemeAssociatedKeys.labelStyle,
+                newValue?.rawValue,
+                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+            )
+        }
+    }
+
+    func refreshThemeLabelColor() {
+        guard let style = themeLabelStyle else { return }
+        textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
+    }
+}
+
+extension NSImageView {
+    var usesThemeTint: Bool {
+        get { (objc_getAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint) as? Bool) ?? false }
+        set { objc_setAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
+    }
 }

+ 88 - 42
smart_printer/PaywallView.swift

@@ -42,19 +42,20 @@ enum PaywallPlan: CaseIterable {
 
 // MARK: - Left Panel
 
-private final class PaywallLeftPanelView: NSView {
+private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
     private let gradientLayer = CAGradientLayer()
 
     override init(frame frameRect: NSRect) {
         super.init(frame: frameRect)
         wantsLayer = true
-        gradientLayer.colors = [
-            NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1).cgColor,
-            NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1).cgColor,
-        ]
         gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
         gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
         layer?.insertSublayer(gradientLayer, at: 0)
+        refreshAppearance()
+    }
+
+    func refreshAppearance() {
+        gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
     }
 
     @available(*, unavailable)
@@ -121,8 +122,11 @@ private final class PaywallBadgeView: NSView {
 
 // MARK: - Feature Row
 
-private final class PaywallFeatureRow: NSView {
+private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
+    private let label: NSTextField
+
     init(text: String) {
+        label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
 
@@ -141,9 +145,6 @@ private final class PaywallFeatureRow: NSView {
         }
         checkIcon.contentTintColor = .white
 
-        let label = NSTextField(labelWithString: text)
-        label.font = AppTheme.regularFont(size: 14)
-        label.textColor = AppTheme.navy
         label.translatesAutoresizingMaskIntoConstraints = false
 
         addSubview(checkContainer)
@@ -171,11 +172,15 @@ private final class PaywallFeatureRow: NSView {
 
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        label.refreshThemeLabelColor()
+    }
 }
 
 // MARK: - Plan Card
 
-private final class PaywallPlanCard: NSControl {
+private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
     var onSelect: (() -> Void)?
 
     private let plan: PaywallPlan
@@ -194,7 +199,6 @@ private final class PaywallPlanCard: NSControl {
         translatesAutoresizingMaskIntoConstraints = false
         wantsLayer = true
         layer?.cornerRadius = 12
-        layer?.backgroundColor = NSColor.white.cgColor
         layer?.masksToBounds = false
 
         titleLabel.stringValue = plan.title
@@ -203,6 +207,7 @@ private final class PaywallPlanCard: NSControl {
 
         subtitleLabel.stringValue = plan.subtitle
         subtitleLabel.font = AppTheme.regularFont(size: 11)
+        subtitleLabel.themeLabelStyle = .secondary
         subtitleLabel.textColor = AppTheme.textSecondary
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
 
@@ -263,11 +268,17 @@ private final class PaywallPlanCard: NSControl {
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
+    func refreshAppearance() {
+        updateAppearance()
+        subtitleLabel.refreshThemeLabelColor()
+    }
+
     private func updateAppearance() {
-        let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.navy
+        let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.paywallAccent
         titleLabel.textColor = titleColor
         priceLabel.textColor = titleColor
 
+        layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
         layer?.borderWidth = isChosen ? 2 : 1
         layer?.borderColor = (isChosen ? AppTheme.green : AppTheme.paywallBorder).cgColor
     }
@@ -284,19 +295,23 @@ private final class PaywallPlanCard: NSControl {
 
 // MARK: - Footer Link
 
-private final class PaywallFooterLink: NSButton {
+private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
     init(title: String) {
         super.init(frame: .zero)
         self.title = title
         isBordered = false
         font = AppTheme.regularFont(size: 11)
-        contentTintColor = AppTheme.textSecondary
         translatesAutoresizingMaskIntoConstraints = false
+        refreshAppearance()
     }
 
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
+    func refreshAppearance() {
+        contentTintColor = AppTheme.textSecondary
+    }
+
     override func resetCursorRects() {
         addCursorRect(bounds, cursor: .pointingHand)
     }
@@ -304,36 +319,33 @@ private final class PaywallFooterLink: NSButton {
 
 // MARK: - Footer Trust Item
 
-private final class PaywallTrustItemView: NSView {
+private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
+    private let iconContainer = NSView()
+    private let icon = NSImageView()
+    private let titleLabel: NSTextField
+    private let subtitleLabel: NSTextField
+
     init(iconName: String, title: String, subtitle: String) {
+        titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
+        subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
 
-        let iconContainer = NSView()
         iconContainer.translatesAutoresizingMaskIntoConstraints = false
         iconContainer.wantsLayer = true
-        iconContainer.layer?.backgroundColor = AppTheme.blueLight.cgColor
         iconContainer.layer?.cornerRadius = 10
         iconContainer.layer?.masksToBounds = true
 
-        let icon = NSImageView()
         icon.translatesAutoresizingMaskIntoConstraints = false
         if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
             let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
             icon.image = image.withSymbolConfiguration(config)
         }
-        icon.contentTintColor = AppTheme.navy
 
-        let titleLabel = NSTextField(labelWithString: title)
-        titleLabel.font = AppTheme.semiboldFont(size: 11)
-        titleLabel.textColor = AppTheme.navy
         titleLabel.lineBreakMode = .byTruncatingTail
         titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        let subtitleLabel = NSTextField(labelWithString: subtitle)
-        subtitleLabel.font = AppTheme.regularFont(size: 9)
-        subtitleLabel.textColor = AppTheme.textSecondary
         subtitleLabel.lineBreakMode = .byTruncatingTail
         subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
@@ -366,15 +378,23 @@ private final class PaywallTrustItemView: NSView {
 
         setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         setContentHuggingPriority(.defaultLow, for: .horizontal)
+        refreshAppearance()
     }
 
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
+        icon.contentTintColor = AppTheme.paywallIconAccent
+        titleLabel.refreshThemeLabelColor()
+        subtitleLabel.refreshThemeLabelColor()
+    }
 }
 
 // MARK: - Main Paywall Card
 
-final class PaywallView: NSView {
+final class PaywallView: NSView, AppearanceRefreshable {
     var onClose: (() -> Void)?
     var onPurchase: ((PaywallPlan) -> Void)?
     var onRestore: (() -> Void)?
@@ -382,14 +402,30 @@ final class PaywallView: NSView {
     private var selectedPlan: PaywallPlan = .yearly
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
     private let ctaButton = NSButton()
+    private var leftPanelTitle: NSTextField!
+    private var rightTitle: NSTextField!
+    private var rightSubtitle: NSTextField!
+    private var trustStack: NSStackView!
 
     init() {
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         wantsLayer = true
-        layer?.backgroundColor = NSColor.white.cgColor
         layer?.cornerRadius = 0
         setup()
+        refreshAppearance()
+    }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.paywallBackground.cgColor
+        leftPanelTitle?.refreshThemeLabelColor()
+        rightTitle?.refreshThemeLabelColor()
+        rightSubtitle?.refreshThemeLabelColor()
+        trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
+        trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
+        ctaButton.layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
+        ctaButton.contentTintColor = AppTheme.paywallCTAForeground
+        subviews.forEach { $0.refreshAppearanceRecursively() }
     }
 
     @available(*, unavailable)
@@ -419,11 +455,14 @@ final class PaywallView: NSView {
         let panel = PaywallLeftPanelView()
         panel.translatesAutoresizingMaskIntoConstraints = false
 
-        let title = NSTextField(labelWithString: "Unlock Your Full\nPrinting Potential")
-        title.font = AppTheme.semiboldFont(size: 22)
-        title.textColor = AppTheme.navy
+        let title = NSTextField.themeLabel(
+            "Unlock Your Full\nPrinting Potential",
+            style: .primary,
+            font: AppTheme.semiboldFont(size: 22)
+        )
         title.maximumNumberOfLines = 2
         title.translatesAutoresizingMaskIntoConstraints = false
+        leftPanelTitle = title
 
         let featuresStack = NSStackView()
         featuresStack.orientation = .vertical
@@ -463,18 +502,20 @@ final class PaywallView: NSView {
         let panel = NSView()
         panel.translatesAutoresizingMaskIntoConstraints = false
 
-        let title = NSTextField(labelWithString: "Go Premium")
-        title.font = AppTheme.semiboldFont(size: 26)
-        title.textColor = AppTheme.navy
+        let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
         title.alignment = .center
         title.translatesAutoresizingMaskIntoConstraints = false
+        rightTitle = title
 
-        let subtitle = NSTextField(labelWithString: "Experience professional quality printing and scanning without limits.")
-        subtitle.font = AppTheme.regularFont(size: 13)
-        subtitle.textColor = AppTheme.textSecondary
+        let subtitle = NSTextField.themeLabel(
+            "Experience professional quality printing and scanning without limits.",
+            style: .secondary,
+            font: AppTheme.regularFont(size: 13)
+        )
         subtitle.alignment = .center
         subtitle.maximumNumberOfLines = 2
         subtitle.translatesAutoresizingMaskIntoConstraints = false
+        rightSubtitle = subtitle
 
         let plansStack = NSStackView()
         plansStack.orientation = .vertical
@@ -492,10 +533,8 @@ final class PaywallView: NSView {
         ctaButton.title = selectedPlan.ctaTitle
         ctaButton.isBordered = false
         ctaButton.wantsLayer = true
-        ctaButton.layer?.backgroundColor = AppTheme.navy.cgColor
         ctaButton.layer?.cornerRadius = 12
         ctaButton.font = AppTheme.semiboldFont(size: 15)
-        ctaButton.contentTintColor = .white
         ctaButton.target = self
         ctaButton.action = #selector(purchaseTapped)
         ctaButton.translatesAutoresizingMaskIntoConstraints = false
@@ -569,13 +608,12 @@ final class PaywallView: NSView {
         trustStack.alignment = .top
         trustStack.translatesAutoresizingMaskIntoConstraints = false
         trustStack.wantsLayer = true
-        trustStack.layer?.backgroundColor = NSColor.white.cgColor
         trustStack.layer?.cornerRadius = 12
         trustStack.layer?.borderWidth = 1
-        trustStack.layer?.borderColor = AppTheme.paywallBorder.cgColor
         trustStack.layer?.masksToBounds = true
         trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
         trustStack.translatesAutoresizingMaskIntoConstraints = false
+        self.trustStack = trustStack
 
         return trustStack
     }
@@ -657,18 +695,27 @@ final class PaywallView: NSView {
 
 // MARK: - Overlay Presenter
 
-final class PaywallOverlayView: NSView {
+final class PaywallOverlayView: NSView, AppearanceRefreshable {
     var onDismiss: (() -> Void)?
 
     private let paywallView: PaywallView
     private let blurView = NSVisualEffectView()
     private let backdrop = NSView()
+    private let pattern = WavePatternView()
 
     init() {
         paywallView = PaywallView()
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         setup()
+        refreshAppearance()
+    }
+
+    func refreshAppearance() {
+        backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
+        blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
+        pattern.refreshAppearance()
+        paywallView.refreshAppearance()
     }
 
     @available(*, unavailable)
@@ -684,7 +731,6 @@ final class PaywallOverlayView: NSView {
         backdrop.wantsLayer = true
         backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
 
-        let pattern = WavePatternView()
         pattern.translatesAutoresizingMaskIntoConstraints = false
         pattern.alphaValue = 0.35
 

+ 28 - 18
smart_printer/SettingsView.swift

@@ -2,7 +2,7 @@ import Cocoa
 
 // MARK: - Root
 
-final class SettingsView: NSView {
+final class SettingsView: NSView, AppearanceRefreshable {
     init() {
         super.init(frame: .zero)
         wantsLayer = true
@@ -14,6 +14,10 @@ final class SettingsView: NSView {
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.background.cgColor
+    }
+
     private func setup() {
         let scrollView = NSScrollView()
         scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -81,9 +85,7 @@ final class SettingsView: NSView {
         container.spacing = 12
         container.translatesAutoresizingMaskIntoConstraints = false
 
-        let label = NSTextField(labelWithString: title)
-        label.font = AppTheme.semiboldFont(size: 18)
-        label.textColor = AppTheme.textPrimary
+        let label = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 18))
         label.translatesAutoresizingMaskIntoConstraints = false
 
         container.addArrangedSubview(label)
@@ -137,32 +139,35 @@ final class SettingsView: NSView {
 
 // MARK: - Panel
 
-private final class SettingsPanelView: NSView {
+private final class SettingsPanelView: NSView, AppearanceRefreshable {
     init() {
         super.init(frame: .zero)
         wantsLayer = true
-        layer?.backgroundColor = AppTheme.cardBackground.cgColor
         layer?.cornerRadius = 22
         layer?.borderWidth = 1
-        layer?.borderColor = NSColor(calibratedWhite: 0.92, alpha: 1).cgColor
         applyCardShadow()
+        refreshAppearance()
+    }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        layer?.borderColor = AppTheme.border.cgColor
     }
 
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 }
 
-private final class SettingsGroupCard: NSView {
+private final class SettingsGroupCard: NSView, AppearanceRefreshable {
     private let stack = NSStackView()
 
     init() {
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         wantsLayer = true
-        layer?.backgroundColor = NSColor.white.cgColor
         layer?.cornerRadius = 16
         layer?.borderWidth = 1
-        layer?.borderColor = NSColor(calibratedWhite: 0.93, alpha: 1).cgColor
+        refreshAppearance()
 
         stack.orientation = .vertical
         stack.spacing = 0
@@ -184,17 +189,22 @@ private final class SettingsGroupCard: NSView {
         stack.addArrangedSubview(row)
         row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
     }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.groupCardBackground.cgColor
+        layer?.borderColor = AppTheme.border.cgColor
+    }
 }
 
 // MARK: - Icon
 
-private final class SettingsIconBadge: NSView {
+private final class SettingsIconBadge: NSView, AppearanceRefreshable {
     init(symbolName: String) {
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         wantsLayer = true
-        layer?.backgroundColor = AppTheme.blueLight.cgColor
         layer?.cornerRadius = 10
+        refreshAppearance()
 
         let icon = NSImageView()
         icon.translatesAutoresizingMaskIntoConstraints = false
@@ -217,6 +227,10 @@ private final class SettingsIconBadge: NSView {
 
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.blueLight.cgColor
+    }
 }
 
 // MARK: - Rows
@@ -247,9 +261,7 @@ private class SettingsRowBase: NSView {
 
     func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
         let badge = SettingsIconBadge(symbolName: symbolName)
-        let titleLabel = NSTextField(labelWithString: title)
-        titleLabel.font = AppTheme.mediumFont(size: 15)
-        titleLabel.textColor = AppTheme.textPrimary
+        let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
         trailing.translatesAutoresizingMaskIntoConstraints = false
 
@@ -278,9 +290,7 @@ private final class SettingsActionRow: SettingsRowBase {
         super.init(isLast: isLast)
 
         let badge = SettingsIconBadge(symbolName: symbolName)
-        let titleLabel = NSTextField(labelWithString: title)
-        titleLabel.font = AppTheme.mediumFont(size: 15)
-        titleLabel.textColor = AppTheme.textPrimary
+        let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
         addSubview(badge)

+ 8 - 4
smart_printer/SidebarView.swift

@@ -7,7 +7,8 @@ enum SidebarDestination: Int, CaseIterable {
     case settings
 }
 
-final class SidebarView: NSView {
+final class SidebarView: NSView, AppearanceRefreshable {
+    private var appNameLabel: NSTextField!
     var onDestinationSelected: ((SidebarDestination) -> Void)?
 
     private var navItems: [SidebarNavItem] = []
@@ -29,6 +30,11 @@ final class SidebarView: NSView {
         }
     }
 
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.sidebarBackground.cgColor
+        appNameLabel?.refreshThemeLabelColor()
+    }
+
     private func setup() {
         let logoContainer = NSView()
         logoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -41,9 +47,7 @@ final class SidebarView: NSView {
         }
         logoIcon.contentTintColor = AppTheme.purple
 
-        let appNameLabel = NSTextField(labelWithString: "Smart Printer")
-        appNameLabel.font = AppTheme.semiboldFont(size: 15)
-        appNameLabel.textColor = AppTheme.textPrimary
+        appNameLabel = NSTextField.themeLabel("Smart Printer", style: .primary, font: AppTheme.semiboldFont(size: 15))
         appNameLabel.alignment = .center
         appNameLabel.translatesAutoresizingMaskIntoConstraints = false
 

+ 34 - 17
smart_printer/UIComponents.swift

@@ -28,15 +28,21 @@ final class GradientCardView: NSView {
 
 // MARK: - Wave Pattern
 
-final class WavePatternView: NSView {
+final class WavePatternView: NSView, AppearanceRefreshable {
+    var fixedDotColor: NSColor?
+
     override var isOpaque: Bool { false }
 
+    func refreshAppearance() {
+        guard fixedDotColor == nil else { return }
+        needsDisplay = true
+    }
+
     override func draw(_ dirtyRect: NSRect) {
         super.draw(dirtyRect)
         guard let context = NSGraphicsContext.current?.cgContext else { return }
 
-        let dotColor = NSColor(calibratedWhite: 0.82, alpha: 0.55)
-        context.setFillColor(dotColor.cgColor)
+        context.setFillColor((fixedDotColor ?? AppTheme.waveDotColor).cgColor)
 
         let spacing: CGFloat = 14
         let dotSize: CGFloat = 3
@@ -57,7 +63,7 @@ final class WavePatternView: NSView {
 
 // MARK: - Sidebar Nav Item
 
-final class SidebarNavItem: NSControl {
+final class SidebarNavItem: NSControl, AppearanceRefreshable {
     enum Style {
         case normal
         case active
@@ -86,6 +92,7 @@ final class SidebarNavItem: NSControl {
 
         titleLabel.stringValue = title
         titleLabel.font = AppTheme.mediumFont(size: 14)
+        titleLabel.themeLabelStyle = .primary
         titleLabel.textColor = AppTheme.textPrimary
 
         if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
@@ -127,6 +134,10 @@ final class SidebarNavItem: NSControl {
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
+    func refreshAppearance() {
+        applyStyle(isSelected ? .active : .normal)
+    }
+
     private func applyStyle(_ style: Style) {
         container.wantsLayer = true
         container.layer?.cornerRadius = AppTheme.cornerRadius
@@ -296,13 +307,14 @@ final class QuickStartCardView: NSView {
 
         let subtitleLabel = NSTextField(labelWithString: data.subtitle)
         subtitleLabel.font = AppTheme.regularFont(size: 13)
-        subtitleLabel.textColor = AppTheme.textSecondary
+        subtitleLabel.textColor = AppTheme.quickStartCardSubtitle
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
 
         let button = PillButton(title: data.buttonTitle, color: data.accentColor)
         button.translatesAutoresizingMaskIntoConstraints = false
 
         let wavePattern = WavePatternView()
+        wavePattern.fixedDotColor = AppTheme.quickStartWaveDot
         wavePattern.translatesAutoresizingMaskIntoConstraints = false
 
         addSubview(gradient)
@@ -365,39 +377,34 @@ struct FeatureCardData {
     let iconKind: FeatureIconKind
 }
 
-final class FeatureCardView: NSView {
+final class FeatureCardView: NSView, AppearanceRefreshable {
     private let iconView: FeatureIconView
+    private let titleLabel: NSTextField
+    private let subtitleLabel: NSTextField
+    private let arrowButton: NSButton
     private var iconWidthConstraint: NSLayoutConstraint!
     private var iconHeightConstraint: NSLayoutConstraint!
 
     init(data: FeatureCardData) {
         iconView = FeatureIconView(kind: data.iconKind)
+        titleLabel = NSTextField.themeLabel(data.title, style: .primary, font: AppTheme.semiboldFont(size: 14))
+        subtitleLabel = NSTextField.themeLabel(data.subtitle, style: .secondary, font: AppTheme.regularFont(size: 11))
+        arrowButton = NSButton()
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         setContentHuggingPriority(.defaultLow, for: .horizontal)
         setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         wantsLayer = true
-        layer?.backgroundColor = AppTheme.cardBackground.cgColor
         layer?.cornerRadius = AppTheme.featureCardCornerRadius
         applyCardShadow()
 
-        let titleLabel = NSTextField(labelWithString: data.title)
-        titleLabel.font = AppTheme.semiboldFont(size: 14)
-        titleLabel.textColor = AppTheme.textPrimary
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
-
-        let subtitleLabel = NSTextField(labelWithString: data.subtitle)
-        subtitleLabel.font = AppTheme.regularFont(size: 11)
-        subtitleLabel.textColor = AppTheme.textSecondary
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        let arrowButton = NSButton()
         arrowButton.isBordered = false
         arrowButton.wantsLayer = true
-        arrowButton.layer?.backgroundColor = NSColor.white.cgColor
         arrowButton.layer?.cornerRadius = 13
         arrowButton.layer?.borderWidth = 1
-        arrowButton.layer?.borderColor = NSColor(calibratedWhite: 0.88, alpha: 1).cgColor
         arrowButton.translatesAutoresizingMaskIntoConstraints = false
         if let arrow = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Open") {
             let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
@@ -434,11 +441,21 @@ final class FeatureCardView: NSView {
         iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
         iconWidthConstraint.isActive = true
         iconHeightConstraint.isActive = true
+        refreshAppearance()
     }
 
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        titleLabel.refreshThemeLabelColor()
+        subtitleLabel.refreshThemeLabelColor()
+        arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
+        arrowButton.layer?.borderColor = AppTheme.border.cgColor
+        arrowButton.contentTintColor = AppTheme.textSecondary
+    }
+
     override func layout() {
         super.layout()
         let size = AppTheme.featureIconSize(forCardWidth: bounds.width)

+ 23 - 7
smart_printer/ViewController.swift

@@ -38,6 +38,12 @@ class ViewController: NSViewController {
             name: .showSettings,
             object: nil
         )
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(appearanceDidChange),
+            name: .appearanceDidChange,
+            object: nil
+        )
     }
 
     deinit {
@@ -48,6 +54,19 @@ class ViewController: NSViewController {
         navigateToSettings()
     }
 
+    @objc private func appearanceDidChange() {
+        refreshAppearance()
+    }
+
+    private func refreshAppearance() {
+        view.layer?.backgroundColor = AppTheme.background.cgColor
+        mainContentView?.layer?.backgroundColor = AppTheme.background.cgColor
+        view.window?.backgroundColor = AppTheme.background
+        sidebar?.refreshAppearance()
+        view.refreshAppearanceRecursively()
+        paywallOverlay?.refreshAppearance()
+    }
+
     private func setupLayout() {
         sidebar = SidebarView()
 
@@ -270,9 +289,7 @@ class ViewController: NSViewController {
         let header = NSView()
         header.translatesAutoresizingMaskIntoConstraints = false
 
-        let titleLabel = NSTextField(labelWithString: "Smart Printer")
-        titleLabel.font = AppTheme.semiboldFont(size: 18)
-        titleLabel.textColor = AppTheme.textPrimary
+        let titleLabel = NSTextField.themeLabel("Smart Printer", style: .primary, font: AppTheme.semiboldFont(size: 18))
         titleLabel.alignment = .center
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
@@ -375,6 +392,7 @@ class ViewController: NSViewController {
                     gridIcon.image = image.withSymbolConfiguration(config)
                 }
                 gridIcon.contentTintColor = AppTheme.textPrimary
+                gridIcon.usesThemeTint = true
                 titleIcon = gridIcon
             case .none:
                 titleIcon = NSView()
@@ -393,9 +411,7 @@ class ViewController: NSViewController {
             iconSpacing = 8
         }
 
-        let label = NSTextField(labelWithString: text)
-        label.font = .systemFont(ofSize: 20, weight: .bold)
-        label.textColor = AppTheme.textPrimary
+        let label = NSTextField.themeLabel(text, style: .primary, font: .systemFont(ofSize: 20, weight: .bold))
         label.translatesAutoresizingMaskIntoConstraints = false
         container.addSubview(label)
 
@@ -428,7 +444,7 @@ class ViewController: NSViewController {
                 subtitle: "Take a photo from gallery",
                 buttonTitle: "Open Gallery",
                 accentColor: AppTheme.blue,
-                gradientColors: [AppTheme.blueLight, NSColor(red: 0.82, green: 0.90, blue: 1.0, alpha: 1)],
+                gradientColors: [AppTheme.quickStartBlueLight, NSColor(red: 0.82, green: 0.90, blue: 1.0, alpha: 1)],
                 iconKind: .photos
             ),
             QuickStartCardData(