fix(bar-app): settings as standalone window, real theme forcing, roomier layout

Move Settings out of the menu-bar popover into a standalone resizable AppKit
window so clicking it no longer steals focus and dismisses the bar (the .sheet
inside MenuBarExtra(.window) was the root cause). Force the actual NSWindow
appearance (aqua/darkAqua/system) on both the popover and the settings window
so Light/Dark visibly flips materials and semantic colors, not just custom
tokens. Replace the fragile popover quit dialog with an inline two-step
arm/confirm. Widen the dropdown and increase spacing/type for readability. Fill
the settings window responsively and make Done close it via the window
controller (dismiss() is a no-op in a hosted NSWindow).
This commit is contained in:
Tam Nhu Tran
2026-06-09 16:44:10 -04:00
parent 97ce63ff75
commit b950b41503
7 changed files with 266 additions and 62 deletions
@@ -36,7 +36,7 @@ struct BarAnalyticsView: View {
/// By-surface + top-models detail, tightened and subordinate.
@ViewBuilder private var breakdown: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 8) {
// Surface breakdown: "how much Claude Code vs Codex" only shown when
// the backend supplies at least one surface entry. Top 5 keeps it compact.
if !analytics.bySurface.isEmpty {
@@ -67,7 +67,7 @@ struct BarAnalyticsView: View {
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
/// line, and a thin inline 30-day sparkline when there is real spend.
private var spendStrip: some View {
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: 5) {
SectionLabel("Spend")
if analytics.hasRecentData {
Text(spendCaption)
@@ -75,7 +75,7 @@ struct BarAnalyticsView: View {
.foregroundStyle(.secondary)
if !sparklineIsEmpty {
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent)
.frame(height: 16)
.frame(height: 18)
}
} else {
Text(idleCaption)
@@ -134,10 +134,10 @@ private struct SurfaceBar: View {
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 8)
.padding(.horizontal, 10)
}
}
.frame(height: 22)
.frame(height: 26)
}
}
@@ -164,10 +164,10 @@ private struct ModelBar: View {
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.horizontal, 10)
}
}
.frame(height: 22)
.frame(height: 26)
}
}
@@ -177,7 +177,7 @@ struct SectionLabel: View {
init(_ text: String) { self.text = text }
var body: some View {
Text(text.uppercased())
.font(.system(size: 10, weight: .bold))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
.padding(.top, 1)
}
+65 -43
View File
@@ -7,12 +7,15 @@ import CCSBarCore
/// footer controls.
struct BarMenuView: View {
@ObservedObject var viewModel: BarViewModel
/// Drives the preferences sheet from the footer gear.
@State private var showingPrefs = false
@State private var confirmingQuit = false
/// The prefs adapter the sheet edits; shares the standard suite with the
/// view model so a write-through is visible on the next poll.
private let prefs = BarPreferences()
/// Resolved theme injected by ThemedRoot used to tint the armed Quit control
/// with the themed red ramp so it matches the dropdown on both plates.
@Environment(\.barTheme) private var theme
/// Two-step inline quit confirm. First footer-Quit click arms it (icon swaps
/// hollow->filled, tints red); second click terminates. Reset on every popover
/// open via .onAppear so a stale armed state never carries across sessions
/// no modal, no .confirmationDialog (those steal focus and dismiss the popover,
/// the exact fragility of BUG 1).
@State private var quitArmed = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -30,12 +33,12 @@ struct BarMenuView: View {
// for the common 1-4 subscription setup; the scroll only engages for
// genuine pool/model overflow.
ScrollView {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 12) {
// (1) ALERTS first urgent quota crossings surface above everything.
// Spend-cap alerts are opt-in OFF by default, so by default only
// quota/reauth/cooldown conditions appear here.
if !viewModel.activeAlerts.isEmpty {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 8) {
SectionLabel("Alerts")
ForEach(viewModel.activeAlerts) { alert in
AlertRow(alert: alert)
@@ -66,22 +69,24 @@ struct BarMenuView: View {
// scroller at runtime (belt-and-suspenders with .scrollIndicators).
ScrollerHider().frame(width: 0, height: 0)
}
.padding(12)
.padding(14)
}
.scrollIndicators(.never)
// 580 fits the full reordered layout (subscription cards + spend strip +
// pool rows + surface/models) without wasted whitespace for a typical
// 1-4 account setup. Scroll engages gracefully only on real overflow.
.frame(maxHeight: 580)
// 620 fits the full reordered layout (subscription cards + spend strip +
// pool rows + surface/models) plus the added vertical breathing room
// before scroll engages, for a typical 1-4 account setup. Scroll engages
// gracefully only on real overflow.
.frame(maxHeight: 620)
}
Divider()
footer
}
.frame(width: 340)
.onAppear { viewModel.onOpen() }
.sheet(isPresented: $showingPrefs) {
BarPreferencesView(viewModel: viewModel, prefs: prefs)
.frame(width: 380)
.onAppear {
viewModel.onOpen()
// Disarm quit on every popover open so a stale armed state never persists.
quitArmed = false
}
}
@@ -93,7 +98,7 @@ struct BarMenuView: View {
/// present, preserving the single "Accounts" header for a CLIProxy-only setup.
@ViewBuilder private var accountsSection: some View {
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 8) {
if let error = viewModel.lastError {
ErrorBanner(message: error)
}
@@ -124,7 +129,7 @@ struct BarMenuView: View {
@ViewBuilder private var poolSection: some View {
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
if !parts.subscriptions.isEmpty && !parts.pool.isEmpty {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 8) {
SectionLabel("Pool accounts")
ForEach(parts.pool) { row in
BarRowView(row: row, viewModel: viewModel)
@@ -199,7 +204,7 @@ struct BarMenuView: View {
}
private var footer: some View {
HStack(spacing: 10) {
HStack(spacing: 12) {
Button {
openDashboard()
} label: {
@@ -215,7 +220,11 @@ struct BarMenuView: View {
}
.help("Toggle the menu-bar icon between color and monochrome (does not change the bar theme)")
Button {
showingPrefs = true
// Open Settings as a standalone AppKit NSWindow (NOT a .sheet on this
// popover). A sheet hosted in a .window-style MenuBarExtra popover pulls
// focus off the popover and auto-dismisses the whole bar (BUG 1). The
// window opens beside the popover and leaves it untouched.
SettingsWindowController.shared.show(viewModel: viewModel)
} label: {
Label("Settings", systemImage: "gearshape")
}
@@ -227,25 +236,38 @@ struct BarMenuView: View {
Image(systemName: "arrow.clockwise")
}
.help("Refresh")
// Quit confirms first a stray click here previously closed the whole
// app (removing the menu-bar item) with no easy way back.
Button {
confirmingQuit = true
} label: {
Image(systemName: "power")
}
.help("Quit CCS Bar")
.confirmationDialog("Quit CCS Bar?", isPresented: $confirmingQuit) {
Button("Quit", role: .destructive) { NSApplication.shared.terminate(nil) }
Button("Cancel", role: .cancel) {}
} message: {
Text("This closes the menu-bar app. Reopen it from Applications.")
}
// Quit confirms via a two-step INLINE arm/confirm no modal, no sheet, no
// .confirmationDialog. Those all steal focus and auto-dismiss the popover
// (the exact fragility of BUG 1). A stray single click can no longer kill
// the app: the first click only arms; the popover stays open and responsive.
quitButton
}
.buttonStyle(.borderless)
.font(.caption)
.padding(.horizontal, 14)
.padding(.vertical, 9)
.padding(.vertical, 11)
}
/// Two visual states in one footer slot. Disarmed: hollow power icon that arms
/// on click. Armed: filled power icon tinted themed red that terminates on
/// click. Reopening the popover disarms it (.onAppear on the root VStack).
@ViewBuilder private var quitButton: some View {
if !quitArmed {
Button {
quitArmed = true
} label: {
Image(systemName: "power")
}
.help("Quit CCS Bar (click again to confirm)")
} else {
Button {
NSApplication.shared.terminate(nil)
} label: {
Image(systemName: "power.circle.fill")
}
.help("Click to confirm quit")
.foregroundStyle(theme.bandRed)
}
}
private func openDashboard() {
@@ -273,14 +295,14 @@ struct BarRowView: View {
}
var body: some View {
HStack(alignment: .top, spacing: 9) {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(healthColor)
.frame(width: 8, height: 8)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 7) {
Text(row.displayName ?? row.accountId)
.font(.system(.body, design: .default).weight(.medium))
.lineLimit(1)
@@ -327,8 +349,8 @@ struct BarRowView: View {
}
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
}
@@ -433,7 +455,7 @@ struct QuotaGaugeView: View {
.frame(width: max(2, geo.size.width * fill))
}
}
.frame(width: 44, height: 5)
.frame(width: 54, height: 6)
}
private func color(for band: BarQuotaGauge.Band) -> Color {
@@ -543,7 +565,7 @@ struct Chip: View {
}
var body: some View {
Text(text)
.font(.system(size: 9.5, weight: .semibold))
.font(.system(size: 10, weight: .semibold))
.padding(.horizontal, 5)
.padding(.vertical, 1.5)
.background(tint.opacity(0.22), in: Capsule())
@@ -8,7 +8,6 @@ import CCSBarCore
struct BarPreferencesView: View {
@ObservedObject var viewModel: BarViewModel
let prefs: BarPreferences
@Environment(\.dismiss) private var dismiss
@Environment(\.barTheme) private var theme
// Local editable mirror of the persisted prefs. Loaded on appear; each change
@@ -34,7 +33,10 @@ struct BarPreferencesView: View {
Divider()
footer
}
.frame(width: 360, height: 460)
// Fill the hosting window responsively (it is a real resizable NSWindow now,
// not a fixed 360x460 sheet) so there are no dead margins and resizing works.
.frame(minWidth: 420, idealWidth: 460, maxWidth: .infinity,
minHeight: 520, idealHeight: 600, maxHeight: .infinity)
.onAppear(perform: hydrate)
}
@@ -156,7 +158,7 @@ struct BarPreferencesView: View {
private var footer: some View {
HStack {
Spacer()
Button("Done") { commitLevels(); dismiss() }
Button("Done") { commitLevels(); SettingsWindowController.shared.close() }
.keyboardShortcut(.defaultAction)
}
.padding(.horizontal, 14)
@@ -33,7 +33,7 @@ struct BarSubscriptionCard: View {
staleFootnote
}
}
.padding(.vertical, 9)
.padding(.vertical, 11)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
@@ -46,7 +46,7 @@ struct BarSubscriptionCard: View {
/// Health dot + product name + reauth chip + tier chip. No pause toggle
/// subscriptions are not routable pool accounts.
private var titleRow: some View {
HStack(spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(healthColor)
.frame(width: 8, height: 8)
@@ -71,7 +71,7 @@ struct BarSubscriptionCard: View {
let ordered = orderedWindows
let bindingKey = binding?.key
return VStack(alignment: .leading, spacing: 3) {
return VStack(alignment: .leading, spacing: 5) {
ForEach(ordered) { w in
windowBarRow(w, isBinding: w.key == bindingKey)
}
@@ -118,7 +118,7 @@ struct BarSubscriptionCard: View {
? .system(.caption2, design: .monospaced).weight(.semibold)
: .system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? .primary : .secondary)
.frame(width: 28, alignment: .leading)
.frame(width: 32, alignment: .leading)
// Horizontal fill bar wider than the old secondary thinBar so fine
// gradations are visible. Remaining fraction fills from the left so a
@@ -127,9 +127,9 @@ struct BarSubscriptionCard: View {
Capsule().fill(Color.primary.opacity(isBinding ? 0.14 : 0.09))
Capsule()
.fill(barColor)
.frame(width: max(2, 88 * fill))
.frame(width: max(2, 110 * fill))
}
.frame(width: 88, height: isBinding ? 6 : 4)
.frame(width: 110, height: isBinding ? 7 : 5)
Spacer(minLength: 5)
@@ -137,7 +137,7 @@ struct BarSubscriptionCard: View {
Text("\(Int(w.remainingPercent.rounded()))%")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? barColor : .secondary)
.frame(width: 28, alignment: .trailing)
.frame(width: 32, alignment: .trailing)
Spacer(minLength: 5)
@@ -188,7 +188,7 @@ struct BarSubscriptionCard: View {
.foregroundStyle(isBinding ? .secondary : .tertiary)
}
}
.frame(width: 42, alignment: .trailing)
.frame(width: 48, alignment: .trailing)
}
/// Extract the "~Th Mm" part from paceClause for the at-risk inline warning.
@@ -43,8 +43,15 @@ struct ThemedRoot<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
// Order matters: .preferredColorScheme first updates the SwiftUI \.colorScheme
// environment for descendants (so the token resolver + Color.primary/.secondary
// pick up the chosen scheme), THEN the .background WindowAppearanceForcer sets
// the actual host NSWindow.appearance so system materials + semantic-color
// inversions flip at the AppKit layer too not just the custom RGB tokens.
// The two are complementary: env tokens + real window appearance. KEEP both.
ResolvedThemeHost(content: content)
.preferredColorScheme(appearance.forced)
.background(WindowAppearanceForcer(appearance: appearance))
}
}
@@ -0,0 +1,122 @@
import SwiftUI
import AppKit
import CCSBarCore
/// Opens the CCS Bar settings as a standalone AppKit `NSWindow`.
///
/// MECHANISM: a singleton `NSWindowController`-style driver backed by a real
/// `NSWindow` + `NSWindowDelegate`, NOT the SwiftUI `Window` scene. AppKit is the
/// only path that gives deterministic control of the three things a
/// MenuBarExtra-only (no dock icon) app needs:
/// (a) forcing `window.appearance` so the theme flips at the AppKit layer,
/// (b) restoring `.accessory` activation policy on close (drop the dock icon),
/// (c) singleton reuse so a second Settings click focuses the existing window
/// instead of spawning a duplicate.
/// The SwiftUI `Window` scene leaves the app stuck in `.regular` with a lingering
/// dock icon and version-dependent focus behavior, so it is deliberately avoided.
///
/// Crucially, opening this window does NOT touch the MenuBarExtra popover's own
/// NSWindow (a separate AppKit window), so the popover stays open and responsive
/// the exact cross-window isolation `ScrollerHider` already proves works here.
/// This is what fixes BUG 1: the old `.sheet` presented inside the `.window`
/// popover stole focus and auto-dismissed the whole bar.
@MainActor
final class SettingsWindowController {
/// One window, max. A second show() reuses it rather than spawning a duplicate.
static let shared = SettingsWindowController()
private var window: NSWindow?
/// Retained so the delegate isn't deallocated while the window lives.
private var delegate: SettingsWindowDelegate?
private init() {}
/// Show (or re-focus) the settings window, hosting the LIVE view model so the
/// appearance picker drives both this window and the menu-bar popover.
func show(viewModel: BarViewModel) {
if let existing = window {
// Reuse: bring the single window to front instead of opening another.
existing.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
// SwiftUI root: the SAME view model + the SAME ThemedRoot pipeline as the
// popover, so the settings window themes identically and live-syncs.
let root = SettingsWindowRoot(viewModel: viewModel)
let hosting = NSHostingController(rootView: root)
let window = NSWindow(contentViewController: hosting)
window.title = "CCS Bar Settings"
// Titled + closable only: it's a settings dialog, not a document window, so
// no miniaturize/zoom. Resizable (no .nonResizable) per the spec.
window.styleMask = [.titled, .closable, .resizable]
window.setContentSize(NSSize(width: 460, height: 600))
window.minSize = NSSize(width: 420, height: 520)
// Reuse the instance on reopen instead of tearing it down on close.
window.isReleasedWhenClosed = false
window.center()
let delegate = SettingsWindowDelegate(onClose: { [weak self] in self?.handleClose() })
window.delegate = delegate
self.delegate = delegate
self.window = window
// Accessory (menu-bar-only) apps can't take key focus; upgrade to .regular
// so the window focuses and shows in the app switcher while it's open.
NSApp.setActivationPolicy(.regular)
window.makeKeyAndOrderFront(nil)
// Force front even when invoked from another frontmost app.
NSApp.activate(ignoringOtherApps: true)
}
/// Close the window programmatically (e.g. the Done button). Routes through
/// `performClose` so the delegate restores `.accessory` mode exactly like the
/// title-bar close button `@Environment(\.dismiss)` is a no-op in a plain
/// NSHostingController window, so this is the reliable path.
func close() { window?.performClose(nil) }
/// Delegate callback on window close: drop the dock icon back so we return to
/// menu-bar-only mode. The popover is untouched throughout. The window itself
/// is kept (isReleasedWhenClosed = false) for cheap reuse, but we clear our
/// reference so the next show() rebuilds a fresh, correctly-centered window.
private func handleClose() {
NSApp.setActivationPolicy(.accessory)
window = nil
delegate = nil
}
}
/// Bridges `NSWindow` close back to the controller so it can restore the
/// `.accessory` activation policy (menu-bar-only mode).
final class SettingsWindowDelegate: NSObject, NSWindowDelegate {
private let onClose: () -> Void
init(onClose: @escaping () -> Void) {
self.onClose = onClose
}
func windowWillClose(_ notification: Notification) {
onClose()
}
}
/// SwiftUI root hosted inside the settings `NSWindow`. Wraps the reused
/// `BarPreferencesView` in the SAME `ThemedRoot` token pipeline as the popover,
/// so the settings window themes identically. Observing the shared
/// `BarViewModel` means a theme pick here re-renders BOTH windows live.
struct SettingsWindowRoot: View {
@ObservedObject var viewModel: BarViewModel
/// The prefs adapter the view edits; shares the standard suite with the view
/// model so a write-through is visible on the next poll (same as the popover).
private let prefs = BarPreferences()
var body: some View {
// ThemedRoot applies .preferredColorScheme + injects tokens, and its
// .background WindowAppearanceForcer forces THIS NSWindow's appearance so
// system materials + semantic colors flip too single source of truth with
// the popover. Fill the window so the plate covers the full content area.
ThemedRoot(appearance: viewModel.appearance) {
BarPreferencesView(viewModel: viewModel, prefs: prefs)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
@@ -0,0 +1,51 @@
import SwiftUI
import AppKit
import CCSBarCore
/// Zero-size bridge that walks up to the host `NSWindow` and forces its
/// `appearance` to match the user's chosen `BarAppearance`.
///
/// Why this exists on top of `.preferredColorScheme`: that modifier only
/// rewrites the SwiftUI `\.colorScheme` environment for descendant views it
/// does NOT change the host `NSWindow.effectiveAppearance`. So AppKit-level
/// surfaces keep reading the OS appearance and fight the chosen theme:
/// - system materials (the MenuBarExtra popover's backing material)
/// - semantic colors (`Color.primary` / `.secondary`, used by Chip text,
/// health dots, captions) which invert off the window appearance.
/// Setting `window.appearance` directly fixes the theme at the AppKit layer so
/// the whole surface flips, not just the custom RGB tokens.
///
/// Modeled on the proven `ScrollerHider` pattern (which already reaches the host
/// window inside this popover), proving cross-window AppKit access works here.
struct WindowAppearanceForcer: NSViewRepresentable {
let appearance: BarAppearance
func makeNSView(context: Context) -> NSView {
let probe = NSView(frame: .zero)
// Defer until the view is in the hierarchy; at make-time `view.window` is nil.
DispatchQueue.main.async { apply(to: probe) }
return probe
}
func updateNSView(_ nsView: NSView, context: Context) {
// Re-apply on every update: the popover's NSWindow can be rebuilt on content
// changes, and the appearance pick itself changes mid-session.
DispatchQueue.main.async { apply(to: nsView) }
}
/// Force the host window's appearance from the chosen theme.
/// .system -> nil (follow the OS)
/// .light -> aqua
/// .dark -> darkAqua
private func apply(to view: NSView) {
guard let window = view.window else { return }
switch appearance {
case .system:
window.appearance = nil
case .light:
window.appearance = NSAppearance(named: .aqua)
case .dark:
window.appearance = NSAppearance(named: .darkAqua)
}
}
}