Merge pull request #1486 from kaitranntt/kai/feat/ccs-bar-theming

feat(bar): selectable System/Light/Dark theming
This commit is contained in:
Kai (Tam Nhu) Tran
2026-06-09 16:12:06 -04:00
committed by GitHub
9 changed files with 413 additions and 58 deletions
@@ -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)
+35 -19
View File
@@ -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 greenambercoralred 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 {
@@ -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) {
@@ -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
}
}
@@ -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()
}
+39 -1
View File
@@ -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<Content: View>: 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<Content: View>: 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)
}
}
+5 -23
View File
@@ -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 greenambercoralred.
/// 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)
}
+93
View File
@@ -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 greenambercoralred 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)
+196
View File
@@ -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
/// greenambercoralred 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)
}
}