mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
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.
197 lines
7.8 KiB
Swift
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)
|
|
}
|
|
}
|