From 98105cec42c474e44a9c4482fa2cb9d927cd039a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:11:01 -0700 Subject: [PATCH 1/5] Add failing tests for a custom accent color Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxAccentColorTests.swift | 39 +++++++++++ ...hortcutSettingsFileStoreStartupTests.swift | 68 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift index 432a238fc3db..ebfb5a9d9f02 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift @@ -38,6 +38,45 @@ import Testing #expect(CmuxAccentColorMode.stored(in: defaults) == .cmux) } + @Test func customModeDrawsTheStoredColorInBothSchemes() throws { + let suite = "CmuxAccentColorTests.custom.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set(CmuxAccentColorMode.custom.rawValue, forKey: CmuxAccentColorMode.userDefaultsKey) + defaults.set(" ff6a00 ", forKey: CmuxAccentColorMode.customHexUserDefaultsKey) + let accent = CmuxAccentColor.stored(in: defaults) + #expect(accent.mode == .custom) + #expect(accent.customHex == "#FF6A00") + #expect(rgbBytes(accent.nsColor(isDark: false)) == [255, 106, 0]) + #expect(rgbBytes(accent.nsColor(isDark: true)) == [255, 106, 0]) + } + + @Test func customModeWithoutAValidColorFallsBackToCmuxBlue() { + for hex in [nil, "", "not-a-color", "#FF6A00AA"] { + let accent = CmuxAccentColor(mode: .custom, customHex: hex) + #expect(accent.customHex == nil) + #expect(rgbBytes(accent.nsColor(isDark: false)) == [0, 136, 255]) + #expect(rgbBytes(accent.nsColor(isDark: true)) == [0, 145, 255]) + } + } + + @MainActor + @Test func observerPostsWhenTheCustomColorChanges() throws { + let suite = "CmuxAccentColorTests.customObserver.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(CmuxAccentColorMode.custom.rawValue, forKey: CmuxAccentColorMode.userDefaultsKey) + defaults.set("#FF6A00", forKey: CmuxAccentColorMode.customHexUserDefaultsKey) + + let observer = CmuxAccentColorObserver(defaults: defaults, center: NotificationCenter()) + #expect(observer.current.customHex == "#FF6A00") + defaults.set("#00FF00", forKey: CmuxAccentColorMode.customHexUserDefaultsKey) + #expect(observer.refresh() == true) + #expect(rgbBytes(observer.current.nsColor(isDark: true)) == [0, 255, 0]) + #expect(observer.refresh() == false) + } + @Test func appearanceResolvesToMatchingScheme() throws { let dark = try #require(NSAppearance(named: .darkAqua)) let aqua = try #require(NSAppearance(named: .aqua)) diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index 31a1432273f3..98e146aa9de1 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -5,6 +5,8 @@ import Testing // so a blanket `import CmuxSettings` here makes those names ambiguous. Import only // the settings symbols this file needs. import struct CmuxSettings.AppCatalogSection +import struct CmuxFoundation.CmuxAccentColor +import enum CmuxFoundation.CmuxAccentColorMode import struct CmuxSettings.QuitConfirmationStore import enum CmuxSettings.ConfirmQuitMode import enum CmuxSettings.BrowserSearchEngine @@ -106,6 +108,72 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { ) } + func testHexAccentColorSelectsCustomModeWithNormalizedColor() throws { + let defaults = UserDefaults.standard + let keys = [ + CmuxAccentColorMode.userDefaultsKey, + CmuxAccentColorMode.customHexUserDefaultsKey, + settingsFileBackupsDefaultsKey, + importedManagedDefaultsKey, + ] + let previous = keys.map { defaults.object(forKey: $0) } + defer { + for (key, value) in zip(keys, previous) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + for key in keys { + defaults.removeObject(forKey: key) + } + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + let settingsURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "accentColor": "#ff6a00" + } + } + """, + to: settingsURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(defaults.string(forKey: CmuxAccentColorMode.userDefaultsKey), CmuxAccentColorMode.custom.rawValue) + XCTAssertEqual(defaults.string(forKey: CmuxAccentColorMode.customHexUserDefaultsKey), "#FF6A00") + XCTAssertEqual(CmuxAccentColor.stored(in: defaults).customHex, "#FF6A00") + + try writeSettingsFile( + """ + { + "app": { + "accentColor": "custom" + } + } + """, + to: settingsURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertNotEqual(defaults.string(forKey: CmuxAccentColorMode.userDefaultsKey), CmuxAccentColorMode.custom.rawValue) + } + func testSettingsFileStoreRestoresAbsentAppIconBackupDuringStartupWithoutTouchingAppKit() throws { let defaults = UserDefaults.standard let previousMode = defaults.object(forKey: AppIconSettings.modeKey) From 711feffd9b1cfcc3a652ee71f2b63c5cd5392ab9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:11:02 -0700 Subject: [PATCH 2/5] Let users pick a custom accent color Accent Color gains a Custom option with an inline color well. cmux.json accepts a #RRGGBB value for app.accentColor, which selects the custom mode. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Color/CmuxAccentColor.swift | 37 +++++- .../Color/CmuxAccentColorMode.swift | 20 +++ .../Color/CmuxAccentColorObserver.swift | 18 ++- .../CmuxConfigSchema.generated.swift | 13 +- .../CmuxSettings/Keys/AppCatalogSection.swift | 9 ++ .../CmuxAccentColorMode+SettingCodable.swift | 2 +- .../CmuxSettingsUI/Sections/AppSection.swift | 41 ++++-- Resources/Localizable.xcstrings | 118 ++++++++++++++++++ .../CmuxSettingsFileStore+AppSection.swift | 8 +- web/data/cmux.schema.json | 13 +- 10 files changed, 255 insertions(+), 24 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColor.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColor.swift index 145c2d947b53..931399ed4a51 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColor.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColor.swift @@ -1,4 +1,5 @@ public import AppKit +public import Foundation public import SwiftUI /// The one accent for cmux-drawn chrome: the color that means "this is the @@ -23,22 +24,46 @@ public struct CmuxAccentColor: Sendable, Hashable { public let mode: CmuxAccentColorMode + /// Normalized `#RRGGBB` color drawn in ``CmuxAccentColorMode/custom`` + /// mode. `nil` there falls back to cmux blue. + public let customHex: String? + public init(mode: CmuxAccentColorMode = .defaultValue) { + self.init(mode: mode, customHex: nil) + } + + public init(mode: CmuxAccentColorMode, customHex: String?) { self.mode = mode + self.customHex = CmuxAccentColorMode.normalizedCustomHex(customHex) + } + + /// The accent stored in `defaults` (`app.accentColor`). + public static func stored(in defaults: UserDefaults = .standard) -> CmuxAccentColor { + CmuxAccentColor( + mode: .stored(in: defaults), + customHex: CmuxAccentColorMode.storedCustomHex(in: defaults) + ) + } + + /// cmux's own blue for a light or dark appearance. + public static func cmuxBlue(isDark: Bool) -> NSColor { + NSColor( + srgbRed: 0, + green: (isDark ? 145.0 : 136.0) / 255.0, + blue: 1.0, + alpha: 1.0 + ) } /// The accent for a light or dark appearance. public func nsColor(isDark: Bool) -> NSColor { switch mode { case .cmux: - return NSColor( - srgbRed: 0, - green: (isDark ? 145.0 : 136.0) / 255.0, - blue: 1.0, - alpha: 1.0 - ) + return Self.cmuxBlue(isDark: isDark) case .system: return Self.systemAccent(isDark: isDark) + case .custom: + return customHex.flatMap { NSColor(hex: $0) } ?? Self.cmuxBlue(isDark: isDark) } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift index e1d386d2addb..0f94180f95fa 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift @@ -6,10 +6,15 @@ public enum CmuxAccentColorMode: String, CaseIterable, Sendable { case cmux /// The macOS accent color from System Settings > Appearance. case system + /// A user-chosen color stored under ``customHexUserDefaultsKey``. + case custom /// UserDefaults key storing the raw value. public static let userDefaultsKey = "appAccentColor" + /// UserDefaults key storing the `#RRGGBB` color for ``custom``. + public static let customHexUserDefaultsKey = "appAccentColorCustomHex" + /// Mode used when nothing valid is stored. public static let defaultValue: CmuxAccentColorMode = .cmux @@ -19,4 +24,19 @@ public enum CmuxAccentColorMode: String, CaseIterable, Sendable { public static func stored(in defaults: UserDefaults = .standard) -> CmuxAccentColorMode { CmuxAccentColorMode(rawValue: defaults.string(forKey: userDefaultsKey) ?? "") ?? defaultValue } + + /// Reads the stored custom color, or `nil` when it is missing or not a + /// valid `#RRGGBB` hex. + public static func storedCustomHex(in defaults: UserDefaults = .standard) -> String? { + normalizedCustomHex(defaults.string(forKey: customHexUserDefaultsKey)) + } + + /// Uppercased `#RRGGBB` form of a 6-digit hex, or `nil` when invalid. + public static func normalizedCustomHex(_ raw: String?) -> String? { + guard let raw else { return nil } + var digits = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if digits.hasPrefix("#") { digits.removeFirst() } + guard digits.count == 6, digits.allSatisfy(\.isHexDigit) else { return nil } + return "#" + digits.uppercased() + } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorObserver.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorObserver.swift index 3102c02362f4..135325171e23 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorObserver.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorObserver.swift @@ -1,6 +1,7 @@ public import AppKit -/// Resolves ``CmuxAccentColor`` from `app.accentColor` once per change and +/// Resolves ``CmuxAccentColor`` from `app.accentColor` (mode and custom +/// color) once per change and /// posts ``CmuxAccentColor/didChangeNotification`` (with itself as the /// object) when the resolved accent changes: when the setting changes, or /// when the macOS accent changes while the setting follows it. @@ -15,11 +16,12 @@ public final class CmuxAccentColorObserver { private let center: NotificationCenter private var systemColorsToken: (any NSObjectProtocol)? private var modeObservation: NSKeyValueObservation? + private var customHexObservation: NSKeyValueObservation? public init(defaults: UserDefaults = .standard, center: NotificationCenter = .default) { self.defaults = defaults self.center = center - self.current = CmuxAccentColor(mode: .stored(in: defaults)) + self.current = .stored(in: defaults) } public func startObserving() { @@ -29,6 +31,11 @@ public final class CmuxAccentColorObserver { self?.refresh() } } + customHexObservation = defaults.observe(\.appAccentColorCustomHex, options: []) { [weak self] _, _ in + Task { @MainActor [weak self] in + self?.refresh() + } + } systemColorsToken = center.addObserver( forName: NSColor.systemColorsDidChangeNotification, object: nil, @@ -44,7 +51,7 @@ public final class CmuxAccentColorObserver { /// draws differently than before. Returns whether it posted. @discardableResult public func refresh() -> Bool { - let next = CmuxAccentColor(mode: .stored(in: defaults)) + let next = CmuxAccentColor.stored(in: defaults) guard next != current else { return false } current = next center.post(name: CmuxAccentColor.didChangeNotification, object: self) @@ -58,4 +65,9 @@ extension UserDefaults { @objc dynamic var appAccentColor: String? { string(forKey: CmuxAccentColorMode.userDefaultsKey) } + + /// KVO hook for the custom accent hex, named after its UserDefaults key. + @objc dynamic var appAccentColorCustomHex: String? { + string(forKey: CmuxAccentColorMode.customHexUserDefaultsKey) + } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index 6786ef7fca17..e9122fc5b2f9 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -548,10 +548,17 @@ enum CmuxEmbeddedConfigSchema { "description": "App appearance mode." }, "accentColor": { - "type": "string", - "enum": ["cmux", "system"], + "oneOf": [ + { + "type": "string", + "enum": ["cmux", "system"] + }, + { + "$ref": "#/$defs/colorHex" + } + ], "default": "cmux", - "description": "Accent for cmux-drawn chrome: the selected workspace, attention ring and pane flash, agent status, pane swap, canvas focus, and scroll markers. \"cmux\" uses cmux blue; \"system\" follows the macOS accent color and updates when it changes. workspaceColors.selectionColor and notifications.paneFlashColor still override their parts. Native controls always use the macOS accent." + "description": "Accent for cmux-drawn chrome: the selected workspace, attention ring and pane flash, agent status, pane swap, canvas focus, and scroll markers. \"cmux\" uses cmux blue; \"system\" follows the macOS accent color and updates when it changes; a \"#RRGGBB\" hex uses that color in light and dark mode. workspaceColors.selectionColor and notifications.paneFlashColor still override their parts. Native controls always use the macOS accent." }, "appIcon": { "type": "string", diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift index 71a141410c9b..07f7e5745324 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift @@ -17,6 +17,15 @@ public struct AppCatalogSection: SettingCatalogSection { userDefaultsKey: CmuxAccentColorMode.userDefaultsKey ) + /// `#RRGGBB` color drawn when ``accentColor`` is + /// ``CmuxAccentColorMode/custom``. cmux.json sets it through a hex + /// `app.accentColor` value. + public let accentColorCustomHex = DefaultsKey( + id: "app.accentColorCustomHex", + defaultValue: "", + userDefaultsKey: CmuxAccentColorMode.customHexUserDefaultsKey + ) + public let language = DefaultsKey( id: "app.language", defaultValue: .system, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/CmuxAccentColorMode+SettingCodable.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/CmuxAccentColorMode+SettingCodable.swift index e6676c44e6a3..1cba0ab32f67 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/CmuxAccentColorMode+SettingCodable.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/CmuxAccentColorMode+SettingCodable.swift @@ -1,4 +1,4 @@ import CmuxFoundation -/// `app.accentColor` stores the mode's raw value (`"cmux"` or `"system"`). +/// `app.accentColor` stores the mode's raw value (`"cmux"`, `"system"` or `"custom"`). extension CmuxAccentColorMode: SettingCodable {} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift index 15e91a6fef4c..c055ad876971 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -25,6 +25,7 @@ public struct AppSection: View { @State private var language: DefaultsValueModel @State private var appearance: DefaultsValueModel @State private var accentColor: DefaultsValueModel + @State private var accentColorCustomHex: DefaultsValueModel @State private var appIcon: DefaultsValueModel @State private var placement: DefaultsValueModel @State private var inheritDir: DefaultsValueModel @@ -77,6 +78,8 @@ public struct AppSection: View { @State private var renameSelects: DefaultsValueModel @State private var paletteAllSurfaces: DefaultsValueModel + @Environment(\.colorScheme) private var colorScheme + @State private var languageAtAppear: AppLanguage? // Sticky: a picker change can rewrite the OS AppleLanguages override even when the selection returns to its starting value (clearing a preserved foreign override via an explicit pick, then System), so the restart hint must not rely on the value comparison alone. @State private var languageOverrideTouched = false @@ -97,6 +100,7 @@ public struct AppSection: View { _language = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.language)) _appearance = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appearance)) _accentColor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.accentColor)) + _accentColorCustomHex = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.accentColorCustomHex)) _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)) @@ -171,7 +175,7 @@ public struct AppSection: View { AppChannelSwitchCard(hostActions: hostActions) } .task { - startSettingsObservation([language, appearance, accentColor, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, focusHistoryIncludesPanesAndTabs, equalizeSplitsOnCreate, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, fileEditorSyntaxHighlighting, fileEditorLineNumbers, fileEditorIndentGuides, fileEditorCurrentLineHighlight, fileEditorTabWidth, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, desktopNotifications, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundWhenFocused, soundCommand, customSoundFile, soundOverrides, telemetry, confirmQuit, warnCloseTab, warnCloseX, warnCloseWorkspace, warnCloseWindow, hideCloseButton, renameSelects, paletteAllSurfaces]) + startSettingsObservation([language, appearance, accentColor, accentColorCustomHex, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, focusHistoryIncludesPanesAndTabs, equalizeSplitsOnCreate, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, fileEditorSyntaxHighlighting, fileEditorLineNumbers, fileEditorIndentGuides, fileEditorCurrentLineHighlight, fileEditorTabWidth, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, desktopNotifications, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundWhenFocused, soundCommand, customSoundFile, soundOverrides, telemetry, confirmQuit, warnCloseTab, warnCloseX, warnCloseWorkspace, warnCloseWindow, hideCloseButton, renameSelects, paletteAllSurfaces]) await soundAgentCache.loadIfNeeded { await hostActions.notificationSoundAgentOptions() } if languageAtAppear == nil { languageAtAppear = language.current }; if telemetryAtAppear == nil { telemetryAtAppear = telemetry.current } } @@ -192,6 +196,16 @@ public struct AppSection: View { ) } + /// Switching to Custom without a stored color seeds it with the accent + /// currently drawn, so the chrome does not jump before a color is picked. + private func selectAccentColorMode(_ mode: CmuxAccentColorMode) { + if mode == .custom, CmuxAccentColorMode.normalizedCustomHex(accentColorCustomHex.current) == nil { + let current = CmuxAccentColor(mode: accentColor.current, customHex: accentColorCustomHex.current) + accentColorCustomHex.set(current.nsColor(isDark: colorScheme == .dark).hexString()) + } + accentColor.set(mode) + } + private func setGlobalFontMagnification(_ percent: Int) { let clamped = GlobalFontMagnification.clamp(percent) globalFontMagnification.set(clamped) { NotificationCenter.default.post(name: GlobalFontMagnification.didChangeNotification, object: nil) } @@ -232,13 +246,26 @@ public struct AppSection: View { subtitle: String(localized: "settings.app.accentColor.subtitle", defaultValue: "Color of the selected workspace, attention ring, agent status, and other cmux highlights. System follows the macOS accent color."), controlWidth: Self.columnWidth ) { - Picker("", selection: Binding(get: { accentColor.current }, set: { accentColor.set($0) })) { - Text(String(localized: "settings.app.accentColor.cmux", defaultValue: "cmux Blue")).tag(CmuxAccentColorMode.cmux) - Text(String(localized: "settings.app.accentColor.system", defaultValue: "System")).tag(CmuxAccentColorMode.system) + HStack(spacing: 8) { + Picker("", selection: Binding(get: { accentColor.current }, set: { selectAccentColorMode($0) })) { + Text(String(localized: "settings.app.accentColor.cmux", defaultValue: "cmux Blue")).tag(CmuxAccentColorMode.cmux) + Text(String(localized: "settings.app.accentColor.system", defaultValue: "System")).tag(CmuxAccentColorMode.system) + Text(String(localized: "settings.app.accentColor.custom", defaultValue: "Custom")).tag(CmuxAccentColorMode.custom) + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("SettingsAccentColorPicker") + if accentColor.current == .custom { + HexColorPicker( + storedHex: accentColorCustomHex.current, + fallback: Color(nsColor: CmuxAccentColor.cmuxBlue(isDark: colorScheme == .dark)), + reconcileRevision: accentColorCustomHex.revision + ) { hex in + accentColorCustomHex.set(hex) + } + .accessibilityIdentifier("SettingsAccentColorCustomPicker") + } } - .labelsHidden() - .pickerStyle(.menu) - .accessibilityIdentifier("SettingsAccentColorPicker") } SettingsCardDivider() diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8d1e3fce11af..961b1fbd1bef 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -572110,6 +572110,124 @@ } } } + }, + "settings.app.accentColor.custom": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخصص" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prilagođeno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilpasset" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefiniert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Personalizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnalisée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Personalizzato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Egendefinert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Niestandardowy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Personalizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательский" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำหนดเอง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Власний" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自訂" + } + } + } } }, "version": "1.0" diff --git a/Sources/CmuxSettingsFileStore+AppSection.swift b/Sources/CmuxSettingsFileStore+AppSection.swift index 8ef3b142043a..02f616294e40 100644 --- a/Sources/CmuxSettingsFileStore+AppSection.swift +++ b/Sources/CmuxSettingsFileStore+AppSection.swift @@ -25,9 +25,15 @@ extension CmuxSettingsFileStore { snapshot.managedUserDefaults[AppearanceSettings.appearanceModeKey] = .string(normalized) } if section.keys.contains("accentColor") { - if let raw = jsonString(section["accentColor"]), + // "cmux", "system", or a "#RRGGBB" hex that selects the custom mode. + let raw = jsonString(section["accentColor"]) + if let raw, raw != CmuxAccentColorMode.custom.rawValue, let mode = CmuxAccentColorMode(rawValue: raw) { snapshot.managedUserDefaults[CmuxAccentColorMode.userDefaultsKey] = .string(mode.rawValue) + } else if let raw, raw.hasPrefix("#"), + let hex = CmuxAccentColorMode.normalizedCustomHex(raw) { + snapshot.managedUserDefaults[CmuxAccentColorMode.userDefaultsKey] = .string(CmuxAccentColorMode.custom.rawValue) + snapshot.managedUserDefaults[CmuxAccentColorMode.customHexUserDefaultsKey] = .string(hex) } else { logInvalid("app.accentColor", sourcePath: sourcePath) } diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index a6cd5ea57849..527568063fbe 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -538,10 +538,17 @@ "description": "App appearance mode." }, "accentColor": { - "type": "string", - "enum": ["cmux", "system"], + "oneOf": [ + { + "type": "string", + "enum": ["cmux", "system"] + }, + { + "$ref": "#/$defs/colorHex" + } + ], "default": "cmux", - "description": "Accent for cmux-drawn chrome: the selected workspace, attention ring and pane flash, agent status, pane swap, canvas focus, and scroll markers. \"cmux\" uses cmux blue; \"system\" follows the macOS accent color and updates when it changes. workspaceColors.selectionColor and notifications.paneFlashColor still override their parts. Native controls always use the macOS accent." + "description": "Accent for cmux-drawn chrome: the selected workspace, attention ring and pane flash, agent status, pane swap, canvas focus, and scroll markers. \"cmux\" uses cmux blue; \"system\" follows the macOS accent color and updates when it changes; a \"#RRGGBB\" hex uses that color in light and dark mode. workspaceColors.selectionColor and notifications.paneFlashColor still override their parts. Native controls always use the macOS accent." }, "appIcon": { "type": "string", From 9a789cab8cc7059c8d87a70de188dee3bc578540 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:33:53 -0700 Subject: [PATCH 3/5] Add failing tests for writing the accent color to cmux.json Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxAccentColorTests.swift | 17 ++++ .../AccentColorSettingsFileWriterTests.swift | 82 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/AccentColorSettingsFileWriterTests.swift diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift index ebfb5a9d9f02..fda770c51c22 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/CmuxAccentColorTests.swift @@ -77,6 +77,23 @@ import Testing #expect(observer.refresh() == false) } + @Test func settingsFileValueRoundTripsEveryMode() throws { + #expect(CmuxAccentColorMode.settingsFileValue(mode: .cmux, customHex: "#FF6A00") == "cmux") + #expect(CmuxAccentColorMode.settingsFileValue(mode: .system, customHex: nil) == "system") + #expect(CmuxAccentColorMode.settingsFileValue(mode: .custom, customHex: "ff6a00") == "#FF6A00") + #expect(CmuxAccentColorMode.settingsFileValue(mode: .custom, customHex: nil) == nil) + + for (mode, hex) in [(CmuxAccentColorMode.cmux, nil), (.system, nil), (.custom, "#FF6A00")] as [(CmuxAccentColorMode, String?)] { + let value = try #require(CmuxAccentColorMode.settingsFileValue(mode: mode, customHex: hex)) + let parsed = try #require(CmuxAccentColorMode.parseSettingsFileValue(value)) + #expect(parsed.mode == mode) + #expect(parsed.customHex == hex) + } + for invalid in ["custom", "purple", "#FF6A", "#FF6A00AA", ""] { + #expect(CmuxAccentColorMode.parseSettingsFileValue(invalid) == nil) + } + } + @Test func appearanceResolvesToMatchingScheme() throws { let dark = try #require(NSAppearance(named: .darkAqua)) let aqua = try #require(NSAppearance(named: .aqua)) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/AccentColorSettingsFileWriterTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/AccentColorSettingsFileWriterTests.swift new file mode 100644 index 000000000000..5f4581374015 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/AccentColorSettingsFileWriterTests.swift @@ -0,0 +1,82 @@ +import CmuxFoundation +import CmuxSettings +import Foundation +import Testing + +@testable import CmuxSettingsUI + +@MainActor +@Suite struct AccentColorSettingsFileWriterTests { + @Test func writesTheCustomColorToCmuxJSON() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("cmux.json") + let store = JSONConfigStore(fileURL: file) + var reloads = 0 + let writer = AccentColorSettingsFileWriter( + write: { value in + _ = try await store.setWithReceipt(value, for: AccentColorSettingsFileWriter.settingsFileKey) + reloads += 1 + }, + didFail: { error in Issue.record(error) } + ) + + let value = try #require(CmuxAccentColorMode.settingsFileValue(mode: .custom, customHex: "ff6a00")) + writer.request(value) + await writer.waitUntilIdle() + + let root = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any] + ) + #expect((root["app"] as? [String: Any])?["accentColor"] as? String == "#FF6A00") + #expect(reloads == 1) + #expect(writer.requestedValue == nil) + } + + @Test func keepsOnlyTheNewestRequestWhileAWriteIsInFlight() async { + var written: [String] = [] + var releaseFirstWrite: CheckedContinuation? + let writer = AccentColorSettingsFileWriter( + write: { value in + written.append(value) + if written.count == 1 { + await withCheckedContinuation { releaseFirstWrite = $0 } + } + }, + didFail: { error in Issue.record(error) } + ) + + writer.request("#111111") + while releaseFirstWrite == nil { await Task.yield() } + writer.request("#222222") + writer.request("#333333") + #expect(writer.requestedValue == "#333333") + releaseFirstWrite?.resume() + await writer.waitUntilIdle() + + #expect(written == ["#111111", "#333333"]) + #expect(writer.requestedValue == nil) + } + + @Test func aFailedWriteIsReportedAndLaterRequestsStillRun() async { + struct WriteFailed: Error {} + var written: [String] = [] + var failures = 0 + let writer = AccentColorSettingsFileWriter( + write: { value in + if value == "system" { throw WriteFailed() } + written.append(value) + }, + didFail: { _ in failures += 1 } + ) + + writer.request("system") + await writer.waitUntilIdle() + writer.request("cmux") + await writer.waitUntilIdle() + + #expect(failures == 1) + #expect(written == ["cmux"]) + } +} From e77c3cff608002a8535eef21b892d857b7e0ed98 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:33:53 -0700 Subject: [PATCH 4/5] Write the Settings accent color choice to cmux.json The Accent Color row now writes app.accentColor (cmux, system, or the custom #RRGGBB) to cmux.json and reloads the settings file, so cmux.json owns the value and a managed key no longer reverts a Settings change. Writes are serialized and coalesced so a color-well drag ends on the newest color. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Color/CmuxAccentColorMode.swift | 24 +++++++ .../AccentColorSettingsFileWriter.swift | 70 +++++++++++++++++++ .../Environment/SettingsHostActions.swift | 7 ++ .../Scene/SettingsWindowScene+Sections.swift | 2 + .../CmuxSettingsUI/Sections/AppSection.swift | 54 ++++++++++---- .../CmuxSettingsFileStore+AppSection.swift | 15 ++-- Sources/HostSettingsActions.swift | 4 ++ 7 files changed, 155 insertions(+), 21 deletions(-) create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/AccentColorSettingsFileWriter.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift index 0f94180f95fa..cfc83471c800 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Color/CmuxAccentColorMode.swift @@ -39,4 +39,28 @@ public enum CmuxAccentColorMode: String, CaseIterable, Sendable { guard digits.count == 6, digits.allSatisfy(\.isHexDigit) else { return nil } return "#" + digits.uppercased() } + + /// The `app.accentColor` value in cmux.json: `"cmux"`, `"system"`, or + /// the `#RRGGBB` color for ``custom``. `nil` for ``custom`` without a + /// valid color, which cmux.json cannot express. + public static func settingsFileValue(mode: CmuxAccentColorMode, customHex: String?) -> String? { + switch mode { + case .cmux, .system: + return mode.rawValue + case .custom: + return normalizedCustomHex(customHex) + } + } + + /// Parses an `app.accentColor` value from cmux.json. A `#RRGGBB` hex + /// selects ``custom`` with that color. The bare `"custom"` is rejected + /// because it carries no color. + public static func parseSettingsFileValue(_ raw: String) -> (mode: CmuxAccentColorMode, customHex: String?)? { + if raw.hasPrefix("#") { + guard let hex = normalizedCustomHex(raw) else { return nil } + return (.custom, hex) + } + guard let mode = CmuxAccentColorMode(rawValue: raw), mode != .custom else { return nil } + return (mode, nil) + } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/AccentColorSettingsFileWriter.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/AccentColorSettingsFileWriter.swift new file mode 100644 index 000000000000..5f8fa2af8392 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/AccentColorSettingsFileWriter.swift @@ -0,0 +1,70 @@ +import CmuxSettings +import Observation + +/// Writes the Accent Color row's `app.accentColor` value to cmux.json, one +/// write at a time, always ending on the newest request. +/// +/// cmux.json owns the value: after each write the host reloads the file, +/// which applies it to UserDefaults, and the row reads it back from there. +/// A color-well drag requests many values; while a write is in flight only +/// the newest request is kept, so writes never pile up and the last one wins. +/// ``requestedValue`` holds that newest request until every write finishes, +/// so the row shows the user's choice instead of an older value a reload +/// applied on the way. +@MainActor +@Observable +final class AccentColorSettingsFileWriter { + /// The newest requested value while writes are outstanding, else `nil`. + private(set) var requestedValue: String? + + @ObservationIgnored private var queuedValue: String? + @ObservationIgnored private var drainTask: Task? + @ObservationIgnored private let write: @MainActor (String) async throws -> Void + @ObservationIgnored private let didFail: @MainActor (Error) -> Void + + /// - Parameters: + /// - write: Persists one value to cmux.json and applies it. + /// - didFail: Reports a failed write. Later requests still run. + init( + write: @escaping @MainActor (String) async throws -> Void, + didFail: @escaping @MainActor (Error) -> Void + ) { + self.write = write + self.didFail = didFail + } + + /// Queues `value`, replacing any queued value not yet written. + func request(_ value: String) { + requestedValue = value + queuedValue = value + guard drainTask == nil else { return } + drainTask = Task { [weak self] in + await self?.drain() + } + } + + /// Waits until every requested value has been written. + func waitUntilIdle() async { + await drainTask?.value + } + + private func drain() async { + while let value = queuedValue { + queuedValue = nil + do { + try await write(value) + } catch { + didFail(error) + } + } + requestedValue = nil + drainTask = nil + } +} + +extension AccentColorSettingsFileWriter { + /// The cmux.json key the Accent Color row writes. It shares its id with + /// the UserDefaults-backed ``AppCatalogSection/accentColor``, which the + /// settings file store fills from this value. + static let settingsFileKey = JSONKey(id: "app.accentColor", defaultValue: "cmux") +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 5d4cfc6a99af..c2c3c3ca8072 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -302,6 +302,10 @@ public protocol SettingsHostActions: AnyObject { /// Invalidates host-owned shortcut caches after Settings persists a shortcut change. func notifyShortcutSettingsDidChange() + /// Reloads cmux.json after Settings writes it, so its values apply to + /// UserDefaults and live chrome before the file watcher notices. + func reloadSettingsFile() + /// Whether the host can register `shortcut` as its system-wide hotkey. /// /// The macOS host applies Carbon conversion and app-reservation checks that @@ -532,6 +536,9 @@ public extension SettingsHostActions { /// Default no-op for hosts with no app-owned shortcut caches. func notifyShortcutSettingsDidChange() {} + /// Default no-op for hosts without a settings file store. + func reloadSettingsFile() {} + /// Custom-sidebar defaults for package previews and tests without a live host. func customSidebarNames() -> [String] { [] } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift index 5589426cf576..c1e50cf30500 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift @@ -34,7 +34,9 @@ extension SettingsWindowRoot { slot(.app, proxy: proxy) { AppSection( defaultsStore: defaultsStore, + jsonStore: jsonStore, catalog: catalog, + errorLog: runtime.errorLog, hostActions: hostActions, soundAgentCache: soundAgentCache ) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift index c055ad876971..39a20592fe77 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -26,6 +26,7 @@ public struct AppSection: View { @State private var appearance: DefaultsValueModel @State private var accentColor: DefaultsValueModel @State private var accentColorCustomHex: DefaultsValueModel + @State private var accentColorWriter: AccentColorSettingsFileWriter @State private var appIcon: DefaultsValueModel @State private var placement: DefaultsValueModel @State private var inheritDir: DefaultsValueModel @@ -90,13 +91,24 @@ public struct AppSection: View { public init( defaultsStore: UserDefaultsSettingsStore, + jsonStore: JSONConfigStore, catalog: SettingCatalog, + errorLog: SettingsErrorLog, hostActions: SettingsHostActions, soundAgentCache: NotificationSoundAgentCache = NotificationSoundAgentCache() ) { self.catalog = catalog self.hostActions = hostActions self.soundAgentCache = soundAgentCache + _accentColorWriter = State(initialValue: AccentColorSettingsFileWriter( + write: { value in + _ = try await jsonStore.setWithReceipt(value, for: AccentColorSettingsFileWriter.settingsFileKey) + hostActions.reloadSettingsFile() + }, + didFail: { error in + errorLog.record(error, keyID: AccentColorSettingsFileWriter.settingsFileKey.id) + } + )) _language = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.language)) _appearance = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.appearance)) _accentColor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.accentColor)) @@ -196,14 +208,32 @@ public struct AppSection: View { ) } - /// Switching to Custom without a stored color seeds it with the accent - /// currently drawn, so the chrome does not jump before a color is picked. - private func selectAccentColorMode(_ mode: CmuxAccentColorMode) { - if mode == .custom, CmuxAccentColorMode.normalizedCustomHex(accentColorCustomHex.current) == nil { - let current = CmuxAccentColor(mode: accentColor.current, customHex: accentColorCustomHex.current) - accentColorCustomHex.set(current.nsColor(isDark: colorScheme == .dark).hexString()) + /// The accent the row shows: the newest choice still being written to + /// cmux.json, else the applied setting. + private var displayedAccentColor: CmuxAccentColor { + if let requested = accentColorWriter.requestedValue, + let parsed = CmuxAccentColorMode.parseSettingsFileValue(requested) { + return CmuxAccentColor(mode: parsed.mode, customHex: parsed.customHex ?? accentColorCustomHex.current) } - accentColor.set(mode) + return CmuxAccentColor(mode: accentColor.current, customHex: accentColorCustomHex.current) + } + + /// Switching to Custom keeps the last custom color, or seeds it with the + /// accent currently drawn so the chrome does not jump before a color is + /// picked. + private func selectAccentColorMode(_ mode: CmuxAccentColorMode) { + let displayed = displayedAccentColor + let customHex = mode == .custom + ? displayed.customHex ?? displayed.nsColor(isDark: colorScheme == .dark).hexString() + : nil + requestAccentColor(mode: mode, customHex: customHex) + } + + /// Writes the choice to cmux.json (`app.accentColor`); the host reload + /// then applies it to UserDefaults and the live chrome. + private func requestAccentColor(mode: CmuxAccentColorMode, customHex: String?) { + guard let value = CmuxAccentColorMode.settingsFileValue(mode: mode, customHex: customHex) else { return } + accentColorWriter.request(value) } private func setGlobalFontMagnification(_ percent: Int) { @@ -247,7 +277,7 @@ public struct AppSection: View { controlWidth: Self.columnWidth ) { HStack(spacing: 8) { - Picker("", selection: Binding(get: { accentColor.current }, set: { selectAccentColorMode($0) })) { + Picker("", selection: Binding(get: { displayedAccentColor.mode }, set: { selectAccentColorMode($0) })) { Text(String(localized: "settings.app.accentColor.cmux", defaultValue: "cmux Blue")).tag(CmuxAccentColorMode.cmux) Text(String(localized: "settings.app.accentColor.system", defaultValue: "System")).tag(CmuxAccentColorMode.system) Text(String(localized: "settings.app.accentColor.custom", defaultValue: "Custom")).tag(CmuxAccentColorMode.custom) @@ -255,13 +285,13 @@ public struct AppSection: View { .labelsHidden() .pickerStyle(.menu) .accessibilityIdentifier("SettingsAccentColorPicker") - if accentColor.current == .custom { + if displayedAccentColor.mode == .custom { HexColorPicker( - storedHex: accentColorCustomHex.current, + storedHex: displayedAccentColor.customHex ?? "", fallback: Color(nsColor: CmuxAccentColor.cmuxBlue(isDark: colorScheme == .dark)), - reconcileRevision: accentColorCustomHex.revision + reconcileRevision: accentColor.revision &+ accentColorCustomHex.revision ) { hex in - accentColorCustomHex.set(hex) + requestAccentColor(mode: .custom, customHex: hex) } .accessibilityIdentifier("SettingsAccentColorCustomPicker") } diff --git a/Sources/CmuxSettingsFileStore+AppSection.swift b/Sources/CmuxSettingsFileStore+AppSection.swift index 02f616294e40..ae815731e555 100644 --- a/Sources/CmuxSettingsFileStore+AppSection.swift +++ b/Sources/CmuxSettingsFileStore+AppSection.swift @@ -25,15 +25,12 @@ extension CmuxSettingsFileStore { snapshot.managedUserDefaults[AppearanceSettings.appearanceModeKey] = .string(normalized) } if section.keys.contains("accentColor") { - // "cmux", "system", or a "#RRGGBB" hex that selects the custom mode. - let raw = jsonString(section["accentColor"]) - if let raw, raw != CmuxAccentColorMode.custom.rawValue, - let mode = CmuxAccentColorMode(rawValue: raw) { - snapshot.managedUserDefaults[CmuxAccentColorMode.userDefaultsKey] = .string(mode.rawValue) - } else if let raw, raw.hasPrefix("#"), - let hex = CmuxAccentColorMode.normalizedCustomHex(raw) { - snapshot.managedUserDefaults[CmuxAccentColorMode.userDefaultsKey] = .string(CmuxAccentColorMode.custom.rawValue) - snapshot.managedUserDefaults[CmuxAccentColorMode.customHexUserDefaultsKey] = .string(hex) + if let raw = jsonString(section["accentColor"]), + let parsed = CmuxAccentColorMode.parseSettingsFileValue(raw) { + snapshot.managedUserDefaults[CmuxAccentColorMode.userDefaultsKey] = .string(parsed.mode.rawValue) + if let hex = parsed.customHex { + snapshot.managedUserDefaults[CmuxAccentColorMode.customHexUserDefaultsKey] = .string(hex) + } } else { logInvalid("app.accentColor", sourcePath: sourcePath) } diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index c4d06d06b7d2..2ff692daed07 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -227,6 +227,10 @@ final class HostSettingsActions: SettingsHostActions { } func notifyShortcutSettingsDidChange() { + reloadSettingsFile() + } + + func reloadSettingsFile() { // reload() already posts didChangeNotification when the file's // contents changed; posting again here double-notified every // listener. Only post when the reload saw no change, so callers From e5d3054aef20749c54d3db0c4c90fc67778f89ab Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:54:16 -0700 Subject: [PATCH 5/5] List the custom accent hex key as UserDefaults-only Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_cmux_schema_parity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_cmux_schema_parity.py b/tests/test_cmux_schema_parity.py index d693d86cefff..70cfc774c3d7 100644 --- a/tests/test_cmux_schema_parity.py +++ b/tests/test_cmux_schema_parity.py @@ -59,6 +59,9 @@ "account.selectedTeamID", "account.welcomeShown", # App preferences stored only in UserDefaults today. + # The custom accent color's storage; cmux.json sets it through a + # "#RRGGBB" app.accentColor value. + "app.accentColorCustomHex", "app.fileDropDefaultBehavior", "app.systemWideHotkeyEnabled", "app.titlebarControlsStyle",