Sfoglia il codice sorgente

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 6 ore fa
parent
commit
0ca58b3cd0

+ 4 - 1
smart_printer/AppDelegate.swift

@@ -10,8 +10,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
10 10
 
11 11
     private weak var mainWindowController: MainWindowController?
12 12
 
13
-    func applicationDidFinishLaunching(_ notification: Notification) {
13
+    func applicationWillFinishLaunching(_ notification: Notification) {
14 14
         AppSettings.applyAppearance()
15
+    }
16
+
17
+    func applicationDidFinishLaunching(_ notification: Notification) {
15 18
         resolveMainWindowController()
16 19
         configureMainWindow()
17 20
         configurePreferencesMenu()

+ 5 - 0
smart_printer/AppSettings.swift

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

+ 204 - 12
smart_printer/AppTheme.swift

@@ -1,4 +1,5 @@
1 1
 import Cocoa
2
+import ObjectiveC
2 3
 
3 4
 enum AppTheme {
4 5
     static let windowWidth: CGFloat = 760
@@ -29,19 +30,93 @@ enum AppTheme {
29 30
     static let cardCornerRadius: CGFloat = 20
30 31
     static let featureCardCornerRadius: CGFloat = 16
31 32
 
32
-    static let background = NSColor(calibratedWhite: 0.965, alpha: 1)
33
-    static let sidebarBackground = NSColor.white
34
-    static let cardBackground = NSColor.white
35
-    static let textPrimary = NSColor(calibratedWhite: 0.12, alpha: 1)
36
-    static let textSecondary = NSColor(calibratedWhite: 0.48, alpha: 1)
33
+    private static var isDark: Bool { AppSettings.darkModeEnabled }
37 34
 
38
-    static let homeActiveBackground = NSColor(red: 0.91, green: 0.94, blue: 1.0, alpha: 1)
39
-    static let homeActiveForeground = NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
40
-    static let premiumBackground = NSColor(red: 0.95, green: 0.93, blue: 1.0, alpha: 1)
41
-    static let premiumForeground = NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
35
+    static var background: NSColor {
36
+        isDark
37
+            ? NSColor(calibratedWhite: 0.11, alpha: 1)
38
+            : NSColor(calibratedWhite: 0.965, alpha: 1)
39
+    }
40
+
41
+    static var sidebarBackground: NSColor {
42
+        isDark
43
+            ? NSColor(calibratedWhite: 0.15, alpha: 1)
44
+            : NSColor.white
45
+    }
46
+
47
+    static var cardBackground: NSColor {
48
+        isDark
49
+            ? NSColor(calibratedWhite: 0.18, alpha: 1)
50
+            : NSColor.white
51
+    }
52
+
53
+    static var groupCardBackground: NSColor { cardBackground }
54
+
55
+    static var elevatedBackground: NSColor {
56
+        isDark
57
+            ? NSColor(calibratedWhite: 0.22, alpha: 1)
58
+            : NSColor.white
59
+    }
60
+
61
+    static var textPrimary: NSColor {
62
+        isDark
63
+            ? NSColor(calibratedWhite: 0.92, alpha: 1)
64
+            : NSColor(calibratedWhite: 0.12, alpha: 1)
65
+    }
66
+
67
+    static var textSecondary: NSColor {
68
+        isDark
69
+            ? NSColor(calibratedWhite: 0.55, alpha: 1)
70
+            : NSColor(calibratedWhite: 0.48, alpha: 1)
71
+    }
72
+
73
+    static var border: NSColor {
74
+        isDark
75
+            ? NSColor(calibratedWhite: 0.28, alpha: 1)
76
+            : NSColor(calibratedWhite: 0.92, alpha: 1)
77
+    }
78
+
79
+    static var waveDotColor: NSColor {
80
+        isDark
81
+            ? NSColor(calibratedWhite: 0.35, alpha: 0.45)
82
+            : NSColor(calibratedWhite: 0.82, alpha: 0.55)
83
+    }
84
+
85
+    static var homeActiveBackground: NSColor {
86
+        isDark
87
+            ? NSColor(red: 0.18, green: 0.28, blue: 0.48, alpha: 1)
88
+            : NSColor(red: 0.91, green: 0.94, blue: 1.0, alpha: 1)
89
+    }
90
+
91
+    static var homeActiveForeground: NSColor {
92
+        isDark
93
+            ? NSColor(red: 0.45, green: 0.65, blue: 1.0, alpha: 1)
94
+            : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
95
+    }
96
+
97
+    static var premiumBackground: NSColor {
98
+        isDark
99
+            ? NSColor(red: 0.28, green: 0.22, blue: 0.42, alpha: 1)
100
+            : NSColor(red: 0.95, green: 0.93, blue: 1.0, alpha: 1)
101
+    }
102
+
103
+    static var premiumForeground: NSColor {
104
+        isDark
105
+            ? NSColor(red: 0.72, green: 0.58, blue: 1.0, alpha: 1)
106
+            : NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
107
+    }
42 108
 
43 109
     static let blue = NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
44
-    static let blueLight = NSColor(red: 0.88, green: 0.93, blue: 1.0, alpha: 1)
110
+    static let quickStartBlueLight = NSColor(red: 0.88, green: 0.93, blue: 1.0, alpha: 1)
111
+    static let quickStartCardSubtitle = NSColor(calibratedWhite: 0.48, alpha: 1)
112
+    static let quickStartWaveDot = NSColor(calibratedWhite: 0.82, alpha: 0.55)
113
+
114
+    static var blueLight: NSColor {
115
+        isDark
116
+            ? NSColor(red: 0.20, green: 0.28, blue: 0.42, alpha: 1)
117
+            : quickStartBlueLight
118
+    }
119
+
45 120
     static let green = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
46 121
     static let greenLight = NSColor(red: 0.88, green: 0.97, blue: 0.91, alpha: 1)
47 122
     static let orange = NSColor(red: 0.96, green: 0.52, blue: 0.18, alpha: 1)
@@ -53,7 +128,58 @@ enum AppTheme {
53 128
     static let paywallPinkText = NSColor(red: 0.75, green: 0.30, blue: 0.45, alpha: 1)
54 129
     static let paywallGold = NSColor(red: 0.96, green: 0.90, blue: 0.78, alpha: 1)
55 130
     static let paywallGoldText = NSColor(red: 0.65, green: 0.48, blue: 0.22, alpha: 1)
56
-    static let paywallBorder = NSColor(calibratedWhite: 0.88, alpha: 1)
131
+
132
+    static var paywallBorder: NSColor { border }
133
+
134
+    static var paywallBackground: NSColor { cardBackground }
135
+
136
+    static var paywallAccent: NSColor {
137
+        isDark ? textPrimary : navy
138
+    }
139
+
140
+    static var paywallLeftGradientColors: [NSColor] {
141
+        isDark
142
+            ? [
143
+                NSColor(calibratedWhite: 0.17, alpha: 1),
144
+                NSColor(calibratedWhite: 0.13, alpha: 1),
145
+            ]
146
+            : [
147
+                NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1),
148
+                NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1),
149
+            ]
150
+    }
151
+
152
+    static var paywallCTABackground: NSColor {
153
+        isDark
154
+            ? NSColor(calibratedWhite: 0.96, alpha: 1)
155
+            : navy
156
+    }
157
+
158
+    static var paywallCTAForeground: NSColor {
159
+        isDark
160
+            ? NSColor(calibratedWhite: 0.10, alpha: 1)
161
+            : NSColor.white
162
+    }
163
+
164
+    static var paywallTrustIconBackground: NSColor {
165
+        isDark
166
+            ? NSColor(calibratedWhite: 0.24, alpha: 1)
167
+            : quickStartBlueLight
168
+    }
169
+
170
+    static var paywallIconAccent: NSColor {
171
+        isDark
172
+            ? NSColor(calibratedWhite: 0.78, alpha: 1)
173
+            : navy
174
+    }
175
+
176
+    static var paywallTrustBackground: NSColor { elevatedBackground }
177
+
178
+    static var paywallOverlayBackdrop: NSColor {
179
+        isDark
180
+            ? NSColor(calibratedWhite: 0.05, alpha: 0.55)
181
+            : NSColor(calibratedWhite: 0.15, alpha: 0.22)
182
+    }
57 183
 
58 184
     static func semiboldFont(size: CGFloat) -> NSFont {
59 185
         .systemFont(ofSize: size, weight: .semibold)
@@ -68,11 +194,15 @@ enum AppTheme {
68 194
     }
69 195
 }
70 196
 
197
+protocol AppearanceRefreshable: AnyObject {
198
+    func refreshAppearance()
199
+}
200
+
71 201
 extension NSView {
72 202
     func applyCardShadow() {
73 203
         wantsLayer = true
74 204
         layer?.shadowColor = NSColor.black.cgColor
75
-        layer?.shadowOpacity = 0.07
205
+        layer?.shadowOpacity = AppSettings.darkModeEnabled ? 0.25 : 0.07
76 206
         layer?.shadowOffset = NSSize(width: 0, height: -3)
77 207
         layer?.shadowRadius = 14
78 208
         layer?.masksToBounds = false
@@ -83,4 +213,66 @@ extension NSView {
83 213
         layer?.cornerRadius = radius
84 214
         layer?.masksToBounds = true
85 215
     }
216
+
217
+    func refreshAppearanceRecursively() {
218
+        if let refreshable = self as? AppearanceRefreshable {
219
+            refreshable.refreshAppearance()
220
+        }
221
+        if let label = self as? NSTextField {
222
+            label.refreshThemeLabelColor()
223
+        }
224
+        if let imageView = self as? NSImageView, imageView.usesThemeTint {
225
+            imageView.contentTintColor = AppTheme.textPrimary
226
+        }
227
+        subviews.forEach { $0.refreshAppearanceRecursively() }
228
+    }
229
+}
230
+
231
+private enum ThemeAssociatedKeys {
232
+    static var labelStyle = 0
233
+    static var usesThemeTint = 0
234
+}
235
+
236
+enum ThemeLabelStyle: String {
237
+    case primary
238
+    case secondary
239
+}
240
+
241
+extension NSTextField {
242
+    static func themeLabel(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
243
+        let label = NSTextField(labelWithString: string)
244
+        label.font = font
245
+        label.themeLabelStyle = style
246
+        label.textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
247
+        return label
248
+    }
249
+
250
+    var themeLabelStyle: ThemeLabelStyle? {
251
+        get {
252
+            guard let rawValue = objc_getAssociatedObject(self, &ThemeAssociatedKeys.labelStyle) as? String else {
253
+                return nil
254
+            }
255
+            return ThemeLabelStyle(rawValue: rawValue)
256
+        }
257
+        set {
258
+            objc_setAssociatedObject(
259
+                self,
260
+                &ThemeAssociatedKeys.labelStyle,
261
+                newValue?.rawValue,
262
+                .OBJC_ASSOCIATION_RETAIN_NONATOMIC
263
+            )
264
+        }
265
+    }
266
+
267
+    func refreshThemeLabelColor() {
268
+        guard let style = themeLabelStyle else { return }
269
+        textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
270
+    }
271
+}
272
+
273
+extension NSImageView {
274
+    var usesThemeTint: Bool {
275
+        get { (objc_getAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint) as? Bool) ?? false }
276
+        set { objc_setAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
277
+    }
86 278
 }

+ 88 - 42
smart_printer/PaywallView.swift

@@ -42,19 +42,20 @@ enum PaywallPlan: CaseIterable {
42 42
 
43 43
 // MARK: - Left Panel
44 44
 
45
-private final class PaywallLeftPanelView: NSView {
45
+private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {
46 46
     private let gradientLayer = CAGradientLayer()
47 47
 
48 48
     override init(frame frameRect: NSRect) {
49 49
         super.init(frame: frameRect)
50 50
         wantsLayer = true
51
-        gradientLayer.colors = [
52
-            NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1).cgColor,
53
-            NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1).cgColor,
54
-        ]
55 51
         gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
56 52
         gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
57 53
         layer?.insertSublayer(gradientLayer, at: 0)
54
+        refreshAppearance()
55
+    }
56
+
57
+    func refreshAppearance() {
58
+        gradientLayer.colors = AppTheme.paywallLeftGradientColors.map(\.cgColor)
58 59
     }
59 60
 
60 61
     @available(*, unavailable)
@@ -121,8 +122,11 @@ private final class PaywallBadgeView: NSView {
121 122
 
122 123
 // MARK: - Feature Row
123 124
 
124
-private final class PaywallFeatureRow: NSView {
125
+private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
126
+    private let label: NSTextField
127
+
125 128
     init(text: String) {
129
+        label = NSTextField.themeLabel(text, style: .primary, font: AppTheme.regularFont(size: 14))
126 130
         super.init(frame: .zero)
127 131
         translatesAutoresizingMaskIntoConstraints = false
128 132
 
@@ -141,9 +145,6 @@ private final class PaywallFeatureRow: NSView {
141 145
         }
142 146
         checkIcon.contentTintColor = .white
143 147
 
144
-        let label = NSTextField(labelWithString: text)
145
-        label.font = AppTheme.regularFont(size: 14)
146
-        label.textColor = AppTheme.navy
147 148
         label.translatesAutoresizingMaskIntoConstraints = false
148 149
 
149 150
         addSubview(checkContainer)
@@ -171,11 +172,15 @@ private final class PaywallFeatureRow: NSView {
171 172
 
172 173
     @available(*, unavailable)
173 174
     required init?(coder: NSCoder) { nil }
175
+
176
+    func refreshAppearance() {
177
+        label.refreshThemeLabelColor()
178
+    }
174 179
 }
175 180
 
176 181
 // MARK: - Plan Card
177 182
 
178
-private final class PaywallPlanCard: NSControl {
183
+private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
179 184
     var onSelect: (() -> Void)?
180 185
 
181 186
     private let plan: PaywallPlan
@@ -194,7 +199,6 @@ private final class PaywallPlanCard: NSControl {
194 199
         translatesAutoresizingMaskIntoConstraints = false
195 200
         wantsLayer = true
196 201
         layer?.cornerRadius = 12
197
-        layer?.backgroundColor = NSColor.white.cgColor
198 202
         layer?.masksToBounds = false
199 203
 
200 204
         titleLabel.stringValue = plan.title
@@ -203,6 +207,7 @@ private final class PaywallPlanCard: NSControl {
203 207
 
204 208
         subtitleLabel.stringValue = plan.subtitle
205 209
         subtitleLabel.font = AppTheme.regularFont(size: 11)
210
+        subtitleLabel.themeLabelStyle = .secondary
206 211
         subtitleLabel.textColor = AppTheme.textSecondary
207 212
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
208 213
 
@@ -263,11 +268,17 @@ private final class PaywallPlanCard: NSControl {
263 268
     @available(*, unavailable)
264 269
     required init?(coder: NSCoder) { nil }
265 270
 
271
+    func refreshAppearance() {
272
+        updateAppearance()
273
+        subtitleLabel.refreshThemeLabelColor()
274
+    }
275
+
266 276
     private func updateAppearance() {
267
-        let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.navy
277
+        let titleColor = isChosen && plan == .yearly ? AppTheme.green : AppTheme.paywallAccent
268 278
         titleLabel.textColor = titleColor
269 279
         priceLabel.textColor = titleColor
270 280
 
281
+        layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
271 282
         layer?.borderWidth = isChosen ? 2 : 1
272 283
         layer?.borderColor = (isChosen ? AppTheme.green : AppTheme.paywallBorder).cgColor
273 284
     }
@@ -284,19 +295,23 @@ private final class PaywallPlanCard: NSControl {
284 295
 
285 296
 // MARK: - Footer Link
286 297
 
287
-private final class PaywallFooterLink: NSButton {
298
+private final class PaywallFooterLink: NSButton, AppearanceRefreshable {
288 299
     init(title: String) {
289 300
         super.init(frame: .zero)
290 301
         self.title = title
291 302
         isBordered = false
292 303
         font = AppTheme.regularFont(size: 11)
293
-        contentTintColor = AppTheme.textSecondary
294 304
         translatesAutoresizingMaskIntoConstraints = false
305
+        refreshAppearance()
295 306
     }
296 307
 
297 308
     @available(*, unavailable)
298 309
     required init?(coder: NSCoder) { nil }
299 310
 
311
+    func refreshAppearance() {
312
+        contentTintColor = AppTheme.textSecondary
313
+    }
314
+
300 315
     override func resetCursorRects() {
301 316
         addCursorRect(bounds, cursor: .pointingHand)
302 317
     }
@@ -304,36 +319,33 @@ private final class PaywallFooterLink: NSButton {
304 319
 
305 320
 // MARK: - Footer Trust Item
306 321
 
307
-private final class PaywallTrustItemView: NSView {
322
+private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
323
+    private let iconContainer = NSView()
324
+    private let icon = NSImageView()
325
+    private let titleLabel: NSTextField
326
+    private let subtitleLabel: NSTextField
327
+
308 328
     init(iconName: String, title: String, subtitle: String) {
329
+        titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 11))
330
+        subtitleLabel = NSTextField.themeLabel(subtitle, style: .secondary, font: AppTheme.regularFont(size: 9))
309 331
         super.init(frame: .zero)
310 332
         translatesAutoresizingMaskIntoConstraints = false
311 333
 
312
-        let iconContainer = NSView()
313 334
         iconContainer.translatesAutoresizingMaskIntoConstraints = false
314 335
         iconContainer.wantsLayer = true
315
-        iconContainer.layer?.backgroundColor = AppTheme.blueLight.cgColor
316 336
         iconContainer.layer?.cornerRadius = 10
317 337
         iconContainer.layer?.masksToBounds = true
318 338
 
319
-        let icon = NSImageView()
320 339
         icon.translatesAutoresizingMaskIntoConstraints = false
321 340
         if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
322 341
             let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
323 342
             icon.image = image.withSymbolConfiguration(config)
324 343
         }
325
-        icon.contentTintColor = AppTheme.navy
326 344
 
327
-        let titleLabel = NSTextField(labelWithString: title)
328
-        titleLabel.font = AppTheme.semiboldFont(size: 11)
329
-        titleLabel.textColor = AppTheme.navy
330 345
         titleLabel.lineBreakMode = .byTruncatingTail
331 346
         titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
332 347
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
333 348
 
334
-        let subtitleLabel = NSTextField(labelWithString: subtitle)
335
-        subtitleLabel.font = AppTheme.regularFont(size: 9)
336
-        subtitleLabel.textColor = AppTheme.textSecondary
337 349
         subtitleLabel.lineBreakMode = .byTruncatingTail
338 350
         subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
339 351
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
@@ -366,15 +378,23 @@ private final class PaywallTrustItemView: NSView {
366 378
 
367 379
         setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
368 380
         setContentHuggingPriority(.defaultLow, for: .horizontal)
381
+        refreshAppearance()
369 382
     }
370 383
 
371 384
     @available(*, unavailable)
372 385
     required init?(coder: NSCoder) { nil }
386
+
387
+    func refreshAppearance() {
388
+        iconContainer.layer?.backgroundColor = AppTheme.paywallTrustIconBackground.cgColor
389
+        icon.contentTintColor = AppTheme.paywallIconAccent
390
+        titleLabel.refreshThemeLabelColor()
391
+        subtitleLabel.refreshThemeLabelColor()
392
+    }
373 393
 }
374 394
 
375 395
 // MARK: - Main Paywall Card
376 396
 
377
-final class PaywallView: NSView {
397
+final class PaywallView: NSView, AppearanceRefreshable {
378 398
     var onClose: (() -> Void)?
379 399
     var onPurchase: ((PaywallPlan) -> Void)?
380 400
     var onRestore: (() -> Void)?
@@ -382,14 +402,30 @@ final class PaywallView: NSView {
382 402
     private var selectedPlan: PaywallPlan = .yearly
383 403
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
384 404
     private let ctaButton = NSButton()
405
+    private var leftPanelTitle: NSTextField!
406
+    private var rightTitle: NSTextField!
407
+    private var rightSubtitle: NSTextField!
408
+    private var trustStack: NSStackView!
385 409
 
386 410
     init() {
387 411
         super.init(frame: .zero)
388 412
         translatesAutoresizingMaskIntoConstraints = false
389 413
         wantsLayer = true
390
-        layer?.backgroundColor = NSColor.white.cgColor
391 414
         layer?.cornerRadius = 0
392 415
         setup()
416
+        refreshAppearance()
417
+    }
418
+
419
+    func refreshAppearance() {
420
+        layer?.backgroundColor = AppTheme.paywallBackground.cgColor
421
+        leftPanelTitle?.refreshThemeLabelColor()
422
+        rightTitle?.refreshThemeLabelColor()
423
+        rightSubtitle?.refreshThemeLabelColor()
424
+        trustStack?.layer?.backgroundColor = AppTheme.paywallTrustBackground.cgColor
425
+        trustStack?.layer?.borderColor = AppTheme.paywallBorder.cgColor
426
+        ctaButton.layer?.backgroundColor = AppTheme.paywallCTABackground.cgColor
427
+        ctaButton.contentTintColor = AppTheme.paywallCTAForeground
428
+        subviews.forEach { $0.refreshAppearanceRecursively() }
393 429
     }
394 430
 
395 431
     @available(*, unavailable)
@@ -419,11 +455,14 @@ final class PaywallView: NSView {
419 455
         let panel = PaywallLeftPanelView()
420 456
         panel.translatesAutoresizingMaskIntoConstraints = false
421 457
 
422
-        let title = NSTextField(labelWithString: "Unlock Your Full\nPrinting Potential")
423
-        title.font = AppTheme.semiboldFont(size: 22)
424
-        title.textColor = AppTheme.navy
458
+        let title = NSTextField.themeLabel(
459
+            "Unlock Your Full\nPrinting Potential",
460
+            style: .primary,
461
+            font: AppTheme.semiboldFont(size: 22)
462
+        )
425 463
         title.maximumNumberOfLines = 2
426 464
         title.translatesAutoresizingMaskIntoConstraints = false
465
+        leftPanelTitle = title
427 466
 
428 467
         let featuresStack = NSStackView()
429 468
         featuresStack.orientation = .vertical
@@ -463,18 +502,20 @@ final class PaywallView: NSView {
463 502
         let panel = NSView()
464 503
         panel.translatesAutoresizingMaskIntoConstraints = false
465 504
 
466
-        let title = NSTextField(labelWithString: "Go Premium")
467
-        title.font = AppTheme.semiboldFont(size: 26)
468
-        title.textColor = AppTheme.navy
505
+        let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
469 506
         title.alignment = .center
470 507
         title.translatesAutoresizingMaskIntoConstraints = false
508
+        rightTitle = title
471 509
 
472
-        let subtitle = NSTextField(labelWithString: "Experience professional quality printing and scanning without limits.")
473
-        subtitle.font = AppTheme.regularFont(size: 13)
474
-        subtitle.textColor = AppTheme.textSecondary
510
+        let subtitle = NSTextField.themeLabel(
511
+            "Experience professional quality printing and scanning without limits.",
512
+            style: .secondary,
513
+            font: AppTheme.regularFont(size: 13)
514
+        )
475 515
         subtitle.alignment = .center
476 516
         subtitle.maximumNumberOfLines = 2
477 517
         subtitle.translatesAutoresizingMaskIntoConstraints = false
518
+        rightSubtitle = subtitle
478 519
 
479 520
         let plansStack = NSStackView()
480 521
         plansStack.orientation = .vertical
@@ -492,10 +533,8 @@ final class PaywallView: NSView {
492 533
         ctaButton.title = selectedPlan.ctaTitle
493 534
         ctaButton.isBordered = false
494 535
         ctaButton.wantsLayer = true
495
-        ctaButton.layer?.backgroundColor = AppTheme.navy.cgColor
496 536
         ctaButton.layer?.cornerRadius = 12
497 537
         ctaButton.font = AppTheme.semiboldFont(size: 15)
498
-        ctaButton.contentTintColor = .white
499 538
         ctaButton.target = self
500 539
         ctaButton.action = #selector(purchaseTapped)
501 540
         ctaButton.translatesAutoresizingMaskIntoConstraints = false
@@ -569,13 +608,12 @@ final class PaywallView: NSView {
569 608
         trustStack.alignment = .top
570 609
         trustStack.translatesAutoresizingMaskIntoConstraints = false
571 610
         trustStack.wantsLayer = true
572
-        trustStack.layer?.backgroundColor = NSColor.white.cgColor
573 611
         trustStack.layer?.cornerRadius = 12
574 612
         trustStack.layer?.borderWidth = 1
575
-        trustStack.layer?.borderColor = AppTheme.paywallBorder.cgColor
576 613
         trustStack.layer?.masksToBounds = true
577 614
         trustStack.edgeInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
578 615
         trustStack.translatesAutoresizingMaskIntoConstraints = false
616
+        self.trustStack = trustStack
579 617
 
580 618
         return trustStack
581 619
     }
@@ -657,18 +695,27 @@ final class PaywallView: NSView {
657 695
 
658 696
 // MARK: - Overlay Presenter
659 697
 
660
-final class PaywallOverlayView: NSView {
698
+final class PaywallOverlayView: NSView, AppearanceRefreshable {
661 699
     var onDismiss: (() -> Void)?
662 700
 
663 701
     private let paywallView: PaywallView
664 702
     private let blurView = NSVisualEffectView()
665 703
     private let backdrop = NSView()
704
+    private let pattern = WavePatternView()
666 705
 
667 706
     init() {
668 707
         paywallView = PaywallView()
669 708
         super.init(frame: .zero)
670 709
         translatesAutoresizingMaskIntoConstraints = false
671 710
         setup()
711
+        refreshAppearance()
712
+    }
713
+
714
+    func refreshAppearance() {
715
+        backdrop.layer?.backgroundColor = AppTheme.paywallOverlayBackdrop.cgColor
716
+        blurView.material = AppSettings.darkModeEnabled ? .hudWindow : .underWindowBackground
717
+        pattern.refreshAppearance()
718
+        paywallView.refreshAppearance()
672 719
     }
673 720
 
674 721
     @available(*, unavailable)
@@ -684,7 +731,6 @@ final class PaywallOverlayView: NSView {
684 731
         backdrop.wantsLayer = true
685 732
         backdrop.layer?.backgroundColor = NSColor(calibratedWhite: 0.15, alpha: 0.22).cgColor
686 733
 
687
-        let pattern = WavePatternView()
688 734
         pattern.translatesAutoresizingMaskIntoConstraints = false
689 735
         pattern.alphaValue = 0.35
690 736
 

+ 28 - 18
smart_printer/SettingsView.swift

@@ -2,7 +2,7 @@ import Cocoa
2 2
 
3 3
 // MARK: - Root
4 4
 
5
-final class SettingsView: NSView {
5
+final class SettingsView: NSView, AppearanceRefreshable {
6 6
     init() {
7 7
         super.init(frame: .zero)
8 8
         wantsLayer = true
@@ -14,6 +14,10 @@ final class SettingsView: NSView {
14 14
     @available(*, unavailable)
15 15
     required init?(coder: NSCoder) { nil }
16 16
 
17
+    func refreshAppearance() {
18
+        layer?.backgroundColor = AppTheme.background.cgColor
19
+    }
20
+
17 21
     private func setup() {
18 22
         let scrollView = NSScrollView()
19 23
         scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -81,9 +85,7 @@ final class SettingsView: NSView {
81 85
         container.spacing = 12
82 86
         container.translatesAutoresizingMaskIntoConstraints = false
83 87
 
84
-        let label = NSTextField(labelWithString: title)
85
-        label.font = AppTheme.semiboldFont(size: 18)
86
-        label.textColor = AppTheme.textPrimary
88
+        let label = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 18))
87 89
         label.translatesAutoresizingMaskIntoConstraints = false
88 90
 
89 91
         container.addArrangedSubview(label)
@@ -137,32 +139,35 @@ final class SettingsView: NSView {
137 139
 
138 140
 // MARK: - Panel
139 141
 
140
-private final class SettingsPanelView: NSView {
142
+private final class SettingsPanelView: NSView, AppearanceRefreshable {
141 143
     init() {
142 144
         super.init(frame: .zero)
143 145
         wantsLayer = true
144
-        layer?.backgroundColor = AppTheme.cardBackground.cgColor
145 146
         layer?.cornerRadius = 22
146 147
         layer?.borderWidth = 1
147
-        layer?.borderColor = NSColor(calibratedWhite: 0.92, alpha: 1).cgColor
148 148
         applyCardShadow()
149
+        refreshAppearance()
150
+    }
151
+
152
+    func refreshAppearance() {
153
+        layer?.backgroundColor = AppTheme.cardBackground.cgColor
154
+        layer?.borderColor = AppTheme.border.cgColor
149 155
     }
150 156
 
151 157
     @available(*, unavailable)
152 158
     required init?(coder: NSCoder) { nil }
153 159
 }
154 160
 
155
-private final class SettingsGroupCard: NSView {
161
+private final class SettingsGroupCard: NSView, AppearanceRefreshable {
156 162
     private let stack = NSStackView()
157 163
 
158 164
     init() {
159 165
         super.init(frame: .zero)
160 166
         translatesAutoresizingMaskIntoConstraints = false
161 167
         wantsLayer = true
162
-        layer?.backgroundColor = NSColor.white.cgColor
163 168
         layer?.cornerRadius = 16
164 169
         layer?.borderWidth = 1
165
-        layer?.borderColor = NSColor(calibratedWhite: 0.93, alpha: 1).cgColor
170
+        refreshAppearance()
166 171
 
167 172
         stack.orientation = .vertical
168 173
         stack.spacing = 0
@@ -184,17 +189,22 @@ private final class SettingsGroupCard: NSView {
184 189
         stack.addArrangedSubview(row)
185 190
         row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
186 191
     }
192
+
193
+    func refreshAppearance() {
194
+        layer?.backgroundColor = AppTheme.groupCardBackground.cgColor
195
+        layer?.borderColor = AppTheme.border.cgColor
196
+    }
187 197
 }
188 198
 
189 199
 // MARK: - Icon
190 200
 
191
-private final class SettingsIconBadge: NSView {
201
+private final class SettingsIconBadge: NSView, AppearanceRefreshable {
192 202
     init(symbolName: String) {
193 203
         super.init(frame: .zero)
194 204
         translatesAutoresizingMaskIntoConstraints = false
195 205
         wantsLayer = true
196
-        layer?.backgroundColor = AppTheme.blueLight.cgColor
197 206
         layer?.cornerRadius = 10
207
+        refreshAppearance()
198 208
 
199 209
         let icon = NSImageView()
200 210
         icon.translatesAutoresizingMaskIntoConstraints = false
@@ -217,6 +227,10 @@ private final class SettingsIconBadge: NSView {
217 227
 
218 228
     @available(*, unavailable)
219 229
     required init?(coder: NSCoder) { nil }
230
+
231
+    func refreshAppearance() {
232
+        layer?.backgroundColor = AppTheme.blueLight.cgColor
233
+    }
220 234
 }
221 235
 
222 236
 // MARK: - Rows
@@ -247,9 +261,7 @@ private class SettingsRowBase: NSView {
247 261
 
248 262
     func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
249 263
         let badge = SettingsIconBadge(symbolName: symbolName)
250
-        let titleLabel = NSTextField(labelWithString: title)
251
-        titleLabel.font = AppTheme.mediumFont(size: 15)
252
-        titleLabel.textColor = AppTheme.textPrimary
264
+        let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
253 265
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
254 266
         trailing.translatesAutoresizingMaskIntoConstraints = false
255 267
 
@@ -278,9 +290,7 @@ private final class SettingsActionRow: SettingsRowBase {
278 290
         super.init(isLast: isLast)
279 291
 
280 292
         let badge = SettingsIconBadge(symbolName: symbolName)
281
-        let titleLabel = NSTextField(labelWithString: title)
282
-        titleLabel.font = AppTheme.mediumFont(size: 15)
283
-        titleLabel.textColor = AppTheme.textPrimary
293
+        let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
284 294
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
285 295
 
286 296
         addSubview(badge)

+ 8 - 4
smart_printer/SidebarView.swift

@@ -7,7 +7,8 @@ enum SidebarDestination: Int, CaseIterable {
7 7
     case settings
8 8
 }
9 9
 
10
-final class SidebarView: NSView {
10
+final class SidebarView: NSView, AppearanceRefreshable {
11
+    private var appNameLabel: NSTextField!
11 12
     var onDestinationSelected: ((SidebarDestination) -> Void)?
12 13
 
13 14
     private var navItems: [SidebarNavItem] = []
@@ -29,6 +30,11 @@ final class SidebarView: NSView {
29 30
         }
30 31
     }
31 32
 
33
+    func refreshAppearance() {
34
+        layer?.backgroundColor = AppTheme.sidebarBackground.cgColor
35
+        appNameLabel?.refreshThemeLabelColor()
36
+    }
37
+
32 38
     private func setup() {
33 39
         let logoContainer = NSView()
34 40
         logoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -41,9 +47,7 @@ final class SidebarView: NSView {
41 47
         }
42 48
         logoIcon.contentTintColor = AppTheme.purple
43 49
 
44
-        let appNameLabel = NSTextField(labelWithString: "Smart Printer")
45
-        appNameLabel.font = AppTheme.semiboldFont(size: 15)
46
-        appNameLabel.textColor = AppTheme.textPrimary
50
+        appNameLabel = NSTextField.themeLabel("Smart Printer", style: .primary, font: AppTheme.semiboldFont(size: 15))
47 51
         appNameLabel.alignment = .center
48 52
         appNameLabel.translatesAutoresizingMaskIntoConstraints = false
49 53
 

+ 34 - 17
smart_printer/UIComponents.swift

@@ -28,15 +28,21 @@ final class GradientCardView: NSView {
28 28
 
29 29
 // MARK: - Wave Pattern
30 30
 
31
-final class WavePatternView: NSView {
31
+final class WavePatternView: NSView, AppearanceRefreshable {
32
+    var fixedDotColor: NSColor?
33
+
32 34
     override var isOpaque: Bool { false }
33 35
 
36
+    func refreshAppearance() {
37
+        guard fixedDotColor == nil else { return }
38
+        needsDisplay = true
39
+    }
40
+
34 41
     override func draw(_ dirtyRect: NSRect) {
35 42
         super.draw(dirtyRect)
36 43
         guard let context = NSGraphicsContext.current?.cgContext else { return }
37 44
 
38
-        let dotColor = NSColor(calibratedWhite: 0.82, alpha: 0.55)
39
-        context.setFillColor(dotColor.cgColor)
45
+        context.setFillColor((fixedDotColor ?? AppTheme.waveDotColor).cgColor)
40 46
 
41 47
         let spacing: CGFloat = 14
42 48
         let dotSize: CGFloat = 3
@@ -57,7 +63,7 @@ final class WavePatternView: NSView {
57 63
 
58 64
 // MARK: - Sidebar Nav Item
59 65
 
60
-final class SidebarNavItem: NSControl {
66
+final class SidebarNavItem: NSControl, AppearanceRefreshable {
61 67
     enum Style {
62 68
         case normal
63 69
         case active
@@ -86,6 +92,7 @@ final class SidebarNavItem: NSControl {
86 92
 
87 93
         titleLabel.stringValue = title
88 94
         titleLabel.font = AppTheme.mediumFont(size: 14)
95
+        titleLabel.themeLabelStyle = .primary
89 96
         titleLabel.textColor = AppTheme.textPrimary
90 97
 
91 98
         if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
@@ -127,6 +134,10 @@ final class SidebarNavItem: NSControl {
127 134
     @available(*, unavailable)
128 135
     required init?(coder: NSCoder) { nil }
129 136
 
137
+    func refreshAppearance() {
138
+        applyStyle(isSelected ? .active : .normal)
139
+    }
140
+
130 141
     private func applyStyle(_ style: Style) {
131 142
         container.wantsLayer = true
132 143
         container.layer?.cornerRadius = AppTheme.cornerRadius
@@ -296,13 +307,14 @@ final class QuickStartCardView: NSView {
296 307
 
297 308
         let subtitleLabel = NSTextField(labelWithString: data.subtitle)
298 309
         subtitleLabel.font = AppTheme.regularFont(size: 13)
299
-        subtitleLabel.textColor = AppTheme.textSecondary
310
+        subtitleLabel.textColor = AppTheme.quickStartCardSubtitle
300 311
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
301 312
 
302 313
         let button = PillButton(title: data.buttonTitle, color: data.accentColor)
303 314
         button.translatesAutoresizingMaskIntoConstraints = false
304 315
 
305 316
         let wavePattern = WavePatternView()
317
+        wavePattern.fixedDotColor = AppTheme.quickStartWaveDot
306 318
         wavePattern.translatesAutoresizingMaskIntoConstraints = false
307 319
 
308 320
         addSubview(gradient)
@@ -365,39 +377,34 @@ struct FeatureCardData {
365 377
     let iconKind: FeatureIconKind
366 378
 }
367 379
 
368
-final class FeatureCardView: NSView {
380
+final class FeatureCardView: NSView, AppearanceRefreshable {
369 381
     private let iconView: FeatureIconView
382
+    private let titleLabel: NSTextField
383
+    private let subtitleLabel: NSTextField
384
+    private let arrowButton: NSButton
370 385
     private var iconWidthConstraint: NSLayoutConstraint!
371 386
     private var iconHeightConstraint: NSLayoutConstraint!
372 387
 
373 388
     init(data: FeatureCardData) {
374 389
         iconView = FeatureIconView(kind: data.iconKind)
390
+        titleLabel = NSTextField.themeLabel(data.title, style: .primary, font: AppTheme.semiboldFont(size: 14))
391
+        subtitleLabel = NSTextField.themeLabel(data.subtitle, style: .secondary, font: AppTheme.regularFont(size: 11))
392
+        arrowButton = NSButton()
375 393
         super.init(frame: .zero)
376 394
         translatesAutoresizingMaskIntoConstraints = false
377 395
         setContentHuggingPriority(.defaultLow, for: .horizontal)
378 396
         setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
379 397
         wantsLayer = true
380
-        layer?.backgroundColor = AppTheme.cardBackground.cgColor
381 398
         layer?.cornerRadius = AppTheme.featureCardCornerRadius
382 399
         applyCardShadow()
383 400
 
384
-        let titleLabel = NSTextField(labelWithString: data.title)
385
-        titleLabel.font = AppTheme.semiboldFont(size: 14)
386
-        titleLabel.textColor = AppTheme.textPrimary
387 401
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
388
-
389
-        let subtitleLabel = NSTextField(labelWithString: data.subtitle)
390
-        subtitleLabel.font = AppTheme.regularFont(size: 11)
391
-        subtitleLabel.textColor = AppTheme.textSecondary
392 402
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
393 403
 
394
-        let arrowButton = NSButton()
395 404
         arrowButton.isBordered = false
396 405
         arrowButton.wantsLayer = true
397
-        arrowButton.layer?.backgroundColor = NSColor.white.cgColor
398 406
         arrowButton.layer?.cornerRadius = 13
399 407
         arrowButton.layer?.borderWidth = 1
400
-        arrowButton.layer?.borderColor = NSColor(calibratedWhite: 0.88, alpha: 1).cgColor
401 408
         arrowButton.translatesAutoresizingMaskIntoConstraints = false
402 409
         if let arrow = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: "Open") {
403 410
             let config = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
@@ -434,11 +441,21 @@ final class FeatureCardView: NSView {
434 441
         iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: AppTheme.featureIconMax)
435 442
         iconWidthConstraint.isActive = true
436 443
         iconHeightConstraint.isActive = true
444
+        refreshAppearance()
437 445
     }
438 446
 
439 447
     @available(*, unavailable)
440 448
     required init?(coder: NSCoder) { nil }
441 449
 
450
+    func refreshAppearance() {
451
+        layer?.backgroundColor = AppTheme.cardBackground.cgColor
452
+        titleLabel.refreshThemeLabelColor()
453
+        subtitleLabel.refreshThemeLabelColor()
454
+        arrowButton.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
455
+        arrowButton.layer?.borderColor = AppTheme.border.cgColor
456
+        arrowButton.contentTintColor = AppTheme.textSecondary
457
+    }
458
+
442 459
     override func layout() {
443 460
         super.layout()
444 461
         let size = AppTheme.featureIconSize(forCardWidth: bounds.width)

+ 23 - 7
smart_printer/ViewController.swift

@@ -38,6 +38,12 @@ class ViewController: NSViewController {
38 38
             name: .showSettings,
39 39
             object: nil
40 40
         )
41
+        NotificationCenter.default.addObserver(
42
+            self,
43
+            selector: #selector(appearanceDidChange),
44
+            name: .appearanceDidChange,
45
+            object: nil
46
+        )
41 47
     }
42 48
 
43 49
     deinit {
@@ -48,6 +54,19 @@ class ViewController: NSViewController {
48 54
         navigateToSettings()
49 55
     }
50 56
 
57
+    @objc private func appearanceDidChange() {
58
+        refreshAppearance()
59
+    }
60
+
61
+    private func refreshAppearance() {
62
+        view.layer?.backgroundColor = AppTheme.background.cgColor
63
+        mainContentView?.layer?.backgroundColor = AppTheme.background.cgColor
64
+        view.window?.backgroundColor = AppTheme.background
65
+        sidebar?.refreshAppearance()
66
+        view.refreshAppearanceRecursively()
67
+        paywallOverlay?.refreshAppearance()
68
+    }
69
+
51 70
     private func setupLayout() {
52 71
         sidebar = SidebarView()
53 72
 
@@ -270,9 +289,7 @@ class ViewController: NSViewController {
270 289
         let header = NSView()
271 290
         header.translatesAutoresizingMaskIntoConstraints = false
272 291
 
273
-        let titleLabel = NSTextField(labelWithString: "Smart Printer")
274
-        titleLabel.font = AppTheme.semiboldFont(size: 18)
275
-        titleLabel.textColor = AppTheme.textPrimary
292
+        let titleLabel = NSTextField.themeLabel("Smart Printer", style: .primary, font: AppTheme.semiboldFont(size: 18))
276 293
         titleLabel.alignment = .center
277 294
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
278 295
 
@@ -375,6 +392,7 @@ class ViewController: NSViewController {
375 392
                     gridIcon.image = image.withSymbolConfiguration(config)
376 393
                 }
377 394
                 gridIcon.contentTintColor = AppTheme.textPrimary
395
+                gridIcon.usesThemeTint = true
378 396
                 titleIcon = gridIcon
379 397
             case .none:
380 398
                 titleIcon = NSView()
@@ -393,9 +411,7 @@ class ViewController: NSViewController {
393 411
             iconSpacing = 8
394 412
         }
395 413
 
396
-        let label = NSTextField(labelWithString: text)
397
-        label.font = .systemFont(ofSize: 20, weight: .bold)
398
-        label.textColor = AppTheme.textPrimary
414
+        let label = NSTextField.themeLabel(text, style: .primary, font: .systemFont(ofSize: 20, weight: .bold))
399 415
         label.translatesAutoresizingMaskIntoConstraints = false
400 416
         container.addSubview(label)
401 417
 
@@ -428,7 +444,7 @@ class ViewController: NSViewController {
428 444
                 subtitle: "Take a photo from gallery",
429 445
                 buttonTitle: "Open Gallery",
430 446
                 accentColor: AppTheme.blue,
431
-                gradientColors: [AppTheme.blueLight, NSColor(red: 0.82, green: 0.90, blue: 1.0, alpha: 1)],
447
+                gradientColors: [AppTheme.quickStartBlueLight, NSColor(red: 0.82, green: 0.90, blue: 1.0, alpha: 1)],
432 448
                 iconKind: .photos
433 449
             ),
434 450
             QuickStartCardData(