From 6c99318fdb62916dea0cc1356d0412444fb0205f Mon Sep 17 00:00:00 2001 From: Christian North Date: Wed, 19 Aug 2026 11:40:55 -0700 Subject: [PATCH 1/3] test: require sidebar-scoped foreground colors --- cmuxTests/SidebarOrderingTests.swift | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index 6a935b0e71f1..37a18887bd14 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -51,6 +51,36 @@ final class SidebarActiveForegroundColorTests: XCTestCase { XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) } + + func testDarkSidebarUsesWhiteWhenTheAppAppearanceIsLight() { + guard let color = sidebarForegroundNSColor( + opacity: 0.9, + colorScheme: .dark + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.9, accuracy: 0.001) + } + + func testLightSidebarUsesBlackWhenTheAppAppearanceIsDark() { + guard let color = sidebarForegroundNSColor( + opacity: 0.8, + colorScheme: .light + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) + } } From 94b42ecb13b4347a8ce9d29addfecf165997c7ae Mon Sep 17 00:00:00 2001 From: Christian North Date: Wed, 19 Aug 2026 11:41:32 -0700 Subject: [PATCH 2/3] fix: use sidebar color scheme for labels --- Sources/ContentView.swift | 20 +++++++++++++++++-- .../Sidebar/SidebarAppearanceSupport.swift | 9 +++++++++ Sources/SidebarWorkspaceGroupHeaderView.swift | 6 +++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f3cb458856c4..4e15f1312867 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -16278,6 +16278,7 @@ private struct SidebarMetadataRows: View { let onFocus: () -> Void @State private var isExpanded: Bool = false + @Environment(\.colorScheme) private var colorScheme private let collapsedEntryLimit = 3 var body: some View { @@ -16301,7 +16302,14 @@ private struct SidebarMetadataRows: View { } .buttonStyle(.plain) .cmuxFont(size: 10 * fontScale, weight: .semibold) - .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) + .foregroundColor( + isActive + ? activeSecondaryForegroundColor + : Color(nsColor: sidebarForegroundNSColor( + opacity: 0.9, + colorScheme: colorScheme + )) + ) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -16430,6 +16438,7 @@ private struct SidebarMetadataMarkdownBlocks: View { let onFocus: () -> Void @State private var isExpanded: Bool = false + @Environment(\.colorScheme) private var colorScheme private let collapsedBlockLimit = 1 var body: some View { @@ -16453,7 +16462,14 @@ private struct SidebarMetadataMarkdownBlocks: View { } .buttonStyle(.plain) .cmuxFont(size: 10 * fontScale, weight: .semibold) - .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) + .foregroundColor( + isActive + ? activeSecondaryForegroundColor + : Color(nsColor: sidebarForegroundNSColor( + opacity: 0.9, + colorScheme: colorScheme + )) + ) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index ac43a665eba1..1ba6a8f14f74 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -50,6 +50,15 @@ func sidebarActiveForegroundNSColor( return baseColor.withAlphaComponent(clampedOpacity) } +func sidebarForegroundNSColor( + opacity: CGFloat, + colorScheme: ColorScheme +) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + let baseColor: NSColor = colorScheme == .dark ? .white : .black + return baseColor.withAlphaComponent(clampedOpacity) +} + func titlebarControlForegroundNSColor(opacity: CGFloat) -> NSColor { let app = GhosttyApp.shared let bestMatch = NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index d26403a68cda..f566c9bee904 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -95,6 +95,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let onContextMenuDisappear: () -> Void @State private var contextMenuVisible = false + @Environment(\.colorScheme) private var colorScheme #if DEBUG // Plain-value environment probe set only by SidebarLazyLayoutScaleTests; @@ -182,7 +183,10 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { .accessibilityHidden(true) Text(name) .cmuxFont(size: metrics.nameFontSize, weight: .semibold) - .foregroundStyle(isAnchorActive ? Color.primary : Color.primary.opacity(0.9)) + .foregroundStyle(Color(nsColor: sidebarForegroundNSColor( + opacity: isAnchorActive ? 1.0 : 0.9, + colorScheme: colorScheme + ))) .lineLimit(1) .truncationMode(.tail) if anchorUnreadCount > 0 { From c95f9a4fed0edf8a8e47b6e28d1bcb94ef4588d1 Mon Sep 17 00:00:00 2001 From: Christian North Date: Fri, 21 Aug 2026 19:58:55 -0700 Subject: [PATCH 3/3] fix: resolve sidebar label color at draw time A mid-session system appearance switch left group-header and metadata labels on the stale scheme (black-on-dark): the sidebar hosting context does not reliably re-render on appearance changes, so a color captured from the SwiftUI environment goes stale. Return a dynamicProvider NSColor instead, resolved against the drawing appearance, and drop the now-unused colorScheme plumbing. Regression test asserts one color instance draws white under darkAqua and black under aqua. Co-Authored-By: Claude Fable 5 --- Sources/ContentView.swift | 8 +-- .../Sidebar/SidebarAppearanceSupport.swift | 14 ++-- Sources/SidebarWorkspaceGroupHeaderView.swift | 4 +- cmuxTests/SidebarOrderingTests.swift | 69 +++++++++++++------ 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4e15f1312867..322c45c34ce5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -16278,7 +16278,6 @@ private struct SidebarMetadataRows: View { let onFocus: () -> Void @State private var isExpanded: Bool = false - @Environment(\.colorScheme) private var colorScheme private let collapsedEntryLimit = 3 var body: some View { @@ -16306,8 +16305,7 @@ private struct SidebarMetadataRows: View { isActive ? activeSecondaryForegroundColor : Color(nsColor: sidebarForegroundNSColor( - opacity: 0.9, - colorScheme: colorScheme + opacity: 0.9 )) ) .frame(maxWidth: .infinity, alignment: .leading) @@ -16438,7 +16436,6 @@ private struct SidebarMetadataMarkdownBlocks: View { let onFocus: () -> Void @State private var isExpanded: Bool = false - @Environment(\.colorScheme) private var colorScheme private let collapsedBlockLimit = 1 var body: some View { @@ -16466,8 +16463,7 @@ private struct SidebarMetadataMarkdownBlocks: View { isActive ? activeSecondaryForegroundColor : Color(nsColor: sidebarForegroundNSColor( - opacity: 0.9, - colorScheme: colorScheme + opacity: 0.9 )) ) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 1ba6a8f14f74..28464f6f20b8 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -50,13 +50,15 @@ func sidebarActiveForegroundNSColor( return baseColor.withAlphaComponent(clampedOpacity) } -func sidebarForegroundNSColor( - opacity: CGFloat, - colorScheme: ColorScheme -) -> NSColor { +func sidebarForegroundNSColor(opacity: CGFloat) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) - let baseColor: NSColor = colorScheme == .dark ? .white : .black - return baseColor.withAlphaComponent(clampedOpacity) + // Resolved per drawing appearance, not captured at render time: the sidebar + // hosting context does not reliably re-render on mid-session system + // appearance switches, so a snapshot color goes stale (black-on-dark). + return NSColor(name: nil) { appearance in + let baseColor: NSColor = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? .white : .black + return baseColor.withAlphaComponent(clampedOpacity) + } } func titlebarControlForegroundNSColor(opacity: CGFloat) -> NSColor { diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index f566c9bee904..ea3fe46a662d 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -95,7 +95,6 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let onContextMenuDisappear: () -> Void @State private var contextMenuVisible = false - @Environment(\.colorScheme) private var colorScheme #if DEBUG // Plain-value environment probe set only by SidebarLazyLayoutScaleTests; @@ -184,8 +183,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { Text(name) .cmuxFont(size: metrics.nameFontSize, weight: .semibold) .foregroundStyle(Color(nsColor: sidebarForegroundNSColor( - opacity: isAnchorActive ? 1.0 : 0.9, - colorScheme: colorScheme + opacity: isAnchorActive ? 1.0 : 0.9 ))) .lineLimit(1) .truncationMode(.tail) diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index 37a18887bd14..b2e47e6b2820 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -52,34 +52,63 @@ final class SidebarActiveForegroundColorTests: XCTestCase { XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) } - func testDarkSidebarUsesWhiteWhenTheAppAppearanceIsLight() { - guard let color = sidebarForegroundNSColor( - opacity: 0.9, - colorScheme: .dark - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") + func testSidebarForegroundResolvesWhiteUnderDarkAppearance() { + guard let darkAppearance = NSAppearance(named: .darkAqua) else { + XCTFail("Expected darkAqua appearance") return } - XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 0.9, accuracy: 0.001) + darkAppearance.performAsCurrentDrawingAppearance { + guard let color = sidebarForegroundNSColor(opacity: 0.9).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.9, accuracy: 0.001) + } } - func testLightSidebarUsesBlackWhenTheAppAppearanceIsDark() { - guard let color = sidebarForegroundNSColor( - opacity: 0.8, - colorScheme: .light - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") + func testSidebarForegroundResolvesBlackUnderLightAppearance() { + guard let lightAppearance = NSAppearance(named: .aqua) else { + XCTFail("Expected aqua appearance") return } - XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) + lightAppearance.performAsCurrentDrawingAppearance { + guard let color = sidebarForegroundNSColor(opacity: 0.8).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) + } + } + + func testSidebarForegroundTracksAppearanceSwitchesAtDrawTime() { + // Regression: a mid-session light/dark switch must change what the SAME + // color instance draws, without any view re-render. + guard let darkAppearance = NSAppearance(named: .darkAqua), + let lightAppearance = NSAppearance(named: .aqua) else { + XCTFail("Expected system appearances") + return + } + + let color = sidebarForegroundNSColor(opacity: 0.9) + var darkRed: CGFloat = -1 + var lightRed: CGFloat = -1 + darkAppearance.performAsCurrentDrawingAppearance { + darkRed = color.usingColorSpace(.sRGB)?.redComponent ?? -1 + } + lightAppearance.performAsCurrentDrawingAppearance { + lightRed = color.usingColorSpace(.sRGB)?.redComponent ?? -1 + } + + XCTAssertEqual(darkRed, 1, accuracy: 0.001) + XCTAssertEqual(lightRed, 0, accuracy: 0.001) } }