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