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.
This commit is contained in:
Tam Nhu Tran
2026-06-09 16:11:36 -04:00
parent eb384bb999
commit e33da989eb
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)
}
}