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 c4d6d92..c394450 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 { @@ -557,9 +559,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 011ae3f..f1c35b6 100644 --- a/internal/tui/submitview/screen.go +++ b/internal/tui/submitview/screen.go @@ -661,7 +661,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). @@ -777,30 +777,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) )