From 6a41f54d958948a9795b002af0a3d6c8f2bb1539 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 26 Jun 2026 16:13:57 -0400 Subject: [PATCH 1/2] Make the TUIs adapt to light and dark terminal backgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The submit, view, and modify TUIs were tuned for dark terminals. On light or solarized-light backgrounds the result was hard to read and inverted: primary text used ANSI white (invisible on white), dim chrome used light grays (too faint), and accents used bright cyan (low contrast) — so "active" things looked lighter than "disabled" ones. Introduce a centralized, background-aware color palette and migrate all three TUIs to it: - internal/tui/shared/theme.go: a semantic palette of lipgloss.AdaptiveColor values (primary/muted/faint text, chrome/border, accent, PR-state colors, badge backgrounds, row shade, button, switch). lipgloss resolves the light/dark variant per render from the terminal background, which Bubble Tea detects at startup; terminals that don't report it fall back to dark, preserving the original look. - Replace every hardcoded ANSI color in shared/, submitview/, and modifyview/ with palette roles. The four pre-rendered status icons now render at use-time so their adaptive colors resolve correctly. The submit markdown preview picks glamour's light or dark style from the detected background. - GH_STACK_THEME=auto|light|dark forces the palette for terminals that mis-detect (some SSH/tmux setups); wired via the root command's PersistentPreRun before any render. Documented in the README and CLI docs. Neutral text/chrome use truecolor hex (GitHub Primer-inspired) for predictability across themes, including solarized which repurposes ANSI 8-15; lipgloss downsamples on terminals without truecolor. Tests verify the palette resolves differently for light vs dark and that GH_STACK_THEME is honored. --- README.md | 17 ++++ cmd/root.go | 7 ++ docs/src/content/docs/reference/cli.md | 13 +++ internal/tui/modifyview/model.go | 2 +- internal/tui/modifyview/styles.go | 52 +++++----- internal/tui/shared/header.go | 2 +- internal/tui/shared/render.go | 8 +- internal/tui/shared/styles.go | 73 +++++++------- internal/tui/shared/theme.go | 86 +++++++++++++++++ internal/tui/shared/theme_test.go | 74 ++++++++++++++ internal/tui/submitview/editor.go | 10 +- internal/tui/submitview/help.go | 12 ++- internal/tui/submitview/preview.go | 23 +++-- internal/tui/submitview/render.go | 4 +- internal/tui/submitview/screen.go | 22 ++--- internal/tui/submitview/styles.go | 129 +++++++++++++------------ 16 files changed, 379 insertions(+), 155 deletions(-) create mode 100644 internal/tui/shared/theme.go create mode 100644 internal/tui/shared/theme_test.go diff --git a/README.md b/README.md index d008748..b8d6029 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,23 @@ gh stack submit Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -Am "..."` does it all. +## Terminal theme + +The interactive screens (`submit`, `modify`, and `view`) automatically adapt their colors to your terminal's background, so they're readable on both dark and light themes. The background is detected from the terminal; if a terminal doesn't report it (some SSH or `tmux` setups), the dark palette is used. + +Set `GH_STACK_THEME` to force a palette if detection is wrong: + +| Value | Behavior | +|-------|----------| +| `auto` (default) | Detect from the terminal background | +| `light` | Force the light palette | +| `dark` | Force the dark palette | + +```bash +# Force the light palette for one command +GH_STACK_THEME=light gh stack view +``` + ## Exit codes | Code | Meaning | diff --git a/cmd/root.go b/cmd/root.go index 0c9e07e..078b51c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "os" "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/tui/shared" "github.com/spf13/cobra" ) @@ -35,6 +36,12 @@ locally, then push to GitHub to create your stack of PRs.`, Version: Version, SilenceUsage: true, SilenceErrors: true, + // Honor GH_STACK_THEME (auto|light|dark) before any command renders, so + // the background-aware TUI palette can be forced when a terminal + // mis-detects its background. + PersistentPreRun: func(_ *cobra.Command, _ []string) { + shared.ApplyThemeOverride() + }, } root.SetVersionTemplate("gh stack version {{.Version}}\n") diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index ce2d268..6227d01 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -586,6 +586,19 @@ gh stack feedback "Support for reordering branches" --- +## Environment Variables + +| Variable | Values | Description | +|----------|--------|-------------| +| `GH_STACK_THEME` | `auto` (default), `light`, `dark` | Controls the color palette of the interactive screens (`submit`, `modify`, `view`). They adapt to your terminal background automatically; set this to force the light or dark palette when a terminal doesn't report its background (some SSH or `tmux` setups). | + +```sh +# Force the light palette for one command +GH_STACK_THEME=light gh stack view +``` + +--- + ## Exit Codes | Code | Meaning | diff --git a/internal/tui/modifyview/model.go b/internal/tui/modifyview/model.go index 839ad86..314232c 100644 --- a/internal/tui/modifyview/model.go +++ b/internal/tui/modifyview/model.go @@ -1465,7 +1465,7 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { {Icon: "○", Label: branchInfo}, } if pendingSummary != "" { - yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + yellowStyle := lipgloss.NewStyle().Foreground(shared.ColorYellow) infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "■", Label: pendingSummary, IconStyle: &yellowStyle}) } else { infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "□", Label: "No pending changes"}) diff --git a/internal/tui/modifyview/styles.go b/internal/tui/modifyview/styles.go index 4435902..caabf5d 100644 --- a/internal/tui/modifyview/styles.go +++ b/internal/tui/modifyview/styles.go @@ -1,42 +1,48 @@ package modifyview -import "github.com/charmbracelet/lipgloss" +import ( + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/tui/shared" +) + +// Colors come from the background-aware palette in internal/tui/shared so the +// modify view reads well on both dark and light terminals. var ( // Action annotation styles (modify-specific) - dropBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - foldBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - renameBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan - moveBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta/purple - insertBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + dropBadge = lipgloss.NewStyle().Foreground(shared.ColorRed) // drop + foldBadge = lipgloss.NewStyle().Foreground(shared.ColorYellow) // fold + renameBadge = lipgloss.NewStyle().Foreground(shared.ColorAccent) // rename + moveBadge = lipgloss.NewStyle().Foreground(shared.ColorPurple) // move + insertBadge = lipgloss.NewStyle().Foreground(shared.ColorGreen) // insert // Branch name overrides for drop/fold/insert - dropBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Strikethrough(true) // red strikethrough - foldBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Strikethrough(true) // yellow strikethrough - insertBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + dropBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorRed).Strikethrough(true) + foldBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorYellow).Strikethrough(true) + insertBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorGreen) // Connector color overrides for drop/fold/move/insert - dropConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - foldConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - movedConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta/purple - insertConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + dropConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorRed) + foldConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorYellow) + movedConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorPurple) + insertConnectorStyle = lipgloss.NewStyle().Foreground(shared.ColorGreen) // Status line styles - statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - statusCountStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - statusKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - statusDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + statusBarStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) + statusCountStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) + statusKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent) + statusDescStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) // Help overlay styles helpOverlayStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). + BorderForeground(shared.ColorBorder). Padding(1, 2) - helpKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) - helpDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - helpTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) + helpKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true) + helpDescStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + helpTitleStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true).Underline(true) // Transient message styles - transientErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - transientInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + transientErrorStyle = lipgloss.NewStyle().Foreground(shared.ColorRed) + transientInfoStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) ) diff --git a/internal/tui/shared/header.go b/internal/tui/shared/header.go index e00ac14..b40e27a 100644 --- a/internal/tui/shared/header.go +++ b/internal/tui/shared/header.go @@ -361,7 +361,7 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) { } // disabledShortcutStyle renders both key and desc in dim gray. -var disabledShortcutStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) +var disabledShortcutStyle = lipgloss.NewStyle().Foreground(ColorTextFaint) // renderShortcutEntry renders a single shortcut, dimmed if disabled. func renderShortcutEntry(sc ShortcutEntry) string { diff --git a/internal/tui/shared/render.go b/internal/tui/shared/render.go index af594a9..94912a1 100644 --- a/internal/tui/shared/render.go +++ b/internal/tui/shared/render.go @@ -88,16 +88,16 @@ func ResolveConnectorStyle(node BranchNodeData, isFocused bool) (string, lipglos // StatusIcon returns the appropriate status icon for a branch. func StatusIcon(node BranchNodeData) string { if node.Ref.IsMerged() { - return MergedIcon + return mergedIconStyle.Render(mergedGlyph) } if node.Ref.IsQueued() { - return QueuedIcon + return queuedIconStyle.Render(queuedGlyph) } if !node.IsLinear { - return WarningIcon + return warningIconStyle.Render(warningGlyph) } if node.PR != nil && node.PR.Number != 0 { - return OpenIcon + return openIconStyle.Render(openGlyph) } return "" } diff --git a/internal/tui/shared/styles.go b/internal/tui/shared/styles.go index f3a4430..b5ddba8 100644 --- a/internal/tui/shared/styles.go +++ b/internal/tui/shared/styles.go @@ -4,52 +4,59 @@ import "github.com/charmbracelet/lipgloss" var ( // Branch name styles - CurrentBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) - NormalBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - MergedBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - TrunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) + CurrentBranchStyle = lipgloss.NewStyle().Foreground(ColorAccent).Bold(true) + NormalBranchStyle = lipgloss.NewStyle().Foreground(ColorText) + MergedBranchStyle = lipgloss.NewStyle().Foreground(ColorTextMuted) + TrunkStyle = lipgloss.NewStyle().Foreground(ColorTextMuted).Italic(true) - // Status indicators - MergedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render("✓") - WarningIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠") - OpenIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("○") - QueuedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Render("◎") + // Status indicator glyphs. These are rendered at use-time (see StatusIcon) + // with the styles below so their adaptive colors resolve against the detected + // terminal background rather than being baked in at package-init time. + mergedGlyph = "✓" + warningGlyph = "⚠" + openGlyph = "○" + queuedGlyph = "◎" + + mergedIconStyle = lipgloss.NewStyle().Foreground(ColorPurple) + warningIconStyle = lipgloss.NewStyle().Foreground(ColorYellow) + openIconStyle = lipgloss.NewStyle().Foreground(ColorGreen) + queuedIconStyle = lipgloss.NewStyle().Foreground(ColorYellow) // PR status styles - PRLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Underline(true) - PROpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - PRMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) - PRClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - PRDraftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - PRQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) + PRLinkStyle = lipgloss.NewStyle().Foreground(ColorText).Underline(true) + PROpenStyle = lipgloss.NewStyle().Foreground(ColorGreen) + PRMergedStyle = lipgloss.NewStyle().Foreground(ColorPurple) + PRClosedStyle = lipgloss.NewStyle().Foreground(ColorRed) + PRDraftStyle = lipgloss.NewStyle().Foreground(ColorGray) + PRQueuedStyle = lipgloss.NewStyle().Foreground(ColorYellow) // Diff stats - AdditionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - DeletionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + AdditionsStyle = lipgloss.NewStyle().Foreground(ColorGreen) + DeletionsStyle = lipgloss.NewStyle().Foreground(ColorRed) // Commit lines - CommitSHAStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - CommitSubjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - CommitTimeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + CommitSHAStyle = lipgloss.NewStyle().Foreground(ColorYellow) + CommitSubjectStyle = lipgloss.NewStyle().Foreground(ColorText) + CommitTimeStyle = lipgloss.NewStyle().Foreground(ColorTextMuted) // Connector lines - ConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - ConnectorDashedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - ConnectorFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - ConnectorCurrentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - ConnectorMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) - ConnectorQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) + ConnectorStyle = lipgloss.NewStyle().Foreground(ColorBorder) + ConnectorDashedStyle = lipgloss.NewStyle().Foreground(ColorYellow) + ConnectorFocusedStyle = lipgloss.NewStyle().Foreground(ColorText) + ConnectorCurrentStyle = lipgloss.NewStyle().Foreground(ColorAccent) + ConnectorMergedStyle = lipgloss.NewStyle().Foreground(ColorPurple) + ConnectorQueuedStyle = lipgloss.NewStyle().Foreground(ColorYellow) // Dim text - DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + DimStyle = lipgloss.NewStyle().Foreground(ColorTextFaint) // Header styles - HeaderBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - HeaderTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - HeaderInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - HeaderInfoLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - HeaderShortcutKey = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - HeaderShortcutDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + HeaderBorderStyle = lipgloss.NewStyle().Foreground(ColorBorder) + HeaderTitleStyle = lipgloss.NewStyle().Foreground(ColorText).Bold(true) + HeaderInfoStyle = lipgloss.NewStyle().Foreground(ColorAccent) + HeaderInfoLabelStyle = lipgloss.NewStyle().Foreground(ColorTextMuted) + HeaderShortcutKey = lipgloss.NewStyle().Foreground(ColorText) + HeaderShortcutDesc = lipgloss.NewStyle().Foreground(ColorTextMuted) // Expand/collapse icons ExpandedIcon = "▾" diff --git a/internal/tui/shared/theme.go b/internal/tui/shared/theme.go new file mode 100644 index 0000000..0cb51ad --- /dev/null +++ b/internal/tui/shared/theme.go @@ -0,0 +1,86 @@ +package shared + +import ( + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// This file defines the shared, background-aware color palette used by every +// gh-stack TUI (submit, view, modify). +// +// Colors are expressed as lipgloss.AdaptiveColor, whose Light/Dark variant is +// chosen at render time from the terminal's detected background. Bubble Tea +// triggers that detection once at startup (see bubbletea/tea_init.go), so the +// right variant is picked automatically; terminals that don't answer the query +// fall back to the dark palette, preserving the original look. +// +// Values are truecolor hex (GitHub Primer-inspired) rather than ANSI palette +// indices so they render consistently across themes — notably solarized, which +// repurposes ANSI 8–15 as background tones. lipgloss downsamples to the nearest +// ANSI color on terminals without truecolor support. +var ( + // ColorText is primary/emphasis ink: titles, branch names, links, active + // keys, the description scrollbar thumb. + ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} + // ColorTextMuted is secondary ink and dim chrome text: section labels, + // shortcut descriptions, hints, trunk/merged branches, timestamps. + ColorTextMuted = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} + // ColorTextFaint is disabled/de-emphasized ink: skipped branches, disabled + // shortcuts. + ColorTextFaint = lipgloss.AdaptiveColor{Dark: "#656c76", Light: "#818b98"} + + // ColorBorder is structural chrome: panel borders, tree connectors, the + // vertical spine, horizontal rules, scrollbar tracks, segmented-control frame. + ColorBorder = lipgloss.AdaptiveColor{Dark: "#3d444d", Light: "#d1d9e0"} + // ColorRowShade tints the focused (currently-viewed) row's background in the + // left timeline. A neutral wash that reads as a subtle highlight on either + // background — light gray on light terminals, and a lifted slate on dark + // terminals so it stays visible against near-black backgrounds. + ColorRowShade = lipgloss.AdaptiveColor{Dark: "#353941", Light: "#eaeef2"} + + // ColorAccent is interactive emphasis: the current/focused branch, keyboard + // shortcut keys, footer accents. + ColorAccent = lipgloss.AdaptiveColor{Dark: "#2dd4bf", Light: "#0a7ea4"} + + // Semantic status colors, mirroring how GitHub colors PR states. Reused for + // diff stats (green/red), commit SHAs and warnings (yellow), and modify + // action badges. + ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW + ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, insert + ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT + ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA, fold + ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, move + ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, drop, errors + + // ColorOnFill is text drawn on top of a solid colored fill (e.g. the green + // "selected" pill): near-black on the lighter dark-mode fills, white on the + // darker light-mode fills. + ColorOnFill = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} + + // ColorButtonFg/ColorButtonBg style the prominent inverted action button + // (e.g. submit). The background inverts against the terminal so the button + // stays prominent in both modes. + ColorButtonBg = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} + ColorButtonFg = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} +) + +// ApplyThemeOverride honors the GH_STACK_THEME environment variable, forcing the +// light or dark palette regardless of what the terminal reports. It must be +// called before the first render (e.g. before launching a Bubble Tea program). +// +// GH_STACK_THEME=light force the light palette +// GH_STACK_THEME=dark force the dark palette +// GH_STACK_THEME=auto (or unset) detect from the terminal background +// +// Use this for terminals that don't answer the background query (some SSH/tmux +// setups) and therefore mis-detect. +func ApplyThemeOverride() { + switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) { + case "light": + lipgloss.SetHasDarkBackground(false) + case "dark": + lipgloss.SetHasDarkBackground(true) + } +} diff --git a/internal/tui/shared/theme_test.go b/internal/tui/shared/theme_test.go new file mode 100644 index 0000000..ab5279d --- /dev/null +++ b/internal/tui/shared/theme_test.go @@ -0,0 +1,74 @@ +package shared + +import ( + "io" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" +) + +// TestPaletteIsBackgroundAware verifies that the palette's adaptive colors +// resolve to different output under a light vs dark background. It uses a local +// renderer with a color-capable profile so it doesn't mutate global state. +func TestPaletteIsBackgroundAware(t *testing.T) { + colors := map[string]lipgloss.AdaptiveColor{ + "text": ColorText, + "textMuted": ColorTextMuted, + "textFaint": ColorTextFaint, + "border": ColorBorder, + "accent": ColorAccent, + "green": ColorGreen, + "red": ColorRed, + "buttonBg": ColorButtonBg, + } + + for name, c := range colors { + t.Run(name, func(t *testing.T) { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.TrueColor) + + r.SetHasDarkBackground(true) + dark := r.NewStyle().Foreground(c).Render("x") + r.SetHasDarkBackground(false) + light := r.NewStyle().Foreground(c).Render("x") + + assert.NotEqual(t, dark, light, "%s should differ between dark and light backgrounds", name) + }) + } +} + +func TestApplyThemeOverride(t *testing.T) { + // ApplyThemeOverride mutates the default renderer; restore it afterwards. + before := lipgloss.HasDarkBackground() + t.Cleanup(func() { lipgloss.SetHasDarkBackground(before) }) + + t.Run("light forces a light background", func(t *testing.T) { + lipgloss.SetHasDarkBackground(true) + t.Setenv("GH_STACK_THEME", "light") + ApplyThemeOverride() + assert.False(t, lipgloss.HasDarkBackground()) + }) + + t.Run("dark forces a dark background", func(t *testing.T) { + lipgloss.SetHasDarkBackground(false) + t.Setenv("GH_STACK_THEME", "dark") + ApplyThemeOverride() + assert.True(t, lipgloss.HasDarkBackground()) + }) + + t.Run("auto leaves the detected value unchanged", func(t *testing.T) { + lipgloss.SetHasDarkBackground(true) + t.Setenv("GH_STACK_THEME", "auto") + ApplyThemeOverride() + assert.True(t, lipgloss.HasDarkBackground()) + }) + + t.Run("unset leaves the detected value unchanged", func(t *testing.T) { + lipgloss.SetHasDarkBackground(false) + t.Setenv("GH_STACK_THEME", "") + ApplyThemeOverride() + assert.False(t, lipgloss.HasDarkBackground()) + }) +} diff --git a/internal/tui/submitview/editor.go b/internal/tui/submitview/editor.go index 3d7195c..3e3e62d 100644 --- a/internal/tui/submitview/editor.go +++ b/internal/tui/submitview/editor.go @@ -6,6 +6,8 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/lipgloss" + + "github.com/github/gh-stack/internal/tui/shared" ) // currentNode returns a pointer to the focused node, or nil. @@ -266,9 +268,9 @@ func descTextWidth(innerW int) int { // so the scrollbar (the content's last column) sits flush against the right // border with no extra margin. func descBox(content string, width int, focused bool) string { - bc := lipgloss.Color("8") + var bc lipgloss.TerminalColor = shared.ColorBorder if focused { - bc = lipgloss.Color("14") + bc = shared.ColorAccent } w := width - 2 if w < 1 { @@ -577,9 +579,9 @@ func (m Model) draftSegmentBounds() (segStart, dividerX, segEnd int) { // fieldBox wraps a field's content in a rounded box whose border highlights when // focused. width is the desired outer width. func fieldBox(content string, width int, focused bool) string { - bc := lipgloss.Color("8") + var bc lipgloss.TerminalColor = shared.ColorBorder if focused { - bc = lipgloss.Color("14") + bc = shared.ColorAccent } w := width - 2 if w < 1 { diff --git a/internal/tui/submitview/help.go b/internal/tui/submitview/help.go index 21561aa..ef85f0d 100644 --- a/internal/tui/submitview/help.go +++ b/internal/tui/submitview/help.go @@ -4,17 +4,19 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + + "github.com/github/gh-stack/internal/tui/shared" ) var ( helpOverlayStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). + BorderForeground(shared.ColorBorder). Padding(1, 2) - helpTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) - helpSectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) - helpKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - helpDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + helpTitleStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true).Underline(true) + helpSectionStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true) + helpKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) + helpDescStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) ) // helpEntry is a single key/description pair in the help overlay. diff --git a/internal/tui/submitview/preview.go b/internal/tui/submitview/preview.go index 8a2e17f..7ba03f6 100644 --- a/internal/tui/submitview/preview.go +++ b/internal/tui/submitview/preview.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/lipgloss" ) // editorFinishedMsg is delivered after the external $EDITOR process exits. @@ -117,10 +118,12 @@ func writeTempDescription(content string) (string, error) { } // renderMarkdown renders markdown to styled terminal output using Glamour. It -// uses a fixed dark style rather than glamour.WithAutoStyle(): auto-style probes -// the terminal background with an OSC query whose response is consumed by Bubble -// Tea's own input reader, so the query blocks forever and freezes the UI. On any -// error it falls back to the raw markdown so the user still sees their content. +// selects the light or dark Glamour style from the already-detected terminal +// background (lipgloss.HasDarkBackground, cached at startup) rather than +// glamour.WithAutoStyle(): auto-style probes the terminal with an OSC query whose +// response is consumed by Bubble Tea's own input reader, so the query blocks +// forever and freezes the UI. On any error it falls back to the raw markdown so +// the user still sees their content. func renderMarkdown(md string, width int) string { if strings.TrimSpace(md) == "" { return hintStyle.Render("(no description)") @@ -128,11 +131,15 @@ func renderMarkdown(md string, width int) string { if width < 10 { width = 10 } - // Use glamour's "dark" style but drop the document block's default 2-column - // margin so the preview text aligns flush-left with the edit-mode textarea - // instead of being indented. Copying the struct and replacing the Margin - // pointer leaves the shared package-level style untouched. + // Match the preview to the terminal background, then drop the document + // block's default 2-column margin so the preview text aligns flush-left with + // the edit-mode textarea instead of being indented. Copying the struct and + // replacing the Margin pointer leaves the shared package-level style + // untouched. style := styles.DarkStyleConfig + if !lipgloss.HasDarkBackground() { + style = styles.LightStyleConfig + } var noMargin uint style.Document.Margin = &noMargin r, err := glamour.NewTermRenderer( diff --git a/internal/tui/submitview/render.go b/internal/tui/submitview/render.go index 5d9233a..ca92585 100644 --- a/internal/tui/submitview/render.go +++ b/internal/tui/submitview/render.go @@ -10,7 +10,7 @@ import ( // Chrome styles shared across the submit views. var ( - stackInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + stackInfoStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) ) // headerHeight returns the number of screen rows the shared header occupies, or @@ -56,7 +56,7 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { } } if newCount > 0 { - yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + yellowStyle := lipgloss.NewStyle().Foreground(shared.ColorYellow) prWord := "PRs" if newCount == 1 { prWord = "PR" diff --git a/internal/tui/submitview/screen.go b/internal/tui/submitview/screen.go index a06e395..f323e37 100644 --- a/internal/tui/submitview/screen.go +++ b/internal/tui/submitview/screen.go @@ -721,7 +721,7 @@ func leftPanelBox(content string, width, height int) string { } return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). + BorderForeground(shared.ColorBorder). Width(innerW). Height(innerH). MaxHeight(height). @@ -837,30 +837,30 @@ func (m Model) branchCircle(n SubmitNode, focused bool) string { glyph, color := "○", n.State.Color() switch { case n.State == StateNew && n.Included: - glyph, color = "●", lipgloss.Color("14") // filled cyan + glyph, color = "●", lipgloss.TerminalColor(shared.ColorAccent) // filled accent case n.State == StateNew: - glyph, color = "◌", lipgloss.Color("245") // dotted ring: skipped + glyph, color = "◌", lipgloss.TerminalColor(shared.ColorTextFaint) // dotted ring: skipped } return bgIf(lipgloss.NewStyle().Foreground(color), focused).Render(glyph) } -// branchNameStyle returns the full-name style: white and bold for a branch that -// will become a PR, muted gray for skipped or existing-PR branches. +// branchNameStyle returns the full-name style: primary and bold for a branch that +// will become a PR, muted for skipped or existing-PR branches. func (m Model) branchNameStyle(n SubmitNode, focused bool) lipgloss.Style { - st := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + st := lipgloss.NewStyle().Foreground(shared.ColorTextMuted) if n.State == StateNew && n.Included { - st = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + st = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) } return bgIf(st, focused) } -// branchCheckbox renders a NEW branch's include checkbox: cyan [x] when included, -// gray [ ] when skipped. +// branchCheckbox renders a NEW branch's include checkbox: accent [x] when +// included, muted [ ] when skipped. func (m Model) branchCheckbox(n SubmitNode, focused bool) string { if n.Included { - return bgIf(lipgloss.NewStyle().Foreground(lipgloss.Color("14")), focused).Render("[x]") + return bgIf(lipgloss.NewStyle().Foreground(shared.ColorAccent), focused).Render("[x]") } - return bgIf(lipgloss.NewStyle().Foreground(lipgloss.Color("8")), focused).Render("[ ]") + return bgIf(lipgloss.NewStyle().Foreground(shared.ColorTextMuted), focused).Render("[ ]") } // branchMetaLine renders an existing PR's "state · #num" line, the state word in diff --git a/internal/tui/submitview/styles.go b/internal/tui/submitview/styles.go index a691c38..77e7603 100644 --- a/internal/tui/submitview/styles.go +++ b/internal/tui/submitview/styles.go @@ -1,26 +1,32 @@ package submitview -import "github.com/charmbracelet/lipgloss" - -// State foreground colors, matching how GitHub.com colors these PR states. -var stateColors = map[BranchState]lipgloss.Color{ - StateNew: lipgloss.Color("4"), // blue - StateOpen: lipgloss.Color("2"), // green - StateDraft: lipgloss.Color("250"), // gray - StateQueued: lipgloss.Color("137"), // brown - StateMerged: lipgloss.Color("5"), // purple - StateClosed: lipgloss.Color("1"), // red +import ( + "github.com/charmbracelet/lipgloss" + + "github.com/github/gh-stack/internal/tui/shared" +) + +// State foreground colors, matching how GitHub.com colors these PR states. Each +// is background-aware (see internal/tui/shared/theme.go). +var stateColors = map[BranchState]lipgloss.TerminalColor{ + StateNew: shared.ColorBlue, + StateOpen: shared.ColorGreen, + StateDraft: shared.ColorGray, + StateQueued: shared.ColorYellow, + StateMerged: shared.ColorPurple, + StateClosed: shared.ColorRed, } -// State background tints for pill badges (dark 256-color shades that read as a -// low-opacity wash of the foreground color across most terminal themes). -var stateBgColors = map[BranchState]lipgloss.Color{ - StateNew: lipgloss.Color("18"), // dark blue - StateOpen: lipgloss.Color("22"), // dark green - StateDraft: lipgloss.Color("238"), // dark gray - StateQueued: lipgloss.Color("58"), // dark brown - StateMerged: lipgloss.Color("53"), // dark purple - StateClosed: lipgloss.Color("52"), // dark red +// State background tints for pill badges: dark washes on a dark terminal, light +// washes on a light terminal, so the badge reads as a low-opacity tint of its +// foreground color in either mode. +var stateBgColors = map[BranchState]lipgloss.TerminalColor{ + StateNew: lipgloss.AdaptiveColor{Dark: "#10243e", Light: "#cfe7ff"}, + StateOpen: lipgloss.AdaptiveColor{Dark: "#0d2818", Light: "#c8f0d4"}, + StateDraft: lipgloss.AdaptiveColor{Dark: "#272b33", Light: "#e4e9ef"}, + StateQueued: lipgloss.AdaptiveColor{Dark: "#2b2410", Light: "#f4ead9"}, + StateMerged: lipgloss.AdaptiveColor{Dark: "#241a3a", Light: "#ecdcff"}, + StateClosed: lipgloss.AdaptiveColor{Dark: "#2d1417", Light: "#ffdcd7"}, } // Label returns the uppercase badge text for a state (e.g. "NEW"). @@ -44,7 +50,7 @@ func (s BranchState) Label() string { } // Color returns the foreground color associated with a state. -func (s BranchState) Color() lipgloss.Color { return stateColors[s] } +func (s BranchState) Color() lipgloss.TerminalColor { return stateColors[s] } // Dot returns the compact status glyph for a state. func (s BranchState) Dot() string { @@ -84,86 +90,83 @@ func RenderDot(s BranchState) string { // Shared submit-view styles. These are intentionally centralized so the left // stack tree, the editor, and the chrome render with a consistent visual -// language. +// language. Colors come from the background-aware palette in internal/tui/shared. var ( - focusNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // cyan focused label + focusNameStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true) // focused label // headerBranchStyle renders the focused branch name in the right-panel card - // header in white (the left-panel cursor name stays cyan). - headerBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + // header in primary ink (the left-panel cursor name uses the accent color). + headerBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) // rowShadeColor tints the focused (currently-viewed) branch row in the left - // timeline. A neutral cool gray (truecolor, so it doesn't pick up a warm tint - // from a themed 256-color palette) reading as a translucent-white highlight. - rowShadeColor = lipgloss.Color("#3b3e46") + // timeline, reading as a subtle highlight on either background. + rowShadeColor = shared.ColorRowShade // Panel border shared by both panels (focus is shown on the active input // field, not the panel frame). panelBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). + BorderForeground(shared.ColorBorder). Padding(0, 1) // Section labels (e.g. STACK, EDITING, TITLE, DESCRIPTION). - sectionLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Bold(true) + sectionLabelStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted).Bold(true) // Tab strip styles. - tabActiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) - tabInactiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + tabActiveStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true).Underline(true) + tabInactiveStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) // Footer / status styles. - footerKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + footerKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent) - // openLinkStyle renders the underlined white "↗ Open on GitHub" link (arrow + // openLinkStyle renders the underlined "↗ Open on GitHub" link (arrow // included) in a locked PR's read-only card header; lockedTitleStyle renders // that PR's title. - openLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) - lockedTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - - // Footer bottom-right actions: nextBranchStyle is the white "NEXT BRANCH" - // label; submitButtonStyle is the prominent solid-white "SUBMIT N PRs" button - // (dark text) shown on the last PR. - nextBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - submitButtonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("15")).Bold(true).Padding(0, 1) - // prNumberStyle renders a clickable existing-PR number as an underlined - // white link. - prNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Underline(true) + openLinkStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true).Underline(true) + lockedTitleStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) + + // Footer bottom-right actions: nextBranchStyle is the "NEXT BRANCH" label; + // submitButtonStyle is the prominent inverted "SUBMIT N PRs" button shown on + // the last PR. + nextBranchStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) + submitButtonStyle = lipgloss.NewStyle().Foreground(shared.ColorButtonFg).Background(shared.ColorButtonBg).Bold(true).Padding(0, 1) + // prNumberStyle renders a clickable existing-PR number as an underlined link. + prNumberStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Underline(true) // Tree spine + horizontal rules (dim chrome). - spineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - ruleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + spineStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) + ruleStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) // CREATE PR switch in the right-panel header. On: a green pill (matching the - // CREATE AS selected color) with a black square knob inset on the right. Off: - // the colors invert to a light-gray pill with a darker square inset on the - // left. The "CREATE PR" label uses the shared section-heading style. - switchOnStyle = lipgloss.NewStyle().Background(lipgloss.Color("2")) - switchOffStyle = lipgloss.NewStyle().Background(lipgloss.Color("245")) - switchOnKnob = lipgloss.Color("0") // black knob (matches CREATE AS selected text) - switchOffKnob = lipgloss.Color("236") // dark square on a lighter track + // CREATE AS selected color) with the knob inset on the right. Off: a muted + // track with a darker knob inset on the left. + switchOnStyle = lipgloss.NewStyle().Background(shared.ColorGreen) + switchOffStyle = lipgloss.NewStyle().Background(lipgloss.AdaptiveColor{Dark: "#6e7681", Light: "#afb8c1"}) + switchOnKnob = shared.ColorOnFill // contrasts with the green track + switchOffKnob = lipgloss.AdaptiveColor{Dark: "#1c2128", Light: "#57606a"} // Segmented Ready/Draft control: the selected segment is filled green; the - // other is dim. Brackets/divider are dim chrome. + // other is muted. Brackets/divider are dim chrome. segOnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("0")). - Background(lipgloss.Color("2")). + Foreground(shared.ColorOnFill). + Background(shared.ColorGreen). Bold(true). Padding(0, 1) - segOffStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 1) - segFrameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + segOffStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted).Padding(0, 1) + segFrameStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) // dimBodyStyle renders the skipped branch's body as muted, non-interactive // chrome. - dimBodyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + dimBodyStyle = lipgloss.NewStyle().Foreground(shared.ColorTextFaint) // descCursorStyle renders the block cursor overlaid on the scrollable // description view. descCursorStyle = lipgloss.NewStyle().Reverse(true) // Description scrollbar (track + thumb), drawn inside the box. - scrollTrackStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - scrollThumbStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + scrollTrackStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) + scrollThumbStyle = lipgloss.NewStyle().Foreground(shared.ColorText) // Callouts. - calloutErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + calloutErrorStyle = lipgloss.NewStyle().Foreground(shared.ColorRed) + hintStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) ) From b6ddf43582978685d28e3dc252686962adf72873 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Sun, 28 Jun 2026 14:27:59 -0400 Subject: [PATCH 2/2] Apply background-aware colors to all command output Background detection and the GH_STACK_THEME override (added for the TUIs) only affected the interactive screens. Plain command output -- status messages and interactive prompts -- went through the mgutz/ansi library with fixed ANSI palette names (green/red/yellow/cyan/...), so it never adapted to the terminal background and could read poorly on light or solarized themes. Unify everything on the same adaptive palette so all colors react to the detected background and to GH_STACK_THEME. - Extract internal/theme, a foundational package with no internal dependencies, that owns: - the background-aware lipgloss.AdaptiveColor palette (moved out of internal/tui/shared), - ApplyOverride(), the GH_STACK_THEME=auto|light|dark logic, and - non-TUI colorizers (Success/Error/Warning/Blue/Magenta/Cyan/Gray/ Bold) plus FgSeqs(), which returns the raw start/reset escapes used to color the user's echoed prompt input. - internal/tui/shared/theme.go now re-exports the palette, so the TUI code keeps referring to shared.ColorX unchanged. - internal/config/config.go wires the Config.Color* funcs to the theme colorizers and drops mgutz/ansi (now an indirect dependency only). - cmd/utils.go colors the prompt icon and echoed input via theme. - cmd/root.go calls theme.ApplyOverride() in PersistentPreRun. Detection adds no cost: because the command package imports Bubble Tea, its init() already triggers (and caches) the terminal background query for every command, so the non-TUI colorizers just read the cached value. Terminals that don't answer the query fall back to the dark palette; GH_STACK_THEME=light|dark forces it. Colors are truecolor on capable terminals and downsample to the nearest ANSI color elsewhere. Tests: internal/theme covers palette adaptiveness, ApplyOverride, the colorizers, and FgSeqs; a new internal/config test verifies the wired-up Config.Color* funcs adapt to the background when color is enabled. Docs: README and the CLI reference note that GH_STACK_THEME now controls all colored output, not just the interactive screens. No behavior change beyond colors. --- README.md | 6 +- cmd/root.go | 8 +- cmd/utils.go | 13 +-- docs/src/content/docs/reference/cli.md | 2 +- go.mod | 2 +- internal/config/color_test.go | 48 +++++++++ internal/config/config.go | 18 ++-- internal/theme/theme.go | 138 +++++++++++++++++++++++++ internal/theme/theme_test.go | 137 ++++++++++++++++++++++++ internal/tui/shared/theme.go | 100 ++++-------------- internal/tui/shared/theme_test.go | 74 ------------- 11 files changed, 366 insertions(+), 180 deletions(-) create mode 100644 internal/config/color_test.go create mode 100644 internal/theme/theme.go create mode 100644 internal/theme/theme_test.go delete mode 100644 internal/tui/shared/theme_test.go diff --git a/README.md b/README.md index b8d6029..70f0fba 100644 --- a/README.md +++ b/README.md @@ -625,7 +625,7 @@ Compared to the typical workflow, there's no need to name branches, run `git add ## Terminal theme -The interactive screens (`submit`, `modify`, and `view`) automatically adapt their colors to your terminal's background, so they're readable on both dark and light themes. The background is detected from the terminal; if a terminal doesn't report it (some SSH or `tmux` setups), the dark palette is used. +The interactive screens (`submit`, `modify`, and `view`) and all colored command output (status messages, prompts) automatically adapt their colors to your terminal's background, so they're readable on both dark and light themes. The background is detected from the terminal; if a terminal doesn't report it (some SSH or `tmux` setups), the dark palette is used. Set `GH_STACK_THEME` to force a palette if detection is wrong: @@ -636,8 +636,8 @@ Set `GH_STACK_THEME` to force a palette if detection is wrong: | `dark` | Force the dark palette | ```bash -# Force the light palette for one command -GH_STACK_THEME=light gh stack view +# Force the light palette +export GH_STACK_THEME=light && gh stack view ``` ## Exit codes diff --git a/cmd/root.go b/cmd/root.go index 078b51c..bb1a170 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,7 @@ import ( "os" "github.com/github/gh-stack/internal/config" - "github.com/github/gh-stack/internal/tui/shared" + "github.com/github/gh-stack/internal/theme" "github.com/spf13/cobra" ) @@ -36,11 +36,9 @@ locally, then push to GitHub to create your stack of PRs.`, Version: Version, SilenceUsage: true, SilenceErrors: true, - // Honor GH_STACK_THEME (auto|light|dark) before any command renders, so - // the background-aware TUI palette can be forced when a terminal - // mis-detects its background. + // Honor GH_STACK_THEME (auto|light|dark) before any command renders PersistentPreRun: func(_ *cobra.Command, _ []string) { - shared.ApplyThemeOverride() + theme.ApplyOverride() }, } diff --git a/cmd/utils.go b/cmd/utils.go index 488cffe..3a822b0 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -14,7 +14,7 @@ import ( "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" - "github.com/mgutz/ansi" + "github.com/github/gh-stack/internal/theme" ) // ErrSilent indicates the error has already been printed to the user. @@ -97,24 +97,25 @@ func inputWithPrefill(cfg *config.Config, prompt, prefill string) (string, error } defer func() { _ = rr.RestoreTermMode() }() - // Render the prompt in survey style: green bold "?" + message + // Render the prompt in survey style: green "?" + message icon := "?" useColor := cfg.Terminal.IsColorEnabled() if useColor { - icon = ansi.Color("?", "green+hb") + icon = theme.Success("?") } fmt.Fprintf(cfg.Out, "%s %s ", icon, prompt) - // Set cyan color for the user's input text + // Color the user's echoed input with the accent (cyan) color. + cyanStart, cyanReset := theme.FgSeqs(theme.ColorAccent) if useColor { - fmt.Fprint(cfg.Out, ansi.ColorCode("cyan")) + fmt.Fprint(cfg.Out, cyanStart) } line, err := rr.ReadLineWithDefault(0, []rune(prefill)) // Reset color after input if useColor { - fmt.Fprint(cfg.Out, ansi.ColorCode("reset")) + fmt.Fprint(cfg.Out, cyanReset) } if err != nil { diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 6227d01..a7f0c44 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -590,7 +590,7 @@ gh stack feedback "Support for reordering branches" | Variable | Values | Description | |----------|--------|-------------| -| `GH_STACK_THEME` | `auto` (default), `light`, `dark` | Controls the color palette of the interactive screens (`submit`, `modify`, `view`). They adapt to your terminal background automatically; set this to force the light or dark palette when a terminal doesn't report its background (some SSH or `tmux` setups). | +| `GH_STACK_THEME` | `auto` (default), `light`, `dark` | Controls the color palette of the interactive screens (`submit`, `modify`, `view`) and all colored command output. Colors adapt to your terminal background automatically; set this to force the light or dark palette when a terminal doesn't report its background (some SSH or `tmux` setups). | ```sh # Force the light palette for one command diff --git a/go.mod b/go.mod index 561c585..eed0bc5 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/cli/cli/v2 v2.93.0 github.com/cli/go-gh/v2 v2.13.0 github.com/cli/shurcooL-graphql v0.0.4 - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -49,6 +48,7 @@ require ( github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect diff --git a/internal/config/color_test.go b/internal/config/color_test.go new file mode 100644 index 0000000..9c40a3d --- /dev/null +++ b/internal/config/color_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestColorFuncsAreBackgroundAware verifies that, when color is enabled, the +// Config's color functions resolve to background-aware (adaptive) colors so plain +// command output adapts to the terminal like the TUIs do. +func TestColorFuncsAreBackgroundAware(t *testing.T) { + // Force color on (even though tests have no tty) and a color-capable profile. + t.Setenv("NO_COLOR", "") + t.Setenv("CLICOLOR_FORCE", "1") + beforeProfile := lipgloss.ColorProfile() + beforeBg := lipgloss.HasDarkBackground() + t.Cleanup(func() { + lipgloss.SetColorProfile(beforeProfile) + lipgloss.SetHasDarkBackground(beforeBg) + }) + lipgloss.SetColorProfile(termenv.TrueColor) + + cfg := New() + require.True(t, cfg.Terminal.IsColorEnabled(), "CLICOLOR_FORCE should enable color") + + for name, fn := range map[string]func(string) string{ + "ColorSuccess": cfg.ColorSuccess, + "ColorError": cfg.ColorError, + "ColorWarning": cfg.ColorWarning, + "ColorCyan": cfg.ColorCyan, + "ColorBlue": cfg.ColorBlue, + "ColorMagenta": cfg.ColorMagenta, + "ColorGray": cfg.ColorGray, + } { + t.Run(name, func(t *testing.T) { + lipgloss.SetHasDarkBackground(true) + dark := fn("x") + lipgloss.SetHasDarkBackground(false) + light := fn("x") + assert.Contains(t, dark, "x") + assert.NotEqual(t, dark, light, "%s should adapt to the terminal background", name) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d9fee16..02dbff3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,9 +6,9 @@ import ( "github.com/cli/go-gh/v2/pkg/repository" "github.com/cli/go-gh/v2/pkg/term" - "github.com/mgutz/ansi" ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/theme" ) // Config holds shared state for all commands. @@ -69,14 +69,14 @@ func New() *Config { } if terminal.IsColorEnabled() { - cfg.ColorSuccess = ansi.ColorFunc("green") - cfg.ColorError = ansi.ColorFunc("red") - cfg.ColorWarning = ansi.ColorFunc("yellow") - cfg.ColorBold = ansi.ColorFunc("default+b") - cfg.ColorBlue = ansi.ColorFunc("blue") - cfg.ColorMagenta = ansi.ColorFunc("magenta") - cfg.ColorCyan = ansi.ColorFunc("cyan") - cfg.ColorGray = ansi.ColorFunc("default+d") + cfg.ColorSuccess = theme.Success + cfg.ColorError = theme.Error + cfg.ColorWarning = theme.Warning + cfg.ColorBold = theme.Bold + cfg.ColorBlue = theme.Blue + cfg.ColorMagenta = theme.Magenta + cfg.ColorCyan = theme.Cyan + cfg.ColorGray = theme.Gray } else { noop := func(s string) string { return s } cfg.ColorSuccess = noop diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..c23f781 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,138 @@ +// Package theme defines gh-stack's background-aware color palette and the +// helpers that render it, shared by both the interactive TUIs and ordinary +// command output (prompts, status messages). +// +// Colors are expressed as lipgloss.AdaptiveColor, whose Light/Dark variant is +// chosen at render time from the terminal's detected background. Bubble Tea +// triggers that detection once at startup (see bubbletea/tea_init.go) — which, +// because the command package imports Bubble Tea, happens for every command — +// so the right variant is picked automatically and cached. Terminals that don't +// answer the query fall back to the dark palette, preserving the original look. +// GH_STACK_THEME (see ApplyOverride) forces a palette when detection is wrong. +// +// Values are truecolor hex (GitHub Primer-inspired) rather than ANSI palette +// indices so they render consistently across themes — notably solarized, which +// repurposes ANSI 8–15 as background tones. lipgloss downsamples to the nearest +// ANSI color on terminals without truecolor support. +package theme + +import ( + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + // ColorText is primary/emphasis ink: titles, branch names, links, active + // keys, the description scrollbar thumb. + ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} + // ColorTextMuted is secondary ink and dim chrome text: section labels, + // shortcut descriptions, hints, trunk/merged branches, timestamps. + ColorTextMuted = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} + // ColorTextFaint is disabled/de-emphasized ink: skipped branches, disabled + // shortcuts. + ColorTextFaint = lipgloss.AdaptiveColor{Dark: "#656c76", Light: "#818b98"} + + // ColorBorder is structural chrome: panel borders, tree connectors, the + // vertical spine, horizontal rules, scrollbar tracks, segmented-control frame. + ColorBorder = lipgloss.AdaptiveColor{Dark: "#3d444d", Light: "#d1d9e0"} + // ColorRowShade tints the focused (currently-viewed) row's background in the + // left timeline. A neutral wash that reads as a subtle highlight on either + // background — light gray on light terminals, and a lifted slate on dark + // terminals so it stays visible against near-black backgrounds. + ColorRowShade = lipgloss.AdaptiveColor{Dark: "#353941", Light: "#eaeef2"} + + // ColorAccent is interactive emphasis: the current/focused branch, keyboard + // shortcut keys, footer accents, and the cyan used in plain command output. + ColorAccent = lipgloss.AdaptiveColor{Dark: "#2dd4bf", Light: "#0a7ea4"} + + // Semantic status colors, mirroring how GitHub colors PR states. Reused for + // diff stats (green/red), commit SHAs and warnings (yellow), modify action + // badges, and the success/error/warning message icons. + ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW, blue + ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, success + ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT, dim + ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA + ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, magenta + ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, error + + // ColorOnFill is text drawn on top of a solid colored fill (e.g. the green + // "selected" pill): near-black on the lighter dark-mode fills, white on the + // darker light-mode fills. + ColorOnFill = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} + + // ColorButtonFg/ColorButtonBg style the prominent inverted action button + // (e.g. submit). The background inverts against the terminal so the button + // stays prominent in both modes. + ColorButtonBg = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} + ColorButtonFg = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} +) + +// ApplyOverride honors the GH_STACK_THEME environment variable, forcing the +// light or dark palette regardless of what the terminal reports. It must be +// called before the first render (e.g. before any colored output or launching a +// Bubble Tea program). +// +// GH_STACK_THEME=light force the light palette +// GH_STACK_THEME=dark force the dark palette +// GH_STACK_THEME=auto (or unset) detect from the terminal background +// +// Use this for terminals that don't answer the background query (some SSH/tmux +// setups) and therefore mis-detect. +func ApplyOverride() { + switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) { + case "light": + lipgloss.SetHasDarkBackground(false) + case "dark": + lipgloss.SetHasDarkBackground(true) + } +} + +// Colorize renders s in the given adaptive color for plain (non-TUI) output. It +// emits ANSI only when the default renderer detects a color-capable terminal, so +// callers should still gate on their own color-enabled check for consistency. +func Colorize(c lipgloss.TerminalColor, s string) string { + return lipgloss.NewStyle().Foreground(c).Render(s) +} + +// The following helpers color plain command output and prompts. They map the +// semantic roles the command layer uses onto the adaptive palette. + +// Success renders s in the success (green) color. +func Success(s string) string { return Colorize(ColorGreen, s) } + +// Error renders s in the error (red) color. +func Error(s string) string { return Colorize(ColorRed, s) } + +// Warning renders s in the warning (yellow) color. +func Warning(s string) string { return Colorize(ColorYellow, s) } + +// Blue renders s in the blue color. +func Blue(s string) string { return Colorize(ColorBlue, s) } + +// Magenta renders s in the magenta/purple color. +func Magenta(s string) string { return Colorize(ColorPurple, s) } + +// Cyan renders s in the accent (cyan/teal) color. +func Cyan(s string) string { return Colorize(ColorAccent, s) } + +// Gray renders s in the muted gray color. +func Gray(s string) string { return Colorize(ColorGray, s) } + +// Bold renders s in bold using the terminal's default foreground, which already +// contrasts with either background. +func Bold(s string) string { return lipgloss.NewStyle().Bold(true).Render(s) } + +// FgSeqs returns the raw SGR escape that starts foreground rendering in c and +// the matching reset, for coloring text printed outside lipgloss (e.g. echoed +// terminal input). Both are empty when the default renderer has no color +// support. +func FgSeqs(c lipgloss.TerminalColor) (start, reset string) { + rendered := lipgloss.NewStyle().Foreground(c).Render("\x00") + i := strings.IndexByte(rendered, '\x00') + if i < 0 { + return "", "" + } + return rendered[:i], rendered[i+1:] +} diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go new file mode 100644 index 0000000..ba6809c --- /dev/null +++ b/internal/theme/theme_test.go @@ -0,0 +1,137 @@ +package theme + +import ( + "io" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPaletteIsBackgroundAware verifies that the palette's adaptive colors +// resolve to different output under a light vs dark background. It uses a local +// renderer with a color-capable profile so it doesn't mutate global state. +func TestPaletteIsBackgroundAware(t *testing.T) { + colors := map[string]lipgloss.AdaptiveColor{ + "text": ColorText, + "textMuted": ColorTextMuted, + "accent": ColorAccent, + "green": ColorGreen, + "red": ColorRed, + "yellow": ColorYellow, + "blue": ColorBlue, + "purple": ColorPurple, + } + + for name, c := range colors { + t.Run(name, func(t *testing.T) { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.TrueColor) + + r.SetHasDarkBackground(true) + dark := r.NewStyle().Foreground(c).Render("x") + r.SetHasDarkBackground(false) + light := r.NewStyle().Foreground(c).Render("x") + + assert.NotEqual(t, dark, light, "%s should differ between dark and light backgrounds", name) + }) + } +} + +func TestApplyOverride(t *testing.T) { + // ApplyOverride mutates the default renderer; restore it afterwards. + before := lipgloss.HasDarkBackground() + t.Cleanup(func() { lipgloss.SetHasDarkBackground(before) }) + + t.Run("light forces a light background", func(t *testing.T) { + lipgloss.SetHasDarkBackground(true) + t.Setenv("GH_STACK_THEME", "light") + ApplyOverride() + assert.False(t, lipgloss.HasDarkBackground()) + }) + + t.Run("dark forces a dark background", func(t *testing.T) { + lipgloss.SetHasDarkBackground(false) + t.Setenv("GH_STACK_THEME", "dark") + ApplyOverride() + assert.True(t, lipgloss.HasDarkBackground()) + }) + + t.Run("auto leaves the detected value unchanged", func(t *testing.T) { + lipgloss.SetHasDarkBackground(true) + t.Setenv("GH_STACK_THEME", "auto") + ApplyOverride() + assert.True(t, lipgloss.HasDarkBackground()) + }) + + t.Run("unset leaves the detected value unchanged", func(t *testing.T) { + lipgloss.SetHasDarkBackground(false) + t.Setenv("GH_STACK_THEME", "") + ApplyOverride() + assert.False(t, lipgloss.HasDarkBackground()) + }) +} + +// forceColorProfile sets the default renderer to a color-capable profile for the +// duration of a test, restoring the prior profile and background afterwards. The +// colorizers and FgSeqs use the default renderer, so this lets us assert on their +// emitted escapes deterministically. +func forceColorProfile(t *testing.T) { + t.Helper() + beforeProfile := lipgloss.ColorProfile() + beforeBg := lipgloss.HasDarkBackground() + t.Cleanup(func() { + lipgloss.SetColorProfile(beforeProfile) + lipgloss.SetHasDarkBackground(beforeBg) + }) + lipgloss.SetColorProfile(termenv.TrueColor) +} + +func TestColorizersAreBackgroundAware(t *testing.T) { + forceColorProfile(t) + + fns := map[string]func(string) string{ + "Success": Success, + "Error": Error, + "Warning": Warning, + "Blue": Blue, + "Magenta": Magenta, + "Cyan": Cyan, + "Gray": Gray, + } + for name, fn := range fns { + t.Run(name, func(t *testing.T) { + lipgloss.SetHasDarkBackground(true) + dark := fn("x") + lipgloss.SetHasDarkBackground(false) + light := fn("x") + + assert.Contains(t, dark, "x") + assert.NotEqual(t, dark, light, "%s should adapt to the terminal background", name) + }) + } +} + +func TestFgSeqs(t *testing.T) { + forceColorProfile(t) + + start, reset := FgSeqs(ColorAccent) + require.NotEmpty(t, start, "a color-capable terminal yields a start sequence") + require.NotEmpty(t, reset, "a color-capable terminal yields a reset sequence") + assert.True(t, strings.HasPrefix(start, "\x1b["), "start is an SGR escape") + assert.Contains(t, reset, "\x1b[0m") + assert.NotContains(t, start, "\x00", "the sentinel is stripped") +} + +func TestFgSeqsNoColor(t *testing.T) { + beforeProfile := lipgloss.ColorProfile() + t.Cleanup(func() { lipgloss.SetColorProfile(beforeProfile) }) + lipgloss.SetColorProfile(termenv.Ascii) + + start, reset := FgSeqs(ColorAccent) + assert.Empty(t, start) + assert.Empty(t, reset) +} diff --git a/internal/tui/shared/theme.go b/internal/tui/shared/theme.go index 0cb51ad..a0c3977 100644 --- a/internal/tui/shared/theme.go +++ b/internal/tui/shared/theme.go @@ -1,86 +1,24 @@ package shared -import ( - "os" - "strings" +import "github.com/github/gh-stack/internal/theme" - "github.com/charmbracelet/lipgloss" -) - -// This file defines the shared, background-aware color palette used by every -// gh-stack TUI (submit, view, modify). -// -// Colors are expressed as lipgloss.AdaptiveColor, whose Light/Dark variant is -// chosen at render time from the terminal's detected background. Bubble Tea -// triggers that detection once at startup (see bubbletea/tea_init.go), so the -// right variant is picked automatically; terminals that don't answer the query -// fall back to the dark palette, preserving the original look. -// -// Values are truecolor hex (GitHub Primer-inspired) rather than ANSI palette -// indices so they render consistently across themes — notably solarized, which -// repurposes ANSI 8–15 as background tones. lipgloss downsamples to the nearest -// ANSI color on terminals without truecolor support. +// The background-aware color palette lives in internal/theme so it can be shared +// by both the TUIs and ordinary command output. These aliases keep the TUI code +// referring to shared.ColorX. var ( - // ColorText is primary/emphasis ink: titles, branch names, links, active - // keys, the description scrollbar thumb. - ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} - // ColorTextMuted is secondary ink and dim chrome text: section labels, - // shortcut descriptions, hints, trunk/merged branches, timestamps. - ColorTextMuted = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} - // ColorTextFaint is disabled/de-emphasized ink: skipped branches, disabled - // shortcuts. - ColorTextFaint = lipgloss.AdaptiveColor{Dark: "#656c76", Light: "#818b98"} - - // ColorBorder is structural chrome: panel borders, tree connectors, the - // vertical spine, horizontal rules, scrollbar tracks, segmented-control frame. - ColorBorder = lipgloss.AdaptiveColor{Dark: "#3d444d", Light: "#d1d9e0"} - // ColorRowShade tints the focused (currently-viewed) row's background in the - // left timeline. A neutral wash that reads as a subtle highlight on either - // background — light gray on light terminals, and a lifted slate on dark - // terminals so it stays visible against near-black backgrounds. - ColorRowShade = lipgloss.AdaptiveColor{Dark: "#353941", Light: "#eaeef2"} - - // ColorAccent is interactive emphasis: the current/focused branch, keyboard - // shortcut keys, footer accents. - ColorAccent = lipgloss.AdaptiveColor{Dark: "#2dd4bf", Light: "#0a7ea4"} - - // Semantic status colors, mirroring how GitHub colors PR states. Reused for - // diff stats (green/red), commit SHAs and warnings (yellow), and modify - // action badges. - ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW - ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, insert - ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT - ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA, fold - ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, move - ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, drop, errors - - // ColorOnFill is text drawn on top of a solid colored fill (e.g. the green - // "selected" pill): near-black on the lighter dark-mode fills, white on the - // darker light-mode fills. - ColorOnFill = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} - - // ColorButtonFg/ColorButtonBg style the prominent inverted action button - // (e.g. submit). The background inverts against the terminal so the button - // stays prominent in both modes. - ColorButtonBg = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} - ColorButtonFg = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} + ColorText = theme.ColorText + ColorTextMuted = theme.ColorTextMuted + ColorTextFaint = theme.ColorTextFaint + ColorBorder = theme.ColorBorder + ColorRowShade = theme.ColorRowShade + ColorAccent = theme.ColorAccent + ColorBlue = theme.ColorBlue + ColorGreen = theme.ColorGreen + ColorGray = theme.ColorGray + ColorYellow = theme.ColorYellow + ColorPurple = theme.ColorPurple + ColorRed = theme.ColorRed + ColorOnFill = theme.ColorOnFill + ColorButtonBg = theme.ColorButtonBg + ColorButtonFg = theme.ColorButtonFg ) - -// ApplyThemeOverride honors the GH_STACK_THEME environment variable, forcing the -// light or dark palette regardless of what the terminal reports. It must be -// called before the first render (e.g. before launching a Bubble Tea program). -// -// GH_STACK_THEME=light force the light palette -// GH_STACK_THEME=dark force the dark palette -// GH_STACK_THEME=auto (or unset) detect from the terminal background -// -// Use this for terminals that don't answer the background query (some SSH/tmux -// setups) and therefore mis-detect. -func ApplyThemeOverride() { - switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) { - case "light": - lipgloss.SetHasDarkBackground(false) - case "dark": - lipgloss.SetHasDarkBackground(true) - } -} diff --git a/internal/tui/shared/theme_test.go b/internal/tui/shared/theme_test.go deleted file mode 100644 index ab5279d..0000000 --- a/internal/tui/shared/theme_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package shared - -import ( - "io" - "testing" - - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" - "github.com/stretchr/testify/assert" -) - -// TestPaletteIsBackgroundAware verifies that the palette's adaptive colors -// resolve to different output under a light vs dark background. It uses a local -// renderer with a color-capable profile so it doesn't mutate global state. -func TestPaletteIsBackgroundAware(t *testing.T) { - colors := map[string]lipgloss.AdaptiveColor{ - "text": ColorText, - "textMuted": ColorTextMuted, - "textFaint": ColorTextFaint, - "border": ColorBorder, - "accent": ColorAccent, - "green": ColorGreen, - "red": ColorRed, - "buttonBg": ColorButtonBg, - } - - for name, c := range colors { - t.Run(name, func(t *testing.T) { - r := lipgloss.NewRenderer(io.Discard) - r.SetColorProfile(termenv.TrueColor) - - r.SetHasDarkBackground(true) - dark := r.NewStyle().Foreground(c).Render("x") - r.SetHasDarkBackground(false) - light := r.NewStyle().Foreground(c).Render("x") - - assert.NotEqual(t, dark, light, "%s should differ between dark and light backgrounds", name) - }) - } -} - -func TestApplyThemeOverride(t *testing.T) { - // ApplyThemeOverride mutates the default renderer; restore it afterwards. - before := lipgloss.HasDarkBackground() - t.Cleanup(func() { lipgloss.SetHasDarkBackground(before) }) - - t.Run("light forces a light background", func(t *testing.T) { - lipgloss.SetHasDarkBackground(true) - t.Setenv("GH_STACK_THEME", "light") - ApplyThemeOverride() - assert.False(t, lipgloss.HasDarkBackground()) - }) - - t.Run("dark forces a dark background", func(t *testing.T) { - lipgloss.SetHasDarkBackground(false) - t.Setenv("GH_STACK_THEME", "dark") - ApplyThemeOverride() - assert.True(t, lipgloss.HasDarkBackground()) - }) - - t.Run("auto leaves the detected value unchanged", func(t *testing.T) { - lipgloss.SetHasDarkBackground(true) - t.Setenv("GH_STACK_THEME", "auto") - ApplyThemeOverride() - assert.True(t, lipgloss.HasDarkBackground()) - }) - - t.Run("unset leaves the detected value unchanged", func(t *testing.T) { - lipgloss.SetHasDarkBackground(false) - t.Setenv("GH_STACK_THEME", "") - ApplyThemeOverride() - assert.False(t, lipgloss.HasDarkBackground()) - }) -}