Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,18 +268,21 @@ func Run(ns *docker.Namespace, installImageRef string) error {
slog.Info("Starting ONCE UI", "version", version.Version)
defer func() { slog.Info("Stopping ONCE UI") }()

detected := DetectTerminalColors(100 * time.Millisecond)
ApplyPalette(NewPalette(detected))
return WithRawTerminal(func() error {
palette := defaultPalette()
palette.Detect(100 * time.Millisecond)
ApplyPalette(palette)

app := NewApp(ns, installImageRef)
app := NewApp(ns, installImageRef)

var opts []tea.ProgramOption
if detected.SupportsTrueColor() {
opts = append(opts, tea.WithColorProfile(colorprofile.TrueColor))
}
var opts []tea.ProgramOption
if palette.SupportsTrueColor() {
opts = append(opts, tea.WithColorProfile(colorprofile.TrueColor))
}

_, err := tea.NewProgram(app, opts...).Run()
return err
_, err := tea.NewProgram(app, opts...).Run()
return err
})
}

// Private
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/dashboard_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func (p DashboardPanel) renderVisitsCard(width int) string {
}

func (p DashboardPanel) renderTopTransition(selected bool, width int) string {
if !selected {
if !selected || Colors.BackgroundTint == nil {
return strings.Repeat(" ", width)
}
indicatorChar := lipgloss.NewStyle().Foreground(Colors.Focused).Render("▗")
Expand All @@ -223,7 +223,7 @@ func (p DashboardPanel) renderTopTransition(selected bool, width int) string {
}

func (p DashboardPanel) renderBottomTransition(selected bool, width int) string {
if !selected {
if !selected || Colors.BackgroundTint == nil {
return strings.Repeat(" ", width)
}
indicatorChar := lipgloss.NewStyle().Foreground(Colors.Focused).Render("▝")
Expand Down
8 changes: 5 additions & 3 deletions internal/ui/dashboard_panel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ func TestDashboardPanelSelectedHasIndicator(t *testing.T) {
panel := testPanel(true)
view := panel.View(true, false, true, 80, DashboardScales{})

// Selected panels have the indicator character
// Selected panels have the side indicator
assert.Contains(t, view, "▐")
assert.Contains(t, view, "▗")
assert.Contains(t, view, "▝")

// Transition corners only appear with true-color BackgroundTint
assert.NotContains(t, view, "▗")
assert.NotContains(t, view, "▝")
}

func TestDashboardPanelNotSelectedNoIndicator(t *testing.T) {
Expand Down
161 changes: 90 additions & 71 deletions internal/ui/palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@ package ui

import (
"image/color"
"log/slog"
"math"
"os"
"time"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/lucasb-eyer/go-colorful"
)

// Palette holds all colors used by the UI. ANSI color fields always contain
// BasicColor values so the terminal applies its own theme. Synthesized
// colors (FocusOrange, BackgroundTint, LightText) are true-color RGB.
// BasicColor values so the terminal applies its own theme. The synthesized
// colors (FocusOrange, BackgroundTint, LightText) are true-color RGB when
// the terminal supports it, or ANSI fallbacks otherwise.
Comment on lines 15 to +18
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says synthesized colors (including BackgroundTint) use “ANSI fallbacks otherwise”, but BackgroundTint is intentionally left nil when true color isn’t enabled. Consider updating the comment to reflect that BackgroundTint may be nil (and callers should guard), rather than implying an ANSI fallback exists.

Copilot uses AI. Check for mistakes.
type Palette struct {
// ANSI 16 — always BasicColor values for rendering
Black, Red, Green, Yellow, Blue, Magenta, Cyan, White color.Color
BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite color.Color

// Synthesized (always true-color RGB)
// Synthesized (true-color RGB when supported, ANSI fallbacks otherwise)
FocusOrange color.Color
BackgroundTint color.Color
LightText color.Color
Expand All @@ -30,19 +34,29 @@ type Palette struct {
Error color.Color // = Red
Success color.Color // = Green
Warning color.Color // = FocusOrange
// Private: detected RGB samples for calculations
samples [sampleCount]colorful.Color
detected [sampleCount]bool
isDark bool

isDark bool
trueColor bool
gradientGreen colorful.Color
gradientOrange colorful.Color
}

// Gradient interpolates between green and FocusOrange in OKLCH.
// t=0 returns green, t=1 returns orange.
// t=0 returns green, t=1 returns orange. When true color is not
// supported, the gradient is clamped to ANSI green/yellow/red.
func (p *Palette) Gradient(t float64) color.Color {
t = max(0, min(1, t))
greenSample := p.samples[int(ansi.Green)]
orangeSample, _ := colorful.MakeColor(p.FocusOrange)
return greenSample.BlendOkLch(orangeSample, t)
if !p.trueColor {
switch {
case t < 0.33:
return p.Green
case t < 0.67:
return p.Yellow
default:
return p.Red
}
}
return p.gradientGreen.BlendOkLch(p.gradientOrange, t)
}

// HealthColor returns the palette color for the given health state.
Expand All @@ -57,9 +71,31 @@ func (p *Palette) HealthColor(h HealthState) color.Color {
}
}

// DefaultPalette returns a palette with ANSI BasicColor values and
// fallback-derived synthesized colors. This is the package-init value.
func DefaultPalette() *Palette {
// SupportsTrueColor reports whether the terminal is likely to support
// 24-bit color output.
func (p *Palette) SupportsTrueColor() bool {
return p.trueColor
}

// Detect queries the terminal for colors and updates the palette
// accordingly. If detection succeeds (all 18 colors received),
// synthesized colors are computed from the detected RGB values.
// If COLORTERM indicates true-color support, synthesized colors
// are computed from fallback samples. Otherwise the ANSI defaults
// from defaultPalette are kept.
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detect calls DetectTerminalColors, which requires the terminal to already be in raw mode (per the comment in palette_detect.go). The Detect docstring doesn’t mention this precondition, so it’s easy to call it elsewhere and get unreliable results/timeouts. Consider documenting the raw-mode requirement here (or moving the raw-mode handling inside Detect).

Suggested change
// from defaultPalette are kept.
// from defaultPalette are kept.
//
// DetectTerminalColors requires the terminal to already be in raw mode;
// callers of Detect are responsible for putting the terminal into raw mode
// before calling this method and restoring the previous state afterwards.
// Calling Detect without raw mode can lead to unreliable results or timeouts.

Copilot uses AI. Check for mistakes.
func (p *Palette) Detect(timeout time.Duration) {
colors, ok := DetectTerminalColors(timeout)
p.apply(colors, ok)

if !p.SupportsTrueColor() {
slog.Info("True color output is not enabled")
}
}

// defaultPalette returns a palette with ANSI BasicColor values for all
// color fields. This is the starting point; Detect may upgrade the
// synthesized colors to true-color RGB.
func defaultPalette() *Palette {
p := &Palette{
Black: lipgloss.Black,
Red: lipgloss.Red,
Expand All @@ -77,41 +113,14 @@ func DefaultPalette() *Palette {
BrightMagenta: lipgloss.BrightMagenta,
BrightCyan: lipgloss.BrightCyan,
BrightWhite: lipgloss.BrightWhite,
isDark: true,
}

p.samples = defaultSamples()
p.synthesize()
return p
}

// NewPalette creates a palette from detected terminal colors.
func NewPalette(detected DetectedColors) *Palette {
p := DefaultPalette()

p.detected = detected.Detected
defaults := defaultSamples()

for i := range sampleCount {
if detected.Detected[i] {
p.samples[i] = detected.Colors[i]
} else {
p.samples[i] = defaults[i]
}
}

if detected.Detected[sampleBackground] {
l, _, _ := detected.Colors[sampleBackground].OkLch()
p.isDark = l < 0.5
}

p.Primary = pickPrimary(p)
p.synthesize()
FocusOrange: lipgloss.Red,
LightText: lipgloss.BrightBlack,
Primary: lipgloss.BrightBlue,

if !detected.SupportsTrueColor() {
p.BackgroundTint = nil
isDark: true,
}

p.setAliases()
return p
}

Expand All @@ -124,21 +133,42 @@ func ApplyPalette(p *Palette) {

// Private

func (p *Palette) synthesize() {
p.FocusOrange = synthesizeOrange(p.samples[int(ansi.Blue)])
p.BackgroundTint = synthesizeTint(p.samples[sampleBackground])
func (p *Palette) apply(colors detectedColors, ok bool) {
colorterm := os.Getenv("COLORTERM")
p.trueColor = ok || colorterm == "truecolor" || colorterm == "24bit"

if !p.trueColor {
return
}

var samples [sampleCount]colorful.Color
if ok {
samples = colors.Colors
l, _, _ := samples[sampleBackground].OkLch()
p.isDark = l < 0.5
p.Primary = pickPrimary(samples)
} else {
samples = defaultSamples()
}

p.FocusOrange = synthesizeOrange(samples[int(ansi.Blue)])
p.BackgroundTint = synthesizeTint(samples[sampleBackground])
p.LightText = synthesizeLightText(
p.samples[sampleBackground],
p.samples[sampleForeground],
p.samples[int(ansi.Blue)],
samples[sampleBackground],
samples[sampleForeground],
samples[int(ansi.Blue)],
)

p.gradientGreen = samples[int(ansi.Green)]
p.gradientOrange, _ = colorful.MakeColor(p.FocusOrange)

p.setAliases()
}

func (p *Palette) setAliases() {
p.Border = p.LightText
p.Muted = p.LightText
p.Focused = p.FocusOrange
if p.Primary == nil {
p.Primary = lipgloss.BrightBlue
}
p.Error = p.Red
p.Success = p.Green
p.Warning = p.FocusOrange
Expand Down Expand Up @@ -183,22 +213,11 @@ func synthesizeLightText(bg, fg, blue colorful.Color) color.Color {
}

// pickPrimary chooses the better of Blue and BrightBlue for contrast
// against the background. Falls back to BrightBlue when detection is
// incomplete.
func pickPrimary(p *Palette) color.Color {
bothDetected := p.detected[int(ansi.Blue)] &&
p.detected[int(ansi.BrightBlue)] &&
p.detected[sampleBackground]

if !bothDetected {
return lipgloss.BrightBlue
}

bg := p.samples[sampleBackground]
bgL, _, _ := bg.OkLch()

blueL, _, _ := p.samples[int(ansi.Blue)].OkLch()
brightL, _, _ := p.samples[int(ansi.BrightBlue)].OkLch()
// against the background.
func pickPrimary(samples [sampleCount]colorful.Color) color.Color {
bgL, _, _ := samples[sampleBackground].OkLch()
blueL, _, _ := samples[int(ansi.Blue)].OkLch()
brightL, _, _ := samples[int(ansi.BrightBlue)].OkLch()

if math.Abs(brightL-bgL) >= math.Abs(blueL-bgL) {
return lipgloss.BrightBlue
Expand Down
Loading
Loading