on master help sign in
feat: polish popover UI

- Monospaced digits on balance number (no jitter on update) - Sparkline takes balance color, gradient fill under line, dot on latest point - Loading state: pulsing ⚡ instead of generic ProgressView spinner - Error state: icon + text on subtle red rounded background - Lighter dividers (0.3 opacity), more generous spacing - Type scale: all rounded design, deliberate sizes - Hover state on balance number (dims to 0.65 opacity) - Content transitions (.opacity) on balance, trend, sparkline, error - Small control sizes throughout for 280pt popover - "✓ Saved" replaces "Get key" link inline (no popover resize) - Subtle windowBackgroundColor (no material, no gradient) - Footer: more breathing room, hyper.charm.land link smaller/subtler

commita1a8060497b3ccbfd4e1574ec04fddb0d405c42c authoriko <mail@iko.soy> dateSat 11 Jul 2026, 17:37 (3 months ago) parentf3f0f41
1 file changed+231−79
HyperCreditsMenubar/MenuView.swift+231−79
HyperCreditsMenubar/MenuView.swift+231−79
@@ -4,49 +4,77 @@ 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
12+ // MARK: - Type Scale
13+
14+ /// One rounded family at four sizes. The balance is the only large element and
15+ /// everything else steps down from it, so nothing competes with it for attention.
16+ /// Monospaced digits keep the balance from jittering as its digits change.
17+ private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
18+ .monospacedDigit()
19+ private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
20+ private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
21+ private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
22+ private static let footnoteFont = Font.system(size: 10, weight: .regular, design: .rounded)
23+
924 var body: some View {
1025 VStack(alignment: .leading, spacing: 18) {
1126 hero
27+ hairline
1228 settings
29+ hairline
1330 footer
1431 }
15− .padding(16)
32+ .padding(.horizontal, 18)
33+ .padding(.top, 18)
34+ .padding(.bottom, 14)
1635 .frame(width: 280)
36+ // Set as the environment font so the controls inherit the rounded design too.
37+ .font(Self.controlFont)
38+ .background(Color(nsColor: .windowBackgroundColor))
1739 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
1840 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
41+ .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
42+ .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
43+ .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
44+ .animation(.easeInOut(duration: 0.3), value: historyValues.count)
45+ }
46+
47+ /// A section rule light enough to read as a pause rather than a border.
48+ private var hairline: some View {
49+ Divider().opacity(0.3)
1950 }
2051
2152 // MARK: - Hero
2253
2354 private var hero: some View {
24− VStack(spacing: 6) {
55+ VStack(spacing: 8) {
2556 if viewModel.isLoading && viewModel.balance == nil {
26− ProgressView()
27− .scaleEffect(0.8)
28− .frame(height: 50)
29− Text("Loading…")
30− .font(.caption)
31− .foregroundColor(.secondary)
57+ PulsingBolt()
58+ .transition(.opacity)
3259 } else {
33− balanceNumber
34− caption
35− sparkline
60+ VStack(spacing: 6) {
61+ balanceNumber
62+ caption
63+ sparkline
64+ }
65+ .transition(.opacity)
3666 }
3767
3868 if let lastUpdatedText = viewModel.lastUpdatedText {
3969 Text(lastUpdatedText)
40− .font(.caption2)
70+ .font(Self.footnoteFont)
4171 .foregroundColor(.secondary)
72+ .transition(.opacity)
4273 }
4374
4475 if let error = viewModel.errorMessage {
45− Text(error)
46− .font(.caption)
47− .foregroundColor(.red)
48− .multilineTextAlignment(.center)
49− .padding(.top, 2)
76+ errorRow(error)
77+ .transition(.opacity)
5078 }
5179 }
5280 .frame(maxWidth: .infinity)
@@ -57,19 +85,26 @@ struct MenuView: View {
5785 Button(action: { viewModel.copyBalanceToClipboard() }) {
5886 HStack(alignment: .firstTextBaseline, spacing: 6) {
5987 Text(viewModel.formattedBalance)
60− .font(.system(size: 42, weight: .bold, design: .rounded))
88+ .font(Self.heroFont)
6189 .foregroundColor(viewModel.balanceColor)
90+ .contentTransition(.opacity)
6291
6392 if let symbol = viewModel.balanceTrend.symbolName {
6493 Image(systemName: symbol)
6594 .font(.system(size: 12, weight: .semibold))
6695 .foregroundColor(.secondary)
96+ .transition(.opacity)
6797 }
6898 }
99+ // Without this the hover area is only the glyphs themselves.
100+ .contentShape(Rectangle())
69101 }
70102 .buttonStyle(.plain)
71103 .disabled(viewModel.balance == nil)
72104 .help("Click to copy")
105+ .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
106+ .onHover { isHoveringBalance = $0 }
107+ .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
73108 }
74109
75110 /// Doubles as the copy confirmation so the layout doesn't shift.
@@ -83,54 +118,89 @@ struct MenuView: View {
83118 .foregroundColor(.secondary)
84119 }
85120 }
86− .font(.caption)
121+ .font(Self.captionFont)
87122 }
88123
89124 @ViewBuilder
90125 private var sparkline: some View {
91− // Key paths can't index tuple elements, so this stays a closure.
92− let values = viewModel.balanceHistory.map { $0.balance }
93− if values.count >= 2 {
94− Sparkline(values: values)
95− .frame(height: 24)
96− .padding(.top, 2)
126+ if historyValues.count >= 2 {
127+ Sparkline(values: historyValues, color: viewModel.balanceColor)
128+ .frame(height: 26)
129+ .padding(.top, 4)
130+ .transition(.opacity)
131+ }
132+ }
133+
134+ /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
135+ private var historyValues: [Int] {
136+ viewModel.balanceHistory.map { $0.balance }
137+ }
138+
139+ /// Errors read as a quiet inline notice rather than loose red text: an icon, the
140+ /// message, and a tinted rounded background with no border.
141+ private func errorRow(_ message: String) -> some View {
142+ HStack(alignment: .top, spacing: 6) {
143+ Image(systemName: "exclamationmark.triangle.fill")
144+ .font(.system(size: 10, weight: .semibold))
145+
146+ Text(message)
147+ .font(Self.captionFont)
148+ // Long messages wrap instead of being truncated to one line.
149+ .fixedSize(horizontal: false, vertical: true)
150+
151+ Spacer(minLength: 0)
97152 }
153+ .foregroundColor(.red)
154+ .padding(.horizontal, 10)
155+ .padding(.vertical, 7)
156+ .background(
157+ RoundedRectangle(cornerRadius: 8, style: .continuous)
158+ .fill(Color.red.opacity(0.1))
159+ )
160+ .padding(.top, 2)
98161 }
99162
100163 // MARK: - Settings
101164
102165 private var settings: some View {
103− VStack(alignment: .leading, spacing: 10) {
104− Divider()
105−
106− VStack(alignment: .leading, spacing: 6) {
107− HStack {
166+ VStack(alignment: .leading, spacing: 14) {
167+ VStack(alignment: .leading, spacing: 8) {
168+ HStack(spacing: 8) {
108169 Text("API Key")
109− .font(.headline)
110− Spacer()
111− Link("Get key →", destination: Self.websiteURL)
112− .font(.caption)
170+ .font(Self.sectionFont)
171+
172+ Spacer(minLength: 0)
173+
174+ // The confirmation takes the link's place rather than adding a row
175+ // of its own, so saving never resizes the popover.
176+ if viewModel.savedConfirmation {
177+ Text("✓ Saved")
178+ .font(Self.captionFont)
179+ .foregroundColor(.green)
180+ .transition(.opacity)
181+ } else {
182+ Link("Get key →", destination: Self.websiteURL)
183+ .font(Self.captionFont)
184+ .transition(.opacity)
185+ }
113186 }
114187
115− HStack {
116− SecureField("sk-...", text: $viewModel.apiKeyInput)
188+ HStack(spacing: 8) {
189+ SecureField("sk-…", text: $viewModel.apiKeyInput)
117190 .textFieldStyle(.roundedBorder)
191+
118192 Button("Save") {
119193 viewModel.saveAPIKey()
120194 }
121195 }
122−
123− if viewModel.savedConfirmation {
124− Text("✓ Saved")
125− .font(.caption)
126− .foregroundColor(.green)
127− .transition(.opacity)
128− }
196+ .controlSize(.small)
129197 }
130198
131199 HStack {
132200 Text("Refresh every")
133− Spacer()
201+
202+ Spacer(minLength: 0)
203+
134204 Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
135205 ForEach(RefreshInterval.options, id: \.self) { minutes in
136206 Text("\(minutes)m").tag(minutes)
@@ -139,83 +209,165 @@ struct MenuView: View {
139209 .pickerStyle(.menu)
140210 .labelsHidden()
141211 .fixedSize()
212+ .controlSize(.small)
142213 }
143214
144215 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
145216 .toggleStyle(.switch)
217+ .controlSize(.small)
146218 }
147219 }
148220
149221 // MARK: - Footer
150222
151223 private var footer: some View {
152− VStack(alignment: .leading, spacing: 10) {
153− Divider()
154−
224+ VStack(spacing: 14) {
155225 HStack {
156226 Button(action: { viewModel.refresh() }) {
157227 Label("Refresh", systemImage: "arrow.clockwise")
158228 }
159229 .disabled(viewModel.isLoading)
160230
161− Spacer()
231+ Spacer(minLength: 12)
162232
163233 Button("Quit") {
164234 NSApplication.shared.terminate(nil)
165235 }
166236 .keyboardShortcut("q")
167237 }
238+ .controlSize(.small)
168239
169240 Link("hyper.charm.land", destination: Self.websiteURL)
170− .font(.caption2)
241+ .font(Self.footnoteFont)
171242 .foregroundColor(.secondary)
243+ .opacity(0.7)
172244 .frame(maxWidth: .infinity, alignment: .center)
173245 }
174246 }
175247}
176248
249+// MARK: - Loading
250+
251+/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
252+/// the menu bar title rather than a generic spinner, so the two read as the same app.
253+private struct PulsingBolt: View {
254+ @State private var isPulsing = false
255+
256+ var body: some View {
257+ VStack(spacing: 6) {
258+ Text("⚡")
259+ .font(.system(size: 34))
260+ .opacity(isPulsing ? 0.35 : 1)
261+ .scaleEffect(isPulsing ? 0.94 : 1)
262+ .animation(
263+ .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
264+ value: isPulsing
265+ )
266+ // Roughly the height of the balance it stands in for, so the hero
267+ // doesn't jump when the first fetch lands.
268+ .frame(height: 56)
269+
270+ Text("Loading…")
271+ .font(.system(size: 11, weight: .medium, design: .rounded))
272+ .foregroundColor(.secondary)
273+ }
274+ .onAppear { isPulsing = true }
275+ }
276+}
277+
177278// MARK: - Sparkline
178279
179−/// A minimal line chart of recent balances: no axes, no fill, no labels.
180−/// Points are spaced evenly by index rather than by timestamp, which keeps the
181−/// shape readable when fetches are irregular (wake-ups, manual refreshes).
280+/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
281+/// takes the balance's color so the trend reads as part of the number above it, with a
282+/// faint fade underneath for weight and a dot marking the latest point.
283+///
284+/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
285+/// readable when fetches are irregular (wake-ups, manual refreshes).
182286struct Sparkline: View {
183287 let values: [Int]
288+ var color: Color = .secondary
184289
185− /// Keeps the stroke from being clipped at the top and bottom edges.
186− private let verticalInset: CGFloat = 2
290+ /// Keeps the stroke and the dot from being clipped at the edges.
291+ private let verticalInset: CGFloat = 3
292+ private let dotRadius: CGFloat = 2.5
187293
188294 var body: some View {
189295 GeometryReader { geometry in
190− Path { path in
191− guard values.count >= 2 else { return }
192−
193− let size = geometry.size
194− let minValue = values.min() ?? 0
195− let maxValue = values.max() ?? 0
196− let range = Double(maxValue - minValue)
197−
198− let stepX = size.width / CGFloat(values.count - 1)
199− let usableHeight = max(size.height - verticalInset * 2, 1)
200−
201− for (index, value) in values.enumerated() {
202− // A flat series has no range to normalize against; center it.
203− let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
204− let point = CGPoint(
205− x: CGFloat(index) * stepX,
206− y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
207− )
208− if index == 0 {
209− path.move(to: point)
210− } else {
211− path.addLine(to: point)
296+ let points = points(in: geometry.size)
297+
298+ if points.count >= 2 {
299+ ZStack {
300+ fill(under: points, height: geometry.size.height)
301+ .fill(
302+ LinearGradient(
303+ colors: [color.opacity(0.18), color.opacity(0)],
304+ startPoint: .top,
305+ endPoint: .bottom
306+ )
307+ )
308+
309+ line(through: points)
310+ .stroke(
311+ color.opacity(0.9),
312+ style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
313+ )
314+
315+ if let last = points.last {
316+ Circle()
317+ .fill(color)
318+ .frame(width: dotRadius * 2, height: dotRadius * 2)
319+ .position(last)
212320 }
213321 }
214322 }
215− .stroke(
216− Color.secondary.opacity(0.5),
217− style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
323+ }
324+ }
325+
326+ /// Normalizes the values into the available space, oldest at the left.
327+ private func points(in size: CGSize) -> [CGPoint] {
328+ guard values.count >= 2 else { return [] }
329+
330+ let minValue = values.min() ?? 0
331+ let maxValue = values.max() ?? 0
332+ let range = Double(maxValue - minValue)
333+
334+ // The dot is centered on the last point, so the line stops a radius short of
335+ // the trailing edge rather than letting the dot spill outside the frame.
336+ let usableWidth = max(size.width - dotRadius, 1)
337+ let usableHeight = max(size.height - verticalInset * 2, 1)
338+ let stepX = usableWidth / CGFloat(values.count - 1)
339+
340+ return values.enumerated().map { index, value in
341+ // A flat series has no range to normalize against; center it.
342+ let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
343+ return CGPoint(
344+ x: CGFloat(index) * stepX,
345+ y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
218346 )
219347 }
220348 }
349+
350+ private func line(through points: [CGPoint]) -> Path {
351+ Path { path in
352+ guard let first = points.first else { return }
353+ path.move(to: first)
354+ for point in points.dropFirst() {
355+ path.addLine(to: point)
356+ }
357+ }
358+ }
359+
360+ /// The line closed down to the baseline, so it can be filled.
361+ private func fill(under points: [CGPoint], height: CGFloat) -> Path {
362+ Path { path in
363+ guard let first = points.first, let last = points.last else { return }
364+ path.move(to: CGPoint(x: first.x, y: height))
365+ path.addLine(to: first)
366+ for point in points.dropFirst() {
367+ path.addLine(to: point)
368+ }
369+ path.addLine(to: CGPoint(x: last.x, y: height))
370+ path.closeSubpath()
371+ }
372+ }
221373}