@@ -4,15 +4,21 @@ import SwiftUI
44struct MenuView: View {
55 @ObservedObject var viewModel: ViewModel
66
7+ /// Dims the balance while the pointer is over it, hinting that it is clickable.
8+ @State private var isHoveringBalance = false
9+
710 private static let websiteURL = URL(string: "https://hyper.charm.land")!
811 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
912
1013 // MARK: - Type Scale
1114
12− /// One rounded family, sized so that every provider's headline number reads at the
13− /// same weight. With three providers in the popover, the 26pt subhero is the right
14− /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15− /// numbers from jittering as their digits change.
15+ /// One rounded family, sized so that the balance is the only large element and
16+ /// everything else steps down from it — nothing competes with it for attention.
17+ /// The Claude percentage is the one exception, and it is deliberately set well
18+ /// below the balance so it reads as the second thing on the page, not the first.
19+ /// Monospaced digits keep numbers from jittering as their digits change.
20+ private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
21+ .monospacedDigit()
1622 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
1723 .monospacedDigit()
1824 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
@@ -22,9 +28,7 @@ struct MenuView: View {
2228
2329 var body: some View {
2430 VStack(alignment: .leading, spacing: 18) {
25− if viewModel.hyperConfigured {
26− hyper
27− }
31+ hyper
2832 if showsClaude {
2933 serviceBreak
3034 claude
@@ -47,14 +51,10 @@ struct MenuView: View {
4751 .background(Color(nsColor: .windowBackgroundColor))
4852 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
4953 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
50− .animation(.easeInOut(duration: 0.2), value: viewModel.copiedClaudeConfirmation)
51− .animation(.easeInOut(duration: 0.2), value: viewModel.copiedZaiConfirmation)
5254 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
5355 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
5456 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
55− .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56− .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57− .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
57+ .animation(.easeInOut(duration: 0.3), value: historyValues.count)
5858 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
5959 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
6060 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
@@ -66,14 +66,15 @@ struct MenuView: View {
6666 Divider().opacity(0.3)
6767 }
6868
69− /// The rule between services. Wider than the others on purpose: the providers are
70− /// unrelated accounts, and the gap should say so before the headers do.
69+ /// The rule between the two services. Wider than the others on purpose: Hyper and
70+ /// Claude are two unrelated accounts, and the gap should say so before the headers do.
7171 private var serviceBreak: some View {
7272 hairline.padding(.vertical, 5)
7373 }
7474
7575 /// A section header: an icon and a title, sized to match the "API Key" header below
76− /// so the popover reads as three equal sections rather than a hero with appendages.
76+ /// so the popover reads as three sections rather than a hero with two appendices.
77+ /// The icon is what tells the two services apart at a glance.
7778 private func sectionHeader(_ title: String, systemImage: String) -> some View {
7879 HStack(spacing: 5) {
7980 Image(systemName: systemImage)
@@ -84,62 +85,113 @@ struct MenuView: View {
8485 }
8586 }
8687
87− /// A reusable headline number row: the provider's main metric at subhero scale,
88− /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
89− /// copy-confirmation slot so the layout doesn't shift.
90− private func headlineNumber(
91− text: String,
92− color: Color,
93− trend: MetricTrend,
94− copiedConfirmation: Bool,
95− captionText: String,
96− copyAction: @escaping () -> Void
97− ) -> some View {
98− VStack(spacing: 6) {
99− Button(action: copyAction) {
100− HStack(alignment: .firstTextBaseline, spacing: 6) {
101− Text(text)
102− .font(Self.subheroFont)
103− .foregroundColor(color)
104− .contentTransition(.opacity)
88+ // MARK: - Hyper
10589
106− if let symbol = trend.symbolName {
107− Image(systemName: symbol)
108− .font(.system(size: 12, weight: .semibold))
109− .foregroundColor(.secondary)
110− .transition(.opacity)
111− }
90+ /// The Hyper section: its header, then the balance as the popover's hero.
91+ private var hyper: some View {
92+ VStack(alignment: .leading, spacing: 12) {
93+ sectionHeader("Hyper Credits", systemImage: "bolt.fill")
94+
95+ hero
96+ }
97+ }
98+
99+ private var hero: some View {
100+ VStack(spacing: 8) {
101+ if viewModel.isLoading && viewModel.balance == nil {
102+ PulsingBolt()
103+ .transition(.opacity)
104+ } else if viewModel.balance == nil {
105+ VStack(spacing: 6) {
106+ Text("⚡?")
107+ .font(Self.heroFont)
108+ .foregroundColor(.secondary)
109+
110+ Text("Enter your API key below")
111+ .font(Self.captionFont)
112+ .foregroundColor(.secondary)
113+ }
114+ .transition(.opacity)
115+ } else {
116+ VStack(spacing: 6) {
117+ balanceNumber
118+ caption
119+ sparkline
112120 }
113− .contentShape(Rectangle())
121+ .transition(.opacity)
114122 }
115− .buttonStyle(.plain)
116− .help("Click to copy")
117−
118− // Caption doubles as copy confirmation so the layout doesn't shift.
119− Group {
120− if copiedConfirmation {
121− Text("✓ Copied")
122− .foregroundColor(.green)
123− } else {
124− Text(captionText)
123+
124+ if let lastUpdatedText = viewModel.lastUpdatedText {
125+ Text(lastUpdatedText)
126+ .font(Self.footnoteFont)
127+ .foregroundColor(.secondary)
128+ .transition(.opacity)
129+ }
130+
131+ if let error = viewModel.errorMessage {
132+ errorRow(error)
133+ .transition(.opacity)
134+ }
135+ }
136+ .frame(maxWidth: .infinity)
137+ }
138+
139+ /// The balance itself. Clicking it copies the raw number to the clipboard.
140+ private var balanceNumber: some View {
141+ Button(action: { viewModel.copyBalanceToClipboard() }) {
142+ HStack(alignment: .firstTextBaseline, spacing: 6) {
143+ Text(viewModel.formattedBalance)
144+ .font(Self.heroFont)
145+ .foregroundColor(viewModel.balanceColor)
146+ .contentTransition(.opacity)
147+
148+ if let symbol = viewModel.balanceTrend.symbolName {
149+ Image(systemName: symbol)
150+ .font(.system(size: 12, weight: .semibold))
125151 .foregroundColor(.secondary)
152+ .transition(.opacity)
126153 }
127154 }
128− .font(Self.captionFont)
155+ // Without this the hover area is only the glyphs themselves.
156+ .contentShape(Rectangle())
157+ }
158+ .buttonStyle(.plain)
159+ .disabled(viewModel.balance == nil)
160+ .help("Click to copy")
161+ .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
162+ .onHover { isHoveringBalance = $0 }
163+ .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
164+ }
165+
166+ /// Doubles as the copy confirmation so the layout doesn't shift.
167+ private var caption: some View {
168+ Group {
169+ if viewModel.copiedConfirmation {
170+ Text("✓ Copied")
171+ .foregroundColor(.green)
172+ } else {
173+ Text("credits")
174+ .foregroundColor(.secondary)
175+ }
129176 }
177+ .font(Self.captionFont)
130178 }
131179
132− /// A sparkline shown when there are at least 2 history points.
133180 @ViewBuilder
134− private func sparkline(values: [Int], color: Color) -> some View {
135− if values.count >= 2 {
136− Sparkline(values: values, color: color)
181+ private var sparkline: some View {
182+ if historyValues.count >= 2 {
183+ Sparkline(values: historyValues, color: viewModel.balanceColor)
137184 .frame(height: 26)
138185 .padding(.top, 4)
139186 .transition(.opacity)
140187 }
141188 }
142189
190+ /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
191+ private var historyValues: [Int] {
192+ viewModel.balanceHistory.map { $0.balance }
193+ }
194+
143195 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144196 /// message, and a tinted rounded background with no border.
145197 private func errorRow(_ message: String) -> some View {
@@ -164,56 +216,6 @@ struct MenuView: View {
164216 .padding(.top, 2)
165217 }
166218
167− // MARK: - Hyper
168−
169− /// Whether there is anything to say about Hyper. Nothing at all when Hyper isn't
170− /// configured — the section doesn't appear, same as Claude and z.ai.
171− private var showsHyper: Bool {
172− viewModel.hyperConfigured
173− }
174−
175− /// The Hyper section: header, balance as a subhero number, caption, sparkline.
176− private var hyper: some View {
177− VStack(alignment: .leading, spacing: 12) {
178− sectionHeader("Hyper Credits", systemImage: "bolt.fill")
179−
180− if viewModel.isLoading && viewModel.balance == nil {
181− Text("…")
182− .font(Self.subheroFont)
183− .foregroundColor(.secondary)
184− } else if let balance = viewModel.balance {
185− VStack(spacing: 8) {
186− headlineNumber(
187− text: "⚡ \(viewModel.formattedBalance)",
188− color: viewModel.balanceColor,
189− trend: viewModel.balanceTrend,
190− copiedConfirmation: viewModel.copiedConfirmation,
191− captionText: "credits",
192− copyAction: { viewModel.copyBalanceToClipboard() }
193− )
194−
195− sparkline(
196− values: viewModel.balanceSparklineValues,
197− color: viewModel.balanceColor
198− )
199− }
200− .transition(.opacity)
201− }
202−
203− if let lastUpdatedText = viewModel.lastUpdatedText {
204− Text(lastUpdatedText)
205− .font(Self.footnoteFont)
206− .foregroundColor(.secondary)
207− .transition(.opacity)
208− }
209−
210− if let error = viewModel.errorMessage {
211− errorRow(error)
212− .transition(.opacity)
213− }
214− }
215− }
216−
217219 // MARK: - Claude
218220
219221 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
@@ -225,8 +227,8 @@ struct MenuView: View {
225227 return false
226228 }
227229
228− /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229− /// headline number, bars under every window, a sparkline, and error row.
230+ /// Claude Code's plan limits. The percentage leads, as the balance does above it,
231+ /// with the individual windows underneath as quiet supporting detail.
230232 private var claude: some View {
231233 VStack(alignment: .leading, spacing: 12) {
232234 HStack(spacing: 8) {
@@ -253,31 +255,23 @@ struct MenuView: View {
253255
254256 private func usageDetail(_ usage: ClaudeUsage) -> some View {
255257 VStack(alignment: .leading, spacing: 12) {
256− // The 5-hour window leads as the headline number.
258+ // The 5-hour window leads as the hero number, since it's the one
259+ // most likely to bind and the one shown in the menu bar title.
257260 if let fiveHour = usage.fiveHour {
258261 let percent = Int(fiveHour.utilization.rounded())
259− VStack(spacing: 8) {
260− HStack(alignment: .firstTextBaseline, spacing: 8) {
261− headlineNumber(
262− text: "⁕ \\(percent)%",
263− color: Self.usageColor(percent),
264− trend: viewModel.claudeTrend,
265− copiedConfirmation: viewModel.copiedClaudeConfirmation,
266− captionText: "5-hour usage",
267− copyAction: { viewModel.copyClaudePercentToClipboard() }
268− )
262+ HStack(alignment: .firstTextBaseline, spacing: 8) {
263+ Text("🕐 \(percent)%")
264+ .font(Self.subheroFont)
265+ .foregroundColor(Self.usageColor(percent))
266+ .contentTransition(.opacity)
269267
270− Spacer(minLength: 0)
268+ Spacer(minLength: 0)
271269
272− if let resets = fiveHour.resetsInFormatted {
273− Text("resets in \(resets)")
274− .font(Self.footnoteFont)
275− .foregroundColor(.secondary)
276− }
270+ if let resets = fiveHour.resetsInFormatted {
271+ Text("resets in \(resets)")
272+ .font(Self.footnoteFont)
273+ .foregroundColor(.secondary)
277274 }
278−
279− // Bar under the 5-hour window too, same as 7-day windows below.
280− UsageBar(percent: percent, color: Self.usageColor(percent))
281275 }
282276 }
283277
@@ -291,12 +285,6 @@ struct MenuView: View {
291285 window("7-day Opus", opus)
292286 }
293287 }
294−
295− // Sparkline for the Claude 5-hour % history.
296− sparkline(
297− values: viewModel.claudeSparklineValues,
298− color: Self.usageColor(claudeFiveHourPercent ?? 0)
299− )
300288 }
301289 }
302290
@@ -322,11 +310,6 @@ struct MenuView: View {
322310 }
323311 }
324312
325− /// The current Claude 5-hour percentage, or 0 if absent. Used for sparkline color.
326− private var claudeFiveHourPercent: Int? {
327− viewModel.claudeFiveHourPercent
328− }
329−
330313 /// The balance colors read the other way round here, because for a limit more is
331314 /// worse: green with room to spare, yellow closing in, red nearly spent.
332315 private static func usageColor(_ percent: Int) -> Color {
@@ -346,8 +329,8 @@ struct MenuView: View {
346329 return false
347330 }
348331
349− /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350− /// a subhero headline number, bars under every window, a sparkline, and error row.
332+ /// z.ai Coding Plan's quota limits. Same visual structure as the Claude section:
333+ /// the 5-hour percentage leads as a subhero, the weekly quota sits underneath.
351334 private var zai: some View {
352335 VStack(alignment: .leading, spacing: 12) {
353336 HStack(spacing: 8) {
@@ -374,30 +357,21 @@ struct MenuView: View {
374357
375358 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376359 VStack(alignment: .leading, spacing: 12) {
377− // The 5-hour window leads as the headline number.
360+ // The 5-hour window leads as the hero number, same as Claude's.
378361 if let fiveHour = usage.fiveHourPercent {
379− VStack(spacing: 8) {
380− HStack(alignment: .firstTextBaseline, spacing: 8) {
381− headlineNumber(
382− text: "Z \\(fiveHour)%",
383− color: Self.usageColor(fiveHour),
384− trend: viewModel.zaiTrend,
385− copiedConfirmation: viewModel.copiedZaiConfirmation,
386− captionText: "5-hour usage",
387− copyAction: { viewModel.copyZaiPercentToClipboard() }
388− )
362+ HStack(alignment: .firstTextBaseline, spacing: 8) {
363+ Text("🤖 \(fiveHour)%")
364+ .font(Self.subheroFont)
365+ .foregroundColor(Self.usageColor(fiveHour))
366+ .contentTransition(.opacity)
389367
390− Spacer(minLength: 0)
368+ Spacer(minLength: 0)
391369
392− if let resets = usage.fiveHourResetsAt {
393− Text("resets in \(Self.zaiResetsIn(from: resets))")
394− .font(Self.footnoteFont)
395− .foregroundColor(.secondary)
396− }
370+ if let resets = usage.fiveHourResetsAt {
371+ Text("resets in \(Self.zaiResetsIn(from: resets))")
372+ .font(Self.footnoteFont)
373+ .foregroundColor(.secondary)
397374 }
398−
399− // Bar under the 5-hour window too.
400− UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
401375 }
402376 }
403377
@@ -406,35 +380,9 @@ struct MenuView: View {
406380 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
407381 }
408382 }
409−
410− // Peak hours notice.
411− zaiPeakHours
412−
413− // Sparkline for the z.ai 5-hour % history, with an orange tint
414− // when currently in peak hours.
415− if viewModel.zaiSparklineValues.count >= 2 {
416− Sparkline(
417− values: viewModel.zaiSparklineValues,
418− color: Self.usageColor(zaiFiveHourPercent ?? 0)
419− )
420− .frame(height: 26)
421− .padding(.top, 4)
422− .transition(.opacity)
423− .background(
424− ViewModel.zaiInPeakHours
425− ? RoundedRectangle(cornerRadius: 4, style: .continuous)
426− .fill(Color.orange.opacity(0.08))
427− : nil
428− )
429− }
430383 }
431384 }
432385
433− /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
434− private var zaiFiveHourPercent: Int? {
435− viewModel.zaiFiveHourPercent
436− }
437−
438386 /// One window row for z.ai: label, percentage, and a hairline bar.
439387 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
440388 VStack(alignment: .leading, spacing: 5) {
@@ -454,42 +402,6 @@ struct MenuView: View {
454402 }
455403 }
456404
457− /// z.ai peak hours: 14:00–18:00 CST (UTC+8), i.e. 06:00–10:00 UTC.
458− /// Limits are most likely to bind during this window. Displayed in the
459− /// user's local timezone so they know when to expect slowdowns.
460− private var zaiPeakHours: some View {
461− let inPeak = ViewModel.zaiInPeakHours
462−
463− return HStack(spacing: 6) {
464− Circle()
465− .fill(inPeak ? Color.orange : Color.secondary.opacity(0.4))
466− .frame(width: 6, height: 6)
467−
468− Text(inPeak ? "Peak hours now" : "Peak \(Self.zaiPeakHoursLocal)")
469− .font(Self.footnoteFont)
470− .foregroundColor(inPeak ? .orange : .secondary)
471− }
472− }
473−
474− /// z.ai peak hours converted to the user's local timezone, e.g. "09:00–13:00".
475− private static var zaiPeakHoursLocal: String {
476− let formatter = DateFormatter()
477− formatter.timeZone = TimeZone.current
478− formatter.dateFormat = "HH:mm"
479−
480− var utc = DateComponents()
481− utc.timeZone = TimeZone(identifier: "UTC")
482− utc.hour = 6
483− var calendar = Calendar(identifier: .gregorian)
484− calendar.timeZone = TimeZone(identifier: "UTC")!
485− let start = calendar.date(from: utc)!
486−
487− utc.hour = 10
488− let end = calendar.date(from: utc)!
489−
490− return "\(formatter.string(from: start))–\(formatter.string(from: end))"
491− }
492−
493405 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
494406 private static func zaiResetsIn(from date: Date) -> String {
495407 let interval = date.timeIntervalSince(Date())
@@ -611,10 +523,45 @@ struct MenuView: View {
611523 .keyboardShortcut("q")
612524 }
613525 .controlSize(.small)
526+
527+ Link("hyper.charm.land", destination: Self.websiteURL)
528+ .font(Self.footnoteFont)
529+ .foregroundColor(.secondary)
530+ .opacity(0.7)
531+ .frame(maxWidth: .infinity, alignment: .center)
614532 }
615533 }
616534}
617535
536+// MARK: - Loading
537+
538+/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
539+/// the menu bar title rather than a generic spinner, so the two read as the same app.
540+private struct PulsingBolt: View {
541+ @State private var isPulsing = false
542+
543+ var body: some View {
544+ VStack(spacing: 6) {
545+ Text("⚡")
546+ .font(.system(size: 34))
547+ .opacity(isPulsing ? 0.35 : 1)
548+ .scaleEffect(isPulsing ? 0.94 : 1)
549+ .animation(
550+ .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
551+ value: isPulsing
552+ )
553+ // Roughly the height of the balance it stands in for, so the hero
554+ // doesn't jump when the first fetch lands.
555+ .frame(height: 56)
556+
557+ Text("Loading…")
558+ .font(.system(size: 11, weight: .medium, design: .rounded))
559+ .foregroundColor(.secondary)
560+ }
561+ .onAppear { isPulsing = true }
562+ }
563+}
564+
618565// MARK: - Usage Bar
619566
620567/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
@@ -645,8 +592,8 @@ private struct UsageBar: View {
645592
646593// MARK: - Sparkline
647594
648−/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
649−/// takes the metric's color so the trend reads as part of the number above it, with a
595+/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
596+/// takes the balance's color so the trend reads as part of the number above it, with a
650597/// faint fade underneath for weight and a dot marking the latest point.
651598///
652599/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
@@ -738,4 +685,4 @@ struct Sparkline: View {
738685 path.closeSubpath()
739686 }
740687 }
741−}
688+}