From a1ead23f06a3a81fb0576801fde1598e2c549ce4 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 17:32:09 +0300 Subject: [PATCH] fix(tui): follow the terminal background The main TUI drew the dark palette on every terminal. Against #F7F7F7 the six colours measured between 2.04:1 and 4.01:1, all below WCAG AA. #95 added theme.For for the installer and left this UI on the fixed set. Init now requests the background colour and Update applies the matching palette. A terminal that never answers keeps the dark set. The styles stay package-level values: about 150 call sites read them by name, and one program draws with them at a time. Signed-off-by: NovusEdge --- internal/tui/app.go | 9 +++++- internal/tui/theme.go | 65 ++++++++++++++++++++++++++------------ internal/tui/theme_test.go | 52 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 internal/tui/theme_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 079d8f56..d807a85b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -181,7 +181,7 @@ func preflightReport(checks []core.HostCheck) string { return strings.Join(lines, "\n") } -func (m model) Init() tea.Cmd { return loadVMs } +func (m model) Init() tea.Cmd { return tea.Batch(loadVMs, tea.RequestBackgroundColor) } // Update feeds the open byo screen without consuming the message, then runs // the normal update. @@ -253,6 +253,13 @@ func (m model) updateApp(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case tea.BackgroundColorMsg: + // The fixed palette reads as low contrast on a light terminal: every + // colour measured below 4.5:1 against #F7F7F7 before internal/theme + // grew a light set. + applyPalette(msg.IsDark()) + return m, nil + case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height // A taller terminal shows more VMs before paginating; the width stays diff --git a/internal/tui/theme.go b/internal/tui/theme.go index acfac053..9117b581 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -15,35 +15,60 @@ type themeColors struct { accent, up, down, warn, err, dim color.Color } -var th = themeColors{ - accent: theme.Accent, - up: theme.Up, - down: theme.Down, - warn: theme.Warn, - err: theme.Err, - dim: theme.Dim, -} +var th themeColors var ( - accentStyle = lipgloss.NewStyle().Foreground(th.accent) - dimStyle = lipgloss.NewStyle().Foreground(th.dim) - errStyle = lipgloss.NewStyle().Foreground(th.err) - warnStyle = lipgloss.NewStyle().Foreground(th.warn) - upStyle = lipgloss.NewStyle().Foreground(th.up) - downStyle = lipgloss.NewStyle().Foreground(th.down) - selStyle = lipgloss.NewStyle().Foreground(th.accent).Bold(true) + accentStyle lipgloss.Style + dimStyle lipgloss.Style + errStyle lipgloss.Style + warnStyle lipgloss.Style + upStyle lipgloss.Style + downStyle lipgloss.Style + selStyle lipgloss.Style // paneStyle is the one border every screen draws with: a rounded box // in the theme accent, with breathing room inside. No screen builds its // own lipgloss.NewStyle().Border(...); they all go through pane(). - paneStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(th.accent). - Padding(1, 2) + paneStyle lipgloss.Style - paneTitleStyle = accentStyle.Bold(true) + paneTitleStyle lipgloss.Style ) +// applyPalette points every style in this package at the palette for the +// terminal background the caller reports. Update calls it once, when Bubble +// Tea answers the background query started in Init. +// +// The styles stay package-level values because 150-odd call sites read them +// by name. One program draws with them at a time, and the switch happens +// before the first render that follows the query. +func applyPalette(isDark bool) { + p := theme.For(isDark) + th = themeColors{ + accent: p.Accent, + up: p.Up, + down: p.Down, + warn: p.Warn, + err: p.Err, + dim: p.Dim, + } + accentStyle = lipgloss.NewStyle().Foreground(th.accent) + dimStyle = lipgloss.NewStyle().Foreground(th.dim) + errStyle = lipgloss.NewStyle().Foreground(th.err) + warnStyle = lipgloss.NewStyle().Foreground(th.warn) + upStyle = lipgloss.NewStyle().Foreground(th.up) + downStyle = lipgloss.NewStyle().Foreground(th.down) + selStyle = lipgloss.NewStyle().Foreground(th.accent).Bold(true) + paneStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(th.accent). + Padding(1, 2) + paneTitleStyle = accentStyle.Bold(true) +} + +// A terminal that never answers the background query keeps the dark palette, +// which is what the unsuffixed constants in internal/theme are. +func init() { applyPalette(true) } + // paneFrame is the total width a pane() call adds on top of its content: // border on both sides plus the horizontal padding baked into paneStyle. A // caller that needs to bound a pane to the terminal width subtracts this diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go new file mode 100644 index 00000000..f8387d0c --- /dev/null +++ b/internal/tui/theme_test.go @@ -0,0 +1,52 @@ +package tui + +import ( + "image/color" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/novusedge/stoat/internal/theme" +) + +// applyPalette must point every style at the palette for the reported +// background. The fixed dark set measured below 4.5:1 against a light +// terminal for every colour, which is what internal/theme's light set fixes. +func TestApplyPaletteFollowsTheBackground(t *testing.T) { + t.Cleanup(func() { applyPalette(true) }) + + applyPalette(false) + light := theme.For(false) + if th.accent != light.Accent { + t.Errorf("accent = %v, want the light palette's %v", th.accent, light.Accent) + } + if got := accentStyle.GetForeground(); got != light.Accent { + t.Errorf("accentStyle foreground = %v, want %v", got, light.Accent) + } + if got := paneStyle.GetBorderTopForeground(); got != light.Accent { + t.Errorf("pane border = %v, want %v", got, light.Accent) + } + if got := selStyle.GetForeground(); got != light.Accent { + t.Errorf("selStyle foreground = %v, want %v", got, light.Accent) + } + + applyPalette(true) + if dark := theme.For(true); th.accent != dark.Accent { + t.Errorf("accent = %v, want the dark palette's %v", th.accent, dark.Accent) + } +} + +// Bubble Tea answers the query started in Init with this message, so Update +// has to act on it. Without the case, a light terminal keeps the dark palette +// for the life of the program. +func TestBackgroundColorMsgSwitchesThePalette(t *testing.T) { + t.Cleanup(func() { applyPalette(true) }) + + var m model + if _, cmd := m.Update(tea.BackgroundColorMsg{Color: color.White}); cmd != nil { + t.Errorf("the background report should not schedule work, got %T", cmd) + } + if light := theme.For(false); th.accent != light.Accent { + t.Errorf("a white background left accent at %v, want %v", th.accent, light.Accent) + } +}