diff --git a/README.md b/README.md index d008748..70f0fba 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`) 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 | diff --git a/cmd/root.go b/cmd/root.go index 0c9e07e..bb1a170 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/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") 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 ce2d268..a7f0c44 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`) 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 | 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/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..a0c3977 --- /dev/null +++ b/internal/tui/shared/theme.go @@ -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 +) 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) )