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%
1//2// ZyquoTheme.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import AppKit10import SwiftUI1112/// 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 with16/// a faint green undertone and a refined emerald accent. Dark theme is17/// derived: deep graphite-green, never flat gray.18enum ZyquoTheme {19 // MARK: - Color tokens2021 /// 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)4243 // MARK: - Typography (identical scale to Zyquo Cloud)4445 /// 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)5556 /// Line spacing that yields a 1.45 line height at a given font size.57 static func lineSpacing(fontSize: CGFloat) -> CGFloat {58 fontSize * 0.4559 }6061 /// User-adjustable chat body font (12–18 pt, from Settings).62 @MainActor static var chatBody: Font {63 Font.system(size: ThemeStore.shared.chatFontSize)64 }6566 @MainActor static var chatFontSize: CGFloat { ThemeStore.shared.chatFontSize }6768 /// 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 }7273 // MARK: - Spacing (4/8/12/16/20/24/32)7475 enum Spacing {76 static let xxs: CGFloat = 477 static let xs: CGFloat = 878 static let s: CGFloat = 1279 static let m: CGFloat = 1680 static let l: CGFloat = 2081 static let xl: CGFloat = 2482 static let xxl: CGFloat = 3283 }8485 // MARK: - Radii (6/10/14)8687 enum Radius {88 static let s: CGFloat = 689 static let m: CGFloat = 1090 static let l: CGFloat = 1491 }9293 // MARK: - Metrics9495 /// Message column max width, centered.96 static let messageColumnMaxWidth: CGFloat = 76097 static let sidebarWidth: CGFloat = 26098 static let chatHeaderHeight: CGFloat = 5299 /// Hairline width.100 static let hairline: CGFloat = 0.5101102 // MARK: - Shadows (ultra-soft, floating panels only)103104 static let shadowColor = Color.black.opacity(0.06)105 static let shadowRadius: CGFloat = 12106 static let shadowY: CGFloat = 2107108 // MARK: - Syntax highlighting palette (code blocks)109110 enum Syntax {111 static let comment = NSColor(hex: 0x8A948F)112 static let keyword = NSColor(hex: 0x0E86C4)113 static let number = NSColor(hex: 0xC47A0E)114 static let string = NSColor(hex: 0x2FA36B)115 }116117 // MARK: - Helpers118119 /// Appearance-adaptive color from light/dark hex values.120 private static func dynamic(light: UInt32, dark: UInt32) -> Color {121 Color(nsColor: NSColor(name: nil) { appearance in122 let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua123 return NSColor(hex: isDark ? dark : light)124 })125 }126}127128extension NSColor {129 /// Opaque sRGB color from a 0xRRGGBB literal.130 convenience init(hex: UInt32) {131 self.init(132 srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,133 green: CGFloat((hex >> 8) & 0xFF) / 255,134 blue: CGFloat(hex & 0xFF) / 255,135 alpha: 1136 )137 }138}139140/// User-selectable accent palettes (Settings ▸ Appearance).141enum AccentChoice: String, CaseIterable, Codable, Sendable {142 case emerald143 case graphite144 case sky145 case amber146 case rose147148 var label: String {149 switch self {150 case .emerald: "Emerald"151 case .graphite: "Graphite"152 case .sky: "Sky"153 case .amber: "Amber"154 case .rose: "Rose"155 }156 }157158 /// Primary accent (light, dark).159 private var hexes: (light: UInt32, dark: UInt32) {160 switch self {161 case .emerald: (0x0E9F6E, 0x17C787)162 case .graphite: (0x4A5450, 0x8B9995)163 case .sky: (0x0E86C4, 0x3FB2EC)164 case .amber: (0xC47A0E, 0xE8A33D)165 case .rose: (0xC44D68, 0xE87795)166 }167 }168169 /// Subtle tint for selected rows / user bubbles (light, dark).170 private var subtleHexes: (light: UInt32, dark: UInt32) {171 switch self {172 case .emerald: (0xE7F6F0, 0x163026)173 case .graphite: (0xEDF0EF, 0x272D2A)174 case .sky: (0xE5F3FA, 0x142934)175 case .amber: (0xFAF1E2, 0x33270F)176 case .rose: (0xFAE9ED, 0x341A21)177 }178 }179180 var color: Color {181 let h = hexes182 return Color(nsColor: NSColor(name: nil) { appearance in183 let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua184 return NSColor(hex: isDark ? h.dark : h.light)185 })186 }187188 var subtleColor: Color {189 let h = subtleHexes190 return Color(nsColor: NSColor(name: nil) { appearance in191 let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua192 return NSColor(hex: isDark ? h.dark : h.light)193 })194 }195}196197/// Observable appearance settings backing the theme tokens.198@MainActor199@Observable200final class ThemeStore {201 static let shared = ThemeStore()202203 enum Mode: String, CaseIterable, Codable, Sendable {204 case system205 case light206 case dark207208 var label: String {209 switch self {210 case .system: "System"211 case .light: "Light"212 case .dark: "Dark"213 }214 }215216 var colorScheme: ColorScheme? {217 switch self {218 case .system: nil219 case .light: .light220 case .dark: .dark221 }222 }223 }224225 var mode: Mode {226 didSet { persist() }227 }228229 var accentChoice: AccentChoice {230 didSet { persist() }231 }232233 /// Chat body size, 12–18 pt.234 var chatFontSize: CGFloat {235 didSet { persist() }236 }237238 private struct Persisted: Codable {239 var mode: Mode240 var accent: AccentChoice241 var chatFontSize: CGFloat242 }243244 private init() {245 let stored = PersistenceService.loadDocument(Persisted.self, named: "appearance")246 self.mode = stored?.mode ?? .system247 self.accentChoice = stored?.accent ?? .emerald248 self.chatFontSize = stored.map { min(18, max(12, $0.chatFontSize)) } ?? 13.5249 }250251 private func persist() {252 PersistenceService.saveDocument(253 Persisted(mode: mode, accent: accentChoice, chatFontSize: chatFontSize),254 named: "appearance"255 )256 }257}258