From 72289f71d9e760b38d30936dfcb6bb7ac4f6ed68 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sat, 30 May 2026 18:37:27 +0100 Subject: [PATCH 01/12] Add CmuxAppearance package: Aurean theme token layer Phase 1 of the Aurean appearance system. Introduces a leaf package of pure, testable value types with no app-target coupling: - AureanColor: hex->sRGB parsing (3/6/8-digit, optional hash, case-insensitive), opacity projection, Color/NSColor bridges - AureanOpacity: 1/phi^n opacity ladder stops - AppearancePalette: protocol with 8 semantic roles + opacity helpers - AureanPaletteVariant: cool/dune/warm/obsidian enum - AureanPalette: token values, cool default, warn/crit fixed across variants - AureanMetrics: phi, Fibonacci, scale, motion, geometry, golden split 10 Swift Testing cases cover hex parsing/fallback, palette defaults, signal invariants, variant distinctness, opacity ladder, and golden split. All green via `swift test` (isolated, no app launch). Co-Authored-By: Claude Opus 4.8 (1M context) --- Packages/CmuxAppearance/Package.swift | 17 +++ .../CmuxAppearance/AppearancePalette.swift | 58 +++++++++ .../Sources/CmuxAppearance/AureanColor.swift | 94 +++++++++++++++ .../CmuxAppearance/AureanMetrics.swift | 113 ++++++++++++++++++ .../CmuxAppearance/AureanOpacity.swift | 30 +++++ .../CmuxAppearance/AureanPalette.swift | 65 ++++++++++ .../CmuxAppearance/AureanPaletteVariant.swift | 21 ++++ .../AureanPaletteTests.swift | 87 ++++++++++++++ 8 files changed, 485 insertions(+) create mode 100644 Packages/CmuxAppearance/Package.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/AppearancePalette.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/AureanColor.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/AureanMetrics.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/AureanOpacity.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPalette.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift create mode 100644 Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanPaletteTests.swift diff --git a/Packages/CmuxAppearance/Package.swift b/Packages/CmuxAppearance/Package.swift new file mode 100644 index 000000000000..141010cae433 --- /dev/null +++ b/Packages/CmuxAppearance/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CmuxAppearance", + platforms: [.macOS(.v14)], + products: [ + .library(name: "CmuxAppearance", targets: ["CmuxAppearance"]) + ], + targets: [ + .target(name: "CmuxAppearance"), + .testTarget( + name: "CmuxAppearanceTests", + dependencies: ["CmuxAppearance"] + ) + ] +) diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AppearancePalette.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AppearancePalette.swift new file mode 100644 index 000000000000..4aea861b8389 --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AppearancePalette.swift @@ -0,0 +1,58 @@ +import Foundation + +/// The semantic color surface every Aurean palette must provide. +/// +/// Views consume colors through these eight semantic roles, never through raw hex, +/// so swapping the active palette (cool ⇄ dune ⇄ warm ⇄ obsidian) re-skins the whole +/// app without touching call sites. The four **signal** roles +/// (``accent``/``ok``/``warn``/``crit``) carry meaning that is stable across palettes — +/// only the negative-space temperature and the ``text`` tone shift — which preserves +/// the user's muscle memory. +/// +/// Conform a value type to this protocol; ``AureanPalette`` is the canonical +/// implementation. Keep it `Sendable` so palettes can cross actor boundaries. +public protocol AppearancePalette: Sendable { + /// Primary pane background (the design's `surface.primary` / liminal). + var surfacePrimary: AureanColor { get } + /// Raised surfaces — sidebars, headers, tab bars (`surface.off` / liminal-off). + var surfaceOff: AureanColor { get } + /// Deepest chrome — titlebar and status bar (`surface.abyssal`). + var surfaceAbyssal: AureanColor { get } + + /// Primary typography and grid lines (the design's "sand"). + var text: AureanColor { get } + + /// Signal: active selection, highlight, focus ring, search match (liminal-blue). + var accent: AureanColor { get } + /// Signal: running agent, input prompt, success, spend telemetry (flow-green). + var ok: AureanColor { get } + /// Signal: needs-input and dirty state (gold). Fixed across palettes. + var warn: AureanColor { get } + /// Signal: failure, deletions, forbidden actions (rust). Fixed across palettes. + var crit: AureanColor { get } +} + +extension AppearancePalette { + /// The ``text`` color projected onto a φ-opacity stop. + /// + /// - Parameter stop: The opacity ladder rung (border, faint, dust …). + /// - Returns: Sand at the requested opacity — the standard way to draw rules, + /// secondary labels, and washes without introducing new hues. + public func text(_ stop: AureanOpacity) -> AureanColor { + text.opacity(stop.value) + } + + /// The ``accent`` color projected onto a φ-opacity stop (e.g. selection washes). + /// - Parameter stop: The opacity ladder rung. + /// - Returns: Accent at the requested opacity. + public func accent(_ stop: AureanOpacity) -> AureanColor { + accent.opacity(stop.value) + } + + /// The ``ok`` color projected onto a φ-opacity stop (e.g. agent-pane phosphor wash). + /// - Parameter stop: The opacity ladder rung. + /// - Returns: Flow-green at the requested opacity. + public func ok(_ stop: AureanOpacity) -> AureanColor { + ok.opacity(stop.value) + } +} diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanColor.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanColor.swift new file mode 100644 index 000000000000..edbbffa882c6 --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanColor.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// A resolution-independent, testable color value used throughout the Aurean theme layer. +/// +/// ``AureanColor`` stores straight (non-premultiplied) sRGB components in the `0...1` +/// range plus an alpha channel. It deliberately avoids wrapping `SwiftUI.Color` or +/// `NSColor` as its storage so that the whole theme layer can be unit-tested without +/// launching AppKit or a rendering context — tests assert on ``red``/``green``/``blue``/ +/// ``alpha`` directly. +/// +/// Convert to platform colors only at the view boundary via ``color`` or ``nsColor``. +/// +/// ```swift +/// let sand = AureanColor(hex: "#C4C7CC") +/// Text("hello").foregroundStyle(sand.color) +/// ``` +public struct AureanColor: Sendable, Hashable, Codable { + /// The red component, in the sRGB `0...1` range. + public let red: Double + /// The green component, in the sRGB `0...1` range. + public let green: Double + /// The blue component, in the sRGB `0...1` range. + public let blue: Double + /// The opacity, in the `0...1` range, where `1` is fully opaque. + public let alpha: Double + + /// Creates a color from straight sRGB components. + /// + /// - Parameters: + /// - red: Red component, clamped to `0...1`. + /// - green: Green component, clamped to `0...1`. + /// - blue: Blue component, clamped to `0...1`. + /// - alpha: Opacity, clamped to `0...1`. Defaults to `1` (opaque). + public init(red: Double, green: Double, blue: Double, alpha: Double = 1) { + self.red = red.clamped01 + self.green = green.clamped01 + self.blue = blue.clamped01 + self.alpha = alpha.clamped01 + } + + /// Creates a color from a `#RRGGBB` or `#RRGGBBAA` hex string. + /// + /// The leading `#` is optional and parsing is case-insensitive. An unparseable + /// string yields opaque black, so this initializer never fails — the Aurean tokens + /// are compile-time constants validated by tests, not user input. + /// + /// - Parameter hex: A 6- or 8-digit hex string, e.g. `"#161819"` or `"161819FF"`. + public init(hex: String) { + var s = Substring(hex) + if s.first == "#" { s = s.dropFirst() } + let digits = String(s) + func byte(_ start: Int) -> Double { + let lo = digits.index(digits.startIndex, offsetBy: start) + let hi = digits.index(lo, offsetBy: 2) + return Double(UInt8(digits[lo.. AureanColor { + AureanColor(red: red, green: green, blue: blue, alpha: opacity) + } + + /// The SwiftUI representation, in the sRGB color space. + public var color: Color { + Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + } + + /// The AppKit representation, in the sRGB color space. + public var nsColor: NSColor { + NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha) + } +} + +private extension Double { + var clamped01: Double { Swift.min(1, Swift.max(0, self)) } +} diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanMetrics.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanMetrics.swift new file mode 100644 index 000000000000..d88f8f316672 --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanMetrics.swift @@ -0,0 +1,113 @@ +import CoreGraphics +import Foundation + +/// The dimensionless constants of the Aurean Protocol: the golden ratio, the Fibonacci +/// spacing ladder, the φ type scale, motion timings, and the fixed chrome geometry. +/// +/// These are pure declarations transcribed from the design's `tokens.css`. They carry no +/// behavior — only the numbers every surface is measured against. Multiply spacing by a +/// runtime `density` (0.75–1.4) at the call site; the ladder here is at density `1.0`. +public struct AureanMetrics: Sendable { + /// The golden ratio, φ. + public static let phi: CGFloat = 1.618033988749 + /// `1/φ` — the major share of a golden split (`0.618`). + public static let phiInverse: CGFloat = 0.618033988749 + /// `1/φ²` (`0.382`) — the minor share of a golden split. + public static let phiInverse2: CGFloat = 0.381966011250 + /// `1/φ³` (`0.236`). + public static let phiInverse3: CGFloat = 0.236067977499 + /// `1/φ⁴` (`0.145`). + public static let phiInverse4: CGFloat = 0.145898033750 + + /// The Fibonacci spacing ladder in points, at density `1.0`. + public struct Spacing: Sendable { + public static let s1: CGFloat = 1 + public static let s2: CGFloat = 2 + public static let s3: CGFloat = 3 + public static let s5: CGFloat = 5 + public static let s8: CGFloat = 8 + public static let s13: CGFloat = 13 + public static let s21: CGFloat = 21 + public static let s34: CGFloat = 34 + public static let s55: CGFloat = 55 + public static let s89: CGFloat = 89 + public static let s144: CGFloat = 144 + public static let s233: CGFloat = 233 + } + + /// The φ-anchored type scale (anchor 16px, ratio φ) and its line metrics. + public struct TypeScale: Sendable { + /// The anchor size all others derive from. + public static let anchor: CGFloat = 16 + /// `anchor / φ` ≈ 10 — uppercase technical labels. + public static let micro: CGFloat = 10 + /// The body size (`anchor`). + public static let base: CGFloat = 16 + /// `anchor · φ` ≈ 26. + public static let h3: CGFloat = 26 + /// `h3 · φ` ≈ 42. + public static let h2: CGFloat = 42 + /// `h2 · φ` ≈ 68. + public static let h1: CGFloat = 68 + /// Base line-height multiple (φ). + public static let lineHeight: CGFloat = 1.618 + /// Default letter tracking, in em. + public static let tracking: CGFloat = 0.01 + /// Wide tracking for uppercase labels, in em. + public static let trackingWide: CGFloat = 0.14 + /// The primary mono family, with fallbacks resolved by the platform. + public static let monoFamily = "JetBrains Mono" + /// Ordered fallbacks when the primary face is unavailable. + public static let monoFallbacks = ["Fira Code", "Berkeley Mono", "SF Mono", "Menlo"] + } + + /// Fibonacci-derived motion durations (seconds) and φ easing control points. + public struct Motion: Sendable { + /// Instant feedback (`160ms`). + public static let feedback: Double = 0.160 + /// Micro interactions — selection, focus ring (`260ms`). + public static let micro: Double = 0.260 + /// Panel and split transitions (`420ms`). + public static let panel: Double = 0.420 + /// Context and workspace switches (`680ms`). + public static let context: Double = 0.680 + /// The status-dot pulse cycle (`2618ms`, looped). + public static let pulse: Double = 2.618 + /// Control points for the φ ease curve: `cubic-bezier(0.382, 0, 0.236, 1)`. + public static let ease: (CGFloat, CGFloat, CGFloat, CGFloat) = (0.382, 0, 0.236, 1) + /// Control points for the φ ease-out curve: `cubic-bezier(0.145, 0.618, 0.236, 1)`. + public static let easeOut: (CGFloat, CGFloat, CGFloat, CGFloat) = (0.145, 0.618, 0.236, 1) + } + + /// Fixed chrome geometry in points. + public struct Geometry: Sendable { + /// Titlebar height. + public static let titlebarHeight: CGFloat = 38 + /// Status bar height. + public static let statusBarHeight: CGFloat = 27 + /// Pane header height. + public static let paneHeaderHeight: CGFloat = 28 + /// Workspace rail width in the dense Cockpit direction. + public static let railCockpitWidth: CGFloat = 208 + /// Workspace rail width in the calm Atelier direction (glyph-only). + public static let railAtelierWidth: CGFloat = 64 + /// Right sidebar width in Cockpit. + public static let rightSidebarCockpitWidth: CGFloat = 296 + /// Right sidebar width in Atelier. + public static let rightSidebarAtelierWidth: CGFloat = 264 + /// Split gutter thickness. + public static let gutter: CGFloat = 1 + /// Drag-grab tick length on the gutter. + public static let gutterGrabTick: CGFloat = 34 + /// Inner focus-ring thickness (never a glow or shadow). + public static let focusRing: CGFloat = 1 + } + + /// The golden pane split: there is no 50/50, and equalize (`⌥⌘=`) returns to this. + public struct Split: Sendable { + /// The major pane's fraction (`0.618`). + public static let major: CGFloat = AureanMetrics.phiInverse + /// The minor pane's fraction (`0.382`). + public static let minor: CGFloat = AureanMetrics.phiInverse2 + } +} diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanOpacity.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanOpacity.swift new file mode 100644 index 000000000000..f2c19b3940ec --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanOpacity.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The φ-derived opacity ladder that encodes depth in the Aurean theme. +/// +/// Depth is expressed **only** through opacity — never drop shadows or bevels (the +/// single exception is the window's own near-black cast shadow, which lives outside +/// this theme). Each stop is `1/φⁿ`, matching the design tokens. +/// +/// ```swift +/// let border = palette.text.opacity(AureanOpacity.border.value) // sand @ 0.236 +/// ``` +public enum AureanOpacity: Double, Sendable, CaseIterable { + /// Fully opaque (`1.0`) — solid fills. + case solid = 1.0 + /// `1/φ` (`0.618`) — organic primary detail. + case organic = 0.618 + /// `1/φ²` (`0.382`) — secondary content. + case secondary = 0.382 + /// `1/φ³` (`0.236`) — standard rules and borders. + case border = 0.236 + /// `1/φ⁴` (`0.145`) — faint trails and wash gradients. + case faint = 0.145 + /// `0.090` — telemetry "dust" and ghost rules. + case dust = 0.090 + /// `0.045` — the faintest pane-header wash. + case whisper = 0.045 + + /// The raw opacity value in the `0...1` range. + public var value: Double { rawValue } +} diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPalette.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPalette.swift new file mode 100644 index 000000000000..f6969d5acf64 --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPalette.swift @@ -0,0 +1,65 @@ +import Foundation + +/// The canonical ``AppearancePalette`` implementation, carrying the exact token values +/// for each ``AureanPaletteVariant``. +/// +/// Hex values are transcribed verbatim from the design's `tokens.css` (the source of +/// truth). The `warn` (gold `#E5C07B`) and `crit` (rust `#FF8A66`) signals are identical +/// across every variant by design; only negative space, sand, and the cool signals shift. +/// +/// ```swift +/// let palette = AureanPalette(variant: .cool) // cmux default +/// view.background(palette.surfacePrimary.color) +/// ``` +public struct AureanPalette: AppearancePalette, Sendable, Hashable { + public let surfacePrimary: AureanColor + public let surfaceOff: AureanColor + public let surfaceAbyssal: AureanColor + public let text: AureanColor + public let accent: AureanColor + public let ok: AureanColor + public let warn: AureanColor + public let crit: AureanColor + + /// The temperature this palette was built from. + public let variant: AureanPaletteVariant + + /// Builds the concrete palette for a temperature. + /// - Parameter variant: The Aurean temperature; defaults to ``AureanPaletteVariant/cool``. + public init(variant: AureanPaletteVariant = .cool) { + self.variant = variant + // Signals shared across all palettes (muscle-memory invariants). + self.warn = AureanColor(hex: "#E5C07B") // gold + self.crit = AureanColor(hex: "#FF8A66") // rust + switch variant { + case .cool: + surfacePrimary = AureanColor(hex: "#161819") + surfaceOff = AureanColor(hex: "#121314") + surfaceAbyssal = AureanColor(hex: "#0E1011") + text = AureanColor(hex: "#C4C7CC") + accent = AureanColor(hex: "#B8D8E8") + ok = AureanColor(hex: "#B6D4B0") + case .dune: + surfacePrimary = AureanColor(hex: "#1A1817") + surfaceOff = AureanColor(hex: "#141312") + surfaceAbyssal = AureanColor(hex: "#100E0D") + text = AureanColor(hex: "#D2C0B0") + accent = AureanColor(hex: "#AEE2EA") + ok = AureanColor(hex: "#C5E1A5") + case .warm: + surfacePrimary = AureanColor(hex: "#1E1A15") + surfaceOff = AureanColor(hex: "#18140F") + surfaceAbyssal = AureanColor(hex: "#140F0A") + text = AureanColor(hex: "#E0CAB2") + accent = AureanColor(hex: "#B8DDDC") + ok = AureanColor(hex: "#D0DFA5") + case .obsidian: + surfacePrimary = AureanColor(hex: "#15171A") + surfaceOff = AureanColor(hex: "#101214") + surfaceAbyssal = AureanColor(hex: "#0B0D0F") + text = AureanColor(hex: "#C9D0D4") + accent = AureanColor(hex: "#A3C9D6") + ok = AureanColor(hex: "#B6D8B8") + } + } +} diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift new file mode 100644 index 000000000000..d9ce60649fba --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift @@ -0,0 +1,21 @@ +import Foundation + +/// The four Aurean palette temperatures shipped by the design. +/// +/// All variants share the same signal hues for `warn`/`crit`; they differ only in the +/// temperature of the negative space and the tone of the sand text (and a slight shift +/// in `accent`/`ok` warmth). ``cool`` is the cmux default; ``dune`` is the canonical +/// Aurean Protocol base. +public enum AureanPaletteVariant: String, Sendable, CaseIterable, Codable { + /// Cool blue-grey negative space — the cmux default delivery. + case cool + /// Warm sand/desert base — the canonical Aurean Protocol palette. + case dune + /// Warmest amber negative space. + case warm + /// Cold near-black with cool sand — maximum contrast. + case obsidian + + /// The concrete palette of colors for this temperature. + public var palette: AureanPalette { AureanPalette(variant: self) } +} diff --git a/Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanPaletteTests.swift b/Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanPaletteTests.swift new file mode 100644 index 000000000000..6ec97dda0cca --- /dev/null +++ b/Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanPaletteTests.swift @@ -0,0 +1,87 @@ +import Testing +@testable import CmuxAppearance + +@Suite("Aurean theme layer") +struct AureanPaletteTests { + + @Test("Hex parsing yields correct sRGB components") + func hexParsing() { + let c = AureanColor(hex: "#161819") + #expect(abs(c.red - 0x16 / 255.0) < 1e-9) + #expect(abs(c.green - 0x18 / 255.0) < 1e-9) + #expect(abs(c.blue - 0x19 / 255.0) < 1e-9) + #expect(c.alpha == 1) + } + + @Test("Leading hash is optional and parsing is case-insensitive") + func hexFlexibility() { + #expect(AureanColor(hex: "ffffff") == AureanColor(hex: "#FFFFFF")) + #expect(AureanColor(hex: "#abcdef") == AureanColor(hex: "ABCDEF")) + } + + @Test("8-digit hex carries alpha") + func hexAlpha() { + let c = AureanColor(hex: "#00000080") + #expect(abs(c.alpha - 0x80 / 255.0) < 1e-9) + } + + @Test("Invalid hex falls back to opaque black, never crashes") + func hexInvalid() { + let c = AureanColor(hex: "nope") + #expect(c == AureanColor(red: 0, green: 0, blue: 0, alpha: 1)) + } + + @Test("Cool is the default palette with the delivered token values") + func coolDefault() { + let p = AureanPalette() + #expect(p.variant == .cool) + #expect(p.surfacePrimary == AureanColor(hex: "#161819")) + #expect(p.surfaceOff == AureanColor(hex: "#121314")) + #expect(p.surfaceAbyssal == AureanColor(hex: "#0E1011")) + #expect(p.text == AureanColor(hex: "#C4C7CC")) + #expect(p.accent == AureanColor(hex: "#B8D8E8")) + #expect(p.ok == AureanColor(hex: "#B6D4B0")) + } + + @Test("warn and crit signals are identical across every palette (muscle memory)") + func signalsInvariant() { + let gold = AureanColor(hex: "#E5C07B") + let rust = AureanColor(hex: "#FF8A66") + for variant in AureanPaletteVariant.allCases { + let p = variant.palette + #expect(p.warn == gold, "warn drifted in \(variant)") + #expect(p.crit == rust, "crit drifted in \(variant)") + } + } + + @Test("Every palette differs in negative space (no accidental duplicates)") + func variantsDistinct() { + let primaries = Set(AureanPaletteVariant.allCases.map { $0.palette.surfacePrimary }) + #expect(primaries.count == AureanPaletteVariant.allCases.count) + } + + @Test("Opacity projection keeps hue, replaces alpha") + func opacityProjection() { + let p = AureanPalette() + let border = p.text(.border) + #expect(border.red == p.text.red) + #expect(border.green == p.text.green) + #expect(border.blue == p.text.blue) + #expect(abs(border.alpha - 0.236) < 1e-9) + } + + @Test("Opacity ladder follows the 1/φⁿ stops") + func opacityLadder() { + #expect(AureanOpacity.organic.value == 0.618) + #expect(AureanOpacity.secondary.value == 0.382) + #expect(AureanOpacity.border.value == 0.236) + #expect(AureanOpacity.faint.value == 0.145) + } + + @Test("Golden split shares are φ-derived and sum to one") + func goldenSplit() { + #expect(abs(AureanMetrics.Split.major - 0.618033988749) < 1e-9) + #expect(abs(AureanMetrics.Split.minor - 0.381966011250) < 1e-9) + #expect(abs((AureanMetrics.Split.major + AureanMetrics.Split.minor) - 1) < 1e-6) + } +} From 79e1c592178a6ea7060b6753ead903c0135c5f18 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sat, 30 May 2026 18:40:54 +0100 Subject: [PATCH 02/12] Add Aurean theme provider and SwiftUI environment plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Aurean appearance system. Adds the writable owner and the view-tree distribution seam, still inside the leaf package with no app-target coupling: - AureanTheme: @Observable @MainActor owner of the active variant; resolves to a concrete palette and notifies observers on change. No shared/default instance — constructed at the app root and injected. - EnvironmentValues.aureanPalette: value-type palette in the environment, defaulting to the cool palette so views read sane colors even unthemed. - View.aureanTheme(_:): injects the owner and its resolved palette; reading the palette from the observable owner makes a variant flip re-skin the subtree. 6 Swift Testing cases cover default resolution, variant switching, observation firing (via confirmation), signal invariants surviving a switch, and the environment default/override. All 16 package tests green via `swift test`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/CmuxAppearance/AureanTheme.swift | 42 +++++++++++++ .../EnvironmentValues+AureanPalette.swift | 28 +++++++++ .../CmuxAppearance/View+AureanTheme.swift | 22 +++++++ .../AureanThemeTests.swift | 60 +++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/AureanTheme.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/EnvironmentValues+AureanPalette.swift create mode 100644 Packages/CmuxAppearance/Sources/CmuxAppearance/View+AureanTheme.swift create mode 100644 Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanThemeTests.swift diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanTheme.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanTheme.swift new file mode 100644 index 000000000000..a0a20000f541 --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanTheme.swift @@ -0,0 +1,42 @@ +import Observation + +/// The mutable owner of the active Aurean appearance, created once and injected at the app root. +/// +/// ``AureanTheme`` holds the selected ``AureanPaletteVariant`` and resolves it to a concrete +/// ``AureanPalette``. It is the single writable seam for re-skinning the app: flipping +/// ``variant`` notifies every observer (SwiftUI re-renders, `withObservationTracking` fires), +/// because the type is `@Observable`. +/// +/// Construct it at the app's startup site and inject it with +/// ``SwiftUI/View/aureanTheme(_:)`` — there is no shared/`default` instance, so tests +/// instantiate a fresh, isolated owner. Views read colors through +/// `@Environment(\.aureanPalette)` and reach back to this owner (to switch temperature) +/// through `@Environment(AureanTheme.self)`. +/// +/// ```swift +/// @State private var theme = AureanTheme(variant: .cool) +/// var body: some Scene { +/// WindowGroup { ContentView().aureanTheme(theme) } +/// } +/// ``` +@Observable +@MainActor +public final class AureanTheme { + /// The active temperature. Mutating this re-resolves ``palette`` and re-skins observers. + public var variant: AureanPaletteVariant + + /// The concrete palette resolved from the current ``variant``. + /// + /// Reading this from a SwiftUI body (directly or via ``SwiftUI/View/aureanTheme(_:)``) + /// registers an observation dependency on ``variant``, so a later variant change + /// re-invalidates the reader. + public var palette: AureanPalette { variant.palette } + + /// Creates a theme owner. + /// + /// - Parameter variant: The initial temperature; defaults to + /// ``AureanPaletteVariant/cool`` (the cmux delivery default). + public init(variant: AureanPaletteVariant = .cool) { + self.variant = variant + } +} diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/EnvironmentValues+AureanPalette.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/EnvironmentValues+AureanPalette.swift new file mode 100644 index 000000000000..bfa342db71f8 --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/EnvironmentValues+AureanPalette.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Carries the resolved ``AureanPalette`` down the view tree, defaulting to cool. +private struct AureanPaletteKey: EnvironmentKey { + static let defaultValue = AureanPalette(variant: .cool) +} + +extension EnvironmentValues { + /// The Aurean palette in effect for this view subtree. + /// + /// Defaults to `AureanPalette(variant: .cool)` when no ``AureanTheme`` has been + /// injected, so a view reads sane colors even outside a themed root. Inject the active + /// palette with ``SwiftUI/View/aureanTheme(_:)`` (which sources it from the observable + /// ``AureanTheme``); read it with `@Environment(\.aureanPalette)`. + /// + /// ```swift + /// @Environment(\.aureanPalette) private var palette + /// var body: some View { + /// Text("ready") + /// .foregroundStyle(palette.ok.color) + /// .background(palette.surfacePrimary.color) + /// } + /// ``` + public var aureanPalette: AureanPalette { + get { self[AureanPaletteKey.self] } + set { self[AureanPaletteKey.self] = newValue } + } +} diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/View+AureanTheme.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/View+AureanTheme.swift new file mode 100644 index 000000000000..b041a00b2b6a --- /dev/null +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/View+AureanTheme.swift @@ -0,0 +1,22 @@ +import SwiftUI + +extension View { + /// Injects an ``AureanTheme`` owner and its resolved palette into this view's environment. + /// + /// Apply once near the app root. Children read colors with + /// `@Environment(\.aureanPalette)` and reach the owner (to switch temperature) with + /// `@Environment(AureanTheme.self)`. Because the palette is read from the observable + /// ``AureanTheme`` here, flipping ``AureanTheme/variant`` re-skins the whole subtree. + /// + /// ```swift + /// ContentView().aureanTheme(theme) + /// ``` + /// + /// - Parameter theme: The theme owner, typically created once at the app root. + /// - Returns: A view whose environment carries both the theme and its current palette. + @MainActor + public func aureanTheme(_ theme: AureanTheme) -> some View { + environment(theme) + .environment(\.aureanPalette, theme.palette) + } +} diff --git a/Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanThemeTests.swift b/Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanThemeTests.swift new file mode 100644 index 000000000000..818e1d94515d --- /dev/null +++ b/Packages/CmuxAppearance/Tests/CmuxAppearanceTests/AureanThemeTests.swift @@ -0,0 +1,60 @@ +import Observation +import SwiftUI +import Testing +@testable import CmuxAppearance + +@MainActor +@Suite("Aurean theme provider") +struct AureanThemeTests { + + @Test("Default owner resolves to the cool palette") + func defaultVariant() { + let theme = AureanTheme() + #expect(theme.variant == .cool) + #expect(theme.palette == AureanPalette(variant: .cool)) + } + + @Test("Switching variant re-resolves the palette") + func switchVariant() { + let theme = AureanTheme(variant: .cool) + theme.variant = .obsidian + #expect(theme.palette == AureanPalette(variant: .obsidian)) + #expect(theme.palette.surfacePrimary == AureanColor(hex: "#15171A")) + } + + @Test("Mutating variant notifies observers") + func observationFires() async { + let theme = AureanTheme(variant: .cool) + await confirmation("palette observation fires on variant change") { fired in + withObservationTracking { + _ = theme.palette + } onChange: { + fired() + } + theme.variant = .dune + } + } + + @Test("warn/crit survive a variant switch (muscle-memory invariant holds through the owner)") + func signalsSurviveSwitch() { + let theme = AureanTheme(variant: .cool) + let warn = theme.palette.warn + let crit = theme.palette.crit + theme.variant = .warm + #expect(theme.palette.warn == warn) + #expect(theme.palette.crit == crit) + } + + @Test("Environment palette defaults to cool when no theme is injected") + func environmentDefault() { + let env = EnvironmentValues() + #expect(env.aureanPalette == AureanPalette(variant: .cool)) + } + + @Test("Environment palette carries an explicitly set palette") + func environmentOverride() { + var env = EnvironmentValues() + env.aureanPalette = AureanPalette(variant: .dune) + #expect(env.aureanPalette == AureanPalette(variant: .dune)) + } +} From 551e9cb34cf9d7d9d7cd93960a112c3fcb344be0 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sat, 30 May 2026 19:22:40 +0100 Subject: [PATCH 03/12] Wire CmuxAppearance into the app target and inject AureanTheme at root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Aurean appearance system. Links the CmuxAppearance package to the cmux app target (mirroring the CmuxSettings local-package wiring across the six pbxproj entries) and injects the theme at the window root: - Construct AureanTheme once at launch (@State, defaults to cool) - Apply .aureanTheme(_:) on MainWindowBootstrapView so descendant chrome can read @Environment(\.aureanPalette) No visible change yet — this only establishes the seam. Verified the Debug app builds clean via `./scripts/reload.sh --tag aurean` (BUILD SUCCEEDED). Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/cmuxApp.swift | 7 +++++++ cmux.xcodeproj/project.pbxproj | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0fdc7b27456c..48705b1f4353 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppearance import CmuxSettings import CmuxSettingsUI import SwiftUI @@ -14,6 +15,11 @@ struct cmuxApp: App { /// through it via the `@Setting` property wrapper. private let settingsRuntime: SettingsRuntime + /// Owner of the active Aurean appearance. Created once at launch and injected + /// into the window's environment via `.aureanTheme(_:)`; descendant chrome reads + /// colors through `@Environment(\.aureanPalette)`. Defaults to the cool palette. + @State private var aureanTheme = AureanTheme() + @StateObject private var tabManager: TabManager @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject var closedItemHistoryStore = ClosedItemHistoryStore.shared @@ -244,6 +250,7 @@ struct cmuxApp: App { WindowGroup { MainWindowBootstrapView() .settingsRuntime(settingsRuntime) + .aureanTheme(aureanTheme) .cmuxAppearanceColorScheme(appearanceMode) .onAppear { SettingsWindowPresenter.configure( diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 866393c0a0f1..5265c50c0fc9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -104,6 +104,7 @@ E3309A07 /* cmuxApp+EqualizeSplitsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A08 /* cmuxApp+EqualizeSplitsMenu.swift */; }; C4160A010000000000000001 /* cmuxApp+HistoryMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A010000000000000002 /* cmuxApp+HistoryMenu.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; + CDAEEA0100000000CDAEEA01 /* CmuxAppearance in Frameworks */ = {isa = PBXBuildFile; productRef = CDAEEA0200000000CDAEEA02 /* CmuxAppearance */; }; D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; @@ -1193,6 +1194,7 @@ files = ( A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, F53000A0A1B2C3D4E5F60718 /* CMUXAgentVault in Frameworks */, + CDAEEA0100000000CDAEEA01 /* CmuxAppearance in Frameworks */, 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */, C0DE46000000000000000001 /* CMUXExtensionClient in Frameworks */, C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, @@ -1905,6 +1907,7 @@ A5354305A5354305A5354305 /* CMUXSocketPathDomain */, A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, CD0CFE5200000000CD0CFE52 /* CmuxSettings */, + CDAEEA0200000000CDAEEA02 /* CmuxAppearance */, CD0CFE5500000000CD0CFE55 /* CmuxSettingsUI */, A8BD195031FC4B82B4354297 /* StackAuth */, ); @@ -2042,6 +2045,7 @@ A500D012A1B2C3D4E5F60718 /* XCLocalSwiftPackageReference "CMUXDebugLog" */, A5354304A5354304A5354304 /* XCLocalSwiftPackageReference "CMUXSocketPathDomain" */, CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */, + CDAEEA0300000000CDAEEA03 /* XCLocalSwiftPackageReference "CmuxAppearance" */, CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */, 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */, ); @@ -3154,6 +3158,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSettings; }; + CDAEEA0300000000CDAEEA03 /* XCLocalSwiftPackageReference "CmuxAppearance" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxAppearance; + }; CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSettingsUI; @@ -3255,6 +3263,11 @@ package = CD0CFE5100000000CD0CFE51 /* XCLocalSwiftPackageReference "CmuxSettings" */; productName = CmuxSettings; }; + CDAEEA0200000000CDAEEA02 /* CmuxAppearance */ = { + isa = XCSwiftPackageProductDependency; + package = CDAEEA0300000000CDAEEA03 /* XCLocalSwiftPackageReference "CmuxAppearance" */; + productName = CmuxAppearance; + }; CD0CFE5500000000CD0CFE55 /* CmuxSettingsUI */ = { isa = XCSwiftPackageProductDependency; package = CD0CFE5400000000CD0CFE54 /* XCLocalSwiftPackageReference "CmuxSettingsUI" */; From 62aeaee53229af45dc61668eed135a0bd156e8f1 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sat, 30 May 2026 19:54:12 +0100 Subject: [PATCH 04/12] Drive the dark-mode accent from the active Aurean palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the Aurean appearance system. The central accent color (selection, focus rings, drop targets — every caller of cmuxAccentNSColor) now resolves from the active Aurean palette in dark mode, replacing the hardcoded blue. - Add AureanAppearanceSettings: reads the persisted palette variant from UserDefaults (defaults to cool), so ambient AppKit color helpers rendered outside the SwiftUI environment still track the chosen temperature. This is the same key the settings picker and root AureanTheme will write in Phase 6. - cmuxAccentNSColor(for:) returns the palette accent in dark mode; light mode keeps the original blue (the pale Aurean accent lacks contrast on light). Verified the Debug app builds clean via reload.sh --tag aurean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sidebar/SidebarAppearanceSupport.swift | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 8503dcc62864..b0182dbf8b00 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppearance import Foundation import SwiftUI @@ -7,6 +8,21 @@ enum SidebarMatchTerminalBackgroundSettings { static let legacyAppliedSettingsFileDefaultKey = "cmux.settingsFile.sidebarMatchTerminalBackground.appliedDefault.v1" } +// Persisted selection of the active Aurean palette temperature. The settings UI and the +// root `AureanTheme` write `userDefaultsKey`; ambient AppKit color helpers read it here so +// chrome rendered outside the SwiftUI environment still tracks the chosen variant. +enum AureanAppearanceSettings { + static let userDefaultsKey = "aureanPaletteVariant" + + static var activeVariant: AureanPaletteVariant { + guard let raw = UserDefaults.standard.string(forKey: userDefaultsKey), + let variant = AureanPaletteVariant(rawValue: raw) else { return .cool } + return variant + } + + static var activePalette: AureanPalette { AureanPalette(variant: activeVariant) } +} + extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) @@ -53,13 +69,12 @@ func titlebarControlForegroundNSColor( func cmuxAccentNSColor(for colorScheme: ColorScheme) -> NSColor { switch colorScheme { case .dark: - return NSColor( - srgbRed: 0, - green: 145.0 / 255.0, - blue: 1.0, - alpha: 1.0 - ) + // Aurean is a dark-first design; the active palette's accent drives selection, + // focus rings, and drop targets across the app. + return AureanAppearanceSettings.activePalette.accent.nsColor default: + // Light mode keeps the original cmux blue — the pale Aurean accent lacks contrast + // on light backgrounds. return NSColor( srgbRed: 0, green: 136.0 / 255.0, From 011bf05082d38d3c9832db1e925d02f0586d6817 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sat, 30 May 2026 20:27:30 +0100 Subject: [PATCH 05/12] Make the Aurean surface the cohesive canvas for terminal and chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the Aurean appearance system. The window chrome (sidebar, titlebar, backdrop) mirrors the terminal background by design, so a single lever recolors the whole app: the terminal canvas now resolves from the active Aurean palette. - Seed GhosttyApp.defaultBackgroundColor with the active palette's surfacePrimary so the first paint matches the chrome that mirrors it. - In applyDefaultBackground, drive the stored background from the active Aurean surface while still honoring the user's Ghostty theme for foreground, cursor, and selection. Opacity/blur are preserved, so terminal transparency still works. This is a runtime, in-app override — it does not write the user's ghostty config, so it only affects this build. applyDefaultBackground runs on theme/appearance changes, not per keystroke, so the typing hot path is untouched. Verified the Debug app builds clean via reload.sh --tag aurean. Visual verification deferred: the screen was locked at capture time. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/GhosttyTerminalView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 80a82ceb6590..fa4a2e0d9070 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import AppKit +import CmuxAppearance import Metal import QuartzCore import Combine @@ -1732,7 +1733,9 @@ class GhosttyApp { /// pending tick on the main queue at any time. private var _tickScheduled = false private let _tickLock = NSLock() - private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor + // Seed the canvas with the active Aurean surface so the first paint (before Ghostty + // reports its theme colors) already matches the chrome that mirrors this value. + private(set) var defaultBackgroundColor: NSColor = AureanAppearanceSettings.activePalette.surfacePrimary.nsColor private(set) var defaultBackgroundOpacity: Double = 1.0 private(set) var defaultBackgroundBlur: GhosttyBackgroundBlur = .disabled private(set) var defaultForegroundColor: NSColor = GhosttyApp.fallbackAppearanceConfig.foregroundColor @@ -4032,11 +4035,15 @@ class GhosttyApp { let previousSelectionBackgroundHex = defaultSelectionBackground.hexString() let previousSelectionForegroundHex = defaultSelectionForeground.hexString() let previousColorScheme = effectiveTerminalColorSchemePreference - defaultBackgroundColor = color + // Aurean owns the canvas: the active palette's surface drives the terminal + // background, and the window chrome (which mirrors this color) follows. The + // user's Ghostty theme still supplies foreground, cursor, and selection colors. + let aureanBackground = AureanAppearanceSettings.activePalette.surfacePrimary.nsColor + defaultBackgroundColor = aureanBackground defaultBackgroundOpacity = opacity defaultBackgroundBlur = backgroundBlur effectiveTerminalColorSchemePreference = Self.terminalRuntimeColorSchemePreference( - forBackgroundColor: color + forBackgroundColor: aureanBackground ) if let foregroundColor { defaultForegroundColor = foregroundColor From bc2a1d30c448a6ced7042b1794324491b52ea615 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sun, 31 May 2026 01:12:06 +0100 Subject: [PATCH 06/12] Add Aurean palette variant picker with live re-skin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of the Aurean appearance system. Lets the user pick the palette temperature (cool / dune / warm / obsidian) in Settings → App, persists it, and re-skins the whole app live. - CmuxSettingsUI depends on CmuxAppearance (acyclic: CmuxAppearance is a leaf). - AureanPaletteVariant: SettingCodable (free via String RawRepresentable) so a DefaultsKey can back the picker; key declared inline (the enum lives in CmuxAppearance, which the schema layer does not depend on). userDefaultsKey matches the app's ambient AureanAppearanceSettings reader. - AureanPalettePickerRow: presentational swatch picker mirroring ThemePickerRow, one tile per variant previewing surface + sand + signal dots. - AppSection wires a DefaultsValueModel below the Theme row. - GhosttyApp.reapplyAureanSurface(): forces the canvas to re-resolve from the new surface (reusing the current scope so precedence is preserved, forceNotify so every workspace + the key window re-skin). applyDefaultBackground already overrides the stored background with the active Aurean surface. - cmuxApp observes the persisted variant: seeds AureanTheme on launch and, on change, updates the SwiftUI theme owner and calls reapplyAureanSurface. Verified the Debug app builds clean via reload.sh --tag aurean. Visual/live-switch verification deferred (screen locked). New picker strings carry English defaultValues; xcstrings/Japanese entries are a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- Packages/CmuxSettingsUI/Package.swift | 2 + .../Rows/AureanPalettePickerRow.swift | 97 +++++++++++++++++++ .../AureanPaletteVariant+SettingCodable.swift | 9 ++ .../CmuxSettingsUI/Sections/AppSection.swift | 20 ++++ Sources/GhosttyTerminalView.swift | 21 ++++ Sources/cmuxApp.swift | 13 +++ 6 files changed, 162 insertions(+) create mode 100644 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPalettePickerRow.swift create mode 100644 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPaletteVariant+SettingCodable.swift diff --git a/Packages/CmuxSettingsUI/Package.swift b/Packages/CmuxSettingsUI/Package.swift index b8f6c50a55c6..4b7f78f39cd2 100644 --- a/Packages/CmuxSettingsUI/Package.swift +++ b/Packages/CmuxSettingsUI/Package.swift @@ -15,12 +15,14 @@ let package = Package( ], dependencies: [ .package(path: "../CmuxSettings"), + .package(path: "../CmuxAppearance"), ], targets: [ .target( name: "CmuxSettingsUI", dependencies: [ .product(name: "CmuxSettings", package: "CmuxSettings"), + .product(name: "CmuxAppearance", package: "CmuxAppearance"), ] ), .testTarget( diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPalettePickerRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPalettePickerRow.swift new file mode 100644 index 000000000000..0ff2d143d9b7 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPalettePickerRow.swift @@ -0,0 +1,97 @@ +import CmuxAppearance +import SwiftUI + +/// Visual picker row for the active ``AureanPaletteVariant``. +/// +/// Mirrors ``ThemePickerRow``: a leading "Palette" title and a trailing row of tappable +/// swatches, one per temperature (cool / dune / warm / obsidian). Each swatch previews the +/// variant's negative space, sand text, and the accent/ok signal dots; the selected swatch +/// gets an accent border and tinted background. +@MainActor +struct AureanPalettePickerRow: View { + let selectedVariant: AureanPaletteVariant + let onSelect: (AureanPaletteVariant) -> Void + + private let swatchWidth: CGFloat = 60 + private let swatchHeight: CGFloat = 44 + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(String(localized: "settings.app.aureanPalette", defaultValue: "Palette")) + .font(.system(size: 13, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + ForEach(AureanPaletteVariant.allCases, id: \.self) { variant in + let isSelected = selectedVariant == variant + Button { + onSelect(variant) + } label: { + VStack(spacing: 4) { + swatch(for: variant.palette) + .frame(width: swatchWidth, height: swatchHeight) + + Text(variantDisplayName(variant)) + .font(.system(size: 10)) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(isSelected ? .primary : .secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.12) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + .focusable(false) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .layoutPriority(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func swatch(for palette: AureanPalette) -> some View { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(palette.surfacePrimary.color) + .overlay( + HStack(spacing: 4) { + Circle().fill(palette.accent.color).frame(width: 7, height: 7) + Circle().fill(palette.ok.color).frame(width: 7, height: 7) + Circle().fill(palette.warn.color).frame(width: 7, height: 7) + } + .padding(.horizontal, 6), + alignment: .bottomLeading + ) + .overlay( + Rectangle() + .fill(palette.text.color) + .frame(height: 2) + .padding(.horizontal, 6) + .padding(.top, 6), + alignment: .top + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(palette.text.opacity(0.145).color, lineWidth: 1) + ) + } + + private func variantDisplayName(_ variant: AureanPaletteVariant) -> String { + switch variant { + case .cool: return String(localized: "aurean.palette.cool", defaultValue: "Cool") + case .dune: return String(localized: "aurean.palette.dune", defaultValue: "Dune") + case .warm: return String(localized: "aurean.palette.warm", defaultValue: "Warm") + case .obsidian: return String(localized: "aurean.palette.obsidian", defaultValue: "Obsidian") + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPaletteVariant+SettingCodable.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPaletteVariant+SettingCodable.swift new file mode 100644 index 000000000000..5118de5a5c1c --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/AureanPaletteVariant+SettingCodable.swift @@ -0,0 +1,9 @@ +import CmuxAppearance +import CmuxSettings + +/// Persists ``AureanPaletteVariant`` through `UserDefaults` and the cmux JSON config. +/// +/// `AureanPaletteVariant` is a `String`-backed `RawRepresentable`, so it picks up +/// `SettingCodable`'s default implementation for free — this empty conformance just +/// opts it in so a `DefaultsKey` can back the settings picker. +extension AureanPaletteVariant: @retroactive SettingCodable {} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift index a8e845c60096..acd3924365e1 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppearance import CmuxSettings import SwiftUI import UniformTypeIdentifiers @@ -25,6 +26,7 @@ public struct AppSection: View { // actually drives invalidation. @State private var language: DefaultsValueModel @State private var appearance: DefaultsValueModel + @State private var aureanPalette: DefaultsValueModel @State private var appIcon: DefaultsValueModel @State private var placement: DefaultsValueModel @State private var inheritDir: DefaultsValueModel @@ -65,6 +67,17 @@ public struct AppSection: View { self.hostActions = hostActions _language = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.language)) _appearance = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appearance)) + // The Aurean palette variant is not part of the shared catalog (its enum lives in + // CmuxAppearance, which the schema layer does not depend on), so the key is declared + // inline here. `userDefaultsKey` matches the app's ambient AureanAppearanceSettings reader. + _aureanPalette = State(initialValue: DefaultsValueModel( + store: defaultsStore, + key: DefaultsKey( + id: "app.aureanPaletteVariant", + defaultValue: .cool, + userDefaultsKey: "aureanPaletteVariant" + ) + )) _appIcon = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appIcon)) _placement = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.newWorkspacePlacement)) _inheritDir = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.workspaceInheritWorkingDirectory)) @@ -148,6 +161,13 @@ public struct AppSection: View { ) SettingsCardDivider() + // Aurean palette — temperature picker (cool / dune / warm / obsidian) + AureanPalettePickerRow( + selectedVariant: aureanPalette.current, + onSelect: { aureanPalette.set($0) } + ) + SettingsCardDivider() + // App Icon — three-up visual picker mirroring legacy AppIconPickerRow( selectedMode: appIcon.current, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fa4a2e0d9070..6c3f229a454c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4080,6 +4080,27 @@ class GhosttyApp { } } + /// Re-applies the canvas background after the active Aurean palette variant changes. + /// + /// `applyDefaultBackground` already overrides the stored background with the active + /// Aurean surface, but it only runs when Ghostty reports colors. A variant switch in + /// settings changes no Ghostty color, so this forces a re-apply: it reuses the current + /// scope (so the scope-precedence guard passes without altering precedence) and forces + /// a change notification, which re-resolves every workspace's appearance config and the + /// key window backdrop — re-skinning the terminal canvas and the chrome that mirrors it. + @MainActor + func reapplyAureanSurface(source: String = "aurean.variantChanged") { + applyDefaultBackground( + color: defaultBackgroundColor, + opacity: defaultBackgroundOpacity, + backgroundBlur: defaultBackgroundBlur, + source: source, + scope: defaultBackgroundUpdateScope, + forceNotify: true + ) + applyBackgroundToKeyWindow() + } + private func nextBackgroundEventId() -> UInt64 { precondition(Thread.isMainThread, "Background event IDs must be generated on main thread") backgroundEventCounter &+= 1 diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 48705b1f4353..53be027faa10 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -26,6 +26,7 @@ struct cmuxApp: App { @StateObject private var sidebarState = SidebarState() @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue + @AppStorage("aureanPaletteVariant") private var aureanVariantRaw = AureanPaletteVariant.cool.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @@ -266,11 +267,15 @@ struct cmuxApp: App { UpdateLogStore.shared.append("ui test: cmuxApp onAppear") } #endif + aureanTheme.variant = AureanPaletteVariant(rawValue: aureanVariantRaw) ?? .cool bootstrapMainWindowScene() } .onChange(of: appearanceMode) { _ in applyAppearance() } + .onChange(of: aureanVariantRaw) { _ in + applyAureanVariant() + } .onChange(of: socketControlMode) { _ in updateSocketController() } @@ -886,6 +891,14 @@ struct cmuxApp: App { } } + /// Applies the persisted Aurean palette variant: updates the SwiftUI theme owner (so + /// environment-driven chrome re-renders) and forces the terminal canvas to re-resolve + /// from the new surface (so the AppKit chrome that mirrors it re-skins too). + private func applyAureanVariant() { + aureanTheme.variant = AureanPaletteVariant(rawValue: aureanVariantRaw) ?? .cool + GhosttyApp.shared.reapplyAureanSurface() + } + private static func applyAppearance(_ mode: AppearanceMode, duringLaunch: Bool = false) { AppearanceSettings.applyLiveMode( mode, From c6e3bb3ef9b9de17695dd594eb061c9848c52565 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sun, 31 May 2026 10:38:50 +0100 Subject: [PATCH 07/12] Make the window-root backdrop an opaque Aurean surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to Phase 5. The window-root backdrop previously inherited the terminal's background opacity, so a translucent terminal let the desktop wallpaper bleed through and diluted the Aurean canvas. Render the window-root backdrop as the opaque Aurean surface on the window host layer. A translucent terminal now composites over the palette canvas instead of the wallpaper, so the chrome reads solid Aurean while the terminal keeps its own background opacity (the "opaque chrome, transparent terminal" choice). Glass/blur styles still short-circuit to .clear, so this only affects the standard backdrop path. Verified by launching reload.sh --tag aurean and sampling the rendered window: canvas reads ~(26,28,28) ≈ Aurean cool surfacePrimary #161819, no warm wallpaper bleed; sidebar dark with the pale-blue Aurean accent on the selected workspace. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Windowing/WindowAppearanceSnapshot.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index b8facce0fc1d..ffa295915863 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -325,10 +325,14 @@ struct WindowAppearanceSnapshot { if terminalBackgroundBlur.isMacOSGlassStyle { return .clear } + // Aurean "opaque chrome": the window-root backdrop is the opaque palette surface, + // hosted on the window layer. A translucent terminal then composites over the Aurean + // canvas instead of the desktop wallpaper, so the chrome reads solid while the + // terminal keeps its own background opacity. return .ghosttyTerminalBackdrop( color: terminalBackgroundColor, - opacity: terminalBackgroundOpacity, - renderingMode: terminalRenderingMode + opacity: 1, + renderingMode: .windowHostBackdrop ) } } From 06baa10d31748a931f26f1163f2aebc79c2bd6fc Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sun, 31 May 2026 10:55:28 +0100 Subject: [PATCH 08/12] Make the sidebar backdrop an opaque Aurean surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the "opaque chrome" appearance: the sidebar backdrop was a translucent vibrancy material that let the desktop wallpaper bleed through. Resolve the sidebar material policy to a solid Aurean surfaceOff fill — material nil (no vibrancy is built) plus an opaque tint, which ContentView.backdrop paints as a flat Color over the backdrop layer. The sidebar now reads as part of the cohesive opaque Aurean chrome with no wallpaper bleed. (The sidebar list view still contributes its own slightly lighter background above this backdrop; theming that internal fill to exactly surfaceOff is a separate, more invasive change and is left as follow-up.) Verified by launching reload.sh --tag aurean: window reads as a uniform dark Aurean surface across terminal and sidebar, with the pale-blue Aurean accent on the selected workspace and no warm wallpaper bleed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Windowing/WindowAppearanceSnapshot.swift | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index ffa295915863..c22090b69c16 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppearance import SwiftUI enum GhosttyTerminalBackdropRenderingMode { @@ -106,31 +107,21 @@ struct SidebarBackdropSettingsSnapshot { let colorScheme: ColorScheme var materialPolicy: SidebarBackdropMaterialPolicy { - let materialOption = SidebarMaterialOption(rawValue: materialRawValue) + // Aurean "opaque chrome": the sidebar is a solid surfaceOff fill rather than a + // translucent vibrancy material, so it reads as part of the opaque Aurean chrome and + // never lets the desktop wallpaper bleed through. `material` is nil so no vibrancy is + // built; the opaque tint overlay (see ContentView.backdrop) paints the surface. let blendingMode = SidebarBlendModeOption(rawValue: blendModeRawValue)?.mode ?? .behindWindow let state = SidebarStateOption(rawValue: stateRawValue)?.state ?? .active - let resolvedHex: String - if colorScheme == .dark, let tintHexDark { - resolvedHex = tintHexDark - } else if colorScheme == .light, let tintHexLight { - resolvedHex = tintHexLight - } else { - resolvedHex = tintHex - } - let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: tintHex) ?? .black) - .withAlphaComponent(tintOpacity) - let preferLiquidGlass = materialOption?.usesLiquidGlass ?? false - let usesWindowLevelGlass = preferLiquidGlass && blendingMode == .behindWindow - return SidebarBackdropMaterialPolicy( - material: materialOption?.material, + material: nil, blendingMode: blendingMode, state: state, - opacity: blurOpacity, - tintColor: tintColor, + opacity: 1, + tintColor: AureanAppearanceSettings.activePalette.surfaceOff.nsColor, cornerRadius: CGFloat(max(0, cornerRadius)), - preferLiquidGlass: preferLiquidGlass, - usesWindowLevelGlass: usesWindowLevelGlass + preferLiquidGlass: false, + usesWindowLevelGlass: false ) } From 350fe8ca7afe408d80c9e6aa4def53302d04431b Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sun, 31 May 2026 10:57:35 +0100 Subject: [PATCH 09/12] Localize the Aurean palette picker strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the five user-facing strings introduced by the Aurean palette picker to Localizable.xcstrings with English and Japanese, per the localization rule: - settings.app.aureanPalette ("Palette" / パレット) - aurean.palette.cool / dune / warm / obsidian (Cool/Dune/Warm/Obsidian with katakana transliterations) Only en + ja are provided (the documented required set); the other 18 languages in the catalog fall back to the English source until translated. Verified the app builds clean via reload.sh --tag aurean (xcstrings compiles). Co-Authored-By: Claude Opus 4.8 (1M context) --- Resources/Localizable.xcstrings | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b22f74122c51..da2202c0d5d0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2,6 +2,91 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "settings.app.aureanPalette": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Palette" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パレット" + } + } + } + }, + "aurean.palette.cool": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cool" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クール" + } + } + } + }, + "aurean.palette.dune": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dune" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デューン" + } + } + } + }, + "aurean.palette.warm": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warm" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォーム" + } + } + } + }, + "aurean.palette.obsidian": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Obsidian" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オブシディアン" + } + } + } + }, "settings.error.alert.dismiss": { "localizations": { "en": { From 7fc7f96acbe77efa23ac0d1900b716e9e92b12db Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sun, 31 May 2026 11:30:03 +0100 Subject: [PATCH 10/12] Address code-review findings on the Aurean appearance work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review pass on the branch before merge: 1. Light mode (HIGH): the Aurean surface override was unconditional, so in light mode it forced a dark canvas/chrome under light-mode foreground text (unreadable) and was inconsistent with the light-aware accent. Gate all surface overrides on AureanAppearanceSettings.isActiveForCurrentAppearance (dark only); light mode keeps the user's Ghostty theme, opacity, rendering mode, and sidebar material. This also restores honoring OSC 11 / dynamic terminal backgrounds in light mode. 2. Sidebar stale on variant switch (MED): SidebarBackdropSettingsSnapshot .appKitMutationID omitted the active variant, so the AppKit backdrop could stay stale after a palette switch. Fold the active variant and the appearance-active flag into the id. 3. OSC 11 in dark mode is intentionally owned by the Aurean canvas (the headline feature); the light-mode guard above restores normal behavior where Aurean stands down. 4. Hot-path allocation: AureanPaletteVariant.palette now returns a shared cached value instead of re-parsing tokens on every accent/drop-target read. 5. Dead stored props: resolved by (1) — the light-mode branch uses tintHex/ tintOpacity/blurOpacity/material again. 6. Single source of truth: the UserDefaults key now lives once as AureanPaletteVariant.userDefaultsKey, referenced by the ambient settings, the @AppStorage binding, and the settings DefaultsKey. 7. Redundant variant seeding: AureanTheme is seeded from the persisted variant at construction, dropping the duplicate onAppear assignment. Verified: CmuxAppearance 16/16 tests green; app builds clean via reload.sh --tag aurean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CmuxAppearance/AureanPaletteVariant.swift | 14 +++- Sources/GhosttyTerminalView.swift | 18 +++-- .../Sidebar/SidebarAppearanceSupport.swift | 14 +++- .../Windowing/WindowAppearanceSnapshot.swift | 70 +++++++++++++++---- Sources/cmuxApp.swift | 15 ++-- 5 files changed, 101 insertions(+), 30 deletions(-) diff --git a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift index d9ce60649fba..81e691b3c159 100644 --- a/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift +++ b/Packages/CmuxAppearance/Sources/CmuxAppearance/AureanPaletteVariant.swift @@ -16,6 +16,18 @@ public enum AureanPaletteVariant: String, Sendable, CaseIterable, Codable { /// Cold near-black with cool sand — maximum contrast. case obsidian + /// The `UserDefaults` key under which the active variant is persisted. + /// + /// Shared so the settings picker, the app's ambient AppKit color helpers, and the + /// SwiftUI theme owner all read and write the same key with no drift. + public static let userDefaultsKey = "aureanPaletteVariant" + /// The concrete palette of colors for this temperature. - public var palette: AureanPalette { AureanPalette(variant: self) } + /// + /// Returns a shared cached value, so reading a variant's palette on a hot path (drop + /// targets, focus rings) costs a dictionary lookup rather than re-parsing the tokens. + public var palette: AureanPalette { Self.cachedPalettes[self] ?? AureanPalette(variant: self) } + + private static let cachedPalettes: [AureanPaletteVariant: AureanPalette] = + Dictionary(uniqueKeysWithValues: AureanPaletteVariant.allCases.map { ($0, AureanPalette(variant: $0)) }) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6c3f229a454c..951ec3e335e5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1734,8 +1734,11 @@ class GhosttyApp { private var _tickScheduled = false private let _tickLock = NSLock() // Seed the canvas with the active Aurean surface so the first paint (before Ghostty - // reports its theme colors) already matches the chrome that mirrors this value. - private(set) var defaultBackgroundColor: NSColor = AureanAppearanceSettings.activePalette.surfacePrimary.nsColor + // reports its theme colors) already matches the chrome that mirrors this value. In light + // mode Aurean stands down (it is dark-first), so seed the system background there. + private(set) var defaultBackgroundColor: NSColor = AureanAppearanceSettings.isActiveForCurrentAppearance + ? AureanAppearanceSettings.activePalette.surfacePrimary.nsColor + : .windowBackgroundColor private(set) var defaultBackgroundOpacity: Double = 1.0 private(set) var defaultBackgroundBlur: GhosttyBackgroundBlur = .disabled private(set) var defaultForegroundColor: NSColor = GhosttyApp.fallbackAppearanceConfig.foregroundColor @@ -4035,10 +4038,13 @@ class GhosttyApp { let previousSelectionBackgroundHex = defaultSelectionBackground.hexString() let previousSelectionForegroundHex = defaultSelectionForeground.hexString() let previousColorScheme = effectiveTerminalColorSchemePreference - // Aurean owns the canvas: the active palette's surface drives the terminal - // background, and the window chrome (which mirrors this color) follows. The - // user's Ghostty theme still supplies foreground, cursor, and selection colors. - let aureanBackground = AureanAppearanceSettings.activePalette.surfacePrimary.nsColor + // Aurean owns the canvas in dark mode: the active palette's surface drives the + // terminal background, and the window chrome (which mirrors this color) follows. The + // user's Ghostty theme still supplies foreground, cursor, and selection colors. In + // light mode Aurean stands down and the reported color (incl. OSC 11) is honored. + let aureanBackground = AureanAppearanceSettings.isActiveForCurrentAppearance + ? AureanAppearanceSettings.activePalette.surfacePrimary.nsColor + : color defaultBackgroundColor = aureanBackground defaultBackgroundOpacity = opacity defaultBackgroundBlur = backgroundBlur diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index b0182dbf8b00..2f3786c9161f 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -12,7 +12,7 @@ enum SidebarMatchTerminalBackgroundSettings { // root `AureanTheme` write `userDefaultsKey`; ambient AppKit color helpers read it here so // chrome rendered outside the SwiftUI environment still tracks the chosen variant. enum AureanAppearanceSettings { - static let userDefaultsKey = "aureanPaletteVariant" + static let userDefaultsKey = AureanPaletteVariant.userDefaultsKey static var activeVariant: AureanPaletteVariant { guard let raw = UserDefaults.standard.string(forKey: userDefaultsKey), @@ -20,7 +20,17 @@ enum AureanAppearanceSettings { return variant } - static var activePalette: AureanPalette { AureanPalette(variant: activeVariant) } + static var activePalette: AureanPalette { activeVariant.palette } + + /// Whether the Aurean palette should drive surfaces for the current app appearance. + /// + /// Aurean is a dark-first design with no light variant, so in light mode the app keeps + /// the user's existing (Ghostty/system) colors instead of forcing a dark surface that + /// would render light-mode foreground text unreadable. Defaults to `false` when no app + /// is available yet (early init), so the seed prefers the system background. + static var isActiveForCurrentAppearance: Bool { + NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + } } extension Color { diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index c22090b69c16..2905a8c05ddd 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -107,21 +107,48 @@ struct SidebarBackdropSettingsSnapshot { let colorScheme: ColorScheme var materialPolicy: SidebarBackdropMaterialPolicy { - // Aurean "opaque chrome": the sidebar is a solid surfaceOff fill rather than a - // translucent vibrancy material, so it reads as part of the opaque Aurean chrome and - // never lets the desktop wallpaper bleed through. `material` is nil so no vibrancy is - // built; the opaque tint overlay (see ContentView.backdrop) paints the surface. let blendingMode = SidebarBlendModeOption(rawValue: blendModeRawValue)?.mode ?? .behindWindow let state = SidebarStateOption(rawValue: stateRawValue)?.state ?? .active + // Aurean "opaque chrome" (dark mode only): the sidebar is a solid surfaceOff fill + // rather than a translucent vibrancy material, so it reads as part of the opaque + // Aurean chrome and never lets the desktop wallpaper bleed through. `material` is nil + // so no vibrancy is built; the opaque tint overlay (see ContentView.backdrop) paints + // the surface. + if AureanAppearanceSettings.isActiveForCurrentAppearance { + return SidebarBackdropMaterialPolicy( + material: nil, + blendingMode: blendingMode, + state: state, + opacity: 1, + tintColor: AureanAppearanceSettings.activePalette.surfaceOff.nsColor, + cornerRadius: CGFloat(max(0, cornerRadius)), + preferLiquidGlass: false, + usesWindowLevelGlass: false + ) + } + // Light mode: honor the user's configured sidebar material and tint. + let materialOption = SidebarMaterialOption(rawValue: materialRawValue) + let resolvedHex: String + if colorScheme == .dark, let tintHexDark { + resolvedHex = tintHexDark + } else if colorScheme == .light, let tintHexLight { + resolvedHex = tintHexLight + } else { + resolvedHex = tintHex + } + let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: tintHex) ?? .black) + .withAlphaComponent(tintOpacity) + let preferLiquidGlass = materialOption?.usesLiquidGlass ?? false + let usesWindowLevelGlass = preferLiquidGlass && blendingMode == .behindWindow return SidebarBackdropMaterialPolicy( - material: nil, + material: materialOption?.material, blendingMode: blendingMode, state: state, - opacity: 1, - tintColor: AureanAppearanceSettings.activePalette.surfaceOff.nsColor, + opacity: blurOpacity, + tintColor: tintColor, cornerRadius: CGFloat(max(0, cornerRadius)), - preferLiquidGlass: false, - usesWindowLevelGlass: false + preferLiquidGlass: preferLiquidGlass, + usesWindowLevelGlass: usesWindowLevelGlass ) } @@ -137,6 +164,11 @@ struct SidebarBackdropSettingsSnapshot { Self.identityComponent(cornerRadius), Self.identityComponent(blurOpacity), String(describing: colorScheme), + // The resolved sidebar fill depends on the active Aurean variant (and whether + // Aurean is driving at all), so both must enter the mutation id or the AppKit + // backdrop would stay stale after a palette switch. + AureanAppearanceSettings.activeVariant.rawValue, + String(AureanAppearanceSettings.isActiveForCurrentAppearance), ].joined(separator: "|") } @@ -316,14 +348,22 @@ struct WindowAppearanceSnapshot { if terminalBackgroundBlur.isMacOSGlassStyle { return .clear } - // Aurean "opaque chrome": the window-root backdrop is the opaque palette surface, - // hosted on the window layer. A translucent terminal then composites over the Aurean - // canvas instead of the desktop wallpaper, so the chrome reads solid while the - // terminal keeps its own background opacity. + // Aurean "opaque chrome" (dark mode only): the window-root backdrop is the opaque + // palette surface, hosted on the window layer. A translucent terminal then composites + // over the Aurean canvas instead of the desktop wallpaper, so the chrome reads solid + // while the terminal keeps its own background opacity. In light mode Aurean stands + // down and the user's own opacity/rendering mode are honored. + if AureanAppearanceSettings.isActiveForCurrentAppearance { + return .ghosttyTerminalBackdrop( + color: terminalBackgroundColor, + opacity: 1, + renderingMode: .windowHostBackdrop + ) + } return .ghosttyTerminalBackdrop( color: terminalBackgroundColor, - opacity: 1, - renderingMode: .windowHostBackdrop + opacity: terminalBackgroundOpacity, + renderingMode: terminalRenderingMode ) } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 53be027faa10..a4777bc0d9a0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,10 +15,14 @@ struct cmuxApp: App { /// through it via the `@Setting` property wrapper. private let settingsRuntime: SettingsRuntime - /// Owner of the active Aurean appearance. Created once at launch and injected - /// into the window's environment via `.aureanTheme(_:)`; descendant chrome reads - /// colors through `@Environment(\.aureanPalette)`. Defaults to the cool palette. - @State private var aureanTheme = AureanTheme() + /// Owner of the active Aurean appearance. Created once at launch (seeded from the + /// persisted variant) and injected into the window's environment via `.aureanTheme(_:)`; + /// descendant chrome reads colors through `@Environment(\.aureanPalette)`. + @State private var aureanTheme = AureanTheme( + variant: AureanPaletteVariant( + rawValue: UserDefaults.standard.string(forKey: AureanPaletteVariant.userDefaultsKey) ?? "" + ) ?? .cool + ) @StateObject private var tabManager: TabManager @StateObject private var notificationStore = TerminalNotificationStore.shared @@ -26,7 +30,7 @@ struct cmuxApp: App { @StateObject private var sidebarState = SidebarState() @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue - @AppStorage("aureanPaletteVariant") private var aureanVariantRaw = AureanPaletteVariant.cool.rawValue + @AppStorage(AureanPaletteVariant.userDefaultsKey) private var aureanVariantRaw = AureanPaletteVariant.cool.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @@ -267,7 +271,6 @@ struct cmuxApp: App { UpdateLogStore.shared.append("ui test: cmuxApp onAppear") } #endif - aureanTheme.variant = AureanPaletteVariant(rawValue: aureanVariantRaw) ?? .cool bootstrapMainWindowScene() } .onChange(of: appearanceMode) { _ in From f96e62b4b2524a71bc564409090f6a7c5cee0ebb Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva <12453785+leosar@users.noreply.github.com> Date: Sun, 31 May 2026 12:18:57 +0100 Subject: [PATCH 11/12] Document the Aurean appearance: changelog + feature doc - CHANGELOG: add an Unreleased entry for the Aurean appearance (#5050). - docs/aurean-appearance.md: how to use the palette picker, the light-mode and terminal-transparency behavior, the CmuxAppearance package architecture, and how the theme reaches the app (root injection, ambient AppKit, live switch). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++ docs/aurean-appearance.md | 69 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 docs/aurean-appearance.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d615c76bc312..bbb6f6141585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to cmux are documented here. +## [Unreleased] + +### Added +- **Aurean appearance**: a golden-ratio (φ) based theme. cmux ships a cohesive dark palette across the terminal canvas and the window chrome, with a palette-temperature picker (Cool / Dune / Warm / Obsidian) in Settings → App that re-skins the app live. Light mode keeps the existing theme. ([#5050](https://github.com/manaflow-ai/cmux/pull/5050)) + ## [0.64.10] - 2026-05-23 ### Added diff --git a/docs/aurean-appearance.md b/docs/aurean-appearance.md new file mode 100644 index 000000000000..03f9f87969be --- /dev/null +++ b/docs/aurean-appearance.md @@ -0,0 +1,69 @@ +# Aurean appearance + +Aurean is cmux's golden-ratio (φ) based appearance system. It dresses the whole app — +the terminal canvas and the window chrome (sidebar, titlebar, backdrop) — in one cohesive +dark palette, and lets you switch the palette *temperature* live from Settings. + +## Using it + +Open **Settings → App → Palette** and pick a temperature: + +| Variant | Negative space | Notes | +| :-- | :-- | :-- | +| **Cool** | blue-grey | the cmux default | +| **Dune** | warm sand | the canonical Aurean Protocol base | +| **Warm** | amber | warmest negative space | +| **Obsidian** | cold near-black | maximum contrast | + +The choice is persisted and re-skins the running app immediately — no relaunch. Every +variant keeps the same **signal** colors so muscle memory survives: gold for +needs-input/dirty (`warn`) and rust for failure/destructive (`crit`) never move; only the +negative space, the sand text, and the accent/ok signals shift with temperature. + +**Light mode.** Aurean is a dark-first design with no light variant. In light appearance +mode it stands down: the app keeps your existing Ghostty/system colors, opacity, and +sidebar material, and the accent falls back to the original cmux blue. Aurean drives the +surfaces only in dark mode. + +**Terminal transparency.** Aurean owns the window backdrop as an opaque surface, so a +translucent terminal composites over the palette canvas instead of the desktop wallpaper. +Your terminal's own background opacity is preserved. + +## Architecture + +The color system lives in the leaf Swift package **`Packages/CmuxAppearance`** (no +dependencies; unit-tested without launching the app): + +- `AureanColor` — a resolution-independent sRGB color value (hex parsing, the φ-opacity + ladder, `Color`/`NSColor` bridges). Storage is plain components, so the whole layer is + testable without AppKit. +- `AppearancePalette` — the protocol of eight semantic roles + (`surfacePrimary`/`surfaceOff`/`surfaceAbyssal`, `text`, `accent`/`ok`/`warn`/`crit`). +- `AureanPalette` / `AureanPaletteVariant` — the concrete token values per temperature. + `AureanPaletteVariant.userDefaultsKey` is the single persisted key, and `.palette` + returns a shared cached value (cheap to read on hot paths). +- `AureanMetrics` — φ, the Fibonacci spacing ladder, the type scale, and the golden split. +- `AureanTheme` — an `@Observable @MainActor` owner of the active variant, injected at the + app root via `View.aureanTheme(_:)` and read through `@Environment(\.aureanPalette)`. + +### How it reaches the app + +The app target adopts the package at three seams: + +1. **Root injection** — `cmuxApp` constructs `AureanTheme` (seeded from the persisted + variant) and injects it; SwiftUI chrome reads `@Environment(\.aureanPalette)`. +2. **Ambient AppKit** — chrome rendered outside SwiftUI (accent, window/sidebar backdrops) + reads `AureanAppearanceSettings` (`Sources/Sidebar/SidebarAppearanceSupport.swift`), + which resolves the active palette from the same `UserDefaults` key. The window chrome + mirrors the terminal background, so driving the canvas re-skins the chrome with it. +3. **Live switch** — changing the variant updates the SwiftUI theme owner and calls + `GhosttyApp.reapplyAureanSurface()`, which forces the canvas (and the chrome that + mirrors it) to re-resolve from the new surface. + +The override is a runtime, in-app behavior — it does **not** write your `~/.config/ghostty`. + +## Adding a palette role or variant + +Add the case to `AureanPaletteVariant`, fill its token values in `AureanPalette`, and the +picker (`AureanPalettePickerRow`) and tests pick it up from `allCases`. Keep `warn`/`crit` +identical across variants — that invariant is asserted by `AureanPaletteTests`. From ad41bed778dec8fa348e1ae88f983de504367af4 Mon Sep 17 00:00:00 2001 From: Leonardo Saraiva Date: Sun, 31 May 2026 12:58:53 +0100 Subject: [PATCH 12/12] Default pane splits to the golden ratio (61.8 / 38.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aurean: space divides on Φ, never 50/50. A split node with no explicit ratio now resolves to 0.618 instead of 0.5, so freshly created splits open at the golden ratio. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/CmuxConfig.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index e46d54cd3895..657d7a9e5b91 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1762,7 +1762,9 @@ struct CmuxSplitDefinition: Codable, Sendable { } var clampedSplitPosition: Double { - let value = split ?? 0.5 + // Aurean: space divides on the golden ratio (61.8 / 38.2), never 50/50, when a + // split carries no explicit ratio. + let value = split ?? 0.618033988749 return min(0.9, max(0.1, value)) }