Files
ccs/macos-bar/Sources/CCSBarCore/BarTheme.swift
T
Tam Nhu Tran e33da989eb 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.
2026-06-09 16:11:36 -04:00

197 lines
7.8 KiB
Swift

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)
}
}