@@ -129,368 +129,182 @@ final class ViewModel: ObservableObject {
130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
132−/// Redesigned with a card-based layout, a hero balance ring, animated numeric
132+/// Minimal, airy design: one big balance number as the hero, generous spacing,
133−/// transitions, a collapsible API-key section, and a polished footer. All
133+/// restrained color (balance color is the only accent), no cards or material
134−/// underlying ViewModel logic (API calls, Keychain, launch-at-login) is
134+/// backgrounds. Inspired by Apple's Battery widget and trading apps.
135−/// unchanged — only the presentation layer has been rewritten.
136struct MenuView: View {
135struct MenuView: View {
137 @ObservedObject var viewModel: ViewModel
136 @ObservedObject var viewModel: ViewModel
139− /// Controls whether the API-key disclosure section is expanded.
138+ var body: some View {
140− @State private var isAPIKeyExpanded: Bool = false
139+ VStack(spacing: 0) {
141− /// Drives the popover content entrance animation.
140+ heroSection
142− @State private var appeared: Bool = false
141+ statusRow
144− private let hyperAccent = Color(
144+ spacer
145− red: 0.906, green: 0.345, blue: 0.078, opacity: 1.0
148− var body: some View {
150− VStack(spacing: 14) {
152− if viewModel.needsOnboarding {
154− .transition(.opacity.combined(with: .move(edge: .top)))
156− if let error = viewModel.errorMessage {
158− .transition(.opacity.combined(with: .scale(scale: 0.95)))
160− apiKeySection
146+ apiKeySection
164− .frame(maxWidth: .infinity)
150+ footerRow
166− .frame(width: 320, height: 460)
154+ .frame(width: 280)
167− .background(
155+ .fixedSize(horizontal: false, vertical: true)
169− // Subtle radial glow tinted by the balance color.
174− viewModel.balanceColor.opacity(0.10),
184− Color(nsColor: .windowBackgroundColor)
156+ .background(Color(nsColor: .windowBackgroundColor))
188− .opacity(appeared ? 1 : 0)
189− .offset(y: appeared ? 0 : 8)
190− .animation(.easeOut(duration: 0.28), value: appeared)
191− .onAppear { appeared = true }
192− .animation(.easeInOut(duration: 0.3), value: viewModel.balance)
157+ .animation(.easeInOut(duration: 0.2), value: viewModel.balance)
193− .animation(.easeInOut(duration: 0.3), value: viewModel.errorMessage)
158+ .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
194− .animation(.easeInOut(duration: 0.3), value: viewModel.needsOnboarding)
159+ .animation(.easeInOut(duration: 0.2), value: viewModel.hasAPIKey)
162+ /// A light vertical spacer between sections — spacing, not borders.
163+ private var spacer: some View {
164+ Spacer().frame(height: 16)
197 // MARK: - Hero
167 // MARK: - Hero
169+ /// The balance number is the hero — big, bold, beautiful. Everything else
199 private var heroSection: some View {
171 private var heroSection: some View {
200− VStack(spacing: 8) {
172+ VStack(spacing: 6) {
201− ZStack {
173+ Group {
203− balance: viewModel.balance,
204− color: viewModel.balanceColor,
205− isLoading: viewModel.isLoading
207− .frame(width: 120, height: 120)
209− VStack(spacing: 2) {
210− if viewModel.isLoading && viewModel.balance == nil {
174+ if viewModel.isLoading && viewModel.balance == nil {
211− ProgressView()
175+ ProgressView()
212− .scaleEffect(0.9)
176+ .controlSize(.small)
213− } else if let balance = viewModel.balance {
177+ } else if let balance = viewModel.balance {
214− Text("\(balance)")
178+ Text("\(balance)")
215− .font(.system(size: 38, weight: .heavy, design: .rounded))
179+ .font(.system(size: 52, weight: .heavy, design: .rounded))
216− .monospacedDigit()
180+ .monospacedDigit()
217− .foregroundColor(viewModel.balanceColor)
181+ .foregroundColor(viewModel.balanceColor)
218− .contentTransition(.opacity)
182+ .contentTransition(.opacity)
219− } else if viewModel.needsOnboarding {
183+ } else if viewModel.needsOnboarding {
220− Image(systemName: "bolt.fill")
184+ Image(systemName: "bolt.fill")
221− .font(.system(size: 34, weight: .bold))
185+ .font(.system(size: 40, weight: .regular))
222− .symbolRenderingMode(.hierarchical)
186+ .symbolRenderingMode(.hierarchical)
223− .foregroundStyle(.secondary)
187+ .foregroundStyle(.secondary)
226− .font(.system(size: 38, weight: .heavy, design: .rounded))
227− .foregroundColor(.secondary)
231− .font(.system(size: 11, weight: .medium, design: .rounded))
232− .foregroundColor(.secondary)
233− .textCase(.uppercase)
239− // Last-updated + refresh row
240− HStack(spacing: 6) {
241− if let relative = viewModel.relativeUpdateText {
242− Label(relative, systemImage: "clock")
243− .labelStyle(.titleAndIcon)
244− } else if viewModel.isLoading {
247− Text("Not yet updated")
189+ Text("—")
190+ .font(.system(size: 52, weight: .heavy, design: .rounded))
191+ .foregroundColor(.secondary)
250− .font(.system(size: 11, weight: .regular, design: .rounded))
194+ .frame(height: 56)
251− .foregroundStyle(.tertiary)
253− refreshButton
196+ Text("credits")
197+ .font(.system(size: 11, weight: .medium, design: .rounded))
198+ .foregroundColor(.secondary)
199+ .textCase(.uppercase)
256 .frame(maxWidth: .infinity)
202 .frame(maxWidth: .infinity)
257− .background(
203+ .padding(.top, 4)
258− RoundedRectangle(cornerRadius: 16, style: .continuous)
259− .fill(.regularMaterial)
262− RoundedRectangle(cornerRadius: 16, style: .continuous)
263− .strokeBorder(viewModel.balanceColor.opacity(0.15), lineWidth: 1)
265− .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3)
268− // MARK: - Refresh button
206+ // MARK: - Status
270− private var refreshButton: some View {
208+ /// Subtle "Updated 2m ago" or "Not connected" — plain text, no icons.
271− Button(action: { viewModel.refresh() }) {
272− HStack(spacing: 5) {
273− Image(systemName: "arrow.clockwise")
274− .symbolRenderingMode(.hierarchical)
275− .rotationEffect(.degrees(viewModel.isLoading ? 360 : 0))
278− ? .linear(duration: 0.9).repeatForever(autoreverses: false)
279− : .easeOut(duration: 0.3),
280− value: viewModel.isLoading
282− Text(viewModel.isLoading ? "Refreshing" : "Refresh")
283− .font(.system(size: 12, weight: .medium, design: .rounded))
285− .padding(.horizontal, 14)
286− .padding(.vertical, 6)
289− .fill(hyperAccent.opacity(viewModel.isLoading ? 0.15 : 0.12))
291− .foregroundColor(hyperAccent)
293− .buttonStyle(.plain)
294− .disabled(viewModel.isLoading)
297− // MARK: - Onboarding
299− private var onboardingCard: some View {
209+ private var statusRow: some View {
300− VStack(alignment: .leading, spacing: 10) {
210+ Group {
301− HStack(spacing: 10) {
211+ if let relative = viewModel.relativeUpdateText {
302− Image(systemName: "key.fill")
212+ Text("Updated \(relative)")
303− .font(.system(size: 22, weight: .semibold))
213+ } else {
304− .symbolRenderingMode(.hierarchical)
214+ Text("Not connected")
305− .foregroundStyle(hyperAccent)
306− VStack(alignment: .leading, spacing: 2) {
307− Text("Welcome to HyperCredits")
308− .font(.system(size: 15, weight: .semibold, design: .rounded))
309− Text("Connect your account to see your balance here.")
310− .font(.system(size: 12, weight: .regular, design: .rounded))
311− .foregroundStyle(.secondary)
315− withAnimation(.easeInOut(duration: 0.3)) {
316− isAPIKeyExpanded = true
319− HStack(spacing: 4) {
320− Text("Add your API key")
321− .font(.system(size: 12, weight: .medium, design: .rounded))
322− Image(systemName: "arrow.right")
323− .font(.system(size: 10, weight: .bold))
325− .foregroundColor(hyperAccent)
327− .buttonStyle(.plain)
217+ .font(.system(size: 11, weight: .regular, design: .rounded))
218+ .foregroundStyle(.tertiary)
329− .padding(14)
219+ .padding(.top, 2)
330− .frame(maxWidth: .infinity, alignment: .leading)
332− RoundedRectangle(cornerRadius: 12, style: .continuous)
333− .fill(hyperAccent.opacity(0.08))
336− RoundedRectangle(cornerRadius: 12, style: .continuous)
337− .strokeBorder(hyperAccent.opacity(0.25), lineWidth: 1)
341− // MARK: - Error
222+ // MARK: - Refresh
224+ /// Minimal text button — no icon rotation, just a text change.
343− private func errorCard(_ message: String) -> some View {
225+ private var refreshButton: some View {
344− VStack(alignment: .leading, spacing: 8) {
345− HStack(alignment: .top, spacing: 8) {
346− Image(systemName: "exclamationmark.triangle.fill")
347− .font(.system(size: 14, weight: .semibold))
348− .symbolRenderingMode(.hierarchical)
349− .foregroundStyle(.red.opacity(0.8))
351− .font(.system(size: 12, weight: .regular, design: .rounded))
352− .foregroundColor(.red.opacity(0.85))
353− .multilineTextAlignment(.leading)
354− .fixedSize(horizontal: false, vertical: true)
356− Button(action: { viewModel.refresh() }) {
226+ Button(action: { viewModel.refresh() }) {
357− HStack(spacing: 4) {
227+ Text(viewModel.isLoading ? "Refreshing…" : "Refresh")
358− Image(systemName: "arrow.clockwise")
359− .font(.system(size: 10, weight: .bold))
361− .font(.system(size: 12, weight: .medium, design: .rounded))
228+ .font(.system(size: 12, weight: .medium, design: .rounded))
363− .foregroundColor(.red.opacity(0.85))
229+ .foregroundColor(.secondary)
365− .buttonStyle(.plain)
231+ .buttonStyle(.plain)
232+ .disabled(viewModel.isLoading)
367− .padding(12)
233+ .padding(.top, 6)
368− .frame(maxWidth: .infinity, alignment: .leading)
370− RoundedRectangle(cornerRadius: 12, style: .continuous)
371− .fill(.red.opacity(0.07))
374− RoundedRectangle(cornerRadius: 12, style: .continuous)
375− .strokeBorder(.red.opacity(0.2), lineWidth: 1)
379− // MARK: - API key section
236+ // MARK: - API Key
238+ /// Clean SecureField + Save button. No DisclosureGroup, no lock icons.
381 private var apiKeySection: some View {
239 private var apiKeySection: some View {
382− DisclosureGroup(isExpanded: $isAPIKeyExpanded) {
240+ VStack(spacing: 8) {
383− VStack(alignment: .leading, spacing: 8) {
384− HStack(spacing: 8) {
241+ HStack(spacing: 8) {
385− Image(systemName: "key.fill")
386− .font(.system(size: 12, weight: .semibold))
387− .symbolRenderingMode(.hierarchical)
388− .foregroundStyle(hyperAccent)
389− SecureField("sk-…", text: $viewModel.apiKeyInput)
242+ SecureField("Hyper API Key", text: $viewModel.apiKeyInput)
390− .textFieldStyle(.roundedBorder)
243+ .textFieldStyle(.roundedBorder)
391− .font(.system(size: 13, design: .monospaced))
244+ .font(.system(size: 13, design: .monospaced))
392− }
245+ .onSubmit { viewModel.saveAPIKey() }
393 Button(action: { viewModel.saveAPIKey() }) {
247 Button(action: { viewModel.saveAPIKey() }) {
394− Text("Save API Key")
248+ Text("Save")
395 .font(.system(size: 13, weight: .medium, design: .rounded))
249 .font(.system(size: 13, weight: .medium, design: .rounded))
396− .frame(maxWidth: .infinity)
397− .padding(.vertical, 7)
399− RoundedRectangle(cornerRadius: 8, style: .continuous)
400− .fill(hyperAccent.opacity(viewModel.apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.3 : 1.0))
402− .foregroundColor(.white)
404− .buttonStyle(.plain)
251+ .buttonStyle(.borderedProminent)
405− .transition(.opacity)
252+ .controlSize(.small)
409− HStack(spacing: 6) {
410− Image(systemName: "lock.fill")
411− .font(.system(size: 12, weight: .semibold))
412− .symbolRenderingMode(.hierarchical)
413− .foregroundStyle(.secondary)
414− Text(viewModel.hasAPIKey ? "API Key saved" : "API Key")
415− .font(.system(size: 13, weight: .medium, design: .rounded))
416− if viewModel.hasAPIKey {
417− Image(systemName: "checkmark.circle.fill")
418− .font(.system(size: 11))
419− .foregroundStyle(.green)
423− .accentColor(hyperAccent)
426− RoundedRectangle(cornerRadius: 12, style: .continuous)
427− .fill(.ultraThinMaterial)
433− private var footerSection: some View {
255+ if viewModel.hasAPIKey {
434− VStack(spacing: 8) {
256+ Text("✓ Saved")
435− // Launch at login
257+ .font(.system(size: 11, weight: .medium, design: .rounded))
436− HStack {
258+ .foregroundColor(.green)
437− Label("Launch at Login", systemImage: "arrow.up.forward.square")
438− .labelStyle(.titleAndIcon)
439− .font(.system(size: 12, weight: .regular, design: .rounded))
440− .symbolRenderingMode(.hierarchical)
442− Toggle("", isOn: $viewModel.launchAtLogin)
443− .toggleStyle(.switch)
444− .controlSize(.small)
447− .padding(.horizontal, 12)
448− .padding(.vertical, 8)
450− RoundedRectangle(cornerRadius: 10, style: .continuous)
451− .fill(.ultraThinMaterial)
455− HStack(spacing: 0) {
456− if let url = URL(string: "https://hyper.charm.land") {
259+ } else if let url = URL(string: "https://hyper.charm.land") {
458− NSWorkspace.shared.open(url)
460− HStack(spacing: 4) {
461− Image(systemName: "arrow.up.right.square")
462− .font(.system(size: 10, weight: .semibold))
463− Text("hyper.charm.land")
464− .font(.system(size: 11, weight: .regular, design: .rounded))
466− .foregroundColor(.secondary)
468− .buttonStyle(.plain)
472− NSApplication.shared.terminate(nil)
261+ NSWorkspace.shared.open(url)
473 } label: {
262 } label: {
474− Text("Quit")
263+ Text("Get your key at hyper.charm.land")
475 .font(.system(size: 11, weight: .regular, design: .rounded))
264 .font(.system(size: 11, weight: .regular, design: .rounded))
476 .foregroundColor(.secondary)
265 .foregroundColor(.secondary)
478 .buttonStyle(.plain)
267 .buttonStyle(.plain)
479− .keyboardShortcut("q")
481− .padding(.horizontal, 4)
269+ }
483− // Version
272+ // MARK: - Footer
484− Text("HyperCredits \(appVersion)")
273+
274+ /// Launch at Login toggle on the left, Quit on the right. Very subtle.
275+ private var footerRow: some View {
277+ Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
278+ .toggleStyle(.switch)
279+ .controlSize(.small)
280+ .font(.system(size: 12, weight: .regular, design: .rounded))
285+ NSApplication.shared.terminate(nil)
485− .font(.system(size: 10, weight: .regular, design: .rounded))
288+ .font(.system(size: 12, weight: .regular, design: .rounded))
486− .foregroundStyle(.tertiary)
289+ .foregroundColor(.secondary)
291+ .buttonStyle(.plain)
292+ .keyboardShortcut("q")
298+ /// Tiny version label at the very bottom.
299+ private var versionText: some View {
300+ Text("v\(appVersion)")
301+ .font(.system(size: 10, weight: .regular, design: .rounded))
302+ .foregroundStyle(.tertiary)
490− /// Reads the marketing version + build from the main bundle.
306+ /// Reads the marketing version from the main bundle.
491 private var appVersion: String {
307 private var appVersion: String {
492− let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
308+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
493− let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"