on master help sign in
feat: delightful UI/UX redesign

- Hero balance ring (BalanceRingView) with gradient arc + glow - Card-based layout with .regularMaterial backgrounds and shadows - Radial gradient background tinted by balance color - Animated numeric transitions on balance changes - Relative 'Xm ago' last-updated timestamp - Rotating refresh icon while loading - Onboarding card with CTA when no API key set - Collapsible API key section (DisclosureGroup) with saved indicator - Error card with retry button - Compact footer: launch-at-login, subtle links, version - Popover entrance animation (fade + slide) - Status bar: SF Symbol bolt.fill tinted by balance color - Loading pulse animation on status bar icon

commitbd186b18bf09508cd792565cf1620cbe1a6194e7 authoriko <mail@iko.soy> dateThu 9 Jul 2026, 14:47 (3 months ago) parent5610f88
3 files changed+520−59
HyperCreditsMenubar/BalanceRingView.swift+53 HyperCreditsMenubar/HyperCreditsApp.swift+94−4 HyperCreditsMenubar/MenuView.swift+373−55
HyperCreditsMenubar/BalanceRingView.swiftnew file+53
@@ -0,0 +1,53 @@
1+import SwiftUI
2+
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.
7+///
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.
11+struct BalanceRingView: View {
12+ let balance: Int?
13+ 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.
16+ var referenceMax: Int = 500
17+ /// True while a fetch is in flight — dims the ring slightly.
18+ var isLoading: Bool = false
19+
20+ /// Progress in the range 0...1.
21+ private var progress: Double {
22+ guard let balance, balance > 0 else { return 0 }
23+ return min(Double(balance) / Double(referenceMax), 1.0)
24+ }
25+
26+ var body: some View {
27+ ZStack {
28+ // Track
29+ Circle()
30+ .stroke(color.opacity(0.15), lineWidth: ringWidth)
31+
32+ // Progress arc
33+ Circle()
34+ .trim(from: 0, to: progress)
35+ .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)
43+ )
44+ .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)
47+ }
48+ .animation(.easeInOut(duration: 0.6), value: progress)
49+ .animation(.easeInOut(duration: 0.25), value: isLoading)
50+ }
51+
52+ private let ringWidth: CGFloat = 9
53+}
HyperCreditsMenubar/HyperCreditsApp.swift+94−4
@@ -1,5 +1,6 @@
11import SwiftUI
22import Combine
3+import AppKit
34
45/// Main app delegate that manages the menu bar item, the refresh timer,
56/// and the SwiftUI popover hosting `MenuView`.
@@ -11,8 +12,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
1112 /// The shared view model. Shared between the menu bar and the popover.
1213 let viewModel = ViewModel()
1314
14− /// Combine cancellable for observing balance changes.
15+ /// Combine cancellables for observing balance / loading / colour changes.
1516 private var statusTextCancellable: AnyCancellable?
17+ private var statusColorCancellable: AnyCancellable?
18+ private var loadingCancellable: AnyCancellable?
19+ /// Drives the subtle "pulse" of the menu bar icon while loading.
20+ private var pulseTimer: Timer?
21+ private var pulseStep: Int = 0
1622
1723 private let refreshInterval: TimeInterval = 5 * 60 // 5 minutes
1824
@@ -23,8 +29,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
2329 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
2430
2531 if let button = statusItem.button {
26− button.image = nil
27− button.title = "⚡?"
32+ button.image = statusBarIcon(color: .secondary, opacity: 1.0)
33+ button.image?.isTemplate = false
34+ button.title = "?"
2835 button.target = self
2936 button.action = #selector(togglePopover(_:))
3037 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
@@ -41,7 +48,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4148 .removeDuplicates()
4249 .receive(on: RunLoop.main)
4350 .sink { [weak self] text in
44− self?.statusItem.button?.title = text
51+ // statusBarItemText is "⚡NN"; we split off the leading ⚡ and
52+ // keep only the number/`?` as the title, rendering the bolt as
53+ // a tinted SF Symbol image.
54+ let stripped = text.hasPrefix("⚡") ? String(text.dropFirst()) : text
55+ self?.statusItem.button?.title = stripped
56+ }
57+
58+ // Observe balance to tint the status bar icon by balance colour.
59+ statusColorCancellable = viewModel.$balance
60+ .map { [weak viewModel] _ in viewModel?.balanceColor ?? .secondary }
61+ .removeDuplicates()
62+ .receive(on: RunLoop.main)
63+ .sink { [weak self] color in
64+ guard let self else { return }
65+ self.statusItem.button?.image = self.statusBarIcon(color: color, opacity: 1.0)
66+ self.statusItem.button?.image?.isTemplate = false
67+ }
68+
69+ // Observe loading state to start/stop the icon pulse.
70+ loadingCancellable = viewModel.$isLoading
71+ .removeDuplicates()
72+ .receive(on: RunLoop.main)
73+ .sink { [weak self] isLoading in
74+ if isLoading {
75+ self?.startPulse()
76+ } else {
77+ self?.stopPulse()
78+ }
4579 }
4680
4781 // Initial fetch
@@ -58,6 +92,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5892 func applicationWillTerminate(_ notification: Notification) {
5993 refreshTimer?.invalidate()
6094 statusTextCancellable?.cancel()
95+ statusColorCancellable?.cancel()
96+ loadingCancellable?.cancel()
97+ stopPulse()
98+ }
99+
100+ // MARK: - Status bar icon
101+
102+ /// Renders `bolt.fill` as an NSImage tinted with the balance colour.
103+ /// The number is kept as the button `title`; this image is the icon only.
104+ private func statusBarIcon(color: Color, opacity: Double) -> NSImage? {
105+ guard let baseImage = NSImage(
106+ systemSymbolName: "bolt.fill",
107+ accessibilityDescription: "Hyper credits"
108+ ) else { return nil }
109+
110+ // Lock a consistent point-size so the tint rasterisation is crisp.
111+ let targetSize = NSSize(width: 16, height: 16)
112+ let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
113+ let symbol = baseImage.withSymbolConfiguration(config) ?? baseImage
114+
115+ // Tint by filling with colour and compositing the symbol with sourceIn.
116+ let tinted = NSImage(size: targetSize, flipped: false) { [nsColor = NSColor(color).withAlphaComponent(opacity)] rect in
117+ nsColor.setFill()
118+ rect.fill()
119+ symbol.draw(in: rect, from: .zero, operation: .sourceIn, fraction: 1.0)
120+ return true
121+ }
122+ tinted.isTemplate = false
123+ return tinted
124+ }
125+
126+ // MARK: - Loading pulse
127+
128+ private func startPulse() {
129+ pulseStep = 0
130+ pulseTimer?.invalidate()
131+ let timer = Timer(timeInterval: 0.12, repeats: true) { [weak self] _ in
132+ guard let self else { return }
133+ self.pulseStep += 1
134+ let wave = sin(Double(self.pulseStep) * 0.5)
135+ let opacity = 0.35 + 0.65 * (0.5 + 0.5 * wave)
136+ self.statusItem.button?.image = self.statusBarIcon(
137+ color: self.viewModel.balanceColor,
138+ opacity: opacity
139+ )
140+ self.statusItem.button?.image?.isTemplate = false
141+ }
142+ RunLoop.main.add(timer, forMode: .common)
143+ pulseTimer = timer
144+ }
145+
146+ private func stopPulse() {
147+ pulseTimer?.invalidate()
148+ pulseTimer = nil
149+ statusItem.button?.image = statusBarIcon(color: viewModel.balanceColor, opacity: 1.0)
150+ statusItem.button?.image?.isTemplate = false
61151 }
62152
63153 // MARK: - Popover
HyperCreditsMenubar/MenuView.swift+373−55
@@ -24,15 +24,28 @@ final class ViewModel: ObservableObject {
2424 }
2525 }
2626
27+ /// Timestamp of the last successful balance fetch, for relative "x ago" display.
28+ @Published var lastUpdated: Date?
29+
30+ /// Whether an API key is currently stored in the Keychain.
31+ @Published var hasAPIKey: Bool
32+
2733 private let checker = CreditsChecker()
2834
2935 init() {
30− apiKeyInput = KeychainHelper.load() ?? ""
36+ let storedKey = KeychainHelper.load()
37+ apiKeyInput = storedKey ?? ""
38+ hasAPIKey = (storedKey ?? "").isEmpty == false
3139 launchAtLogin = SMAppService.mainApp.status == .enabled
3240 }
3341
3442 // MARK: - Balance
3543
44+ /// `true` when there is no stored API key and no balance yet (onboarding state).
45+ var needsOnboarding: Bool {
46+ !hasAPIKey && balance == nil && errorMessage == nil
47+ }
48+
3649 /// The text to show in the menu bar: `⚡{balance}` or `⚡?`.
3750 var statusBarItemText: String {
3851 if let balance = balance {
@@ -49,12 +62,21 @@ final class ViewModel: ObservableObject {
4962 return .red
5063 }
5164
65+ /// A human-readable relative time for the last update, e.g. "2m ago".
66+ var relativeUpdateText: String? {
67+ guard let lastUpdated else { return nil }
68+ let formatter = RelativeDateTimeFormatter()
69+ formatter.unitsStyle = .abbreviated
70+ return formatter.localizedString(for: lastUpdated, relativeTo: Date())
71+ }
72+
5273 /// Refreshes the balance from the API. No-op if no API key is set.
5374 func refresh() {
5475 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
5576 guard !key.isEmpty else {
5677 balance = nil
5778 errorMessage = nil
79+ lastUpdated = nil
5880 return
5981 }
6082
@@ -64,9 +86,11 @@ final class ViewModel: ObservableObject {
6486 do {
6587 let result = try await checker.fetchBalance(apiKey: key)
6688 balance = result
89+ lastUpdated = Date()
6790 } catch {
6891 balance = nil
6992 errorMessage = error.localizedDescription
93+ lastUpdated = nil
7094 }
7195 isLoading = false
7296 }
@@ -79,10 +103,13 @@ final class ViewModel: ObservableObject {
79103 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
80104 if key.isEmpty {
81105 KeychainHelper.delete()
106+ hasAPIKey = false
82107 balance = nil
83108 errorMessage = nil
109+ lastUpdated = nil
84110 } else {
85111 KeychainHelper.save(key)
112+ hasAPIKey = true
86113 refresh()
87114 }
88115 }
@@ -101,88 +128,379 @@ final class ViewModel: ObservableObject {
101128// MARK: - MenuView
102129
103130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
131+///
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.
104136struct MenuView: View {
105137 @ObservedObject var viewModel: ViewModel
106138
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
143+
144+ private let hyperAccent = Color(
145+ red: 0.906, green: 0.345, blue: 0.078, opacity: 1.0
146+ )
147+
107148 var body: some View {
108− VStack(spacing: 16) {
109− // Balance display
110− VStack(spacing: 4) {
111− if viewModel.isLoading && viewModel.balance == nil {
112− ProgressView()
113− .scaleEffect(0.8)
114− Text("Loading…")
115− .font(.caption)
116− .foregroundColor(.secondary)
117− } else if let balance = viewModel.balance {
118− Text("\(balance)")
119− .font(.system(size: 42, weight: .bold, design: .rounded))
120− .foregroundColor(viewModel.balanceColor)
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)
165+ }
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)
195+ }
196+
197+ // MARK: - Hero
198+
199+ 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(.numericTransition(for: Double(balance)))
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+
121230 Text("credits")
122− .font(.caption)
231+ .font(.system(size: 11, weight: .medium, design: .rounded))
123232 .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…")
124246 } else {
125− Text("?")
126− .font(.system(size: 42, weight: .bold, design: .rounded))
127− .foregroundColor(.secondary)
128− Text("credits")
129− .font(.caption)
130− .foregroundColor(.secondary)
247+ Text("Not yet updated")
131248 }
132249 }
250+ .font(.system(size: 11, weight: .regular, design: .rounded))
251+ .foregroundStyle(.tertiary)
133252
134− if let error = viewModel.errorMessage {
135− Text(error)
136− .font(.caption)
137− .foregroundColor(.red)
138− .multilineTextAlignment(.center)
139− .padding(.horizontal)
253+ refreshButton
254+ }
255+ .padding(16)
256+ .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)
266+ }
267+
268+ // MARK: - Refresh button
269+
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))
140284 }
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+ }
141296
142− Divider()
297+ // MARK: - Onboarding
143298
144− // Refresh button
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+ }
313+ }
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)
328+ }
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+ )
339+ }
340+
341+ // MARK: - Error
342+
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+ }
145356 Button(action: { viewModel.refresh() }) {
146− Label("Refresh now", systemImage: "arrow.clockwise")
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))
147364 }
148− .disabled(viewModel.isLoading)
149−
150− // API key section
151− VStack(alignment: .leading, spacing: 6) {
152− Text("API Key")
153− .font(.headline)
154− HStack {
155− SecureField("sk-...", text: $viewModel.apiKeyInput)
365+ .buttonStyle(.plain)
366+ }
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+ )
377+ }
378+
379+ // MARK: - API key section
380+
381+ 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)
156390 .textFieldStyle(.roundedBorder)
157− Button("Save") {
158− viewModel.saveAPIKey()
159− }
391+ .font(.system(size: 13, design: .monospaced))
392+ }
393+ Button(action: { viewModel.saveAPIKey() }) {
394+ Text("Save API Key")
395+ .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)
403+ }
404+ .buttonStyle(.plain)
405+ .transition(.opacity)
406+ }
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)
160420 }
161421 }
422+ }
423+ .accentColor(hyperAccent)
424+ .padding(12)
425+ .background(
426+ RoundedRectangle(cornerRadius: 12, style: .continuous)
427+ .fill(.ultraThinMaterial)
428+ )
429+ }
162430
163− // Launch at login toggle
164− Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
165− .toggleStyle(.switch)
431+ // MARK: - Footer
166432
167− // Links
433+ private var footerSection: some View {
434+ VStack(spacing: 8) {
435+ // Launch at login
168436 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) {
169456 if let url = URL(string: "https://hyper.charm.land") {
170− Button("Open hyper.charm.land") {
457+ Button {
171458 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)
172467 }
468+ .buttonStyle(.plain)
173469 }
174470 Spacer()
471+ Button {
472+ NSApplication.shared.terminate(nil)
473+ } label: {
474+ Text("Quit")
475+ .font(.system(size: 11, weight: .regular, design: .rounded))
476+ .foregroundColor(.secondary)
477+ }
478+ .buttonStyle(.plain)
479+ .keyboardShortcut("q")
175480 }
481+ .padding(.horizontal, 4)
176482
177− Divider()
178−
179− // Quit
180− Button("Quit HyperCredits") {
181− NSApplication.shared.terminate(nil)
182− }
183− .keyboardShortcut("q")
483+ // Version
484+ Text("HyperCredits \(appVersion)")
485+ .font(.system(size: 10, weight: .regular, design: .rounded))
486+ .foregroundStyle(.tertiary)
184487 }
185− .padding(16)
186− .frame(width: 280)
488+ }
489+
490+ /// Reads the marketing version + build from the main bundle.
491+ 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)"
495+ }
496+}
497+
498+// MARK: - Numeric content transition helper
499+
500+private extension ContentTransition {
501+ /// Returns a numeric content transition suitable for animating balance changes.
502+ /// Falls back gracefully across OS versions.
503+ static func numericTransition(for value: Double) -> ContentTransition {
504+ .numericValue(value: value)
187505 }
188506}