// // OnboardingHeroView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// First-run hero: no model downloaded yet. Recommends starter models sized /// for THIS Mac with one-click download. struct OnboardingHeroView: View { @Environment(AppModel.self) private var app @State private var appeared = false var body: some View { VStack(spacing: ZyquoTheme.Spacing.xl) { Spacer() VStack(spacing: ZyquoTheme.Spacing.m) { ZyquoGlyph(size: 56) .padding(ZyquoTheme.Spacing.l) .background(ZyquoTheme.accentSubtle, in: RoundedRectangle(cornerRadius: 22)) Text("Download your first model") .font(ZyquoTheme.title) .foregroundStyle(ZyquoTheme.textPrimary) Text("Zyquo Local runs language models entirely on this Mac.\nNothing ever leaves your machine — pick a starter model sized for your \(Int(MemoryAdvisor.physicalMemoryBytes / 1_073_741_824)) GB of memory.") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textSecondary) .multilineTextAlignment(.center) .lineSpacing(ZyquoTheme.lineSpacing(fontSize: 13.5) * 0.5) } HStack(spacing: ZyquoTheme.Spacing.m) { ForEach(ModelCatalog.starterPicks()) { model in StarterCard(model: model) } } .padding(.horizontal, ZyquoTheme.Spacing.xl) Button { app.route = .library } label: { Text("Browse all models in the Library") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.accent) } .buttonStyle(.plain) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(ZyquoTheme.background) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 8) .onAppear { withAnimation(.easeOut(duration: 0.25)) { appeared = true } } } } /// One recommended starter model card. struct StarterCard: View { let model: CatalogModel @Environment(AppModel.self) private var app @State private var hovering = false private var download: DownloadTask? { app.downloads.task(for: model.repoID) } var body: some View { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { HStack { Text(shortModelName(model.repoID)) .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) .lineLimit(1) Spacer() VerdictBadge(verdict: model.verdict) } HStack(spacing: ZyquoTheme.Spacing.xxs) { InfoBadge(text: model.params) InfoBadge(text: model.quant) InfoBadge(text: String(format: "%.1f GB", model.sizeGB)) } Text(model.blurb) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) .lineLimit(2, reservesSpace: true) if let task = download, task.state == .downloading || task.state == .verifying { VStack(alignment: .leading, spacing: 3) { ProgressView(value: task.fractionCompleted) .tint(ZyquoTheme.accent) HStack { Text("\(Int(task.fractionCompleted * 100))%") Spacer() if let speed = app.downloads.speeds[model.repoID] { Text(formatSpeed(speed)) } } .font(ZyquoTheme.caption.monospacedDigit()) .foregroundStyle(ZyquoTheme.textTertiary) } } else { Button { Task { await app.downloads.download(repoID: model.repoID) } } label: { Text("Download") .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 6) .background(ZyquoTheme.accent, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) } .buttonStyle(PressableButtonStyle()) } } .padding(ZyquoTheme.Spacing.m) .frame(width: 190) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) .stroke(hovering ? ZyquoTheme.accent.opacity(0.4) : ZyquoTheme.border, lineWidth: hovering ? 1 : ZyquoTheme.hairline) ) .floatingShadow() .onHover { inside in withAnimation(.easeOut(duration: 0.08)) { hovering = inside } } } }