Repository navigation
Pick tab bar text color by WCAG contrast #260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
25f30f9
ae65185
a5a7972
2398bd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
|
@@ -338,15 +345,28 @@ 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 | ||
| var blue: CGFloat = 0 | ||
| 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 | ||
|
Comment on lines
+367
to
+369
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: rg -n 'effectiveTextColor|nsColorActiveText|activeText|opacity|alphaComponent' Sources/Bonsplit/Internal/Styling/TabBarColors.swift Sources/Bonsplit/Internal/Views/TabItemView.swift Tests/BonsplitTests/TabBarTextSchemeTests.swift
sed -n '1,100p' Tests/BonsplitTests/TabBarTextSchemeTests.swiftRepository: manaflow-ai/bonsplit Length of output: 7885 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- TabBarColors outline ---'
ast-grep outline Sources/Bonsplit/Internal/Styling/TabBarColors.swift
printf '%s\n' '--- TabBarColors relevant source ---'
sed -n '1,125p' Sources/Bonsplit/Internal/Styling/TabBarColors.swift
sed -n '190,250p' Sources/Bonsplit/Internal/Styling/TabBarColors.swift
sed -n '330,375p' Sources/Bonsplit/Internal/Styling/TabBarColors.swift
printf '%s\n' '--- TabItemView active rendering source ---'
sed -n '525,610p' Sources/Bonsplit/Internal/Views/TabItemView.swift
sed -n '680,715p' Sources/Bonsplit/Internal/Views/TabItemView.swift
sed -n '760,790p' Sources/Bonsplit/Internal/Views/TabItemView.swift
sed -n '1050,1085p' Sources/Bonsplit/Internal/Views/TabItemView.swiftRepository: manaflow-ai/bonsplit Length of output: 19651 🤖 get_repo_knowledge executed:
Length of output: 422 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- active tab background implementation ---'
sed -n '245,330p' Sources/Bonsplit/Internal/Styling/TabBarColors.swift
printf '%s\n' '--- TabItemView selected/background context ---'
rg -n -C 8 'activeTabBackground|tabContent|standardContent|background\(' Sources/Bonsplit/Internal/Views/TabItemView.swift
printf '%s\n' '--- numeric contrast calculation ---'
python3 - <<'PY'
import math
rgb = [0xE4/255, 0x43/255, 0x30/255]
def lin(c):
return c/12.92 if c <= 0.03928 else ((c+0.055)/1.055)**2.4
L = sum(w*lin(c) for w,c in zip((.2126,.7152,.0722), rgb))
print('background_luminance', L)
print('opaque_black', (L+.05)/.05)
# Standard source-over in encoded sRGB, which is the direct channel result of black alpha over the RGB background.
out = [(1-.82)*c for c in rgb]
Lo = sum(w*lin(c) for w,c in zip((.2126,.7152,.0722), out))
print('sRGB_composited_rgb', out)
print('sRGB_composited_luminance', Lo)
print('sRGB_composited_contrast', (L+.05)/(Lo+.05))
# Linear-light source-over.
Lo_linear = (1-.82)*L
print('linear_composited_contrast', (L+.05)/(Lo_linear+.05))
PYRepository: manaflow-ai/bonsplit Length of output: 13013 🏁 Script executed: #!/bin/bash
set -e
sed -n '210,255p' Sources/Bonsplit/Internal/Styling/TabBarColors.swiftRepository: manaflow-ai/bonsplit Length of output: 1840 🏁 Script executed: #!/bin/bash
set -e
rg -n -C 20 'static func activeTabBackground|activeTabBackground' Sources/Bonsplit/Internal/Styling/TabBarColors.swiftRepository: manaflow-ai/bonsplit Length of output: 3125 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- appearance definition and defaults ---'
rg -n -C 12 'usesSharedBackdrop|struct Appearance|init\(chromeColors' Sources Tests
printf '%s\n' '--- darken implementation ---'
rg -n -C 12 'func bonsplitDarken|bonsplitDarken' Sources/Bonsplit/Internal/Styling/TabBarColors.swiftRepository: manaflow-ai/bonsplit Length of output: 33744 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
def lin(c):
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def luminance(rgb):
return sum(w * lin(c) for w, c in zip((0.2126, 0.7152, 0.0722), rgb))
def contrast(a, b):
la, lb = luminance(a), luminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
raw = [0xE4/255, 0x43/255, 0x30/255]
active = [max(0, c - 0.065) for c in raw]
print('raw', raw, 'active', active)
for name, text in [('black', [0, 0, 0]), ('white', [1, 1, 1])]:
print(name, 'opaque', contrast(text, active))
for alpha in (0.82,):
composited = [alpha*t + (1-alpha)*b for t,b in zip(text, active)]
print(name, 'alpha', alpha, 'composited', composited, 'contrast', contrast(composited, active))
PYRepository: manaflow-ai/bonsplit Length of output: 592 Base text selection on the rendered active-tab surface.
Calculate contrast against the actual active-tab background and choose or composite a final text color that meets the 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| func bonsplitSaturating(by amount: Double) -> NSColor { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| 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")) | ||
| } | ||
|
|
||
| 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)! | ||
| return text.redComponent < 0.5 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: manaflow-ai/bonsplit
Length of output: 17780
🏁 Script executed:
Repository: manaflow-ai/bonsplit
Length of output: 9925
🏁 Script executed:
Repository: manaflow-ai/bonsplit
Length of output: 4781
🏁 Script executed:
Repository: manaflow-ai/bonsplit
Length of output: 31666
🏁 Script executed:
Repository: manaflow-ai/bonsplit
Length of output: 22554
🏁 Script executed:
Repository: manaflow-ai/bonsplit
Length of output: 1707
Handle translucent tab-bar backgrounds in the text classifier.
ChromeColorsaccepts#RRGGBBAA, and the parsed alpha reaches bothisBonsplitLightColorand the non-opaque tab-bar layer. The classifier ignores that alpha. For#E4433080over a dark surface, it can select black even though white has higher contrast against the visible color.Composite the color against the actual tab-bar backdrop before classification. If that backdrop is unavailable, use semantic system text colors for translucent backgrounds instead of applying the black/white classifier.
🐛 Suggested fix
🤖 Prompt for AI Agents