diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift index 6abc79fc..83e515b5 100644 --- a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -12,6 +12,7 @@ import CCSBarCore /// When the trailing 30 days carry no spend the strip reads the honest idle line /// ("No usage in N days") instead of three dead "$0.00" cells. struct BarAnalyticsView: View { + @Environment(\.barTheme) private var theme let analytics: BarAnalytics /// Which slot of the dropdown this instance renders. `.spend` is the thin strip /// placed below the subscriptions cockpit; `.breakdown` is the by-surface / @@ -73,7 +74,8 @@ struct BarAnalyticsView: View { .font(.caption2) .foregroundStyle(.secondary) if !sparklineIsEmpty { - Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 16) + Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent) + .frame(height: 16) } } else { Text(idleCaption) @@ -106,6 +108,7 @@ struct BarAnalyticsView: View { /// One usage-surface row: surface name + proportional accent bar + cost and /// request count. Mirrors ModelBar visually so the two sections feel cohesive. private struct SurfaceBar: View { + @Environment(\.barTheme) private var theme let surface: BarAnalyticsSurface let peak: Double @@ -114,7 +117,7 @@ private struct SurfaceBar: View { let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0 ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 5) - .fill(BarTheme.accent.opacity(0.16)) + .fill(theme.accent.opacity(0.16)) .frame(width: max(8, geo.size.width * fraction)) HStack { Text(surface.surface) @@ -140,6 +143,7 @@ private struct SurfaceBar: View { /// One top-model row: name + spend with a proportional accent bar behind. private struct ModelBar: View { + @Environment(\.barTheme) private var theme let model: BarAnalytics.Model let peak: Double @@ -148,7 +152,7 @@ private struct ModelBar: View { let fraction = peak > 0 ? CGFloat(model.cost / peak) : 0 ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 5) - .fill(BarTheme.accent.opacity(0.16)) + .fill(theme.accent.opacity(0.16)) .frame(width: max(8, geo.size.width * fraction)) HStack { Text(model.model) diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 83b6f6a5..bccc69dd 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -253,6 +253,7 @@ struct BarMenuView: View { /// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume /// toggle, and the overflow menu (set-default / solo / tier-lock). struct BarRowView: View { + @Environment(\.barTheme) private var theme let row: BarSummaryRow @ObservedObject var viewModel: BarViewModel @@ -276,22 +277,22 @@ struct BarRowView: View { .lineLimit(1) .truncationMode(.middle) if row.isDefault { - Chip("default", tint: BarTheme.accent) + Chip("default", tint: theme.accent) } if row.paused { Chip("paused", tint: .secondary) } if row.needsReauth { - Chip("reauth", tint: .red) + Chip("reauth", tint: theme.bandRed) } if isNativeSubscription { - Chip("subscription", tint: BarTheme.subscription) + Chip("subscription", tint: theme.subscription) } } HStack(spacing: 6) { Chip( BarFormatting.providerLabel(row.provider), - tint: isNativeSubscription ? BarTheme.subscription : BarTheme.accent) + tint: isNativeSubscription ? theme.subscription : theme.accent) if let tier = row.tier { Chip(tier, tint: .secondary) } QuotaGaugeView( percentage: row.quotaPercentage, @@ -368,10 +369,12 @@ struct BarRowView: View { /// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved /// for genuine transient fetch failures, red for accounts needing reauth. private var healthColor: Color { + // Use the themed band ramp (not raw system .red/.orange/.green) so the dot + // matches the rest of the dropdown and stays legible on both plates. switch row.health { - case "error": return .red - case "warning": return .orange - default: return .green + case "error": return theme.bandRed + case "warning": return theme.bandAmber + default: return theme.bandGreen } } } @@ -383,6 +386,7 @@ struct BarRowView: View { /// color, and countdown logic lives in the pure Core `BarQuotaGauge`; this view /// is a thin render. struct QuotaGaugeView: View { + @Environment(\.barTheme) private var theme let percentage: Double? let status: String let nextReset: String? @@ -424,11 +428,14 @@ struct QuotaGaugeView: View { } private func color(for band: BarQuotaGauge.Band) -> Color { + // Themed band ramp for whole-dropdown consistency. .orange maps to the coral + // band (the warning step in the green→amber→coral→red ramp) so it stays + // distinct from the brand accent orange on both plates. switch band { - case .green: return .green - case .yellow: return .yellow - case .orange: return .orange - case .red: return .red + case .green: return theme.bandGreen + case .yellow: return theme.bandAmber + case .orange: return theme.bandCoral + case .red: return theme.bandRed case .none: return .secondary } } @@ -437,11 +444,12 @@ struct QuotaGaugeView: View { /// Inline banner surfacing the last failed action so it is visible rather than /// silently swallowed. Success is confirmed by the default/paused badge updating. struct ErrorBanner: View { + @Environment(\.barTheme) private var theme let message: String var body: some View { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(theme.accent) Text(message) .font(.caption2) .foregroundStyle(.secondary) @@ -450,7 +458,7 @@ struct ErrorBanner: View { .padding(.vertical, 5) .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + .background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) } } @@ -458,6 +466,7 @@ struct ErrorBanner: View { /// are visible even when system notifications are denied. The icon is keyed off /// the alert kind so each rule reads at a glance. struct AlertRow: View { + @Environment(\.barTheme) private var theme let alert: BarNotification var body: some View { @@ -492,10 +501,12 @@ struct AlertRow: View { } private var tint: Color { + // Themed: quota warnings take the brand accent, reauth the critical band, + // so alert chips match the rest of the dropdown on both plates. switch alert.kind { - case .quotaRemainingBelow: return .orange - case .dailySpendAbove, .monthSpendAbove: return BarTheme.accent - case .reauthNeeded: return .red + case .quotaRemainingBelow: return theme.accent + case .dailySpendAbove, .monthSpendAbove: return theme.accent + case .reauthNeeded: return theme.bandRed case .accountCooldownOrPaused: return .secondary } } @@ -503,17 +514,22 @@ struct AlertRow: View { /// Small pill label used in account sublines. struct Chip: View { + @Environment(\.colorScheme) private var colorScheme let text: String let tint: Color init(_ text: String, tint: Color) { self.text = text self.tint = tint } - /// Lift the tint toward white so the small 9pt label stays legible on the dark - /// surface — the raw tint (e.g. the indigo subscription color) was too dim to read. + /// Lift the small 9pt label toward the opposite of the surface so it stays + /// legible: toward white on the dark plate (the raw indigo subscription tint + /// was too dim to read), toward black on the light plate (lifting toward white + /// there would wash the text out). The forced scheme is already in effect on + /// this subtree, so `colorScheme` reflects exactly the plate being drawn. private var textColor: Color { if tint == .secondary { return .secondary } - let lifted = NSColor(tint).blended(withFraction: 0.5, of: .white) ?? NSColor(tint) + let target: NSColor = (colorScheme == .light) ? .black : .white + let lifted = NSColor(tint).blended(withFraction: 0.5, of: target) ?? NSColor(tint) return Color(nsColor: lifted) } var body: some View { diff --git a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift index 7ee26bfb..b0e4e438 100644 --- a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift +++ b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift @@ -9,6 +9,7 @@ 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 // is written through immediately so there is no separate "save" step. @@ -22,6 +23,7 @@ struct BarPreferencesView: View { header Divider() Form { + appearanceSection glanceSection quotaSection spendSection @@ -39,7 +41,7 @@ struct BarPreferencesView: View { private var header: some View { HStack(spacing: 8) { Image(systemName: "bell.badge") - .foregroundStyle(BarTheme.accent) + .foregroundStyle(theme.accent) Text("Alerts & Glance").font(.headline) Spacer() } @@ -47,6 +49,21 @@ struct BarPreferencesView: View { .padding(.vertical, 10) } + /// Theme picker — placed first because it affects the whole dropdown, so it is + /// the most prominent setting. Bound directly to `$viewModel.appearance` (NOT + /// the alert-pref draft): appearance is global chrome, its `didSet` persists, + /// and @Published drives the live re-render — no writeThrough() needed. + private var appearanceSection: some View { + Section("Appearance") { + Picker("Menu bar theme", selection: $viewModel.appearance) { + Text("System").tag(BarAppearance.system) + Text("Light").tag(BarAppearance.light) + Text("Dark").tag(BarAppearance.dark) + } + .pickerStyle(.segmented) + } + } + private var glanceSection: some View { Section("Menu-bar glance") { Picker("Show in menu bar", selection: $draft.glanceMode) { diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift index b3a631c8..021f4803 100644 --- a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -11,6 +11,7 @@ import CCSBarCore /// highlighted and is the only place where the at-risk pace warning appears. /// Verbose prose lines ("week window · resets in ...") are removed entirely. struct BarSubscriptionCard: View { + @Environment(\.barTheme) private var theme let row: BarSummaryRow /// Injected clock — defaults to live Date() in production, pinned in previews /// and tests so countdown math is deterministic. @@ -36,7 +37,7 @@ struct BarSubscriptionCard: View { .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) .background( - BarTheme.cardSurface, + theme.cardSurface, in: RoundedRectangle(cornerRadius: 9)) } @@ -53,11 +54,11 @@ struct BarSubscriptionCard: View { .font(.system(.body, design: .default).weight(.semibold)) .lineLimit(1) if row.needsReauth { - Chip("reauth", tint: BarTheme.bandRed) + Chip("reauth", tint: theme.bandRed) } Spacer(minLength: 4) if let tier = row.tier { - Chip(tier, tint: BarTheme.subscription) + Chip(tier, tint: theme.subscription) } } } @@ -149,7 +150,7 @@ struct BarSubscriptionCard: View { if isAtRisk, let pace = paceWarningText(for: w) { Text(pace) .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(BarTheme.bandCoral) + .foregroundStyle(theme.bandCoral) .lineLimit(1) .padding(.leading, 5) } @@ -244,18 +245,18 @@ struct BarSubscriptionCard: View { private var healthColor: Color { switch row.health { - case "error": return BarTheme.bandRed - case "warning": return BarTheme.bandAmber - default: return BarTheme.bandGreen + case "error": return theme.bandRed + case "warning": return theme.bandAmber + default: return theme.bandGreen } } private func color(for band: BarQuotaGauge.Band) -> Color { switch band { - case .green: return BarTheme.bandGreen - case .yellow: return BarTheme.bandAmber - case .orange: return BarTheme.bandCoral - case .red: return BarTheme.bandRed + case .green: return theme.bandGreen + case .yellow: return theme.bandAmber + case .orange: return theme.bandCoral + case .red: return theme.bandRed case .none: return .secondary } } diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index 0dae3d7b..f0283e76 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -15,6 +15,13 @@ final class BarViewModel: ObservableObject { @Published var iconStyle: BarIconStyle { didSet { MenuBarIcon.saveStyle(iconStyle) } } + /// User-selected dropdown theme (System / Light / Dark). Global chrome, not an + /// alert pref, so it persists on its own key and bypasses the draft/writeThrough + /// path. Being @Published makes the MenuBarExtra re-render `ThemedRoot` the + /// instant it changes, giving a live theme switch. + @Published var appearance: BarAppearance { + didSet { BarAppearanceStore.save(appearance) } + } /// Which figure leads the always-on title. Persisted; a change re-derives /// `statusTitle` live because it is @Published. @Published var glanceMode: BarGlanceMode @@ -42,6 +49,7 @@ final class BarViewModel: ObservableObject { // Default to the real UN-backed notifier; tests inject a recording one. self.notifier = notifier ?? BarNotifier() self.iconStyle = MenuBarIcon.loadStyle() + self.appearance = BarAppearanceStore.load() self.glanceMode = prefs.load().glanceMode reconnect() } diff --git a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift index b150705a..de57f800 100644 --- a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift +++ b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import CCSBarCore /// CCS Bar entry point. A menu-bar-only app (no dock icon) whose title shows /// the leading account's quota and today's total cost, with a dropdown for @@ -16,7 +17,12 @@ struct CCSBarApp: App { var body: some Scene { MenuBarExtra { - BarMenuView(viewModel: viewModel) + // ThemedRoot forces the chosen scheme and injects the resolved tokens, so + // the whole dropdown follows the user's appearance pick independently of + // the macOS system appearance. The label (status item) stays OS-tinted. + ThemedRoot(appearance: viewModel.appearance) { + BarMenuView(viewModel: viewModel) + } } label: { // The CCS mark + compact glance. The image re-renders when the style // preference changes because `iconStyle` is observed. @@ -26,3 +32,35 @@ struct CCSBarApp: App { .menuBarExtraStyle(.window) } } + +/// Forces the chosen color scheme on the dropdown content at the boundary, then +/// hands off to `ResolvedThemeHost` to read the now-forced scheme and inject +/// tokens. The split is deliberate: `.preferredColorScheme` rewrites the +/// environment for DESCENDANTS only, so a view cannot read its own forced scheme +/// in the same scope. `ResolvedThemeHost` is a descendant and therefore sees it. +struct ThemedRoot: View { + let appearance: BarAppearance + @ViewBuilder var content: Content + + var body: some View { + ResolvedThemeHost(content: content) + .preferredColorScheme(appearance.forced) + } +} + +/// Reads the (already-forced) color scheme, resolves the matching `BarTheme`, +/// paints the themed window plate behind the content, and injects the tokens. +/// In dark the plate is `.clear` (native MenuBarExtra material shows through — +/// zero regression); in light it is the explicit #F5F5F7 plate so the dropdown +/// renders light even when macOS is in dark mode. +struct ResolvedThemeHost: View { + @Environment(\.colorScheme) private var colorScheme + let content: Content + + var body: some View { + let theme = BarTheme.resolve(colorScheme) + content + .background(theme.windowSurface) + .environment(\.barTheme, theme) + } +} diff --git a/macos-bar/Sources/CCSBarApp/Sparkline.swift b/macos-bar/Sources/CCSBarApp/Sparkline.swift index 2b47e42c..2e4f0705 100644 --- a/macos-bar/Sources/CCSBarApp/Sparkline.swift +++ b/macos-bar/Sources/CCSBarApp/Sparkline.swift @@ -1,10 +1,14 @@ import SwiftUI +import CCSBarCore /// A compact bar sparkline for daily values (e.g. cost per day over 7 days). /// Zero-value days render as faint placeholders so the cadence stays readable. struct Sparkline: View { let values: [Double] - var accent: Color = BarTheme.accent + // Default is the dark preset's accent: a default argument can't read the + // environment, so this is the static fallback. Live callers pass the themed + // `theme.accent` from the parent so the rendered bar follows the chosen theme. + var accent: Color = BarTheme.dark.accent var body: some View { GeometryReader { geo in @@ -21,25 +25,3 @@ struct Sparkline: View { } } } - -/// Shared visual tokens for the menu. The accent matches the CCS logo orange. -enum BarTheme { - static let accent = Color(red: 0.886, green: 0.451, blue: 0.137) // ~#E2732A - /// Distinct tint for native first-party subscription rows (Claude Code / Codex) - /// so the user's own plan reads apart from CLIProxy pool accounts. A cool indigo - /// contrasts with the warm orange accent used for everything else. - static let subscription = Color(red: 0.357, green: 0.388, blue: 0.851) // ~#5B63D9 - - /// Headroom palette for quota bars. Muted for the dark surface (raw system - /// green/yellow/orange/red read garish here) and intuitive green→amber→coral→red. - /// Deliberately leans coral/red for "low" rather than the brand orange, so a - /// nearly-empty window never gets mistaken for the accent. - static let bandGreen = Color(red: 0.36, green: 0.74, blue: 0.56) // ~#5CBC8F emerald - static let bandAmber = Color(red: 0.86, green: 0.67, blue: 0.31) // ~#DBAB4F gold - static let bandCoral = Color(red: 0.91, green: 0.46, blue: 0.36) // ~#E8755C warning - static let bandRed = Color(red: 0.85, green: 0.34, blue: 0.31) // ~#D9564F critical - - /// Neutral elevated surface for the subscription card — a faint light lift - /// rather than a colored wash, so the warm headroom bars read cleanly on top. - static let cardSurface = Color.primary.opacity(0.05) -} diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 55435a20..5687ae46 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI // for ColorScheme equality in the theme-token checks import CCSBarCore // Lightweight assert harness used in place of XCTest (unavailable without a @@ -1254,6 +1255,98 @@ do { check(BarQuotaGauge.headroomLeader([]) == nil, "headroom: empty -> nil") } +// MARK: - Theme token model (BarPalette / BarAppearance / BarTheme) +// +// These assert on the raw RGB Doubles, NOT on SwiftUI Color equality (which is +// unreliable — identical components are not guaranteed `==`). The dark values +// are the regression lock: they must equal the original Sparkline constants. + +// Forced-scheme mapping: .system inherits OS, .light/.dark override. +check(BarAppearance.system.forced == nil, "appearance .system -> forced nil (inherit OS)") +check(BarAppearance.light.forced == .light, "appearance .light -> forced .light") +check(BarAppearance.dark.forced == .dark, "appearance .dark -> forced .dark") +check(BarAppearance.allCases.count == 3, "appearance has exactly 3 cases") + +// DARK == today (regression lock). Exact constants from the original +// Sparkline BarTheme enum — any drift fails the build. +check(BarPalette.dark.accentRGB == RGB(0.886, 0.451, 0.137), "dark accent == #E2732A (locked)") +check( + BarPalette.dark.subscriptionRGB == RGB(0.357, 0.388, 0.851), + "dark subscription == #5B63D9 (locked)") +check(BarPalette.dark.bandGreenRGB == RGB(0.36, 0.74, 0.56), "dark bandGreen == #5CBC8F (locked)") +check(BarPalette.dark.bandAmberRGB == RGB(0.86, 0.67, 0.31), "dark bandAmber == #DBAB4F (locked)") +check(BarPalette.dark.bandCoralRGB == RGB(0.91, 0.46, 0.36), "dark bandCoral == #E8755C (locked)") +check(BarPalette.dark.bandRedRGB == RGB(0.85, 0.34, 0.31), "dark bandRed == #D9564F (locked)") + +// LIGHT differs from DARK for every themed token (so light can't silently +// collapse back to dark) AND equals the locked light values. +check(BarPalette.light.accentRGB != BarPalette.dark.accentRGB, "light accent differs from dark") +check( + BarPalette.light.subscriptionRGB != BarPalette.dark.subscriptionRGB, + "light subscription differs from dark") +check( + BarPalette.light.bandGreenRGB != BarPalette.dark.bandGreenRGB, "light bandGreen differs from dark") +check( + BarPalette.light.bandAmberRGB != BarPalette.dark.bandAmberRGB, "light bandAmber differs from dark") +check( + BarPalette.light.bandCoralRGB != BarPalette.dark.bandCoralRGB, "light bandCoral differs from dark") +check(BarPalette.light.bandRedRGB != BarPalette.dark.bandRedRGB, "light bandRed differs from dark") + +check(BarPalette.light.accentRGB == RGB(0.812, 0.357, 0.063), "light accent == #CF5B10 (locked)") +check( + BarPalette.light.subscriptionRGB == RGB(0.275, 0.302, 0.745), + "light subscription == #464DBE (locked)") +check(BarPalette.light.bandGreenRGB == RGB(0.106, 0.580, 0.357), "light bandGreen == #1B945B (locked)") +check(BarPalette.light.bandAmberRGB == RGB(0.722, 0.490, 0.043), "light bandAmber == #B87D0B (locked)") +check(BarPalette.light.bandCoralRGB == RGB(0.831, 0.302, 0.157), "light bandCoral == #D44D28 (locked)") +check(BarPalette.light.bandRedRGB == RGB(0.776, 0.157, 0.137), "light bandRed == #C62823 (locked)") + +// Light ramp separability: the four band colors must be mutually distinct so +// the green→amber→coral→red status ramp stays readable on the light plate. +// (We assert distinctness, not a lightness ordering: hue, not luminance, is what +// separates these bands — amber and coral are intentionally near in luminance.) +let lightBands = [ + BarPalette.light.bandGreenRGB, BarPalette.light.bandAmberRGB, + BarPalette.light.bandCoralRGB, BarPalette.light.bandRedRGB, +] +check(Set(lightBands.map { "\($0.r),\($0.g),\($0.b)" }).count == 4, "light ramp: 4 distinct bands") +// Red is the deepest/most-saturated end of the ramp: it has the lowest green +// channel, anchoring "critical" as the visually heaviest band. +check( + BarPalette.light.bandRedRGB.g < BarPalette.light.bandGreenRGB.g + && BarPalette.light.bandRedRGB.g < BarPalette.light.bandAmberRGB.g + && BarPalette.light.bandRedRGB.g < BarPalette.light.bandCoralRGB.g, + "light ramp: red has the lowest green channel (critical anchor)") + +// Resolver picks the right palette per scheme (verified via the stored palette +// ref, staying Color-equality-free). +check(BarTheme.resolve(.dark).palette == BarPalette.dark, "resolve(.dark) draws from dark palette") +check( + BarTheme.resolve(.light).palette == BarPalette.light, "resolve(.light) draws from light palette") +check(BarTheme.dark.palette == BarPalette.dark, "BarTheme.dark preset uses dark palette") +check(BarTheme.light.palette == BarPalette.light, "BarTheme.light preset uses light palette") +check(BarThemeKey.defaultValue.palette == BarPalette.dark, "environment default is the dark preset") + +// BarAppearanceStore round-trips, defaults to .dark on an absent key. Use an +// isolated suite so we never pollute (or depend on) real user defaults. +do { + let suiteName = "ccs-bar-check-\(ProcessInfo.processInfo.globallyUniqueString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removeObject(forKey: BarAppearanceStore.defaultsKey) + // Absent key -> .dark (the no-registration fallback). + let raw = defaults.string(forKey: BarAppearanceStore.defaultsKey) ?? BarAppearance.dark.rawValue + check(BarAppearance(rawValue: raw) == .dark, "appearance store defaults to .dark on absent key") + // Round-trip save(.light) -> load(). + defaults.set(BarAppearance.light.rawValue, forKey: BarAppearanceStore.defaultsKey) + let back = BarAppearance(rawValue: defaults.string(forKey: BarAppearanceStore.defaultsKey) ?? "") + check(back == .light, "appearance store round-trips save(.light) -> load() == .light") + // Garbage string -> nil (load() coalesces to .dark). + defaults.set("nonsense", forKey: BarAppearanceStore.defaultsKey) + let g = BarAppearance(rawValue: defaults.string(forKey: BarAppearanceStore.defaultsKey) ?? "") + check(g == nil, "appearance store: garbage raw -> nil (load() coalesces to .dark)") + defaults.removePersistentDomain(forName: suiteName) +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarTheme.swift b/macos-bar/Sources/CCSBarCore/BarTheme.swift new file mode 100644 index 00000000..1a49eaf1 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarTheme.swift @@ -0,0 +1,196 @@ +import SwiftUI + +// Theme token model for the menu-bar dropdown. +// +// Lives in CCSBarCore (not the SwiftUI app target) so the assert harness can +// import and verify the palette/enum/resolver without a full Xcode/XCTest +// toolchain. This is the ONE file in CCSBarCore that imports SwiftUI — every +// other Core file stays Foundation-only. SwiftUI's ColorScheme/Color compile +// and run on the CommandLineTools toolchain, which is why this is safe here. + +// MARK: - Raw palette (harness-assertable) + +/// A plain RGB triple. We keep raw Doubles (not SwiftUI `Color`) as the source +/// of truth because `Color` equality is unreliable for tests — two Colors built +/// from identical components are not guaranteed `==`. Asserting on these Doubles +/// is exact, so the dark-regression lock and light-value lock are byte-precise. +public struct RGB: Equatable, Sendable { + public let r: Double + public let g: Double + public let b: Double + public init(_ r: Double, _ g: Double, _ b: Double) { + self.r = r + self.g = g + self.b = b + } +} + +/// Named color triples for one appearance. Pure data so it round-trips through +/// the harness with exact equality. +public struct BarPalette: Equatable, Sendable { + public let accentRGB: RGB + public let subscriptionRGB: RGB + public let bandGreenRGB: RGB + public let bandAmberRGB: RGB + public let bandCoralRGB: RGB + public let bandRedRGB: RGB + /// Light-mode window plate. In dark mode the window defers to the native + /// MenuBarExtra material, so this value is unused there (windowSurface == .clear). + public let windowSurfaceRGB: RGB + + public init( + accentRGB: RGB, subscriptionRGB: RGB, bandGreenRGB: RGB, bandAmberRGB: RGB, + bandCoralRGB: RGB, bandRedRGB: RGB, windowSurfaceRGB: RGB + ) { + self.accentRGB = accentRGB + self.subscriptionRGB = subscriptionRGB + self.bandGreenRGB = bandGreenRGB + self.bandAmberRGB = bandAmberRGB + self.bandCoralRGB = bandCoralRGB + self.bandRedRGB = bandRedRGB + self.windowSurfaceRGB = windowSurfaceRGB + } + + /// DARK = today's exact values, lifted verbatim from the original Sparkline + /// `BarTheme` enum. These are LOCKED: any drift fails the harness, guaranteeing + /// byte-identical rendering on upgrade for users who stay on the default theme. + public static let dark = BarPalette( + accentRGB: RGB(0.886, 0.451, 0.137), // #E2732A CCS orange + subscriptionRGB: RGB(0.357, 0.388, 0.851), // #5B63D9 indigo + bandGreenRGB: RGB(0.36, 0.74, 0.56), // #5CBC8F emerald + bandAmberRGB: RGB(0.86, 0.67, 0.31), // #DBAB4F gold + bandCoralRGB: RGB(0.91, 0.46, 0.36), // #E8755C warning + bandRedRGB: RGB(0.85, 0.34, 0.31), // #D9564F critical + windowSurfaceRGB: RGB(0, 0, 0) // unused in dark (windowSurface == .clear) + ) + + /// LIGHT = deepened/saturated variants tuned for legibility on a ~#F5F5F7 + /// white plate. The dark-tuned muted values read too pale on white, so each + /// themed token is darkened with more saturation while preserving the + /// green→amber→coral→red ramp ordering. + public static let light = BarPalette( + accentRGB: RGB(0.812, 0.357, 0.063), // #CF5B10 deeper orange + subscriptionRGB: RGB(0.275, 0.302, 0.745), // #464DBE darker indigo + bandGreenRGB: RGB(0.106, 0.580, 0.357), // #1B945B emerald + bandAmberRGB: RGB(0.722, 0.490, 0.043), // #B87D0B ochre + bandCoralRGB: RGB(0.831, 0.302, 0.157), // #D44D28 coral + bandRedRGB: RGB(0.776, 0.157, 0.137), // #C62823 critical red + windowSurfaceRGB: RGB(0.961, 0.961, 0.969) // #F5F5F7 light plate + ) +} + +// MARK: - Appearance enum + forced-scheme mapping + +/// User-selectable menu-bar theme. `.system` follows the real OS appearance; +/// `.light`/`.dark` force a scheme regardless of OS. +public enum BarAppearance: String, CaseIterable, Sendable { + case system + case light + case dark + + /// The scheme to force on the dropdown. `nil` => inherit the real OS + /// appearance; `.light`/`.dark` => override it. + public var forced: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +// MARK: - Resolved token struct (views read this) + +/// The resolved SwiftUI tokens consumed by the dropdown views. Built from a +/// `BarPalette`, plus two derived `Color.primary.opacity(...)` surfaces that +/// auto-invert with the forced scheme (primary is black on light, white on +/// dark) and so are identical in both presets. +public struct BarTheme: Sendable { + /// The palette this theme resolved from — kept so the harness can verify the + /// resolver picked the right set without relying on Color equality. + public let palette: BarPalette + + public let accent: Color + public let subscription: Color + public let bandGreen: Color + public let bandAmber: Color + public let bandCoral: Color + public let bandRed: Color + /// Faint elevated surface; derived, auto-inverts. Centralizes the inline + /// `Color.primary.opacity(0.05)` references. + public let cardSurface: Color + /// Quota-bar track; derived, auto-inverts. Centralizes `Color.primary.opacity(0.12)`. + public let barTrack: Color + /// Window plate. Dark = `.clear` (defer to native material, zero regression); + /// light = explicit #F5F5F7 so tokens never render on a leftover dark material. + public let windowSurface: Color + + public init(palette: BarPalette) { + self.palette = palette + self.accent = Color(rgb: palette.accentRGB) + self.subscription = Color(rgb: palette.subscriptionRGB) + self.bandGreen = Color(rgb: palette.bandGreenRGB) + self.bandAmber = Color(rgb: palette.bandAmberRGB) + self.bandCoral = Color(rgb: palette.bandCoralRGB) + self.bandRed = Color(rgb: palette.bandRedRGB) + self.cardSurface = Color.primary.opacity(0.05) + self.barTrack = Color.primary.opacity(0.12) + // Dark defers to the native MenuBarExtra material; only light owns a plate. + self.windowSurface = (palette == .dark) ? .clear : Color(rgb: palette.windowSurfaceRGB) + } + + public static let dark = BarTheme(palette: .dark) + public static let light = BarTheme(palette: .light) + + /// Pure resolver: returns the token set for a given (already-forced) scheme. + /// The root view applies `.preferredColorScheme(appearance.forced)` and then + /// reads `\.colorScheme` on a descendant, so the scheme passed here always + /// reflects exactly what the user sees (for `.system`, the real OS scheme). + public static func resolve(_ scheme: ColorScheme) -> BarTheme { + scheme == .dark ? .dark : .light + } +} + +extension Color { + /// Builds a Color from a raw RGB triple. + fileprivate init(rgb: RGB) { + self.init(red: rgb.r, green: rgb.g, blue: rgb.b) + } +} + +// MARK: - Environment propagation + +/// Injects the resolved theme down the view tree. The default is the tuned +/// dark preset so any view rendered outside an injected subtree (SwiftUI +/// previews, a stray child) gets the exact current look — no crash, no +/// regression. +public struct BarThemeKey: EnvironmentKey { + public static let defaultValue = BarTheme.dark +} + +extension EnvironmentValues { + public var barTheme: BarTheme { + get { self[BarThemeKey.self] } + set { self[BarThemeKey.self] = newValue } + } +} + +// MARK: - Persistence + +/// Persists the chosen appearance. Structurally mirrors `MenuBarIcon` load/save. +/// Appearance is global chrome (not an alert pref), so it is NOT registered in +/// `registerDefaults()`; the `?? .dark` fallback on a nil string read is the +/// source of the default, avoiding any registration-domain trap. +public enum BarAppearanceStore { + public static let defaultsKey = "ccsbar.appearance" + + public static func load() -> BarAppearance { + let raw = + UserDefaults.standard.string(forKey: defaultsKey) ?? BarAppearance.dark.rawValue + return BarAppearance(rawValue: raw) ?? .dark + } + + public static func save(_ appearance: BarAppearance) { + UserDefaults.standard.set(appearance.rawValue, forKey: defaultsKey) + } +}