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>
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 | |