SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%

phase4: ZyquoTheme design tokens, appearance store, components; pin builds to macOS 26 SDK (CLT lacks SwiftUIMacros plugin for SDK 27)

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

Showing 5 changed files with +418 and −0

modified Makefile +5 −0
@@ -12,6 +12,11 @@
12 12 # make clean → remove build products
13 13 #
14 14
15 +# CLT-only toolchain: SDK 27's SwiftUI declares @State & co. as macros whose
16 +# plugin (libSwiftUIMacros.dylib) ships only with Xcode. SDK 26 keeps the
17 +# property-wrapper forms, so all builds pin to it.
18 +export SDKROOT := /Library/Developer/CommandLineTools/SDKs/MacOSX26.sdk
19 +
15 20 APP_NAME := Zyquo Cloud
16 21 EXEC_NAME := ZyquoCloud
17 22 BUNDLE_ID := com.zyquo.cloud
added Sources/ZyquoCloud/DesignSystem/AppearanceStore.swift +135 −0
@@ -0,0 +1,135 @@
1 +//
2 +// AppearanceStore.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// User appearance preferences: theme mode, accent choice, chat font size.
9 +// Persisted to settings.json via PersistenceService.
10 +//
11 +
12 +import SwiftUI
13 +
14 +/// Accent color choices offered in Settings → Appearance. Sky-indigo is the
15 +/// flagship default; alternates keep the same subtle-tint relationship.
16 +enum AccentChoice: String, Codable, CaseIterable, Identifiable {
17 + case skyIndigo
18 + case graphite
19 + case teal
20 + case amber
21 + case rose
22 +
23 + var id: String { rawValue }
24 +
25 + var displayName: String {
26 + switch self {
27 + case .skyIndigo: return "Sky Indigo"
28 + case .graphite: return "Graphite"
29 + case .teal: return "Teal"
30 + case .amber: return "Amber"
31 + case .rose: return "Rose"
32 + }
33 + }
34 +
35 + /// (light, dark) accent hex pair.
36 + var accentHex: (UInt32, UInt32) {
37 + switch self {
38 + case .skyIndigo: return (0x4E6AF0, 0x6D84F5)
39 + case .graphite: return (0x5B6270, 0x8A93A6)
40 + case .teal: return (0x1D9A8F, 0x3BB5AA)
41 + case .amber: return (0xC77D1D, 0xDD9B45)
42 + case .rose: return (0xC94F7C, 0xDE7099)
43 + }
44 + }
45 +
46 + /// (light, dark) subtle-tint hex pair (selected rows, user bubbles).
47 + var subtleHex: (UInt32, UInt32) {
48 + switch self {
49 + case .skyIndigo: return (0xEBEFFD, 0x28304C)
50 + case .graphite: return (0xEEF0F4, 0x2A2E3A)
51 + case .teal: return (0xE6F6F4, 0x1F3A38)
52 + case .amber: return (0xFBF1E3, 0x3D3222)
53 + case .rose: return (0xFAEAF1, 0x3D2733)
54 + }
55 + }
56 +}
57 +
58 +enum ThemeMode: String, Codable, CaseIterable, Identifiable {
59 + case system, light, dark
60 + var id: String { rawValue }
61 +
62 + var displayName: String {
63 + switch self {
64 + case .system: return "System"
65 + case .light: return "Light"
66 + case .dark: return "Dark"
67 + }
68 + }
69 +
70 + var colorScheme: ColorScheme? {
71 + switch self {
72 + case .system: return nil
73 + case .light: return .light
74 + case .dark: return .dark
75 + }
76 + }
77 +}
78 +
79 +/// Observable appearance preferences, persisted as part of app settings.
80 +@MainActor
81 +final class AppearanceStore: ObservableObject {
82 + struct Stored: Codable {
83 + var themeMode: ThemeMode = .system
84 + var accent: AccentChoice = .skyIndigo
85 + var chatFontSize: Double = 13.5
86 + }
87 +
88 + static let fileName = "appearance.json"
89 +
90 + @Published var themeMode: ThemeMode { didSet { save() } }
91 + @Published var accent: AccentChoice { didSet { save() } }
92 + /// Chat body font size, clamped to the spec's 12–18pt range.
93 + @Published var chatFontSize: Double {
94 + didSet {
95 + let clamped = min(18, max(12, chatFontSize))
96 + if clamped != chatFontSize { chatFontSize = clamped }
97 + save()
98 + }
99 + }
100 +
101 + private let persistence: PersistenceService
102 +
103 + init(persistence: PersistenceService = .shared) {
104 + self.persistence = persistence
105 + let stored = persistence.load(Stored.self, from: Self.fileName) ?? Stored()
106 + themeMode = stored.themeMode
107 + accent = stored.accent
108 + chatFontSize = stored.chatFontSize
109 + }
110 +
111 + /// Current accent color resolved for the active appearance.
112 + var accentColor: Color {
113 + let (light, dark) = accent.accentHex
114 + return Color(nsColor: NSColor(name: nil) { appearance in
115 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
116 + return NSColor(hex: hex)
117 + })
118 + }
119 +
120 + /// Current subtle accent tint resolved for the active appearance.
121 + var accentSubtleColor: Color {
122 + let (light, dark) = accent.subtleHex
123 + return Color(nsColor: NSColor(name: nil) { appearance in
124 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
125 + return NSColor(hex: hex)
126 + })
127 + }
128 +
129 + private func save() {
130 + persistence.save(
131 + Stored(themeMode: themeMode, accent: accent, chatFontSize: chatFontSize),
132 + to: Self.fileName
133 + )
134 + }
135 +}
added Sources/ZyquoCloud/DesignSystem/Components.swift +112 −0
@@ -0,0 +1,112 @@
1 +//
2 +// Components.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Small reusable design-system components and interaction modifiers shared
9 +// across all screens: hover states, press scaling, badges, provider glyphs.
10 +//
11 +
12 +import SwiftUI
13 +
14 +// MARK: - Interaction modifiers
15 +
16 +/// Standard hover feedback: background tint fades in over 80ms.
17 +struct HoverHighlight: ViewModifier {
18 + var cornerRadius: CGFloat = ZyquoRadius.small
19 + @State private var hovering = false
20 +
21 + func body(content: Content) -> some View {
22 + content
23 + .background(
24 + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
25 + .fill(hovering ? ZyquoColor.surfaceSecondary : .clear)
26 + )
27 + .onHover { inside in
28 + withAnimation(ZyquoMotion.hover) { hovering = inside }
29 + }
30 + }
31 +}
32 +
33 +/// Button press feedback: scale to 0.97.
34 +struct PressableButtonStyle: ButtonStyle {
35 + func makeBody(configuration: Configuration) -> some View {
36 + configuration.label
37 + .scaleEffect(configuration.isPressed ? ZyquoMotion.pressedScale : 1)
38 + .animation(ZyquoMotion.hover, value: configuration.isPressed)
39 + }
40 +}
41 +
42 +extension View {
43 + func zyquoHoverHighlight(cornerRadius: CGFloat = ZyquoRadius.small) -> some View {
44 + modifier(HoverHighlight(cornerRadius: cornerRadius))
45 + }
46 +}
47 +
48 +// MARK: - Badges
49 +
50 +/// Small capsule badge (model chips metadata, capability tags).
51 +struct ZyquoBadge: View {
52 + let text: String
53 + var color: Color = ZyquoColor.textSecondary
54 +
55 + var body: some View {
56 + Text(text)
57 + .font(ZyquoFont.caption)
58 + .foregroundStyle(color)
59 + .padding(.horizontal, ZyquoSpacing.xs)
60 + .padding(.vertical, 2)
61 + .background(
62 + Capsule().fill(ZyquoColor.surfaceSecondary)
63 + )
64 + }
65 +}
66 +
67 +/// Colored status dot (provider key state: verified / unset / failed).
68 +struct StatusDot: View {
69 + enum Status {
70 + case verified, unset, failed
71 +
72 + var color: Color {
73 + switch self {
74 + case .verified: return ZyquoColor.success
75 + case .unset: return ZyquoColor.textTertiary
76 + case .failed: return ZyquoColor.danger
77 + }
78 + }
79 + }
80 +
81 + let status: Status
82 +
83 + var body: some View {
84 + Circle()
85 + .fill(status.color)
86 + .frame(width: 8, height: 8)
87 + }
88 +}
89 +
90 +// MARK: - Provider glyphs
91 +
92 +extension ProviderID {
93 + /// SF Symbol used as the provider's glyph in chips, avatars, and Settings.
94 + /// (Custom vector logos can replace these later without touching call sites.)
95 + var symbolName: String {
96 + switch self {
97 + case .openai: return "sparkle"
98 + case .anthropic: return "asterisk"
99 + case .xai: return "xmark.diamond"
100 + case .mistral: return "wind"
101 + case .gemini: return "diamond.lefthalf.filled"
102 + case .qwen: return "q.circle"
103 + case .deepseek: return "water.waves"
104 + case .kimi: return "moon.stars"
105 + case .perplexity: return "magnifyingglass.circle"
106 + case .together: return "circle.hexagongrid"
107 + case .deepinfra: return "cube.transparent"
108 + case .cerebras: return "bolt.circle"
109 + case .custom: return "wrench.and.screwdriver"
110 + }
111 + }
112 +}
added Sources/ZyquoCloud/DesignSystem/ZyquoTheme.swift +164 −0
@@ -0,0 +1,164 @@
1 +//
2 +// ZyquoTheme.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The design system. Every color, font, spacing, radius, and shadow in the app
9 +// comes from these tokens — views contain zero raw hex values or magic numbers.
10 +// The light theme is the flagship (Phase 4 spec); dark derives from the same
11 +// semantic tokens with a deep night-sky navy base.
12 +//
13 +
14 +import SwiftUI
15 +
16 +// MARK: - Colors
17 +
18 +/// Semantic color tokens. Resolved per appearance via dynamic NSColor so the
19 +/// system handles light/dark switching natively.
20 +enum ZyquoColor {
21 + /// Main chat canvas — airy, faintly cool off-white / deep night sky.
22 + static let background = dynamic(light: 0xFAFBFD, dark: 0x14161E)
23 + /// Cards, assistant bubbles, input bar.
24 + static let surface = dynamic(light: 0xFFFFFF, dark: 0x1C1F2A)
25 + /// Hover states, code block background.
26 + static let surfaceSecondary = dynamic(light: 0xF2F4F8, dark: 0x232734)
27 + /// Primary actions, selection, links, send button (sky-indigo).
28 + static let accent = dynamic(light: 0x4E6AF0, dark: 0x6D84F5)
29 + /// Selected conversation row, user bubble tint.
30 + static let accentSubtle = dynamic(light: 0xEBEFFD, dark: 0x28304C)
31 + static let textPrimary = dynamic(light: 0x1A1C22, dark: 0xE8EAF2)
32 + static let textSecondary = dynamic(light: 0x6B7080, dark: 0x9BA1B5)
33 + static let textTertiary = dynamic(light: 0x9EA3B0, dark: 0x6A7188)
34 + /// Hairline separators (draw at 0.5pt).
35 + static let border = dynamic(light: 0xE4E7EE, dark: 0x2C3040)
36 + static let success = dynamic(light: 0x2FA36B, dark: 0x43BD83)
37 + static let warning = dynamic(light: 0xD9822B, dark: 0xE59A4D)
38 + static let danger = dynamic(light: 0xD64545, dark: 0xE36363)
39 +
40 + /// Builds a dynamic color that resolves per appearance.
41 + private static func dynamic(light: UInt32, dark: UInt32) -> Color {
42 + Color(nsColor: NSColor(name: nil) { appearance in
43 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
44 + return NSColor(hex: hex)
45 + })
46 + }
47 +}
48 +
49 +extension NSColor {
50 + /// 0xRRGGBB → NSColor (sRGB).
51 + convenience init(hex: UInt32) {
52 + self.init(
53 + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
54 + green: CGFloat((hex >> 8) & 0xFF) / 255,
55 + blue: CGFloat(hex & 0xFF) / 255,
56 + alpha: 1
57 + )
58 + }
59 +}
60 +
61 +// MARK: - Typography
62 +
63 +/// Type scale (SF Pro system font; SF Mono for code). Chat body scales with the
64 +/// user's font size preference (12–18pt) via `ChatFontScale`.
65 +enum ZyquoFont {
66 + /// 20pt semibold — window/section titles.
67 + static let title = Font.system(size: 20, weight: .semibold)
68 + /// Message body at a given user-selected size (default 13.5).
69 + static func body(size: Double = 13.5) -> Font {
70 + .system(size: size, weight: .regular)
71 + }
72 + static func bodyEmphasis(size: Double = 13.5) -> Font {
73 + .system(size: size, weight: .medium)
74 + }
75 + /// 11pt — timestamps, token counts, captions.
76 + static let caption = Font.system(size: 11, weight: .regular)
77 + /// Code blocks and inline code, scaled slightly below body.
78 + static func code(size: Double = 12.5) -> Font {
79 + .system(size: size, weight: .regular, design: .monospaced)
80 + }
81 + /// Mandatory generous line height for message text (spec: 1.45).
82 + static let bodyLineSpacingFactor: Double = 0.45
83 +}
84 +
85 +// MARK: - Spacing, radii, shadows, metrics
86 +
87 +/// Spacing scale: 4 / 8 / 12 / 16 / 20 / 24 / 32.
88 +enum ZyquoSpacing {
89 + static let xxs: CGFloat = 4
90 + static let xs: CGFloat = 8
91 + static let sm: CGFloat = 12
92 + static let md: CGFloat = 16
93 + static let lg: CGFloat = 20
94 + static let xl: CGFloat = 24
95 + static let xxl: CGFloat = 32
96 +}
97 +
98 +/// Corner radii: 6 (small controls), 10 (bubbles/cards), 14 (floating panels).
99 +enum ZyquoRadius {
100 + static let small: CGFloat = 6
101 + static let medium: CGFloat = 10
102 + static let large: CGFloat = 14
103 +}
104 +
105 +/// Shadows: extremely soft, used sparingly (floating panels, popovers, input bar).
106 +enum ZyquoShadow {
107 + static let soft = ShadowStyle(color: .black.opacity(0.06), radius: 12, y: 2)
108 +
109 + struct ShadowStyle {
110 + let color: Color
111 + let radius: CGFloat
112 + var x: CGFloat = 0
113 + var y: CGFloat = 0
114 + }
115 +}
116 +
117 +/// Fixed layout metrics from the Phase 4 spec.
118 +enum ZyquoMetrics {
119 + static let sidebarWidth: CGFloat = 260
120 + static let chatHeaderHeight: CGFloat = 52
121 + static let maxMessageColumnWidth: CGFloat = 760
122 + static let contentInset: CGFloat = 16
123 + static let hairline: CGFloat = 0.5
124 + static let windowMinWidth: CGFloat = 980
125 + static let windowMinHeight: CGFloat = 640
126 + static let windowDefaultWidth: CGFloat = 1240
127 + static let windowDefaultHeight: CGFloat = 800
128 + static let quickChatWidth: CGFloat = 640
129 + static let settingsWidth: CGFloat = 720
130 + static let settingsHeight: CGFloat = 520
131 + static let attachmentThumbnail: CGFloat = 56
132 + static let verticalTurnRhythm: CGFloat = 16
133 +}
134 +
135 +/// Motion tokens: hover 80ms ease, send 150ms ease-out, pressed scale 0.97.
136 +enum ZyquoMotion {
137 + static let hover = Animation.easeInOut(duration: 0.08)
138 + static let send = Animation.easeOut(duration: 0.15)
139 + static let pressedScale: CGFloat = 0.97
140 + static let picker = Animation.snappy
141 +}
142 +
143 +// MARK: - View helpers
144 +
145 +extension View {
146 + /// Standard soft shadow for floating panels/popovers only.
147 + func zyquoSoftShadow() -> some View {
148 + shadow(
149 + color: ZyquoShadow.soft.color,
150 + radius: ZyquoShadow.soft.radius,
151 + x: ZyquoShadow.soft.x,
152 + y: ZyquoShadow.soft.y
153 + )
154 + }
155 +}
156 +
157 +/// 0.5pt hairline separator in the border token color.
158 +struct ZyquoHairline: View {
159 + var body: some View {
160 + Rectangle()
161 + .fill(ZyquoColor.border)
162 + .frame(height: ZyquoMetrics.hairline)
163 + }
164 +}
modified scripts/test.sh +2 −0
@@ -14,6 +14,8 @@ set -euo pipefail
14 14 cd "$(dirname "$0")/.."
15 15
16 16 CLT="/Library/Developer/CommandLineTools"
17 +# See Makefile: SDK 27 SwiftUI macros need Xcode; pin to SDK 26.
18 +export SDKROOT="$CLT/SDKs/MacOSX26.sdk"
17 19 CLT_FRAMEWORKS="$CLT/Library/Developer/Frameworks"
18 20 TESTING_PLUGINS="$CLT/usr/lib/swift/host/plugins/testing"
19 21
20 22