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%
7.8 KB · 258 lines swift
Raw Blame History
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