// // ZyquoTheme.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import AppKit import SwiftUI /// The single source of truth for colors, typography, spacing and radii. /// Views never use raw hex values or magic numbers — only these tokens. /// /// Light theme (flagship) per the Phase 4 spec: warm neutral off-white with /// a faint green undertone and a refined emerald accent. Dark theme is /// derived: deep graphite-green, never flat gray. enum ZyquoTheme { // MARK: - Color tokens /// Main canvas. static let background = dynamic(light: 0xFAFBFA, dark: 0x121614) /// Cards, assistant bubbles, input bar. static let surface = dynamic(light: 0xFFFFFF, dark: 0x1A201C) /// Hover states, code blocks. static let surfaceSecondary = dynamic(light: 0xF2F5F3, dark: 0x232A26) /// Primary actions, selection, links, send. @MainActor static var accent: Color { ThemeStore.shared.accentChoice.color } /// Selected rows, user bubble tint. @MainActor static var accentSubtle: Color { ThemeStore.shared.accentChoice.subtleColor } /// Body text — never pure black on pure white. static let textPrimary = dynamic(light: 0x1A1E1C, dark: 0xE8ECEA) /// Metadata. static let textSecondary = dynamic(light: 0x6B7472, dark: 0x9BA6A2) /// Placeholders. static let textTertiary = dynamic(light: 0x9EA8A5, dark: 0x6A7572) /// 0.5 pt hairlines. static let border = dynamic(light: 0xE4E9E6, dark: 0x2A322D) static let success = dynamic(light: 0x2FA36B, dark: 0x37B57C) static let warning = dynamic(light: 0xD9822B, dark: 0xE09244) static let danger = dynamic(light: 0xD64545, dark: 0xE25D5D) // MARK: - Typography (identical scale to Zyquo Cloud) /// 20 pt semibold. static let title = Font.system(size: 20, weight: .semibold) /// 13.5 pt regular (body line height 1.45 → use `bodyLineSpacing`). static let body = Font.system(size: 13.5) /// 13.5 pt medium. static let bodyEmphasis = Font.system(size: 13.5, weight: .medium) /// 11 pt. static let caption = Font.system(size: 11) /// 12.5 pt SF Mono. static let code = Font.system(size: 12.5, design: .monospaced) /// Line spacing that yields a 1.45 line height at a given font size. static func lineSpacing(fontSize: CGFloat) -> CGFloat { fontSize * 0.45 } /// User-adjustable chat body font (12–18 pt, from Settings). @MainActor static var chatBody: Font { Font.system(size: ThemeStore.shared.chatFontSize) } @MainActor static var chatFontSize: CGFloat { ThemeStore.shared.chatFontSize } /// Code font scaled proportionally to the chat size. @MainActor static var chatCode: Font { Font.system(size: max(11, ThemeStore.shared.chatFontSize - 1), design: .monospaced) } // MARK: - Spacing (4/8/12/16/20/24/32) enum Spacing { static let xxs: CGFloat = 4 static let xs: CGFloat = 8 static let s: CGFloat = 12 static let m: CGFloat = 16 static let l: CGFloat = 20 static let xl: CGFloat = 24 static let xxl: CGFloat = 32 } // MARK: - Radii (6/10/14) enum Radius { static let s: CGFloat = 6 static let m: CGFloat = 10 static let l: CGFloat = 14 } // MARK: - Metrics /// Message column max width, centered. static let messageColumnMaxWidth: CGFloat = 760 static let sidebarWidth: CGFloat = 260 static let chatHeaderHeight: CGFloat = 52 /// Hairline width. static let hairline: CGFloat = 0.5 // MARK: - Shadows (ultra-soft, floating panels only) static let shadowColor = Color.black.opacity(0.06) static let shadowRadius: CGFloat = 12 static let shadowY: CGFloat = 2 // MARK: - Syntax highlighting palette (code blocks) enum Syntax { static let comment = NSColor(hex: 0x8A948F) static let keyword = NSColor(hex: 0x0E86C4) static let number = NSColor(hex: 0xC47A0E) static let string = NSColor(hex: 0x2FA36B) } // MARK: - Helpers /// Appearance-adaptive color from light/dark hex values. private static func dynamic(light: UInt32, dark: UInt32) -> Color { Color(nsColor: NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua return NSColor(hex: isDark ? dark : light) }) } } extension NSColor { /// Opaque sRGB color from a 0xRRGGBB literal. convenience init(hex: UInt32) { self.init( srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1 ) } } /// User-selectable accent palettes (Settings ▸ Appearance). enum AccentChoice: String, CaseIterable, Codable, Sendable { case emerald case graphite case sky case amber case rose var label: String { switch self { case .emerald: "Emerald" case .graphite: "Graphite" case .sky: "Sky" case .amber: "Amber" case .rose: "Rose" } } /// Primary accent (light, dark). private var hexes: (light: UInt32, dark: UInt32) { switch self { case .emerald: (0x0E9F6E, 0x17C787) case .graphite: (0x4A5450, 0x8B9995) case .sky: (0x0E86C4, 0x3FB2EC) case .amber: (0xC47A0E, 0xE8A33D) case .rose: (0xC44D68, 0xE87795) } } /// Subtle tint for selected rows / user bubbles (light, dark). private var subtleHexes: (light: UInt32, dark: UInt32) { switch self { case .emerald: (0xE7F6F0, 0x163026) case .graphite: (0xEDF0EF, 0x272D2A) case .sky: (0xE5F3FA, 0x142934) case .amber: (0xFAF1E2, 0x33270F) case .rose: (0xFAE9ED, 0x341A21) } } var color: Color { let h = hexes return Color(nsColor: NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua return NSColor(hex: isDark ? h.dark : h.light) }) } var subtleColor: Color { let h = subtleHexes return Color(nsColor: NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua return NSColor(hex: isDark ? h.dark : h.light) }) } } /// Observable appearance settings backing the theme tokens. @MainActor @Observable final class ThemeStore { static let shared = ThemeStore() enum Mode: String, CaseIterable, Codable, Sendable { case system case light case dark var label: String { switch self { case .system: "System" case .light: "Light" case .dark: "Dark" } } var colorScheme: ColorScheme? { switch self { case .system: nil case .light: .light case .dark: .dark } } } var mode: Mode { didSet { persist() } } var accentChoice: AccentChoice { didSet { persist() } } /// Chat body size, 12–18 pt. var chatFontSize: CGFloat { didSet { persist() } } private struct Persisted: Codable { var mode: Mode var accent: AccentChoice var chatFontSize: CGFloat } private init() { let stored = PersistenceService.loadDocument(Persisted.self, named: "appearance") self.mode = stored?.mode ?? .system self.accentChoice = stored?.accent ?? .emerald self.chatFontSize = stored.map { min(18, max(12, $0.chatFontSize)) } ?? 13.5 } private func persist() { PersistenceService.saveDocument( Persisted(mode: mode, accent: accentChoice, chatFontSize: chatFontSize), named: "appearance" ) } }