on master help sign in
feat: clean minimal UI redesign

- Big balance number (52pt heavy rounded) as the hero, no ring - Generous spacing instead of cards/borders/materials - Plain window background, no gradients or glows - Subtle status text: 'Updated 2m ago' / 'Not connected' - Minimal text-only Refresh button (no rotating icon) - Clean API key field + Save, with ✓ Saved or 'Get key' link - Compact footer: launch-at-login toggle + Quit - Narrower popover (280pt), natural height - Subtle loading pulse on menu bar icon (0.6-1.0 opacity) - Shows '…' in menu bar while loading - Removed orange accent, cards, DisclosureGroup, ring from UI

commitb2430c2fd029bd50dce54620fc78309e1fa42d84 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 15:04 (3 months ago) parenta8e941e
3 files changed+156−348
HyperCreditsMenubar/BalanceRingView.swift+14−27 HyperCreditsMenubar/HyperCreditsApp.swift+12−5 HyperCreditsMenubar/MenuView.swift+130−316
HyperCreditsMenubar/BalanceRingView.swift+14−27
@@ -1,21 +1,18 @@
11import SwiftUI
22
3−/// A circular progress "activity ring" that visualises the credit balance
4−/// relative to a reference maximum. The arc fills clockwise from 12 o'clock
5−/// and uses a gradient driven by the balance colour, giving a quick at-a-glance
6−/// sense of how healthy the balance is.
3+/// A very thin, very subtle circular progress ring that visualises the credit
4+/// balance relative to a reference maximum. Not currently used in the main
5+/// popover — the balance number is the hero, not a ring — but kept as a
6+/// clean, minimal implementation that could be layered behind the number
7+/// if a visual indicator is desired.
78///
8−/// When the balance exceeds `referenceMax` the ring simply renders as full
9−/// (clamped to 1.0) so very large balances still look good rather than
10−/// wrapping around.
9+/// The track is nearly invisible (0.08 opacity) and the arc is a flat color
10+/// (no gradient, no shadow) to stay understated.
1111struct BalanceRingView: View {
1212 let balance: Int?
1313 let color: Color
14− /// The balance value that maps to a full ring. 500 is a sensible default
15− /// that keeps low double-digit balances visible on the arc.
14+ /// The balance value that maps to a full ring.
1615 var referenceMax: Int = 500
17− /// True while a fetch is in flight — dims the ring slightly.
18− var isLoading: Bool = false
1916
2017 /// Progress in the range 0...1.
2118 private var progress: Double {
@@ -25,29 +22,19 @@ struct BalanceRingView: View {
2522
2623 var body: some View {
2724 ZStack {
28− // Track
25+ // Track — barely visible
2926 Circle()
30− .stroke(color.opacity(0.15), lineWidth: ringWidth)
27+ .stroke(color.opacity(0.08), lineWidth: 3)
3128
32− // Progress arc
29+ // Progress arc — flat color, no gradient, no shadow
3330 Circle()
3431 .trim(from: 0, to: progress)
3532 .stroke(
36− AngularGradient(
37− colors: [color.opacity(0.6), color],
38− center: .center,
39− startAngle: .degrees(0),
40− endAngle: .degrees(360 * progress)
41− ),
42− style: StrokeStyle(lineWidth: ringWidth, lineCap: .round)
33+ color,
34+ style: StrokeStyle(lineWidth: 3, lineCap: .round)
4335 )
4436 .rotationEffect(.degrees(-90)) // start from top
45− .shadow(color: color.opacity(0.35), radius: 4, x: 0, y: 2)
46− .opacity(isLoading ? 0.45 : 1.0)
4737 }
48− .animation(.easeInOut(duration: 0.6), value: progress)
49− .animation(.easeInOut(duration: 0.25), value: isLoading)
38+ .animation(.easeInOut(duration: 0.4), value: progress)
5039 }
51−
52− private let ringWidth: CGFloat = 9
5340}
HyperCreditsMenubar/HyperCreditsApp.swift+12−5
@@ -42,9 +42,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4242 popover.behavior = .transient
4343 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4444
45− // Observe balance changes to update the menu bar title
46− statusTextCancellable = viewModel.$balance
47− .map { [weak viewModel] _ in viewModel?.statusBarItemText ?? "⚡?" }
45+ // Observe balance changes to update the menu bar title.
46+ // Shows "⚡…" while loading (no balance yet), otherwise the number.
47+ statusTextCancellable = viewModel.$isLoading
48+ .combineLatest(viewModel.$balance)
49+ .map { [weak viewModel] isLoading, balance in
50+ if isLoading && balance == nil { return "…" }
51+ return viewModel?.statusBarItemText ?? "⚡?"
52+ }
4853 .removeDuplicates()
4954 .receive(on: RunLoop.main)
5055 .sink { [weak self] text in
@@ -125,14 +130,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
125130
126131 // MARK: - Loading pulse
127132
133+ /// Subtle pulse: 0.3s interval, opacity oscillates gently between 0.6–1.0.
128134 private func startPulse() {
129135 pulseStep = 0
130136 pulseTimer?.invalidate()
131− let timer = Timer(timeInterval: 0.12, repeats: true) { [weak self] _ in
137+ let timer = Timer(timeInterval: 0.3, repeats: true) { [weak self] _ in
132138 guard let self else { return }
133139 self.pulseStep += 1
134140 let wave = sin(Double(self.pulseStep) * 0.5)
135− let opacity = 0.35 + 0.65 * (0.5 + 0.5 * wave)
141+ // 0.6 – 1.0 opacity range (subtle, not flashy)
142+ let opacity = 0.8 + 0.2 * wave
136143 self.statusItem.button?.image = self.statusBarIcon(
137144 color: self.viewModel.balanceColor,
138145 opacity: opacity
HyperCreditsMenubar/MenuView.swift+130−316
@@ -129,368 +129,182 @@ final class ViewModel: ObservableObject {
129129
130130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
131131///
132−/// Redesigned with a card-based layout, a hero balance ring, animated numeric
133−/// transitions, a collapsible API-key section, and a polished footer. All
134−/// underlying ViewModel logic (API calls, Keychain, launch-at-login) is
135−/// unchanged — only the presentation layer has been rewritten.
132+/// Minimal, airy design: one big balance number as the hero, generous spacing,
133+/// restrained color (balance color is the only accent), no cards or material
134+/// backgrounds. Inspired by Apple's Battery widget and trading apps.
136135struct MenuView: View {
137136 @ObservedObject var viewModel: ViewModel
138137
139− /// Controls whether the API-key disclosure section is expanded.
140− @State private var isAPIKeyExpanded: Bool = false
141− /// Drives the popover content entrance animation.
142− @State private var appeared: Bool = false
138+ var body: some View {
139+ VStack(spacing: 0) {
140+ heroSection
141+ statusRow
142+ refreshButton
143143
144− private let hyperAccent = Color(
145− red: 0.906, green: 0.345, blue: 0.078, opacity: 1.0
146− )
144+ spacer
147145
148− var body: some View {
149− ScrollView {
150− VStack(spacing: 14) {
151− heroSection
152− if viewModel.needsOnboarding {
153− onboardingCard
154− .transition(.opacity.combined(with: .move(edge: .top)))
155− }
156− if let error = viewModel.errorMessage {
157− errorCard(error)
158− .transition(.opacity.combined(with: .scale(scale: 0.95)))
159− }
160− apiKeySection
161− footerSection
162− }
163− .padding(16)
164− .frame(maxWidth: .infinity)
146+ apiKeySection
147+
148+ spacer
149+
150+ footerRow
151+ versionText
165152 }
166− .frame(width: 320, height: 460)
167− .background(
168− ZStack {
169− // Subtle radial glow tinted by the balance color.
170− Circle()
171− .fill(
172− RadialGradient(
173− colors: [
174− viewModel.balanceColor.opacity(0.10),
175− Color.clear,
176− ],
177− center: .top,
178− startRadius: 10,
179− endRadius: 260
180− )
181− )
182− .blur(radius: 20)
183− .offset(y: -60)
184− Color(nsColor: .windowBackgroundColor)
185− }
186− .ignoresSafeArea()
187− )
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)
193− .animation(.easeInOut(duration: 0.3), value: viewModel.errorMessage)
194− .animation(.easeInOut(duration: 0.3), value: viewModel.needsOnboarding)
153+ .padding(20)
154+ .frame(width: 280)
155+ .fixedSize(horizontal: false, vertical: true)
156+ .background(Color(nsColor: .windowBackgroundColor))
157+ .animation(.easeInOut(duration: 0.2), value: viewModel.balance)
158+ .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
159+ .animation(.easeInOut(duration: 0.2), value: viewModel.hasAPIKey)
160+ }
161+
162+ /// A light vertical spacer between sections — spacing, not borders.
163+ private var spacer: some View {
164+ Spacer().frame(height: 16)
195165 }
196166
197167 // MARK: - Hero
198168
169+ /// The balance number is the hero — big, bold, beautiful. Everything else
170+ /// is secondary.
199171 private var heroSection: some View {
200− VStack(spacing: 8) {
201− ZStack {
202− BalanceRingView(
203− balance: viewModel.balance,
204− color: viewModel.balanceColor,
205− isLoading: viewModel.isLoading
206− )
207− .frame(width: 120, height: 120)
208−
209− VStack(spacing: 2) {
210− if viewModel.isLoading && viewModel.balance == nil {
211− ProgressView()
212− .scaleEffect(0.9)
213− } else if let balance = viewModel.balance {
214− Text("\(balance)")
215− .font(.system(size: 38, weight: .heavy, design: .rounded))
216− .monospacedDigit()
217− .foregroundColor(viewModel.balanceColor)
218− .contentTransition(.opacity)
219− } else if viewModel.needsOnboarding {
220− Image(systemName: "bolt.fill")
221− .font(.system(size: 34, weight: .bold))
222− .symbolRenderingMode(.hierarchical)
223− .foregroundStyle(.secondary)
224− } else {
225− Text("?")
226− .font(.system(size: 38, weight: .heavy, design: .rounded))
227− .foregroundColor(.secondary)
228− }
229−
230− Text("credits")
231− .font(.system(size: 11, weight: .medium, design: .rounded))
232− .foregroundColor(.secondary)
233− .textCase(.uppercase)
234− .tracking(1.2)
235− }
236− }
237− .padding(.top, 4)
238−
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 {
245− Text("Updating…")
172+ VStack(spacing: 6) {
173+ Group {
174+ if viewModel.isLoading && viewModel.balance == nil {
175+ ProgressView()
176+ .controlSize(.small)
177+ } else if let balance = viewModel.balance {
178+ Text("\(balance)")
179+ .font(.system(size: 52, weight: .heavy, design: .rounded))
180+ .monospacedDigit()
181+ .foregroundColor(viewModel.balanceColor)
182+ .contentTransition(.opacity)
183+ } else if viewModel.needsOnboarding {
184+ Image(systemName: "bolt.fill")
185+ .font(.system(size: 40, weight: .regular))
186+ .symbolRenderingMode(.hierarchical)
187+ .foregroundStyle(.secondary)
246188 } else {
247− Text("Not yet updated")
189+ Text("—")
190+ .font(.system(size: 52, weight: .heavy, design: .rounded))
191+ .foregroundColor(.secondary)
248192 }
249193 }
250− .font(.system(size: 11, weight: .regular, design: .rounded))
251− .foregroundStyle(.tertiary)
194+ .frame(height: 56)
252195
253− refreshButton
196+ Text("credits")
197+ .font(.system(size: 11, weight: .medium, design: .rounded))
198+ .foregroundColor(.secondary)
199+ .textCase(.uppercase)
200+ .tracking(1.5)
254201 }
255− .padding(16)
256202 .frame(maxWidth: .infinity)
257− .background(
258− RoundedRectangle(cornerRadius: 16, style: .continuous)
259− .fill(.regularMaterial)
260− )
261− .overlay(
262− RoundedRectangle(cornerRadius: 16, style: .continuous)
263− .strokeBorder(viewModel.balanceColor.opacity(0.15), lineWidth: 1)
264− )
265− .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3)
203+ .padding(.top, 4)
266204 }
267205
268− // MARK: - Refresh button
206+ // MARK: - Status
269207
270− private var refreshButton: some View {
271− Button(action: { viewModel.refresh() }) {
272− HStack(spacing: 5) {
273− Image(systemName: "arrow.clockwise")
274− .symbolRenderingMode(.hierarchical)
275− .rotationEffect(.degrees(viewModel.isLoading ? 360 : 0))
276− .animation(
277− viewModel.isLoading
278− ? .linear(duration: 0.9).repeatForever(autoreverses: false)
279− : .easeOut(duration: 0.3),
280− value: viewModel.isLoading
281− )
282− Text(viewModel.isLoading ? "Refreshing" : "Refresh")
283− .font(.system(size: 12, weight: .medium, design: .rounded))
284− }
285− .padding(.horizontal, 14)
286− .padding(.vertical, 6)
287− .background(
288− Capsule()
289− .fill(hyperAccent.opacity(viewModel.isLoading ? 0.15 : 0.12))
290− )
291− .foregroundColor(hyperAccent)
292− }
293− .buttonStyle(.plain)
294− .disabled(viewModel.isLoading)
295− }
296−
297− // MARK: - Onboarding
298−
299− private var onboardingCard: some View {
300− VStack(alignment: .leading, spacing: 10) {
301− HStack(spacing: 10) {
302− Image(systemName: "key.fill")
303− .font(.system(size: 22, weight: .semibold))
304− .symbolRenderingMode(.hierarchical)
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)
312− }
208+ /// Subtle "Updated 2m ago" or "Not connected" — plain text, no icons.
209+ private var statusRow: some View {
210+ Group {
211+ if let relative = viewModel.relativeUpdateText {
212+ Text("Updated \(relative)")
213+ } else {
214+ Text("Not connected")
313215 }
314− Button {
315− withAnimation(.easeInOut(duration: 0.3)) {
316− isAPIKeyExpanded = true
317− }
318− } label: {
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))
324− }
325− .foregroundColor(hyperAccent)
326− }
327− .buttonStyle(.plain)
328216 }
329− .padding(14)
330− .frame(maxWidth: .infinity, alignment: .leading)
331− .background(
332− RoundedRectangle(cornerRadius: 12, style: .continuous)
333− .fill(hyperAccent.opacity(0.08))
334− )
335− .overlay(
336− RoundedRectangle(cornerRadius: 12, style: .continuous)
337− .strokeBorder(hyperAccent.opacity(0.25), lineWidth: 1)
338− )
217+ .font(.system(size: 11, weight: .regular, design: .rounded))
218+ .foregroundStyle(.tertiary)
219+ .padding(.top, 2)
339220 }
340221
341− // MARK: - Error
222+ // MARK: - Refresh
342223
343− private func errorCard(_ message: String) -> 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))
350− Text(message)
351− .font(.system(size: 12, weight: .regular, design: .rounded))
352− .foregroundColor(.red.opacity(0.85))
353− .multilineTextAlignment(.leading)
354− .fixedSize(horizontal: false, vertical: true)
355− }
356− Button(action: { viewModel.refresh() }) {
357− HStack(spacing: 4) {
358− Image(systemName: "arrow.clockwise")
359− .font(.system(size: 10, weight: .bold))
360− Text("Retry")
361− .font(.system(size: 12, weight: .medium, design: .rounded))
362− }
363− .foregroundColor(.red.opacity(0.85))
364− }
365− .buttonStyle(.plain)
224+ /// Minimal text button — no icon rotation, just a text change.
225+ private var refreshButton: some View {
226+ Button(action: { viewModel.refresh() }) {
227+ Text(viewModel.isLoading ? "Refreshing…" : "Refresh")
228+ .font(.system(size: 12, weight: .medium, design: .rounded))
229+ .foregroundColor(.secondary)
366230 }
367− .padding(12)
368− .frame(maxWidth: .infinity, alignment: .leading)
369− .background(
370− RoundedRectangle(cornerRadius: 12, style: .continuous)
371− .fill(.red.opacity(0.07))
372− )
373− .overlay(
374− RoundedRectangle(cornerRadius: 12, style: .continuous)
375− .strokeBorder(.red.opacity(0.2), lineWidth: 1)
376− )
231+ .buttonStyle(.plain)
232+ .disabled(viewModel.isLoading)
233+ .padding(.top, 6)
377234 }
378235
379− // MARK: - API key section
236+ // MARK: - API Key
380237
238+ /// Clean SecureField + Save button. No DisclosureGroup, no lock icons.
381239 private var apiKeySection: some View {
382− DisclosureGroup(isExpanded: $isAPIKeyExpanded) {
383− VStack(alignment: .leading, spacing: 8) {
384− 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)
390− .textFieldStyle(.roundedBorder)
391− .font(.system(size: 13, design: .monospaced))
392− }
240+ VStack(spacing: 8) {
241+ HStack(spacing: 8) {
242+ SecureField("Hyper API Key", text: $viewModel.apiKeyInput)
243+ .textFieldStyle(.roundedBorder)
244+ .font(.system(size: 13, design: .monospaced))
245+ .onSubmit { viewModel.saveAPIKey() }
246+
393247 Button(action: { viewModel.saveAPIKey() }) {
394− Text("Save API Key")
248+ Text("Save")
395249 .font(.system(size: 13, weight: .medium, design: .rounded))
396− .frame(maxWidth: .infinity)
397− .padding(.vertical, 7)
398− .background(
399− RoundedRectangle(cornerRadius: 8, style: .continuous)
400− .fill(hyperAccent.opacity(viewModel.apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.3 : 1.0))
401− )
402− .foregroundColor(.white)
403250 }
404− .buttonStyle(.plain)
405− .transition(.opacity)
251+ .buttonStyle(.borderedProminent)
252+ .controlSize(.small)
406253 }
407− .padding(.top, 10)
408− } label: {
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)
420− }
421− }
422− }
423− .accentColor(hyperAccent)
424− .padding(12)
425− .background(
426− RoundedRectangle(cornerRadius: 12, style: .continuous)
427− .fill(.ultraThinMaterial)
428− )
429− }
430−
431− // MARK: - Footer
432254
433− private var footerSection: some View {
434− VStack(spacing: 8) {
435− // Launch at login
436− HStack {
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)
441− Spacer()
442− Toggle("", isOn: $viewModel.launchAtLogin)
443− .toggleStyle(.switch)
444− .controlSize(.small)
445− .labelsHidden()
446− }
447− .padding(.horizontal, 12)
448− .padding(.vertical, 8)
449− .background(
450− RoundedRectangle(cornerRadius: 10, style: .continuous)
451− .fill(.ultraThinMaterial)
452− )
453−
454− // Links row
455− HStack(spacing: 0) {
456− if let url = URL(string: "https://hyper.charm.land") {
457− Button {
458− NSWorkspace.shared.open(url)
459− } label: {
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))
465− }
466− .foregroundColor(.secondary)
467− }
468− .buttonStyle(.plain)
469− }
470− Spacer()
255+ if viewModel.hasAPIKey {
256+ Text("✓ Saved")
257+ .font(.system(size: 11, weight: .medium, design: .rounded))
258+ .foregroundColor(.green)
259+ } else if let url = URL(string: "https://hyper.charm.land") {
471260 Button {
472− NSApplication.shared.terminate(nil)
261+ NSWorkspace.shared.open(url)
473262 } label: {
474− Text("Quit")
263+ Text("Get your key at hyper.charm.land")
475264 .font(.system(size: 11, weight: .regular, design: .rounded))
476265 .foregroundColor(.secondary)
477266 }
478267 .buttonStyle(.plain)
479− .keyboardShortcut("q")
480268 }
481− .padding(.horizontal, 4)
269+ }
270+ }
482271
483− // Version
484− Text("HyperCredits \(appVersion)")
485− .font(.system(size: 10, weight: .regular, design: .rounded))
486− .foregroundStyle(.tertiary)
272+ // MARK: - Footer
273+
274+ /// Launch at Login toggle on the left, Quit on the right. Very subtle.
275+ private var footerRow: some View {
276+ HStack {
277+ Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
278+ .toggleStyle(.switch)
279+ .controlSize(.small)
280+ .font(.system(size: 12, weight: .regular, design: .rounded))
281+
282+ Spacer()
283+
284+ Button {
285+ NSApplication.shared.terminate(nil)
286+ } label: {
287+ Text("Quit")
288+ .font(.system(size: 12, weight: .regular, design: .rounded))
289+ .foregroundColor(.secondary)
290+ }
291+ .buttonStyle(.plain)
292+ .keyboardShortcut("q")
487293 }
488294 }
489295
490− /// Reads the marketing version + build from the main bundle.
296+ // MARK: - Version
297+
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)
303+ .padding(.top, 10)
304+ }
305+
306+ /// Reads the marketing version from the main bundle.
491307 private var appVersion: String {
492− let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
493− let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
494− return "v\(v).\(b)"
308+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
495309 }
496310}