From 25f30f943c15c1739b269a11f10438f06035379a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:14:33 -0700 Subject: [PATCH 1/4] test: tab bar text picks black or white by WCAG contrast Co-Authored-By: Claude Opus 5.5 (1M context) --- .../BonsplitTests/TabBarTextSchemeTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Tests/BonsplitTests/TabBarTextSchemeTests.swift diff --git a/Tests/BonsplitTests/TabBarTextSchemeTests.swift b/Tests/BonsplitTests/TabBarTextSchemeTests.swift new file mode 100644 index 00000000..19cc227c --- /dev/null +++ b/Tests/BonsplitTests/TabBarTextSchemeTests.swift @@ -0,0 +1,28 @@ +import AppKit +import XCTest +@testable import Bonsplit + +/// Tab bar text picks black or white by WCAG contrast, matching the host +/// sidebar, so saturated mid-tone backgrounds get the more readable color. +final class TabBarTextSchemeTests: XCTestCase { + func testSaturatedRedUsesDarkText() { + // Hot Dog Stand: black reads at 5.4:1, white at 3.2:1. + XCTAssertTrue(activeTextIsDark(onBackgroundHex: "#E44330")) + } + + func testDarkGreenUsesLightText() { + // Grass: white reads at 5.5:1, black at 3.8:1. + XCTAssertFalse(activeTextIsDark(onBackgroundHex: "#1C763F")) + } + + func testNeutralBackgroundsKeepTheirScheme() { + XCTAssertTrue(activeTextIsDark(onBackgroundHex: "#FDF6E3")) + XCTAssertFalse(activeTextIsDark(onBackgroundHex: "#272822")) + } + + private func activeTextIsDark(onBackgroundHex hex: String) -> Bool { + let appearance = BonsplitConfiguration.Appearance(chromeColors: .init(backgroundHex: hex)) + let text = TabBarColors.nsColorActiveText(for: appearance).usingColorSpace(.sRGB)! + return text.redComponent < 0.5 + } +} From ae65185e483f2301c7778c875ab3df9cff9adbb0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:14:33 -0700 Subject: [PATCH 2/4] Pick tab bar text color by WCAG contrast isBonsplitLightColor used a gamma-space brightness cutoff at 0.5, which called saturated mid-tones such as #E44330 dark and drew white text at 3.2:1 where black reads at 5.4:1. It now compares WCAG contrast of black and white against the relative luminance, the rule cmux uses for its sidebar, so the tab bar and sidebar agree on every terminal theme. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/Styling/TabBarColors.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index e5621a3b..302eea9f 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -338,6 +338,12 @@ private extension NSColor { self.init(red: red, green: green, blue: blue, alpha: alpha) } + /// Whether dark text reads better than light text on this color. + /// + /// Compares WCAG contrast of black and white against the color's relative + /// luminance, the same choice the host app makes for its sidebar. A plain + /// gamma-space brightness cutoff called saturated mid-tones such as + /// `#E44330` dark and drew white text at 3.2:1 where black reads at 5.4:1. var isBonsplitLightColor: Bool { var red: CGFloat = 0 var green: CGFloat = 0 @@ -345,8 +351,15 @@ private extension NSColor { var alpha: CGFloat = 0 let color = usingColorSpace(.sRGB) ?? self color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue) - return luminance > 0.5 + func linearized(_ component: CGFloat) -> CGFloat { + component <= 0.03928 + ? component / 12.92 + : CGFloat(pow(Double((component + 0.055) / 1.055), 2.4)) + } + let luminance = 0.2126 * linearized(red) + 0.7152 * linearized(green) + 0.0722 * linearized(blue) + let blackContrast = (luminance + 0.05) / 0.05 + let whiteContrast = 1.05 / (luminance + 0.05) + return blackContrast > whiteContrast } func bonsplitSaturating(by amount: Double) -> NSColor { From a5a7972201d0a5e175571f7d88cd6581467d7520 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:08:48 -0700 Subject: [PATCH 3/4] Opaque shortcut-hint pill that follows the tab bar's light/dark choice The Cmd-hold hint pill drew .regularMaterial with Color.primary, so the material resolved against the window appearance while the tab bar picked its own scheme from the terminal background; a light theme in a dark window gave a dark pill with dark text. The pill now uses an opaque palette (dark #3A3A3C with 95% white text, light white with 85% black text) chosen by the same WCAG rule as the tab text. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/Styling/TabBarColors.swift | 7 ++++ .../Bonsplit/Internal/Views/TabItemView.swift | 34 ++++++++++++++----- .../BonsplitTests/TabBarTextSchemeTests.swift | 8 +++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index 302eea9f..2f6a25f8 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -106,6 +106,13 @@ enum TabBarColors { return NSColor.white.withAlphaComponent(alpha) } + /// Whether chrome drawn over the tab bar should use its dark palette: + /// the same WCAG choice as the tab text, or `nil` without a custom + /// background, where the view's color scheme decides. + static func usesDarkChrome(for appearance: BonsplitConfiguration.Appearance) -> Bool? { + semanticTabBarBackgroundColor(for: appearance).map { !$0.isBonsplitLightColor } + } + static func paneBackground(for appearance: BonsplitConfiguration.Appearance) -> Color { Color(nsColor: paneBackgroundColor(for: appearance) ?? .textBackgroundColor) } diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 18f0d376..adf52b89 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -107,10 +107,22 @@ enum TabControlShortcutHintStyle { static let fontWeight: Font.Weight = .semibold static let nsFontWeight: NSFont.Weight = .semibold static let fontDesign: Font.Design = .rounded - static let foregroundColor = Color.primary + /// Opaque palette shared with the host's shortcut-hint pills so every + /// Cmd-hold hint reads the same in both schemes: about 11:1 on dark + /// chrome and 12:1 on light chrome, independent of what is behind it. + static func foregroundColor(isDark: Bool) -> Color { + isDark ? Color.white.opacity(0.95) : Color.black.opacity(0.85) + } + static func backgroundColor(isDark: Bool) -> Color { + isDark + ? Color(.sRGB, red: 0x3A / 255, green: 0x3A / 255, blue: 0x3C / 255, opacity: 1) + : Color.white + } + static func borderColor(isDark: Bool) -> Color { + isDark ? Color.white.opacity(0.18) : Color.black.opacity(0.12) + } static let horizontalPadding: CGFloat = 6 static let verticalPadding: CGFloat = 2 - static let strokeOpacity = 0.30 static let strokeWidth: CGFloat = 0.8 static let shadowOpacity = 0.22 static let shadowRadius: CGFloat = 2 @@ -129,13 +141,15 @@ enum TabControlShortcutHintStyle { } struct TabControlShortcutHintPillBackground: View { + let isDark: Bool + var body: some View { Capsule(style: .continuous) - .fill(.regularMaterial) + .fill(TabControlShortcutHintStyle.backgroundColor(isDark: isDark)) .overlay( Capsule(style: .continuous) .stroke( - Color.white.opacity(TabControlShortcutHintStyle.strokeOpacity), + TabControlShortcutHintStyle.borderColor(isDark: isDark), lineWidth: TabControlShortcutHintStyle.strokeWidth ) ) @@ -150,17 +164,21 @@ struct TabControlShortcutHintPillBackground: View { struct TabControlShortcutHintPill: View { let text: String + /// The tab bar's own light/dark choice; `nil` follows the color scheme. + var usesDarkChrome: Bool? = nil + @Environment(\.colorScheme) private var colorScheme var body: some View { + let isDark = usesDarkChrome ?? (colorScheme == .dark) Text(text) .font(TabControlShortcutHintStyle.font) .monospacedDigit() .lineLimit(1) .fixedSize(horizontal: true, vertical: false) - .foregroundColor(TabControlShortcutHintStyle.foregroundColor) + .foregroundColor(TabControlShortcutHintStyle.foregroundColor(isDark: isDark)) .padding(.horizontal, TabControlShortcutHintStyle.horizontalPadding) .padding(.vertical, TabControlShortcutHintStyle.verticalPadding) - .background(TabControlShortcutHintPillBackground()) + .background(TabControlShortcutHintPillBackground(isDark: isDark)) } } @@ -684,7 +702,7 @@ struct TabItemView: View { .allowsHitTesting(!showsShortcutHint) if let shortcutHintLabel { - TabControlShortcutHintPill(text: shortcutHintLabel) + TabControlShortcutHintPill(text: shortcutHintLabel, usesDarkChrome: TabBarColors.usesDarkChrome(for: appearance)) .opacity(showsShortcutHint ? 1 : 0) .allowsHitTesting(false) } @@ -923,7 +941,7 @@ struct TabItemView: View { private var trailingAccessory: some View { ZStack(alignment: .center) { if let shortcutHintLabel { - TabControlShortcutHintPill(text: shortcutHintLabel) + TabControlShortcutHintPill(text: shortcutHintLabel, usesDarkChrome: TabBarColors.usesDarkChrome(for: appearance)) .offset( x: TabControlShortcutHintDebugSettings.clamped(controlShortcutHintXOffset), y: TabControlShortcutHintDebugSettings.clamped(controlShortcutHintYOffset) diff --git a/Tests/BonsplitTests/TabBarTextSchemeTests.swift b/Tests/BonsplitTests/TabBarTextSchemeTests.swift index 19cc227c..d244a7cc 100644 --- a/Tests/BonsplitTests/TabBarTextSchemeTests.swift +++ b/Tests/BonsplitTests/TabBarTextSchemeTests.swift @@ -20,6 +20,14 @@ final class TabBarTextSchemeTests: XCTestCase { XCTAssertFalse(activeTextIsDark(onBackgroundHex: "#272822")) } + func testShortcutHintPillFollowsTheTabBarChoice() { + let red = BonsplitConfiguration.Appearance(chromeColors: .init(backgroundHex: "#E44330")) + let dark = BonsplitConfiguration.Appearance(chromeColors: .init(backgroundHex: "#272822")) + XCTAssertEqual(TabBarColors.usesDarkChrome(for: red), false) + XCTAssertEqual(TabBarColors.usesDarkChrome(for: dark), true) + XCTAssertNil(TabBarColors.usesDarkChrome(for: BonsplitConfiguration.Appearance())) + } + private func activeTextIsDark(onBackgroundHex hex: String) -> Bool { let appearance = BonsplitConfiguration.Appearance(chromeColors: .init(backgroundHex: hex)) let text = TabBarColors.nsColorActiveText(for: appearance).usingColorSpace(.sRGB)! From 2398bd69a450b6647a15926151d57ab15a57b2d7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:10:13 -0700 Subject: [PATCH 4/4] Correct the shortcut-hint contrast figures (10.4:1 dark, 15.1:1 light) Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Bonsplit/Internal/Views/TabItemView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index adf52b89..6d4e7ed7 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -108,8 +108,8 @@ enum TabControlShortcutHintStyle { static let nsFontWeight: NSFont.Weight = .semibold static let fontDesign: Font.Design = .rounded /// Opaque palette shared with the host's shortcut-hint pills so every - /// Cmd-hold hint reads the same in both schemes: about 11:1 on dark - /// chrome and 12:1 on light chrome, independent of what is behind it. + /// Cmd-hold hint reads the same in both schemes: 10.4:1 on dark + /// chrome and 15.1:1 on light chrome, independent of what is behind it. static func foregroundColor(isDark: Bool) -> Color { isDark ? Color.white.opacity(0.95) : Color.black.opacity(0.85) }