SPB Git

Repository Public MIT

Commit

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 · Jul 30, 2026 parent 1fd3b89
12 changed files +1,139 −17 Browse files
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 var body: some Scene { 29 var body: some Scene {
28 WindowGroup { 30 WindowGroup {
29− if HardwareGate.isAppleSilicon { 31+ Group {
30− PlaceholderRootView() 32+ if HardwareGate.isAppleSilicon {
31− .frame(minWidth: 1080, minHeight: 700) 33+ WorkbenchView()
32− } else { 34+ } else {
33− UnsupportedHardwareView() 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. 50+ private var colorScheme: ColorScheme? {
41−private struct PlaceholderRootView: View { 51+ switch appearance {
42− var body: some View { 52+ case "light": .light
43− VStack(spacing: 8) { 53+ case "dark": .dark
44− Text("Zyquo MLX") 54+ default: nil
45− .font(.largeTitle.weight(.semibold))
46− Text("Foundry under construction — Phase 1")
47− .foregroundStyle(.secondary)
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 dequantize-on-fuse, recommends adapter-attached inference, and warns on 121 dequantize-on-fuse, recommends adapter-attached inference, and warns on
122 re-quantization (documented in TRAINING-RESEARCH.md §2.2). Swift-native 122 re-quantization (documented in TRAINING-RESEARCH.md §2.2). Swift-native
123 quantization verified: 1.19 GB fp16 → 335.5 MB 4-bit, predicted 335.3 MB. 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 ## Phase 5 — App Icon (not started) 140 ## Phase 5 — App Icon (not started)
126 ## Phase 6 — Features (not started) 141 ## Phase 6 — Features (not started)
127 ## Phase 7 — Verification (not started) 142 ## Phase 7 — Verification (not started)
128 143
Menu

Theme