SPB Git

spb/zyquo-mlx Public MIT

The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.

Swift 93.4% Python 3.8% Makefile 2.2% Shell 0.5%

phase4: design system + workbench UI — ZyquoTheme tokens (light/dark), navigator shell, section screens with empty states, settings tabs; visual review both themes

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

Showing 12 changed files with +1,139 and −17

modified Sources/ZyquoMLX/App/ZyquoMLXApp.swift +22 −16
@@ -24,28 +24,34 @@ struct ZyquoMLXApp: App {
24 24 }
25 25 }
26 26
27 + @AppStorage("appearance") private var appearance = "system"
28 +
27 29 var body: some Scene {
28 30 WindowGroup {
29 if HardwareGate.isAppleSilicon {
30 PlaceholderRootView()
31 .frame(minWidth: 1080, minHeight: 700)
32 } else {
33 UnsupportedHardwareView()
31 + Group {
32 + if HardwareGate.isAppleSilicon {
33 + WorkbenchView()
34 + } else {
35 + UnsupportedHardwareView()
36 + }
34 37 }
38 + .preferredColorScheme(colorScheme)
39 + }
40 + .defaultSize(
41 + width: ZyquoTheme.defaultWindowSize.width,
42 + height: ZyquoTheme.defaultWindowSize.height)
43 +
44 + Settings {
45 + SettingsView()
46 + .preferredColorScheme(colorScheme)
35 47 }
36 .defaultSize(width: 1360, height: 880)
37 48 }
38 }
39 49
40 /// Temporary Phase 1 root; replaced by the workbench in Phase 2/4.
41 private struct PlaceholderRootView: View {
42 var body: some View {
43 VStack(spacing: 8) {
44 Text("Zyquo MLX")
45 .font(.largeTitle.weight(.semibold))
46 Text("Foundry under construction — Phase 1")
47 .foregroundStyle(.secondary)
50 + private var colorScheme: ColorScheme? {
51 + switch appearance {
52 + case "light": .light
53 + case "dark": .dark
54 + default: nil
48 55 }
49 .frame(maxWidth: .infinity, maxHeight: .infinity)
50 56 }
51 57 }
added Sources/ZyquoMLX/DesignSystem/ZyquoTheme.swift +230 −0
@@ -0,0 +1,230 @@
1 +//
2 +// ZyquoTheme.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// The Zyquo MLX design system — single source of truth for every color,
12 +/// font, spacing, radius, and motion value in the app (charter Phase 4).
13 +/// Identity: molten copper on slate — a foundry, precise and Apple-clean.
14 +/// Light theme is flagship; dark is the "deep slate forge" derivation.
15 +enum ZyquoTheme {
16 +
17 + // MARK: - Colors (light / dark pairs)
18 +
19 + /// Canvas behind everything.
20 + static let background = adaptive(light: 0xFAFAF9, dark: 0x161A22)
21 + /// Cards, panels.
22 + static let surface = adaptive(light: 0xFFFFFF, dark: 0x1E2430)
23 + /// Hover states, code/log blocks.
24 + static let surfaceSecondary = adaptive(light: 0xF3F2F0, dark: 0x252C3A)
25 +
26 + /// Molten copper — primary actions, run/train buttons, active states.
27 + static let accent = adaptive(light: 0xC2410C, dark: 0xEA6A2B)
28 + /// Bright end of the copper gradient.
29 + static let accentBright = adaptive(light: 0xEA6A2B, dark: 0xF0813F)
30 + /// Copper pair: slate for secondary emphasis.
31 + static let slate = adaptive(light: 0x334155, dark: 0x94A3B8)
32 + /// Selected rows, active-run tint.
33 + static let accentSubtle = adaptive(light: 0xFCEDE4, dark: 0x3A2A20)
34 +
35 + static let textPrimary = adaptive(light: 0x1A1A1C, dark: 0xF2F2F4)
36 + static let textSecondary = adaptive(light: 0x6B6B72, dark: 0xA5A8B0)
37 + static let textTertiary = adaptive(light: 0x9E9EA6, dark: 0x71747E)
38 + static let border = adaptive(light: 0xE6E4E1, dark: 0x323A48)
39 +
40 + static let success = adaptive(light: 0x2FA36B, dark: 0x3FBF81)
41 + static let warning = adaptive(light: 0xD9822B, dark: 0xE2953F)
42 + static let danger = adaptive(light: 0xD64545, dark: 0xE25A5A)
43 +
44 + /// Chart tokens: copper = train loss, slate = val loss, teal = tokens/sec.
45 + static let chartTrain = accent
46 + static let chartVal = slate
47 + static let chartThroughput = adaptive(light: 0x0F8B8D, dark: 0x2BB3B6)
48 +
49 + /// The copper gradient for hero actions.
50 + static var copperGradient: LinearGradient {
51 + LinearGradient(
52 + colors: [Color(hex: 0xC2410C), Color(hex: 0xEA6A2B)],
53 + startPoint: .top, endPoint: .bottom)
54 + }
55 +
56 + // MARK: - Typography
57 +
58 + static let titleFont = Font.system(size: 20, weight: .semibold)
59 + static let headlineFont = Font.system(size: 14, weight: .semibold)
60 + static let bodyFont = Font.system(size: 13)
61 + static let captionFont = Font.system(size: 11)
62 + static let captionMediumFont = Font.system(size: 11, weight: .medium)
63 + /// Logs, metrics, code — always SF Mono.
64 + static let monoFont = Font.system(size: 12, design: .monospaced)
65 + static let monoSmallFont = Font.system(size: 11, design: .monospaced)
66 +
67 + // MARK: - Spacing (4pt grid)
68 +
69 + static let spacing2: CGFloat = 2
70 + static let spacing4: CGFloat = 4
71 + static let spacing8: CGFloat = 8
72 + static let spacing12: CGFloat = 12
73 + static let spacing16: CGFloat = 16
74 + static let spacing20: CGFloat = 20
75 + static let spacing24: CGFloat = 24
76 + static let spacing32: CGFloat = 32
77 +
78 + // MARK: - Radii & lines
79 +
80 + static let radiusSmall: CGFloat = 6
81 + static let radiusMedium: CGFloat = 10
82 + static let radiusLarge: CGFloat = 14
83 + /// Hairlines are always 0.5pt (family rule).
84 + static let hairline: CGFloat = 0.5
85 +
86 + // MARK: - Layout constants (charter §4.2)
87 +
88 + static let sidebarWidth: CGFloat = 240
89 + static let headerHeight: CGFloat = 52
90 + static let defaultWindowSize = CGSize(width: 1360, height: 880)
91 + static let minWindowSize = CGSize(width: 1080, height: 700)
92 +
93 + // MARK: - Motion
94 +
95 + static let quickAnimation = Animation.easeOut(duration: 0.15)
96 + static let standardAnimation = Animation.easeInOut(duration: 0.25)
97 +
98 + // MARK: - Helpers
99 +
100 + private static func adaptive(light: UInt32, dark: UInt32) -> Color {
101 + Color(
102 + nsColor: NSColor(name: nil) { appearance in
103 + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
104 + return NSColor(Color(hex: isDark ? dark : light))
105 + })
106 + }
107 +}
108 +
109 +extension Color {
110 + /// Token-internal hex initializer — views never use raw hex directly.
111 + init(hex: UInt32) {
112 + self.init(
113 + .sRGB,
114 + red: Double((hex >> 16) & 0xFF) / 255.0,
115 + green: Double((hex >> 8) & 0xFF) / 255.0,
116 + blue: Double(hex & 0xFF) / 255.0)
117 + }
118 +}
119 +
120 +// MARK: - Shared components
121 +
122 +/// A 0.5pt hairline-bordered card surface.
123 +struct ZyquoCard: ViewModifier {
124 + func body(content: Content) -> some View {
125 + content
126 + .background(ZyquoTheme.surface)
127 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusMedium))
128 + .overlay(
129 + RoundedRectangle(cornerRadius: ZyquoTheme.radiusMedium)
130 + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline))
131 + }
132 +}
133 +
134 +extension View {
135 + func zyquoCard() -> some View { modifier(ZyquoCard()) }
136 +}
137 +
138 +/// Status pill for jobs and runs.
139 +struct StatusPill: View {
140 + let text: String
141 + let color: Color
142 +
143 + var body: some View {
144 + Text(text)
145 + .font(ZyquoTheme.captionMediumFont)
146 + .foregroundStyle(color)
147 + .padding(.horizontal, ZyquoTheme.spacing8)
148 + .padding(.vertical, ZyquoTheme.spacing2)
149 + .background(color.opacity(0.12))
150 + .clipShape(Capsule())
151 + }
152 +}
153 +
154 +/// Model-type badge (LLM/VLM/Embed/Speech/Image).
155 +struct TypeBadge: View {
156 + let type: ModelType
157 +
158 + var body: some View {
159 + Text(type.displayName)
160 + .font(ZyquoTheme.captionMediumFont)
161 + .foregroundStyle(ZyquoTheme.slate)
162 + .padding(.horizontal, ZyquoTheme.spacing8)
163 + .padding(.vertical, ZyquoTheme.spacing2)
164 + .background(ZyquoTheme.surfaceSecondary)
165 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
166 + }
167 +}
168 +
169 +/// Memory-verdict badge driven by MemoryAdvisor.
170 +struct VerdictBadge: View {
171 + let verdict: MemoryVerdict
172 +
173 + private var color: Color {
174 + switch verdict {
175 + case .comfortable: ZyquoTheme.success
176 + case .tight: ZyquoTheme.warning
177 + case .wontFit: ZyquoTheme.danger
178 + }
179 + }
180 +
181 + var body: some View {
182 + StatusPill(text: verdict.displayName, color: color)
183 + }
184 +}
185 +
186 +/// Instructive, intentional empty state (charter: App-Store-front-page quality).
187 +struct EmptyStateView: View {
188 + let icon: String
189 + let title: String
190 + let message: String
191 + var actionLabel: String?
192 + var action: (() -> Void)?
193 +
194 + var body: some View {
195 + VStack(spacing: ZyquoTheme.spacing16) {
196 + ZStack {
197 + Circle()
198 + .fill(ZyquoTheme.accentSubtle)
199 + .frame(width: 88, height: 88)
200 + Image(systemName: icon)
201 + .font(.system(size: 36, weight: .light))
202 + .foregroundStyle(ZyquoTheme.accent)
203 + }
204 + VStack(spacing: ZyquoTheme.spacing8) {
205 + Text(title)
206 + .font(ZyquoTheme.titleFont)
207 + .foregroundStyle(ZyquoTheme.textPrimary)
208 + Text(message)
209 + .font(ZyquoTheme.bodyFont)
210 + .foregroundStyle(ZyquoTheme.textSecondary)
211 + .multilineTextAlignment(.center)
212 + .frame(maxWidth: 420)
213 + }
214 + if let actionLabel, let action {
215 + Button(action: action) {
216 + Text(actionLabel)
217 + .font(ZyquoTheme.headlineFont)
218 + .foregroundStyle(.white)
219 + .padding(.horizontal, ZyquoTheme.spacing20)
220 + .padding(.vertical, ZyquoTheme.spacing8)
221 + .background(ZyquoTheme.copperGradient)
222 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
223 + }
224 + .buttonStyle(.plain)
225 + }
226 + }
227 + .frame(maxWidth: .infinity, maxHeight: .infinity)
228 + .background(ZyquoTheme.background)
229 + }
230 +}
added Sources/ZyquoMLX/ViewModels/AppModel.swift +80 −0
@@ -0,0 +1,80 @@
1 +//
2 +// AppModel.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import Observation
11 +
12 +/// Workbench sections (left navigator, charter §4.2).
13 +enum WorkbenchSection: String, CaseIterable, Identifiable {
14 + case models
15 + case datasets
16 + case train
17 + case convert
18 + case playground
19 + case evaluate
20 +
21 + var id: String { rawValue }
22 +
23 + var title: String {
24 + switch self {
25 + case .models: "Models"
26 + case .datasets: "Datasets"
27 + case .train: "Train"
28 + case .convert: "Convert"
29 + case .playground: "Playground"
30 + case .evaluate: "Evaluate"
31 + }
32 + }
33 +
34 + var icon: String {
35 + switch self {
36 + case .models: "shippingbox"
37 + case .datasets: "tablecells"
38 + case .train: "flame"
39 + case .convert: "arrow.triangle.2.circlepath"
40 + case .playground: "bubble.left.and.text.bubble.right"
41 + case .evaluate: "checkmark.seal"
42 + }
43 + }
44 +}
45 +
46 +/// Root observable state for the workbench: library contents, run history,
47 +/// active jobs, and the live memory readout in the navigator footer.
48 +@Observable
49 +@MainActor
50 +final class AppModel {
51 +
52 + var selectedSection: WorkbenchSection = .models
53 +
54 + var models: [LocalModel] = []
55 + var datasets: [Dataset] = []
56 + var runs: [TrainingRun] = []
57 + var jobs: [Job] = []
58 +
59 + var activeJobCount: Int { jobs.filter { $0.state == .running }.count }
60 +
61 + /// GPU active memory, refreshed by the footer.
62 + var memoryUsedBytes: Int64 = 0
63 + var memoryTotalBytes: Int64 = MemoryAdvisor.physicalMemory
64 +
65 + var lastError: String?
66 +
67 + /// Refresh all library-backed lists from disk.
68 + func refresh() async {
69 + do {
70 + try PersistenceService.ensureDirectories()
71 + models = try await ModelStore.shared.scan()
72 + datasets = try await DatasetService.shared.scan()
73 + runs = try await RunStore.shared.scan()
74 + let snapshot = await InferenceEngine.shared.memorySnapshot()
75 + memoryUsedBytes = Int64(snapshot.active)
76 + } catch {
77 + lastError = error.localizedDescription
78 + }
79 + }
80 +}
added Sources/ZyquoMLX/Views/ConvertView.swift +22 −0
@@ -0,0 +1,22 @@
1 +//
2 +// ConvertView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// Convert section: quantize / fuse / convert job cards (interactive job
12 +/// launcher wired in Phase 6).
13 +struct ConvertView: View {
14 + @Bindable var model: AppModel
15 +
16 + var body: some View {
17 + EmptyStateView(
18 + icon: "arrow.triangle.2.circlepath",
19 + title: "The Conversion Bench",
20 + message: "Quantize models to 4/8-bit with live size previews, fuse trained adapters into standalone models, and convert Hugging Face checkpoints to MLX format — every job tracked with progress and validation.")
21 + }
22 +}
added Sources/ZyquoMLX/Views/DatasetsView.swift +107 −0
@@ -0,0 +1,107 @@
1 +//
2 +// DatasetsView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +import UniformTypeIdentifiers
11 +
12 +/// Datasets section: import, validate, preview (charter §4.2).
13 +struct DatasetsView: View {
14 + @Bindable var model: AppModel
15 + @State private var isImporting = false
16 + @State private var importError: String?
17 +
18 + var body: some View {
19 + Group {
20 + if model.datasets.isEmpty {
21 + EmptyStateView(
22 + icon: "tablecells",
23 + title: "No Datasets Yet",
24 + message: "Import a JSONL file — chat conversations, prompt/completion pairs, or raw text. Zyquo validates every row, reports fixable issues, and splits it for training.",
25 + actionLabel: "Import JSONL…",
26 + action: { isImporting = true })
27 + } else {
28 + ScrollView {
29 + LazyVStack(spacing: ZyquoTheme.spacing8) {
30 + ForEach(model.datasets) { dataset in
31 + DatasetRow(dataset: dataset)
32 + }
33 + }
34 + .padding(ZyquoTheme.spacing20)
35 + }
36 + .toolbar {
37 + ToolbarItem {
38 + Button("Import JSONL…") { isImporting = true }
39 + }
40 + }
41 + }
42 + }
43 + .fileImporter(
44 + isPresented: $isImporting,
45 + allowedContentTypes: [UTType(filenameExtension: "jsonl") ?? .json]
46 + ) { result in
47 + if case .success(let url) = result {
48 + Task {
49 + do {
50 + let gotAccess = url.startAccessingSecurityScopedResource()
51 + defer { if gotAccess { url.stopAccessingSecurityScopedResource() } }
52 + _ = try await DatasetService.shared.importJSONL(
53 + from: url,
54 + name: url.deletingPathExtension().lastPathComponent)
55 + await model.refresh()
56 + } catch {
57 + importError = error.localizedDescription
58 + }
59 + }
60 + }
61 + }
62 + .alert("Import Failed", isPresented: .constant(importError != nil)) {
63 + Button("OK") { importError = nil }
64 + } message: {
65 + Text(importError ?? "")
66 + }
67 + }
68 +}
69 +
70 +struct DatasetRow: View {
71 + let dataset: Dataset
72 +
73 + var body: some View {
74 + HStack(spacing: ZyquoTheme.spacing12) {
75 + Image(systemName: "tablecells")
76 + .font(.system(size: 18, weight: .light))
77 + .foregroundStyle(ZyquoTheme.slate)
78 + .frame(width: 32, height: 32)
79 + .background(ZyquoTheme.surfaceSecondary)
80 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
81 +
82 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) {
83 + Text(dataset.name)
84 + .font(ZyquoTheme.bodyFont.weight(.medium))
85 + .foregroundStyle(ZyquoTheme.textPrimary)
86 + HStack(spacing: ZyquoTheme.spacing8) {
87 + Text(dataset.format.displayName)
88 + Text("train \(dataset.trainCount) · valid \(dataset.validCount)"
89 + + (dataset.testCount > 0 ? " · test \(dataset.testCount)" : ""))
90 + if let tokens = dataset.totalTokens {
91 + Text("≈\(tokens.formatted()) tokens")
92 + }
93 + }
94 + .font(ZyquoTheme.captionFont)
95 + .foregroundStyle(ZyquoTheme.textSecondary)
96 + }
97 +
98 + Spacer()
99 +
100 + Text(dataset.importedAt.formatted(date: .abbreviated, time: .omitted))
101 + .font(ZyquoTheme.captionFont)
102 + .foregroundStyle(ZyquoTheme.textTertiary)
103 + }
104 + .padding(ZyquoTheme.spacing12)
105 + .zyquoCard()
106 + }
107 +}
added Sources/ZyquoMLX/Views/EvaluateView.swift +22 −0
@@ -0,0 +1,22 @@
1 +//
2 +// EvaluateView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// Evaluate section: held-out loss/perplexity + base-vs-tuned scorecard
12 +/// (wired in Phase 6).
13 +struct EvaluateView: View {
14 + @Bindable var model: AppModel
15 +
16 + var body: some View {
17 + EmptyStateView(
18 + icon: "checkmark.seal",
19 + title: "Prove Your Fine-Tune",
20 + message: "Measure held-out loss and perplexity, then compare base and fine-tuned models side by side on your own prompts — a compact scorecard for every run.")
21 + }
22 +}
added Sources/ZyquoMLX/Views/ModelsView.swift +125 −0
@@ -0,0 +1,125 @@
1 +//
2 +// ModelsView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// Models section: installed local library + Discover (Hub search wired in
12 +/// Phase 6).
13 +struct ModelsView: View {
14 + @Bindable var model: AppModel
15 + @State private var tab: Tab = .installed
16 +
17 + enum Tab: String, CaseIterable {
18 + case installed = "Installed"
19 + case discover = "Discover"
20 + }
21 +
22 + var body: some View {
23 + VStack(spacing: 0) {
24 + Picker("", selection: $tab) {
25 + ForEach(Tab.allCases, id: \.self) { Text($0.rawValue) }
26 + }
27 + .pickerStyle(.segmented)
28 + .frame(width: 240)
29 + .padding(.vertical, ZyquoTheme.spacing12)
30 +
31 + switch tab {
32 + case .installed: installed
33 + case .discover: discover
34 + }
35 + }
36 + }
37 +
38 + @ViewBuilder
39 + private var installed: some View {
40 + if model.models.isEmpty {
41 + EmptyStateView(
42 + icon: "shippingbox",
43 + title: "No Models Yet",
44 + message: "Your foundry starts with a model. Discover curated MLX models from the community, or drop an existing MLX model folder into your library.",
45 + actionLabel: "Discover Models",
46 + action: { tab = .discover })
47 + } else {
48 + ScrollView {
49 + LazyVStack(spacing: ZyquoTheme.spacing8) {
50 + ForEach(model.models) { item in
51 + ModelRow(item: item)
52 + }
53 + }
54 + .padding(ZyquoTheme.spacing20)
55 + }
56 + }
57 + }
58 +
59 + private var discover: some View {
60 + // Live Hub search lands in Phase 6 (HubService); the state is designed now.
61 + EmptyStateView(
62 + icon: "sparkle.magnifyingglass",
63 + title: "Discover MLX Models",
64 + message: "Search the mlx-community catalog on Hugging Face — text LLMs, vision-language models, embeddings, and more — with RAM compatibility badges for this Mac.")
65 + }
66 +}
67 +
68 +struct ModelRow: View {
69 + let item: LocalModel
70 + @State private var isHovering = false
71 +
72 + var body: some View {
73 + HStack(spacing: ZyquoTheme.spacing12) {
74 + Image(systemName: iconName)
75 + .font(.system(size: 18, weight: .light))
76 + .foregroundStyle(ZyquoTheme.accent)
77 + .frame(width: 32, height: 32)
78 + .background(ZyquoTheme.accentSubtle)
79 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
80 +
81 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) {
82 + Text(item.repoID ?? item.name)
83 + .font(ZyquoTheme.bodyFont.weight(.medium))
84 + .foregroundStyle(ZyquoTheme.textPrimary)
85 + .lineLimit(1)
86 + HStack(spacing: ZyquoTheme.spacing8) {
87 + Text(item.displayParams)
88 + if let quant = item.quantization { Text(quant.label) }
89 + Text(ByteCountFormatter.string(fromByteCount: item.diskSize, countStyle: .file))
90 + }
91 + .font(ZyquoTheme.captionFont)
92 + .foregroundStyle(ZyquoTheme.textSecondary)
93 + }
94 +
95 + Spacer()
96 +
97 + TypeBadge(type: item.type)
98 + VerdictBadge(verdict: MemoryAdvisor.inferenceVerdict(for: item))
99 +
100 + Button {
101 + NSWorkspace.shared.activateFileViewerSelecting([item.directory])
102 + } label: {
103 + Image(systemName: "magnifyingglass")
104 + .font(.system(size: 11))
105 + .foregroundStyle(ZyquoTheme.textTertiary)
106 + }
107 + .buttonStyle(.plain)
108 + .help("Reveal in Finder")
109 + .opacity(isHovering ? 1 : 0)
110 + }
111 + .padding(ZyquoTheme.spacing12)
112 + .zyquoCard()
113 + .onHover { isHovering = $0 }
114 + }
115 +
116 + private var iconName: String {
117 + switch item.type {
118 + case .llm: "text.alignleft"
119 + case .vlm: "photo.on.rectangle.angled"
120 + case .embedding: "point.3.connected.trianglepath.dotted"
121 + case .speech: "waveform"
122 + case .imageGeneration: "paintbrush"
123 + }
124 + }
125 +}
added Sources/ZyquoMLX/Views/PlaygroundView.swift +29 −0
@@ -0,0 +1,29 @@
1 +//
2 +// PlaygroundView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// Playground section: interactive inference per model type (streaming chat,
12 +/// image+text, embeddings inspector — wired in Phase 6).
13 +struct PlaygroundView: View {
14 + @Bindable var model: AppModel
15 +
16 + var body: some View {
17 + if model.models.isEmpty {
18 + EmptyStateView(
19 + icon: "bubble.left.and.text.bubble.right",
20 + title: "The Playground Awaits",
21 + message: "Load any local model and interact with it — streaming chat for LLMs, images for vision models, a vector inspector for embeddings — with live tokens/sec, time-to-first-token, and memory stats.")
22 + } else {
23 + EmptyStateView(
24 + icon: "bubble.left.and.text.bubble.right",
25 + title: "Pick a Model to Begin",
26 + message: "You have \(model.models.count) model\(model.models.count > 1 ? "s" : "") installed. The interactive playground — streaming chat, VLM images, and the embedding inspector — comes online with Phase 6.")
27 + }
28 + }
29 +}
added Sources/ZyquoMLX/Views/SettingsView.swift +161 −0
@@ -0,0 +1,161 @@
1 +//
2 +// SettingsView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// Settings window (charter §4.2): native tabs, 760×560.
12 +/// Functional depth (HF token vault, compute limits) lands in Phase 6.
13 +struct SettingsView: View {
14 + var body: some View {
15 + TabView {
16 + GeneralSettings()
17 + .tabItem { Label("General", systemImage: "gearshape") }
18 + ComputeSettings()
19 + .tabItem { Label("Compute", systemImage: "cpu") }
20 + PythonSettings()
21 + .tabItem { Label("Python", systemImage: "terminal") }
22 + StorageSettings()
23 + .tabItem { Label("Storage", systemImage: "internaldrive") }
24 + AppearanceSettings()
25 + .tabItem { Label("Appearance", systemImage: "paintpalette") }
26 + }
27 + .frame(width: 760, height: 560)
28 + }
29 +}
30 +
31 +private struct GeneralSettings: View {
32 + var body: some View {
33 + Form {
34 + LabeledContent("Models") {
35 + PathLabel(url: PersistenceService.modelsDirectory)
36 + }
37 + LabeledContent("Datasets") {
38 + PathLabel(url: PersistenceService.datasetsDirectory)
39 + }
40 + LabeledContent("Training runs") {
41 + PathLabel(url: PersistenceService.runsDirectory)
42 + }
43 + }
44 + .formStyle(.grouped)
45 + }
46 +}
47 +
48 +private struct ComputeSettings: View {
49 + var body: some View {
50 + Form {
51 + Section("GPU") {
52 + LabeledContent("Unified memory") {
53 + Text(ByteCountFormatter.string(
54 + fromByteCount: MemoryAdvisor.physicalMemory, countStyle: .memory))
55 + }
56 + LabeledContent("Recommended working set") {
57 + Text(ByteCountFormatter.string(
58 + fromByteCount: MemoryAdvisor.recommendedWorkingSet, countStyle: .memory))
59 + }
60 + }
61 + }
62 + .formStyle(.grouped)
63 + }
64 +}
65 +
66 +private struct PythonSettings: View {
67 + @State private var status = "Checking…"
68 +
69 + var body: some View {
70 + Form {
71 + Section("Environment") {
72 + LabeledContent("Status") { Text(status) }
73 + LabeledContent("Location") {
74 + PathLabel(url: PersistenceService.pythonDirectory)
75 + }
76 + LabeledContent("Pinned packages") {
77 + Text(PythonEnvironment.corePins.joined(separator: ", "))
78 + .font(ZyquoTheme.monoSmallFont)
79 + }
80 + }
81 + }
82 + .formStyle(.grouped)
83 + .task {
84 + status = await PythonEnvironment.shared.isProvisioned
85 + ? "Ready (Python \(PythonEnvironment.pythonVersion))"
86 + : "Not provisioned — installs automatically on first use"
87 + }
88 + }
89 +}
90 +
91 +private struct StorageSettings: View {
92 + @State private var usage: [(String, Int64)] = []
93 +
94 + var body: some View {
95 + Form {
96 + Section("Disk usage") {
97 + ForEach(usage, id: \.0) { name, bytes in
98 + LabeledContent(name) {
99 + Text(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file))
100 + }
101 + }
102 + }
103 + }
104 + .formStyle(.grouped)
105 + .task {
106 + usage = [
107 + ("Models", directorySize(PersistenceService.modelsDirectory)),
108 + ("Datasets", directorySize(PersistenceService.datasetsDirectory)),
109 + ("Runs", directorySize(PersistenceService.runsDirectory)),
110 + ("Python", directorySize(PersistenceService.pythonDirectory)),
111 + ]
112 + }
113 + }
114 +
115 + private func directorySize(_ url: URL) -> Int64 {
116 + let fm = FileManager.default
117 + guard let files = try? fm.subpathsOfDirectory(atPath: url.path) else { return 0 }
118 + return files.reduce(Int64(0)) { total, path in
119 + let size = (try? url.appendingPathComponent(path)
120 + .resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
121 + return total + Int64(size)
122 + }
123 + }
124 +}
125 +
126 +private struct AppearanceSettings: View {
127 + @AppStorage("appearance") private var appearance = "system"
128 +
129 + var body: some View {
130 + Form {
131 + Picker("Appearance", selection: $appearance) {
132 + Text("System").tag("system")
133 + Text("Light").tag("light")
134 + Text("Dark").tag("dark")
135 + }
136 + .pickerStyle(.inline)
137 + }
138 + .formStyle(.grouped)
139 + }
140 +}
141 +
142 +private struct PathLabel: View {
143 + let url: URL
144 +
145 + var body: some View {
146 + HStack {
147 + Text(url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
148 + .font(ZyquoTheme.monoSmallFont)
149 + .foregroundStyle(ZyquoTheme.textSecondary)
150 + .lineLimit(1)
151 + .truncationMode(.middle)
152 + Button {
153 + NSWorkspace.shared.activateFileViewerSelecting([url])
154 + } label: {
155 + Image(systemName: "arrow.right.circle")
156 + }
157 + .buttonStyle(.plain)
158 + .help("Reveal in Finder")
159 + }
160 + }
161 +}
added Sources/ZyquoMLX/Views/TrainView.swift +82 −0
@@ -0,0 +1,82 @@
1 +//
2 +// TrainView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// Train section: run history list (run configurator + live run detail with
12 +/// loss charts are wired in Phase 6).
13 +struct TrainView: View {
14 + @Bindable var model: AppModel
15 +
16 + var body: some View {
17 + if model.runs.isEmpty {
18 + EmptyStateView(
19 + icon: "flame",
20 + title: "No Training Runs Yet",
21 + message: "Fine-tune any local model on your own data with LoRA, QLoRA, or full fine-tuning — live loss curves, checkpoints, and memory gating included. Add a model and a dataset first, then start your first run.")
22 + } else {
23 + ScrollView {
24 + LazyVStack(spacing: ZyquoTheme.spacing8) {
25 + ForEach(model.runs) { run in
26 + RunRow(run: run)
27 + }
28 + }
29 + .padding(ZyquoTheme.spacing20)
30 + }
31 + }
32 + }
33 +}
34 +
35 +struct RunRow: View {
36 + let run: TrainingRun
37 +
38 + var body: some View {
39 + HStack(spacing: ZyquoTheme.spacing12) {
40 + Image(systemName: "flame")
41 + .font(.system(size: 18, weight: .light))
42 + .foregroundStyle(stateColor)
43 + .frame(width: 32, height: 32)
44 + .background(stateColor.opacity(0.1))
45 + .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))
46 +
47 + VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) {
48 + Text(run.name)
49 + .font(ZyquoTheme.bodyFont.weight(.medium))
50 + .foregroundStyle(ZyquoTheme.textPrimary)
51 + HStack(spacing: ZyquoTheme.spacing8) {
52 + Text(run.method.displayName)
53 + Text("\(run.completedIterations)/\(run.hyperParams.iterations) iters")
54 + if !run.checkpoints.isEmpty {
55 + Text("\(run.checkpoints.count) checkpoints")
56 + }
57 + }
58 + .font(ZyquoTheme.captionFont)
59 + .foregroundStyle(ZyquoTheme.textSecondary)
60 + }
61 +
62 + Spacer()
63 +
64 + StatusPill(text: run.state.rawValue.capitalized, color: stateColor)
65 + Text(run.createdAt.formatted(date: .abbreviated, time: .shortened))
66 + .font(ZyquoTheme.captionFont)
67 + .foregroundStyle(ZyquoTheme.textTertiary)
68 + }
69 + .padding(ZyquoTheme.spacing12)
70 + .zyquoCard()
71 + }
72 +
73 + private var stateColor: Color {
74 + switch run.state {
75 + case .running: ZyquoTheme.accent
76 + case .completed: ZyquoTheme.success
77 + case .paused, .configured: ZyquoTheme.slate
78 + case .cancelled: ZyquoTheme.warning
79 + case .failed: ZyquoTheme.danger
80 + }
81 + }
82 +}
added Sources/ZyquoMLX/Views/WorkbenchView.swift +243 −0
@@ -0,0 +1,243 @@
1 +//
2 +// WorkbenchView.swift
3 +// Zyquo MLX
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import SwiftUI
10 +
11 +/// The main workbench: 240pt translucent left navigator + contextual header
12 +/// + section content (charter §4.2).
13 +struct WorkbenchView: View {
14 + @State private var model = AppModel()
15 +
16 + var body: some View {
17 + HStack(spacing: 0) {
18 + SidebarView(model: model)
19 + .frame(width: ZyquoTheme.sidebarWidth)
20 +
21 + Rectangle()
22 + .fill(ZyquoTheme.border)
23 + .frame(width: ZyquoTheme.hairline)
24 +
25 + VStack(spacing: 0) {
26 + HeaderBar(model: model)
27 + .frame(height: ZyquoTheme.headerHeight)
28 +
29 + Rectangle()
30 + .fill(ZyquoTheme.border)
31 + .frame(height: ZyquoTheme.hairline)
32 +
33 + sectionContent
34 + .frame(maxWidth: .infinity, maxHeight: .infinity)
35 + }
36 + .background(ZyquoTheme.background)
37 + }
38 + .frame(
39 + minWidth: ZyquoTheme.minWindowSize.width,
40 + minHeight: ZyquoTheme.minWindowSize.height)
41 + .task { await model.refresh() }
42 + }
43 +
44 + @ViewBuilder
45 + private var sectionContent: some View {
46 + switch model.selectedSection {
47 + case .models: ModelsView(model: model)
48 + case .datasets: DatasetsView(model: model)
49 + case .train: TrainView(model: model)
50 + case .convert: ConvertView(model: model)
51 + case .playground: PlaygroundView(model: model)
52 + case .evaluate: EvaluateView(model: model)
53 + }
54 + }
55 +}
56 +
57 +// MARK: - Sidebar
58 +
59 +struct SidebarView: View {
60 + @Bindable var model: AppModel
61 +
62 + var body: some View {
63 + VStack(alignment: .leading, spacing: 0) {
64 + // Wordmark
65 + HStack(spacing: ZyquoTheme.spacing8) {
66 + Image(systemName: "z.square.fill")
67 + .font(.system(size: 22))
68 + .foregroundStyle(ZyquoTheme.accent)
69 + Text("Zyquo MLX")
70 + .font(ZyquoTheme.headlineFont)
71 + .foregroundStyle(ZyquoTheme.textPrimary)
72 + }
73 + .padding(.horizontal, ZyquoTheme.spacing16)
74 + .padding(.top, ZyquoTheme.spacing20)
75 + .padding(.bottom, ZyquoTheme.spacing24)
76 +
77 + ForEach(WorkbenchSection.allCases) { section in
78 + SidebarRow(
79 + section: section,
80 + isSelected: model.selectedSection == section,
81 + badge: badge(for: section)
82 + ) {
83 + withAnimation(ZyquoTheme.quickAnimation) {
84 + model.selectedSection = section
85 + }
86 + }
87 + }
88 +
89 + Spacer()
90 +
91 + SidebarFooter(model: model)
92 + }
93 + .background(.ultraThinMaterial)
94 + }
95 +
96 + private func badge(for section: WorkbenchSection) -> String? {
97 + switch section {
98 + case .models: model.models.isEmpty ? nil : "\(model.models.count)"
99 + case .datasets: model.datasets.isEmpty ? nil : "\(model.datasets.count)"
100 + case .train: model.runs.isEmpty ? nil : "\(model.runs.count)"
101 + default: nil
102 + }
103 + }
104 +}
105 +
106 +private struct SidebarRow: View {
107 + let section: WorkbenchSection
108 + let isSelected: Bool
109 + let badge: String?
110 + let action: () -> Void
111 +
112 + @State private var isHovering = false
113 +
114 + var body: some View {
115 + Button(action: action) {
116 + HStack(spacing: ZyquoTheme.spacing12) {
117 + Image(systemName: section.icon)
118 + .font(.system(size: 14, weight: .medium))
119 + .foregroundStyle(isSelected ? ZyquoTheme.accent : ZyquoTheme.textSecondary)
120 + .frame(width: 20)
121 + Text(section.title)
122 + .font(ZyquoTheme.bodyFont.weight(isSelected ? .semibold : .regular))
123 + .foregroundStyle(isSelected ? ZyquoTheme.textPrimary : ZyquoTheme.textSecondary)
124 + Spacer()
125 + if let badge {
126 + Text(badge)
127 + .font(ZyquoTheme.captionFont)
128 + .foregroundStyle(ZyquoTheme.textTertiary)
129 + }
130 + }
131 + .padding(.horizontal, ZyquoTheme.spacing12)
132 + .padding(.vertical, ZyquoTheme.spacing8)
133 + .background(
134 + RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall)
135 + .fill(
136 + isSelected
137 + ? ZyquoTheme.accentSubtle
138 + : isHovering ? ZyquoTheme.surfaceSecondary : .clear)
139 + )
140 + .contentShape(Rectangle())
141 + }
142 + .buttonStyle(.plain)
143 + .padding(.horizontal, ZyquoTheme.spacing8)
144 + .onHover { isHovering = $0 }
145 + }
146 +}
147 +
148 +private struct SidebarFooter: View {
149 + @Bindable var model: AppModel
150 + @Environment(\.openSettings) private var openSettings
151 +
152 + var body: some View {
153 + VStack(spacing: ZyquoTheme.spacing8) {
154 + Rectangle()
155 + .fill(ZyquoTheme.border)
156 + .frame(height: ZyquoTheme.hairline)
157 +
158 + HStack(spacing: ZyquoTheme.spacing8) {
159 + Button {
160 + openSettings()
161 + } label: {
162 + Image(systemName: "gearshape")
163 + .font(.system(size: 13))
164 + .foregroundStyle(ZyquoTheme.textSecondary)
165 + }
166 + .buttonStyle(.plain)
167 + .help("Settings")
168 +
169 + if model.activeJobCount > 0 {
170 + HStack(spacing: ZyquoTheme.spacing4) {
171 + ProgressView()
172 + .controlSize(.small)
173 + Text("\(model.activeJobCount)")
174 + .font(ZyquoTheme.captionFont)
175 + .foregroundStyle(ZyquoTheme.textSecondary)
176 + }
177 + .help("Active jobs")
178 + }
179 +
180 + Spacer()
181 +
182 + HStack(spacing: ZyquoTheme.spacing4) {
183 + Circle()
184 + .fill(memoryColor)
185 + .frame(width: 7, height: 7)
186 + Text(memoryLabel)
187 + .font(ZyquoTheme.monoSmallFont)
188 + .foregroundStyle(ZyquoTheme.textTertiary)
189 + }
190 + .help("GPU memory in use / unified memory")
191 + }
192 + .padding(.horizontal, ZyquoTheme.spacing16)
193 + .padding(.bottom, ZyquoTheme.spacing12)
194 + }
195 + }
196 +
197 + private var memoryLabel: String {
198 + let used = Double(model.memoryUsedBytes) / 1_073_741_824
199 + let total = Double(model.memoryTotalBytes) / 1_073_741_824
200 + return String(format: "%.1f / %.0f GB", used, total)
201 + }
202 +
203 + private var memoryColor: Color {
204 + let fraction = Double(model.memoryUsedBytes) / Double(max(1, model.memoryTotalBytes))
205 + if fraction > 0.75 { return ZyquoTheme.danger }
206 + if fraction > 0.5 { return ZyquoTheme.warning }
207 + return ZyquoTheme.success
208 + }
209 +}
210 +
211 +// MARK: - Header
212 +
213 +struct HeaderBar: View {
214 + @Bindable var model: AppModel
215 +
216 + var body: some View {
217 + HStack(spacing: ZyquoTheme.spacing12) {
218 + Text(model.selectedSection.title)
219 + .font(ZyquoTheme.titleFont)
220 + .foregroundStyle(ZyquoTheme.textPrimary)
221 +
222 + Spacer()
223 +
224 + if model.activeJobCount > 0 {
225 + StatusPill(
226 + text: "\(model.activeJobCount) active job\(model.activeJobCount > 1 ? "s" : "")",
227 + color: ZyquoTheme.accent)
228 + }
229 +
230 + Button {
231 + Task { await model.refresh() }
232 + } label: {
233 + Image(systemName: "arrow.clockwise")
234 + .font(.system(size: 12, weight: .medium))
235 + .foregroundStyle(ZyquoTheme.textSecondary)
236 + }
237 + .buttonStyle(.plain)
238 + .help("Refresh library")
239 + }
240 + .padding(.horizontal, ZyquoTheme.spacing20)
241 + .background(ZyquoTheme.background)
242 + }
243 +}
modified docs/PLAN.md +16 −1
@@ -121,7 +121,22 @@ base re-quantizes and rounds away small LoRA deltas — Zyquo defaults to
121 121 dequantize-on-fuse, recommends adapter-attached inference, and warns on
122 122 re-quantization (documented in TRAINING-RESEARCH.md §2.2). Swift-native
123 123 quantization verified: 1.19 GB fp16 → 335.5 MB 4-bit, predicted 335.3 MB.
124 ## Phase 4 — Design System & UI (not started)
124 +## Phase 4 — Design System & UI ✅ (completed 2026-07-30)
125 +
126 +- [x] `DesignSystem/ZyquoTheme` — copper-on-slate tokens (light flagship + derived dark), typography (SF Mono for metrics), 4pt spacing grid, radii, 0.5pt hairlines, motion; zero raw hex in views (hex lives only inside the token table)
127 +- [x] Workbench shell: 240pt translucent navigator (sections + counts, footer: settings gear, active-job spinner, live RAM readout), 52pt contextual header, 1360×880 / min 1080×700
128 +- [x] Screens: Models (Installed rows w/ type badge + quant + size + RAM verdict + reveal-in-Finder; Discover state designed), Datasets (import JSONL w/ validation flow), Train (run history w/ status pills), Convert / Playground / Evaluate intentional empty states
129 +- [x] Settings window (native tabs: General, Compute, Python, Storage, Appearance — HF token vault + Shortcuts + Advanced land with their Phase 6 features)
130 +- [x] Quality gate: light + dark reviewed via screenshots — copper identity reads clearly in both, cards/hairlines/baselines aligned
131 +- [x] Phase checkpoint: build green, 0 warnings
132 +
133 +**Phase 4 summary:** The token system (`ZyquoTheme`) is the single source of
134 +truth for every color/font/spacing value; shared components (`ZyquoCard`,
135 +`StatusPill`, `TypeBadge`, `VerdictBadge`, `EmptyStateView`) established. The
136 +workbench shell renders the real Phase 3 artifacts (4 models incl. the fused
137 +forge model, 1 dataset, 3 runs) with live MemoryAdvisor verdicts. Run
138 +configurator, live loss charts, playground interactions, and Hub Discover are
139 +Phase 6 features and will be built on this spec.
125 140 ## Phase 5 — App Icon (not started)
126 141 ## Phase 6 — Features (not started)
127 142 ## Phase 7 — Verification (not started)
128 143