|
1 |
+// |
|
2 |
+// ZyquoTheme.swift |
|
3 |
+// Zyquo Local |
|
4 |
+// |
|
5 |
+// Author: Simon-Pierre Boucher |
|
6 |
+// Mail: contact@spboucher.ai |
|
7 |
+// |
|
8 |
+ |
|
9 |
+import AppKit |
|
10 |
+import SwiftUI |
|
11 |
+ |
|
12 |
+/// The single source of truth for colors, typography, spacing and radii. |
|
13 |
+/// Views never use raw hex values or magic numbers — only these tokens. |
|
14 |
+/// |
|
15 |
+/// Light theme (flagship) per the Phase 4 spec: warm neutral off-white with |
|
16 |
+/// a faint green undertone and a refined emerald accent. Dark theme is |
|
17 |
+/// derived: deep graphite-green, never flat gray. |
|
18 |
+enum ZyquoTheme { |
|
19 |
+ // MARK: - Color tokens |
|
20 |
+ |
|
21 |
+ /// Main canvas. |
|
22 |
+ static let background = dynamic(light: 0xFAFBFA, dark: 0x121614) |
|
23 |
+ /// Cards, assistant bubbles, input bar. |
|
24 |
+ static let surface = dynamic(light: 0xFFFFFF, dark: 0x1A201C) |
|
25 |
+ /// Hover states, code blocks. |
|
26 |
+ static let surfaceSecondary = dynamic(light: 0xF2F5F3, dark: 0x232A26) |
|
27 |
+ /// Primary actions, selection, links, send. |
|
28 |
+ @MainActor static var accent: Color { ThemeStore.shared.accentChoice.color } |
|
29 |
+ /// Selected rows, user bubble tint. |
|
30 |
+ @MainActor static var accentSubtle: Color { ThemeStore.shared.accentChoice.subtleColor } |
|
31 |
+ /// Body text — never pure black on pure white. |
|
32 |
+ static let textPrimary = dynamic(light: 0x1A1E1C, dark: 0xE8ECEA) |
|
33 |
+ /// Metadata. |
|
34 |
+ static let textSecondary = dynamic(light: 0x6B7472, dark: 0x9BA6A2) |
|
35 |
+ /// Placeholders. |
|
36 |
+ static let textTertiary = dynamic(light: 0x9EA8A5, dark: 0x6A7572) |
|
37 |
+ /// 0.5 pt hairlines. |
|
38 |
+ static let border = dynamic(light: 0xE4E9E6, dark: 0x2A322D) |
|
39 |
+ static let success = dynamic(light: 0x2FA36B, dark: 0x37B57C) |
|
40 |
+ static let warning = dynamic(light: 0xD9822B, dark: 0xE09244) |
|
41 |
+ static let danger = dynamic(light: 0xD64545, dark: 0xE25D5D) |
|
42 |
+ |
|
43 |
+ // MARK: - Typography (identical scale to Zyquo Cloud) |
|
44 |
+ |
|
45 |
+ /// 20 pt semibold. |
|
46 |
+ static let title = Font.system(size: 20, weight: .semibold) |
|
47 |
+ /// 13.5 pt regular (body line height 1.45 → use `bodyLineSpacing`). |
|
48 |
+ static let body = Font.system(size: 13.5) |
|
49 |
+ /// 13.5 pt medium. |
|
50 |
+ static let bodyEmphasis = Font.system(size: 13.5, weight: .medium) |
|
51 |
+ /// 11 pt. |
|
52 |
+ static let caption = Font.system(size: 11) |
|
53 |
+ /// 12.5 pt SF Mono. |
|
54 |
+ static let code = Font.system(size: 12.5, design: .monospaced) |
|
55 |
+ |
|
56 |
+ /// Line spacing that yields a 1.45 line height at a given font size. |
|
57 |
+ static func lineSpacing(fontSize: CGFloat) -> CGFloat { |
|
58 |
+ fontSize * 0.45 |
|
59 |
+ } |
|
60 |
+ |
|
61 |
+ /// User-adjustable chat body font (12–18 pt, from Settings). |
|
62 |
+ @MainActor static var chatBody: Font { |
|
63 |
+ Font.system(size: ThemeStore.shared.chatFontSize) |
|
64 |
+ } |
|
65 |
+ |
|
66 |
+ @MainActor static var chatFontSize: CGFloat { ThemeStore.shared.chatFontSize } |
|
67 |
+ |
|
68 |
+ /// Code font scaled proportionally to the chat size. |
|
69 |
+ @MainActor static var chatCode: Font { |
|
70 |
+ Font.system(size: max(11, ThemeStore.shared.chatFontSize - 1), design: .monospaced) |
|
71 |
+ } |
|
72 |
+ |
|
73 |
+ // MARK: - Spacing (4/8/12/16/20/24/32) |
|
74 |
+ |
|
75 |
+ enum Spacing { |
|
76 |
+ static let xxs: CGFloat = 4 |
|
77 |
+ static let xs: CGFloat = 8 |
|
78 |
+ static let s: CGFloat = 12 |
|
79 |
+ static let m: CGFloat = 16 |
|
80 |
+ static let l: CGFloat = 20 |
|
81 |
+ static let xl: CGFloat = 24 |
|
82 |
+ static let xxl: CGFloat = 32 |
|
83 |
+ } |
|
84 |
+ |
|
85 |
+ // MARK: - Radii (6/10/14) |
|
86 |
+ |
|
87 |
+ enum Radius { |
|
88 |
+ static let s: CGFloat = 6 |
|
89 |
+ static let m: CGFloat = 10 |
|
90 |
+ static let l: CGFloat = 14 |
|
91 |
+ } |
|
92 |
+ |
|
93 |
+ // MARK: - Metrics |
|
94 |
+ |
|
95 |
+ /// Message column max width, centered. |
|
96 |
+ static let messageColumnMaxWidth: CGFloat = 760 |
|
97 |
+ static let sidebarWidth: CGFloat = 260 |
|
98 |
+ static let chatHeaderHeight: CGFloat = 52 |
|
99 |
+ /// Hairline width. |
|
100 |
+ static let hairline: CGFloat = 0.5 |
|
101 |
+ |
|
102 |
+ // MARK: - Shadows (ultra-soft, floating panels only) |
|
103 |
+ |
|
104 |
+ static let shadowColor = Color.black.opacity(0.06) |
|
105 |
+ static let shadowRadius: CGFloat = 12 |
|
106 |
+ static let shadowY: CGFloat = 2 |
|
107 |
+ |
|
108 |
+ // MARK: - Helpers |
|
109 |
+ |
|
110 |
+ /// Appearance-adaptive color from light/dark hex values. |
|
111 |
+ private static func dynamic(light: UInt32, dark: UInt32) -> Color { |
|
112 |
+ Color(nsColor: NSColor(name: nil) { appearance in |
|
113 |
+ let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua |
|
114 |
+ return NSColor(hex: isDark ? dark : light) |
|
115 |
+ }) |
|
116 |
+ } |
|
117 |
+} |
|
118 |
+ |
|
119 |
+extension NSColor { |
|
120 |
+ /// Opaque sRGB color from a 0xRRGGBB literal. |
|
121 |
+ convenience init(hex: UInt32) { |
|
122 |
+ self.init( |
|
123 |
+ srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, |
|
124 |
+ green: CGFloat((hex >> 8) & 0xFF) / 255, |
|
125 |
+ blue: CGFloat(hex & 0xFF) / 255, |
|
126 |
+ alpha: 1 |
|
127 |
+ ) |
|
128 |
+ } |
|
129 |
+} |
|
130 |
+ |
|
131 |
+/// User-selectable accent palettes (Settings ▸ Appearance). |
|
132 |
+enum AccentChoice: String, CaseIterable, Codable, Sendable { |
|
133 |
+ case emerald |
|
134 |
+ case graphite |
|
135 |
+ case sky |
|
136 |
+ case amber |
|
137 |
+ case rose |
|
138 |
+ |
|
139 |
+ var label: String { |
|
140 |
+ switch self { |
|
141 |
+ case .emerald: "Emerald" |
|
142 |
+ case .graphite: "Graphite" |
|
143 |
+ case .sky: "Sky" |
|
144 |
+ case .amber: "Amber" |
|
145 |
+ case .rose: "Rose" |
|
146 |
+ } |
|
147 |
+ } |
|
148 |
+ |
|
149 |
+ /// Primary accent (light, dark). |
|
150 |
+ private var hexes: (light: UInt32, dark: UInt32) { |
|
151 |
+ switch self { |
|
152 |
+ case .emerald: (0x0E9F6E, 0x17C787) |
|
153 |
+ case .graphite: (0x4A5450, 0x8B9995) |
|
154 |
+ case .sky: (0x0E86C4, 0x3FB2EC) |
|
155 |
+ case .amber: (0xC47A0E, 0xE8A33D) |
|
156 |
+ case .rose: (0xC44D68, 0xE87795) |
|
157 |
+ } |
|
158 |
+ } |
|
159 |
+ |
|
160 |
+ /// Subtle tint for selected rows / user bubbles (light, dark). |
|
161 |
+ private var subtleHexes: (light: UInt32, dark: UInt32) { |
|
162 |
+ switch self { |
|
163 |
+ case .emerald: (0xE7F6F0, 0x163026) |
|
164 |
+ case .graphite: (0xEDF0EF, 0x272D2A) |
|
165 |
+ case .sky: (0xE5F3FA, 0x142934) |
|
166 |
+ case .amber: (0xFAF1E2, 0x33270F) |
|
167 |
+ case .rose: (0xFAE9ED, 0x341A21) |
|
168 |
+ } |
|
169 |
+ } |
|
170 |
+ |
|
171 |
+ var color: Color { |
|
172 |
+ let h = hexes |
|
173 |
+ return Color(nsColor: NSColor(name: nil) { appearance in |
|
174 |
+ let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua |
|
175 |
+ return NSColor(hex: isDark ? h.dark : h.light) |
|
176 |
+ }) |
|
177 |
+ } |
|
178 |
+ |
|
179 |
+ var subtleColor: Color { |
|
180 |
+ let h = subtleHexes |
|
181 |
+ return Color(nsColor: NSColor(name: nil) { appearance in |
|
182 |
+ let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua |
|
183 |
+ return NSColor(hex: isDark ? h.dark : h.light) |
|
184 |
+ }) |
|
185 |
+ } |
|
186 |
+} |
|
187 |
+ |
|
188 |
+/// Observable appearance settings backing the theme tokens. |
|
189 |
+@MainActor |
|
190 |
+@Observable |
|
191 |
+final class ThemeStore { |
|
192 |
+ static let shared = ThemeStore() |
|
193 |
+ |
|
194 |
+ enum Mode: String, CaseIterable, Codable, Sendable { |
|
195 |
+ case system |
|
196 |
+ case light |
|
197 |
+ case dark |
|
198 |
+ |
|
199 |
+ var label: String { |
|
200 |
+ switch self { |
|
201 |
+ case .system: "System" |
|
202 |
+ case .light: "Light" |
|
203 |
+ case .dark: "Dark" |
|
204 |
+ } |
|
205 |
+ } |
|
206 |
+ |
|
207 |
+ var colorScheme: ColorScheme? { |
|
208 |
+ switch self { |
|
209 |
+ case .system: nil |
|
210 |
+ case .light: .light |
|
211 |
+ case .dark: .dark |
|
212 |
+ } |
|
213 |
+ } |
|
214 |
+ } |
|
215 |
+ |
|
216 |
+ var mode: Mode { |
|
217 |
+ didSet { persist() } |
|
218 |
+ } |
|
219 |
+ |
|
220 |
+ var accentChoice: AccentChoice { |
|
221 |
+ didSet { persist() } |
|
222 |
+ } |
|
223 |
+ |
|
224 |
+ /// Chat body size, 12–18 pt. |
|
225 |
+ var chatFontSize: CGFloat { |
|
226 |
+ didSet { persist() } |
|
227 |
+ } |
|
228 |
+ |
|
229 |
+ private struct Persisted: Codable { |
|
230 |
+ var mode: Mode |
|
231 |
+ var accent: AccentChoice |
|
232 |
+ var chatFontSize: CGFloat |
|
233 |
+ } |
|
234 |
+ |
|
235 |
+ private init() { |
|
236 |
+ let stored = PersistenceService.loadDocument(Persisted.self, named: "appearance") |
|
237 |
+ self.mode = stored?.mode ?? .system |
|
238 |
+ self.accentChoice = stored?.accent ?? .emerald |
|
239 |
+ self.chatFontSize = stored.map { min(18, max(12, $0.chatFontSize)) } ?? 13.5 |
|
240 |
+ } |
|
241 |
+ |
|
242 |
+ private func persist() { |
|
243 |
+ PersistenceService.saveDocument( |
|
244 |
+ Persisted(mode: mode, accent: accentChoice, chatFontSize: chatFontSize), |
|
245 |
+ named: "appearance" |
|
246 |
+ ) |
|
247 |
+ } |
|
248 |
+} |