Repository: cli/cli
Pull request #10649 (2025-03-21 15:58:06)

Ensure table headers are thematically contrasting
Fixes cli/cli#8292
Fixes github/cli#831

This commit refactors the color format around table headers to ensure the GitHub CLI uses thematically appropriate colors based on dark background, light background, or no color at all.

In order to do so, `ColorScheme` needs information from the terminal about the background appearance (dark, light, none) to determine appropriate muted color.

## Demo

The following demos are using native terminals with native profiles for major distributions.  

The table headers should be a customizable color as well, allowing low vision users to adjust based on terminal preferences.  The following screenshots demonstrate the table headers being changed to bright yellow / orange color:

<img width="1512" alt="mac os, new light appearance, customizing table header color" src="https://github.com/user-attachments/assets/2b56ab23-26ee-426e-ba2d-01ce4abd7130" />

<img width="1512" alt="mac os, new dark appearance, customizing table header color" src="https://github.com/user-attachments/assets/f49f7a92-8cdf-400e-b805-8408e906eea8" />

### Ubuntu

- Default light appearance

  <img width="1512" alt="ubuntu, new light appearance" src="https://github.com/user-attachments/assets/fcd8f9fa-e164-4376-a1b1-3a50fac74537" />

- Disable color and modifier light appearance 

  <img width="1512" alt="ubuntu, new light appearance, color disabled" src="https://github.com/user-attachments/assets/e65dfbef-e87f-4e54-8345-c0702165d88c" />

- No background theme light apperance

  <img width="1512" alt="ubuntu, new light appearance, no background theme" src="https://github.com/user-attachments/assets/2725c9d1-95b9-470f-a3d2-4622a795b748" />

- Default dark appearance

  <img width="1512" alt="ubuntu, new dark appearance" src="https://github.com/user-attachments/assets/262e138a-7226-4a6e-9b73-70604e942f7c" />

- Disable color and modifier dark appearance

  <img width="1512" alt="ubuntu, new dark appearance, color disabled" src="https://github.com/user-attachments/assets/60f2f46a-8574-4d06-baaf-8f04d26478e5" />

- No background theme dark appearance

  <img width="1512" alt="ubuntu, new dark appearance, no background theme" src="https://github.com/user-attachments/assets/16d5a48d-2385-4683-b11d-217a883579fc" />

### MacOS

- Default light appearance

  <img width="1512" alt="macos, new light appearance" src="https://github.com/user-attachments/assets/c8b5ffba-86f0-4420-8591-0b3573b2b8a7" />

- Disable color and modifier light appearance 

  <img width="1512" alt="macos, new light appearance, color disabled" src="https://github.com/user-attachments/assets/5db8bec3-ec07-4340-b62f-03222d6a13e9" />

- No background theme light apperance

  <img width="1512" alt="macos, new light appearance, no background theme" src="https://github.com/user-attachments/assets/d81eac00-085e-4ba6-8a66-65fb1254e94a" />

- Default dark appearance

  <img width="1512" alt="macos, new dark appearance" src="https://github.com/user-attachments/assets/f8d17068-75de-4c63-b1d1-ce0b7a166a6c" />

- Disable color and modifier dark appearance

  <img width="1512" alt="macos, new dark appearance, color disabled" src="https://github.com/user-attachments/assets/c736e382-9ae4-440c-b588-9f5ace53e7c1" />

- No background theme dark appearance

  <img width="1512" alt="macos, new dark appearance, no background theme" src="https://github.com/user-attachments/assets/8db9a173-f2ed-4c50-b4b3-93094d48a258" />


### Windows

> [!NOTE]
> WIP


Patch:
diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go
index e1454170f..69b22be12 100644
--- a/internal/tableprinter/table_printer.go
+++ b/internal/tableprinter/table_printer.go
@@ -80,7 +80,7 @@ func NewWithWriter(w io.Writer, isTTY bool, maxWidth int, cs *iostreams.ColorSch
 		tp.AddHeader(
 			upperCasedHeaders,
 			WithPadding(paddingFunc),
-			WithColor(cs.LightGrayUnderline),
+			WithColor(cs.TableHeader),
 		)
 	}
 
diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go
index c8d48168f..07fdcd79f 100644
--- a/pkg/iostreams/color.go
+++ b/pkg/iostreams/color.go
@@ -9,34 +9,45 @@ import (
 )
 
 const (
+	NoTheme        = "none"
+	DarkTheme      = "dark"
+	LightTheme     = "light"
 	highlightStyle = "black:yellow"
 )
 
+// Special cases like darkThemeTableHeader / lightThemeTableHeader are necessary when using color and modifiers
+// (bold, underline, dim) because ansi.ColorFunc requires a foreground color and resets formats.
 var (
-	magenta            = ansi.ColorFunc("magenta")
-	cyan               = ansi.ColorFunc("cyan")
-	red                = ansi.ColorFunc("red")
-	yellow             = ansi.ColorFunc("yellow")
-	blue               = ansi.ColorFunc("blue")
-	green              = ansi.ColorFunc("green")
-	gray               = ansi.ColorFunc("black+h")
-	lightGrayUnderline = ansi.ColorFunc("white+du")
-	bold               = ansi.ColorFunc("default+b")
-	cyanBold           = ansi.ColorFunc("cyan+b")
-	greenBold          = ansi.ColorFunc("green+b")
-	highlightStart     = ansi.ColorCode(highlightStyle)
-	highlight          = ansi.ColorFunc(highlightStyle)
+	magenta               = ansi.ColorFunc("magenta")
+	cyan                  = ansi.ColorFunc("cyan")
+	red                   = ansi.ColorFunc("red")
+	yellow                = ansi.ColorFunc("yellow")
+	blue                  = ansi.ColorFunc("blue")
+	green                 = ansi.ColorFunc("green")
+	gray                  = ansi.ColorFunc("black+h")
+	bold                  = ansi.ColorFunc("default+b")
+	cyanBold              = ansi.ColorFunc("cyan+b")
+	greenBold             = ansi.ColorFunc("green+b")
+	highlightStart        = ansi.ColorCode(highlightStyle)
+	highlight             = ansi.ColorFunc(highlightStyle)
+	darkThemeTableHeader  = ansi.ColorFunc("white+du")
+	lightThemeTableHeader = ansi.ColorFunc("black+hu")
+	noThemeTableHeader    = ansi.ColorFunc("default+u")
 
 	gray256 = func(t string) string {
 		return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t)
 	}
 )
 
-func NewColorScheme(enabled, is256enabled bool, trueColor bool) *ColorScheme {
+// NewColorScheme initializes color logic based on provided terminal capabilities.
+// Logic dealing with terminal theme detected, such as whether color is enabled, 8-bit color supported, true color supported,
+// and terminal theme detected.
+func NewColorScheme(enabled, is256enabled, trueColor bool, theme string) *ColorScheme {
 	return &ColorScheme{
 		enabled:      enabled,
 		is256enabled: is256enabled,
 		hasTrueColor: trueColor,
+		theme:        theme,
 	}
 }
 
@@ -44,6 +55,7 @@ type ColorScheme struct {
 	enabled      bool
 	is256enabled bool
 	hasTrueColor bool
+	theme        string
 }
 
 func (c *ColorScheme) Enabled() bool {
@@ -115,13 +127,6 @@ func (c *ColorScheme) Grayf(t string, args ...interface{}) string {
 	return c.Gray(fmt.Sprintf(t, args...))
 }
 
-func (c *ColorScheme) LightGrayUnderline(t string) string {
-	if !c.enabled {
-		return t
-	}
-	return lightGrayUnderline(t)
-}
-
 func (c *ColorScheme) Magenta(t string) string {
 	if !c.enabled {
 		return t
@@ -254,3 +259,19 @@ func (c *ColorScheme) HexToRGB(hex string, x string) string {
 	b, _ := strconv.ParseInt(hex[4:6], 16, 64)
 	return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, x)
 }
+
+func (c *ColorScheme) TableHeader(t string) string {
+	// Table headers are only stylized if color is enabled including underline modifier.
+	if !c.enabled {
+		return t
+	}
+
+	switch c.theme {
+	case DarkTheme:
+		return darkThemeTableHeader(t)
+	case LightTheme:
+		return lightThemeTableHeader(t)
+	default:
+		return noThemeTableHeader(t)
+	}
+}
diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go
index 2bc712a3c..6c12d7911 100644
--- a/pkg/iostreams/iostreams.go
+++ b/pkg/iostreams/iostreams.go
@@ -366,7 +366,7 @@ func (s *IOStreams) TerminalWidth() int {
 }
 
 func (s *IOStreams) ColorScheme() *ColorScheme {
-	return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor())
+	return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor(), s.TerminalTheme())
 }
 
 func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) {


Test patch:
diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go
index 0acfae109..4f6c8a9f7 100644
--- a/pkg/cmd/gist/list/list_test.go
+++ b/pkg/cmd/gist/list/list_test.go
@@ -486,7 +486,7 @@ func Test_listRun(t *testing.T) {
 				)
 			},
 			wantOut: heredoc.Docf(`
-				%[1]s[0;2;4;37mID  %[1]s[0m  %[1]s[0;2;4;37mDESCRIPTION                  %[1]s[0m  %[1]s[0;2;4;37mFILES  %[1]s[0m  %[1]s[0;2;4;37mVISIBILITY%[1]s[0m  %[1]s[0;2;4;37mUPDATED          %[1]s[0m
+				%[1]s[0;4;39mID  %[1]s[0m  %[1]s[0;4;39mDESCRIPTION                  %[1]s[0m  %[1]s[0;4;39mFILES  %[1]s[0m  %[1]s[0;4;39mVISIBILITY%[1]s[0m  %[1]s[0;4;39mUPDATED          %[1]s[0m
 				1234  %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m  1 file   %[1]s[0;32mpublic    %[1]s[0m  %[1]s[38;5;242mabout 6 hours ago%[1]s[m
 				2345  %[1]s[0;1;39mmatch in the file name       %[1]s[0m  %[1]s[0;30;43m2 files%[1]s[0m  %[1]s[0;31msecret    %[1]s[0m  %[1]s[38;5;242mabout 6 hours ago%[1]s[m
 			`, "\x1b"),
@@ -694,7 +694,7 @@ func Test_highlightMatch(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			cs := iostreams.NewColorScheme(tt.color, false, false)
+			cs := iostreams.NewColorScheme(tt.color, false, false, iostreams.NoTheme)
 
 			matched := false
 			got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight)
diff --git a/pkg/iostreams/color_test.go b/pkg/iostreams/color_test.go
index 59fea53ed..b35c2eb73 100644
--- a/pkg/iostreams/color_test.go
+++ b/pkg/iostreams/color_test.go
@@ -1,6 +1,7 @@
 package iostreams
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -19,28 +20,28 @@ func TestColorFromRGB(t *testing.T) {
 			hex:   "fc0303",
 			text:  "red",
 			wants: "\033[38;2;252;3;3mred\033[0m",
-			cs:    NewColorScheme(true, true, true),
+			cs:    NewColorScheme(true, true, true, NoTheme),
 		},
 		{
 			name:  "no truecolor",
 			hex:   "fc0303",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(true, true, false),
+			cs:    NewColorScheme(true, true, false, NoTheme),
 		},
 		{
 			name:  "no color",
 			hex:   "fc0303",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			cs:    NewColorScheme(false, false, false, NoTheme),
 		},
 		{
 			name:  "invalid hex",
 			hex:   "fc0",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			cs:    NewColorScheme(false, false, false, NoTheme),
 		},
 	}
 
@@ -63,28 +64,28 @@ func TestHexToRGB(t *testing.T) {
 			hex:   "fc0303",
 			text:  "red",
 			wants: "\033[38;2;252;3;3mred\033[0m",
-			cs:    NewColorScheme(true, true, true),
+			cs:    NewColorScheme(true, true, true, NoTheme),
 		},
 		{
 			name:  "no truecolor",
 			hex:   "fc0303",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(true, true, false),
+			cs:    NewColorScheme(true, true, false, NoTheme),
 		},
 		{
 			name:  "no color",
 			hex:   "fc0303",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			cs:    NewColorScheme(false, false, false, NoTheme),
 		},
 		{
 			name:  "invalid hex",
 			hex:   "fc0",
 			text:  "red",
 			wants: "red",
-			cs:    NewColorScheme(false, false, false),
+			cs:    NewColorScheme(false, false, false, NoTheme),
 		},
 	}
 
@@ -93,3 +94,84 @@ func TestHexToRGB(t *testing.T) {
 		assert.Equal(t, tt.wants, output)
 	}
 }
+
+func TestTableHeader(t *testing.T) {
+	reset := "\x1b[0m"
+	defaultUnderline := "\x1b[0;4;39m"
+	brightBlackUnderline := "\x1b[0;4;90m"
+	dimBlackUnderline := "\x1b[0;2;4;37m"
+
+	tests := []struct {
+		name     string
+		cs       *ColorScheme
+		input    string
+		expected string
+	}{
+		{
+			name:     "when color is disabled, text is not stylized",
+			cs:       NewColorScheme(false, false, false, NoTheme),
+			input:    "this should not be stylized",
+			expected: "this should not be stylized",
+		},
+		{
+			name:     "when 4-bit color is enabled but no theme, 4-bit default color and underline are used",
+			cs:       NewColorScheme(true, false, false, NoTheme),
+			input:    "this should have no explicit color but underlined",
+			expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
+		},
+		{
+			name:     "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used",
+			cs:       NewColorScheme(true, false, false, LightTheme),
+			input:    "this should have dark foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
+		},
+		{
+			name:     "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used",
+			cs:       NewColorScheme(true, false, false, DarkTheme),
+			input:    "this should have light foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
+		},
+		{
+			name:     "when 8-bit color is enabled but no theme, 4-bit default color and underline are used",
+			cs:       NewColorScheme(true, true, false, NoTheme),
+			input:    "this should have no explicit color but underlined",
+			expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
+		},
+		{
+			name:     "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used",
+			cs:       NewColorScheme(true, true, false, LightTheme),
+			input:    "this should have dark foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
+		},
+		{
+			name:     "when 8-bit color is true and theme is dark, 4-bit light color and underline are used",
+			cs:       NewColorScheme(true, true, false, DarkTheme),
+			input:    "this should have light foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
+		},
+		{
+			name:     "when 24-bit color is enabled but no theme, 4-bit default color and underline are used",
+			cs:       NewColorScheme(true, true, true, NoTheme),
+			input:    "this should have no explicit color but underlined",
+			expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
+		},
+		{
+			name:     "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used",
+			cs:       NewColorScheme(true, true, true, LightTheme),
+			input:    "this should have dark foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
+		},
+		{
+			name:     "when 24-bit color is true and theme is dark, 4-bit light color and underline are used",
+			cs:       NewColorScheme(true, true, true, DarkTheme),
+			input:    "this should have light foreground color and underlined",
+			expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, tt.cs.TableHeader(tt.input))
+		})
+	}
+}