mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
Merge pull request #1486 from kaitranntt/kai/feat/ccs-bar-theming
feat(bar): selectable System/Light/Dark theming
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user