Adapt theme colors to light and dark terminals (#149)

* Make the TUIs adapt to light and dark terminal backgrounds

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.

* 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.
This commit is contained in:
Sameen Karim
2026-06-29 20:11:09 -04:00
committed by GitHub
parent b6bcce1bfe
commit 797c62e9b4
21 changed files with 579 additions and 169 deletions
+17
View File
@@ -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`) 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:
| Value | Behavior |
|-------|----------|
| `auto` (default) | Detect from the terminal background |
| `light` | Force the light palette |
| `dark` | Force the dark palette |
```bash
# Force the light palette
export GH_STACK_THEME=light && gh stack view
```
## Exit codes
| Code | Meaning |
+5
View File
@@ -6,6 +6,7 @@ import (
"os"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/theme"
"github.com/spf13/cobra"
)
@@ -35,6 +36,10 @@ 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
PersistentPreRun: func(_ *cobra.Command, _ []string) {
theme.ApplyOverride()
},
}
root.SetVersionTemplate("gh stack version {{.Version}}\n")
+7 -6
View File
@@ -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 {
+13
View File
@@ -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`) 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
GH_STACK_THEME=light gh stack view
```
---
## Exit Codes
| Code | Meaning |
+1 -1
View File
@@ -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
+48
View File
@@ -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)
})
}
}
+9 -9
View File
@@ -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
+138
View File
@@ -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:]
}
+137
View File
@@ -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)
}
+1 -1
View File
@@ -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"})
+29 -23
View File
@@ -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)
)
+1 -1
View File
@@ -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 {
+4 -4
View File
@@ -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 ""
}
+40 -33
View File
@@ -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 = "▾"
+24
View File
@@ -0,0 +1,24 @@
package shared
import "github.com/github/gh-stack/internal/theme"
// 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 = 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
)
+6 -4
View File
@@ -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 {
+7 -5
View File
@@ -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.
+15 -8
View File
@@ -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(
+2 -2
View File
@@ -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"
+11 -11
View File
@@ -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
+64 -61
View File
@@ -1,26 +1,32 @@
package submitview
import "github.com/charmbracelet/lipgloss"
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
"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)
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 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)
// 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)
)