From e33da989eb7c57f1832864e5d9680d18bce400d5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 9 Jun 2026 16:11:36 -0400 Subject: [PATCH] feat(bar-app): selectable System/Light/Dark theming Add a theme token system (CCSBarCore/BarTheme.swift): a BarAppearance enum (system/light/dark, default dark to preserve the current look), light+dark token palettes, a colorScheme-driven resolver, and a SwiftUI EnvironmentKey. The root forces .preferredColorScheme so Light renders light even under a dark macOS, and paints an explicit light window surface. Thread the tokens (accent, headroom bands, subscription, card surface, track) through every view so the whole dropdown themes consistently, with chip text blending toward black on light. Add an appearance picker in Preferences, persisted in UserDefaults. Dark accent/subscription values are locked byte-identical and harness-guarded; pool-account rows now share the muted band palette for cross-section consistency. --- .../Sources/CCSBarApp/BarAnalyticsView.swift | 10 +- macos-bar/Sources/CCSBarApp/BarMenuView.swift | 54 +++-- .../CCSBarApp/BarPreferencesView.swift | 19 +- .../CCSBarApp/BarSubscriptionCard.swift | 23 +- .../Sources/CCSBarApp/BarViewModel.swift | 8 + macos-bar/Sources/CCSBarApp/CCSBarApp.swift | 40 +++- macos-bar/Sources/CCSBarApp/Sparkline.swift | 28 +-- macos-bar/Sources/CCSBarCheck/main.swift | 93 +++++++++ macos-bar/Sources/CCSBarCore/BarTheme.swift | 196 ++++++++++++++++++ 9 files changed, 413 insertions(+), 58 deletions(-) create mode 100644 macos-bar/Sources/CCSBarCore/BarTheme.swift 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) + } +}