SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%

phase4: ZyquoTheme design tokens — emerald light + graphite-green dark, accents, type scale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 12 days ago (Jul 30, 2026) parent 35f5457

Showing 2 changed files with +258 and −1

added Sources/ZyquoLocal/DesignSystem/ZyquoTheme.swift +248 −0
@@ -0,0 +1,248 @@
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 +}
modified docs/PLAN.md +10 −1
@@ -76,7 +76,16 @@ tests in the Phase 7 harness.
76 76 **Checkpoint:** Zero warnings. Coherence sweep: headers green, `LocalModel` is
77 77 the single term, folder structure matches the Phase 2 tree (DesignSystem/,
78 78 ViewModels/, Views/ arrive with Phases 4/6).
79 ## Phase 4 — Design system & UI spec — pending
79 +## Phase 4 — Design system & UI spec — ✅ DONE (2026-07-30)
80 +
81 +- [x] ZyquoTheme tokens: exact light palette from spec, derived graphite-green dark, semantic status colors
82 +- [x] Typography scale (title 20 sb / body 13.5 / emphasis / caption 11 / code 12.5 mono), 1.45 line-height helper, user-adjustable chat size 12–18
83 +- [x] Spacing 4–32, radii 6/10/14, 760 pt message column, 0.5 pt hairlines, ultra-soft shadow tokens
84 +- [x] AccentChoice (emerald default + graphite/sky/amber/rose) + ThemeStore (mode/accent/font persisted)
85 +
86 +**Checkpoint:** Zero warnings. UI layout contract for Phase 6 is §4.2 of the
87 +project CLAUDE.md (exact spec); the design quality gate (§4.4) runs before
88 +Definition of Done.
80 89 ## Phase 5 — App icon — pending
81 90 ## Phase 6 — Features / full UI — pending
82 91 ## Phase 7 — Local model verification — pending
83 92