From 468fcb4bf413d3c46a1a5c87b440f87a167489ba Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Tue, 21 Jul 2026 10:38:10 +0200 Subject: [PATCH] ui: brand-grounded terminal style system (truecolor + light/dark + fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the CLI's ad-hoc colour into one system, built from the design-system tokens and the tracebloc.io identity. Organising idea = the homepage gradient: cyan orients (structure), lime moves (action). Engine (internal/ui): - colorMode {none,16,true} + termBg {dark,light}, detected in New: NO_COLOR / non-TTY / TERM=dumb → none; COLORTERM=truecolor|24bit → 24-bit; else ANSI-16. COLORFGBG picks the background; unknown → dark (the dev norm). - A tone table maps each semantic role to its exact 24-bit hex on dark AND light terminals (primary/secondary ramps) plus the nearest ANSI-16 fallback. hue() is the single brand-colour chokepoint; bright shades on dark, deep shades (primary.700 / secondary.700) on light so it stays legible on white. - Meaning never rests on hue alone — headings/commands carry Bold, alerts carry a distinct glyph — so output still reads under NO_COLOR / for colour-blindness. Roles applied to the existing Printer methods (decisions A–D): - Section / Banner / Step → cyan (structure) - MenuRow command → lime bold; description → soft lime (a7ed6c) [A,B] - ✔ / ✓ → brand lime ("green = go", unified with commands) [A] - ✗ soft red, ⚠ amber, ✖ bold red, dim neutral labels - home.go: inline "run " now rendered in the command tone. Truecolor exactness + light/dark shade + 16-fallback pinned by brand_tones_test.go, so a token drift fails CI. Existing colour-matrix / plain / NO_COLOR tests unchanged and green. First of two surfaces; the bash installer mirrors this next. Co-Authored-By: Claude Opus 4.8 --- internal/cli/home.go | 16 +-- internal/ui/brand_tones_test.go | 56 ++++++++ internal/ui/spinner_coverage_test.go | 16 +-- internal/ui/ui.go | 192 +++++++++++++++++++++++---- internal/ui/ui_test.go | 2 +- 5 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 internal/ui/brand_tones_test.go diff --git a/internal/cli/home.go b/internal/cli/home.go index 7f268598..d4be98c2 100644 --- a/internal/cli/home.go +++ b/internal/cli/home.go @@ -717,21 +717,21 @@ func renderHome(p *ui.Printer, m homeModel) { // don't claim tracebloc's view — it may be heartbeating fine while only // our probe failed — say we couldn't confirm. if m.confirmedNotOnline { - p.WarnLine("%s · running, but tracebloc hasn't heard from it — run %s %s", - envLabel, m.inv, doctorPath) + p.WarnLine("%s · running, but tracebloc hasn't heard from it — run %s", + envLabel, p.Command(m.inv+" "+doctorPath)) } else { - p.WarnLine("%s · running — couldn't confirm it's connected to tracebloc — run %s %s", - envLabel, m.inv, doctorPath) + p.WarnLine("%s · running — couldn't confirm it's connected to tracebloc — run %s", + envLabel, p.Command(m.inv+" "+doctorPath)) } case homeStarting: - p.WarnLine("%s · starting up, not ready yet — run %s %s", - envLabel, m.inv, doctorPath) + p.WarnLine("%s · starting up, not ready yet — run %s", + envLabel, p.Command(m.inv+" "+doctorPath)) case homeOffline: // One honest line for both causes: a stopped/unreachable cluster AND a // reachable cluster that doesn't host this release from the current // kube-context. "can't reach it from here" is true either way. - p.CrossLine("%s · can't reach it from here — run %s %s", - envLabel, m.inv, doctorPath) + p.CrossLine("%s · can't reach it from here — run %s", + envLabel, p.Command(m.inv+" "+doctorPath)) case homeNoEnv: p.WarnLine("No secure environment on this machine yet — run the installer to set one up.") } diff --git a/internal/ui/brand_tones_test.go b/internal/ui/brand_tones_test.go new file mode 100644 index 00000000..f4ca6a45 --- /dev/null +++ b/internal/ui/brand_tones_test.go @@ -0,0 +1,56 @@ +package ui + +import ( + "bytes" + "strings" + "testing" +) + +// renderTone builds a colorized Printer with a pinned mode/bg (independent of the +// runner's COLORTERM/COLORFGBG) and returns what f writes — so the brand-SGR +// assertions below are deterministic on any CI runner. +func renderTone(m colorMode, bg termBg, f func(*Printer)) string { + var b bytes.Buffer + p := New(&b, WithColor(true)) + p.mode, p.bg = m, bg + f(p) + return b.String() +} + +// TestBrandTones_Truecolor pins the exact 24-bit brand SGR the style system +// emits, through the real reachable methods: Section renders primary cyan +// #01a5cc, a MenuRow command renders secondary lime #91e947, and on a light +// terminal both drop to their deep shades (primary.700 / secondary.700) so they +// stay legible on white. These values come straight from the design-system +// tokens — a drift here is a brand regression, caught in CI not the field. +func TestBrandTones_Truecolor(t *testing.T) { + cases := []struct { + name string + bg termBg + render func(*Printer) + want string + }{ + {"heading · dark · cyan #01a5cc", bgDark, func(p *Printer) { p.Section("x") }, "38;2;1;165;204"}, + {"command · dark · lime #91e947", bgDark, func(p *Printer) { p.MenuRow(3, "cmd", "d") }, "38;2;145;233;71"}, + {"heading · light · deep cyan #01637a", bgLight, func(p *Printer) { p.Section("x") }, "38;2;1;99;122"}, + {"command · light · deep lime #578c2b", bgLight, func(p *Printer) { p.MenuRow(3, "cmd", "d") }, "38;2;87;140;43"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if out := renderTone(modeTrue, c.bg, c.render); !strings.Contains(out, c.want) { + t.Errorf("want SGR %q in %q", c.want, out) + } + }) + } +} + +// TestBrandTones_Fallback16: without truecolor, brand hues degrade to the nearest +// ANSI-16 (cyan → 36, green → 32) and never emit a raw 24-bit escape. +func TestBrandTones_Fallback16(t *testing.T) { + if out := renderTone(mode16, bgDark, func(p *Printer) { p.Section("x") }); !strings.Contains(out, "36") || strings.Contains(out, "38;2") { + t.Errorf("16-color heading should use cyan (36), no truecolor, got %q", out) + } + if out := renderTone(mode16, bgDark, func(p *Printer) { p.MenuRow(3, "cmd", "d") }); !strings.Contains(out, "32") || strings.Contains(out, "38;2") { + t.Errorf("16-color command should use green (32), no truecolor, got %q", out) + } +} diff --git a/internal/ui/spinner_coverage_test.go b/internal/ui/spinner_coverage_test.go index 07b2b4ea..7be7aa00 100644 --- a/internal/ui/spinner_coverage_test.go +++ b/internal/ui/spinner_coverage_test.go @@ -26,17 +26,17 @@ func TestAutoColor(t *testing.T) { } }) - // NO_COLOR set → false regardless of the writer. + // NO_COLOR set → modeNone regardless of the writer. _ = os.Setenv("NO_COLOR", "1") - if autoColor(os.Stdout) { - t.Error("NO_COLOR set → autoColor must be false") + if detectMode(os.Stdout) != modeNone { + t.Error("NO_COLOR set → detectMode must be modeNone") } // NO_COLOR unset from here on. _ = os.Unsetenv("NO_COLOR") - // A non-*os.File writer (a buffer) → false. - if autoColor(&bytes.Buffer{}) { - t.Error("a non-*os.File writer → false") + // A non-*os.File writer (a buffer) → modeNone. + if detectMode(&bytes.Buffer{}) != modeNone { + t.Error("a non-*os.File writer → modeNone") } // An *os.File that isn't a terminal (a regular temp file) → false. f, err := os.CreateTemp(t.TempDir(), "ui-*") @@ -44,8 +44,8 @@ func TestAutoColor(t *testing.T) { t.Fatal(err) } defer func() { _ = f.Close() }() - if autoColor(f) { - t.Error("a non-terminal *os.File → false") + if detectMode(f) != modeNone { + t.Error("a non-terminal *os.File → modeNone") } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 211311db..088915d1 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -15,6 +15,7 @@ import ( "io" "os" "runtime" + "strconv" "strings" "time" @@ -32,10 +33,34 @@ import ( // same underlying writer (neither is fmt.Fprintf). type Printer struct { w io.Writer - color bool + color bool // colorized at all? (paint + the spinner gate on this) + mode colorMode // none / 16-color / 24-bit truecolor — how brand hues render + bg termBg // dark or light terminal — picks the legible shade of each hue verbose bool } +// colorMode is how much color the terminal supports and wants. It drives whether +// a brand hue renders as exact 24-bit hex, the nearest ANSI-16 fallback, or not +// at all (NO_COLOR / pipe / TERM=dumb / --plain). +type colorMode uint8 + +const ( + modeNone colorMode = iota // no color + mode16 // 16-color ANSI (the terminal's own cyan/green/…) + modeTrue // 24-bit truecolor (exact brand hex) +) + +// termBg is the terminal's background, dark or light. Bright brand shades that +// pop on dark are unreadable on white, so light terminals get the deep shades +// (primary.700 / secondary.700) instead. Detected best-effort from COLORFGBG; +// defaults to dark, the developer norm. +type termBg uint8 + +const ( + bgDark termBg = iota + bgLight +) + // Option customizes a Printer at construction. This is the functional- // options pattern: rather than a widening New(w, color, ...) signature // or a separate Config struct, each knob is a small func that mutates @@ -47,7 +72,17 @@ type Option func(*Printer) // detection. Wire a --plain flag to WithColor(false); NO_COLOR is // already honored by the default detection. func WithColor(on bool) Option { - return func(p *Printer) { p.color = on } + return func(p *Printer) { + if on { + // Force color on even for a non-TTY writer (tests, an explicit + // --color): pick truecolor when the environment advertises it, else + // 16-color. NO_COLOR/TTY no longer gate — the caller has decided. + p.mode = envMode() + } else { + p.mode = modeNone + } + p.color = p.mode != modeNone + } } // WithVerbose enables verbose output: Detailf lines print only when on. Wire it @@ -62,27 +97,59 @@ func WithVerbose(on bool) Option { // (https://no-color.org). Options are applied after auto-detection, so // WithColor wins over it. func New(w io.Writer, opts ...Option) *Printer { - p := &Printer{w: w, color: autoColor(w)} + m := detectMode(w) + p := &Printer{w: w, mode: m, color: m != modeNone, bg: detectBg()} for _, opt := range opts { opt(p) } return p } -// autoColor reports whether to colorize for w: only when NO_COLOR is -// unset AND w is an *os.File pointing at a terminal. A bytes.Buffer -// (tests), a pipe, or a redirect to a file all fail the *os.File + -// IsTerminal check and get plain output — the conservative default, -// same test internal/push.isTTY uses. -func autoColor(w io.Writer) bool { +// detectMode reports how to colorize for w. It stays plain (modeNone) when +// NO_COLOR is set, TERM=dumb, or w is not a terminal — a bytes.Buffer (tests), +// a pipe, or a redirect all fail the *os.File + IsTerminal check, the same +// conservative default internal/push.isTTY uses. On a real terminal it upgrades +// to truecolor when the environment advertises it (envMode). +func detectMode(w io.Writer) colorMode { if _, ok := os.LookupEnv("NO_COLOR"); ok { - return false + return modeNone + } + if os.Getenv("TERM") == "dumb" { + return modeNone } f, ok := w.(*os.File) - if !ok { - return false + if !ok || !term.IsTerminal(int(f.Fd())) { + return modeNone + } + return envMode() +} + +// envMode picks the richest palette the environment advertises, ignoring TTY / +// NO_COLOR (those are decided upstream). COLORTERM=truecolor|24bit is the de-facto +// truecolor signal (set by iTerm2, VS Code, Windows Terminal, most modern terms); +// everything else falls back to the terminal's own 16-color cyan/green. +func envMode() colorMode { + switch os.Getenv("COLORTERM") { + case "truecolor", "24bit": + return modeTrue + } + return mode16 +} + +// detectBg reports the terminal background so brand hues render in a shade that +// stays legible on it. COLORFGBG (set by Konsole, rxvt, iTerm2, …) is "fg;bg" +// where a trailing 7 or 15 is a light background. Unknown → dark, the dev norm. +func detectBg() termBg { + v := os.Getenv("COLORFGBG") + if v == "" { + return bgDark + } + parts := strings.Split(v, ";") + switch parts[len(parts)-1] { + case "7", "15": + return bgLight } - return term.IsTerminal(int(f.Fd())) + return bgDark } // paint wraps s in the given SGR attributes when this Printer is in @@ -99,6 +166,74 @@ func (p *Printer) paint(s string, attrs ...color.Attribute) string { return c.Sprint(s) } +// ── Brand tones ────────────────────────────────────────────────────────────── +// Each semantic role maps to a tone: the exact 24-bit hex on a dark and on a +// light terminal (from the design-system primary/secondary ramps), plus the +// nearest ANSI-16 attribute for terminals without truecolor. Meaning never rests +// on hue alone — headings/commands also carry Bold and alerts carry a distinct +// glyph — so the output still reads under NO_COLOR or for a colour-blind reader. +type tone struct { + dark, light string // 24-bit hex (no '#'); "" = structural, no hue + c16 color.Attribute // ANSI-16 fallback + bold bool + underline bool +} + +var ( + toneHeading = tone{"01a5cc", "01637a", color.FgCyan, true, false} // primary — structure/headings + toneCommand = tone{"91e947", "578c2b", color.FgGreen, true, false} // secondary — the thing to run + toneDesc = tone{"a7ed6c", "578c2b", color.FgGreen, false, false} // soft lime — supporting text (decision B) + toneAccent = tone{"01a5cc", "01637a", color.FgCyan, false, false} // cyan prompt guidance + toneGo = tone{"91e947", "578c2b", color.FgGreen, false, false} // ✔ / ● — "good/go", brand lime (decision A) + toneWarn = tone{"ffc62b", "8a6a00", color.FgYellow, false, false} + toneErr = tone{"f64c4c", "c0271f", color.FgRed, true, false} // ✖ error (bold) + toneErrSoft = tone{"f64c4c", "c0271f", color.FgRed, false, false} // ✗ offline (lighter, non-bold) + toneLabel = tone{"8e8e8e", "6b6b6b", color.Faint, false, false} // dim metadata labels +) + +// hue renders s in a brand tone: exact 24-bit hex (dark or light shade) when the +// terminal supports truecolor, the ANSI-16 fallback otherwise, and plain text when +// color is off. This is the single brand-colour chokepoint. +func (p *Printer) hue(s string, t tone) string { + if p.mode == modeNone { + return s + } + var c *color.Color + if p.mode == modeTrue && t.dark != "" { + h := t.dark + if p.bg == bgLight { + h = t.light + } + r, g, b := rgbOf(h) + c = color.RGB(r, g, b) + } else { + c = color.New(t.c16) + } + if t.bold { + c.Add(color.Bold) + } + if t.underline { + c.Add(color.Underline) + } + c.EnableColor() + return c.Sprint(s) +} + +// rgbOf splits a 6-digit hex ("01a5cc") into r,g,b; a malformed value yields +// black rather than a panic. +func rgbOf(h string) (int, int, int) { + v, err := strconv.ParseUint(h, 16, 32) + if err != nil { + return 0, 0, 0 + } + return int(v>>16) & 0xff, int(v>>8) & 0xff, int(v) & 0xff +} + +// Command styles a command the user should run in the action tone (lime, bold), +// for use inline in prose — e.g. the "run `tracebloc doctor`" tail of a status +// line. MenuRow already applies this tone to whole command rows. +func (p *Printer) Command(s string) string { return p.hue(s, toneCommand) } + // out is the single write path. The (n, err) result is discarded // explicitly: a failed write to the terminal (closed pipe, /dev/full) // can't be acted on mid-render and shouldn't crash the command — the @@ -111,7 +246,7 @@ func (p *Printer) out(format string, a ...any) { // Banner prints the branded intro block: a bold-cyan title, a dim rule, // and an optional subtitle. Mirrors common.sh print_banner. func (p *Printer) Banner(title, subtitle string) { - p.out("\n %s\n", p.paint(title, color.FgCyan, color.Bold)) + p.out("\n %s\n", p.hue(title, toneHeading)) p.out(" %s\n", p.paint("────────────────────────────────────────", color.Faint)) if subtitle != "" { p.out(" %s\n", subtitle) @@ -132,7 +267,7 @@ func (p *Printer) Para(text string) { // Step prints a major-step header: "Step n/total label" in bold cyan. // Mirrors common.sh step(). func (p *Printer) Step(n, total int, label string) { - head := p.paint(fmt.Sprintf("Step %d/%d", n, total), color.FgCyan, color.Bold) + head := p.hue(fmt.Sprintf("Step %d/%d", n, total), toneHeading) p.out("\n%s %s\n", head, p.paint(label, color.Bold)) } @@ -140,12 +275,12 @@ func (p *Printer) Step(n, total int, label string) { // `f` + (format, args) signature is Go's convention for "takes a format // string" (cf. fmt.Printf vs fmt.Print). func (p *Printer) Successf(format string, a ...any) { - p.out(" %s %s\n", p.paint("✔", color.FgGreen), fmt.Sprintf(format, a...)) + p.out(" %s %s\n", p.hue("✔", toneGo), fmt.Sprintf(format, a...)) } // Warnf prints a non-blocking warning with a yellow ⚠. func (p *Printer) Warnf(format string, a ...any) { - p.out(" %s %s\n", p.paint("⚠", color.FgYellow), fmt.Sprintf(format, a...)) + p.out(" %s %s\n", p.hue("⚠", toneWarn), fmt.Sprintf(format, a...)) } // Infof prints supplementary detail with a dim · bullet. @@ -158,7 +293,7 @@ func (p *Printer) Infof(format string, a ...any) { // description dimmed — so the command clearly stands out against it. Used by the // home screen's command buckets. func (p *Printer) MenuRow(width int, cmd, desc string) { - p.out(" %s %-*s %s\n", p.paint("·", color.Faint), width, cmd, p.paint(desc, color.Faint)) + p.out(" %s %s %s\n", p.paint("·", color.Faint), p.hue(fmt.Sprintf("%-*s", width, cmd), toneCommand), p.hue(desc, toneDesc)) } // CheckLine, CrossLine, and WarnLine render the status-aware home screen's @@ -170,19 +305,19 @@ func (p *Printer) MenuRow(width int, cmd, desc string) { // CheckLine prints an affirmative status line led by a green ✓. func (p *Printer) CheckLine(format string, a ...any) { - p.out(" %s %s\n", p.paint("✓", color.FgGreen), fmt.Sprintf(format, a...)) + p.out(" %s %s\n", p.hue("✓", toneGo), fmt.Sprintf(format, a...)) } // CrossLine prints a negative status line led by a red ✗ (lighter and // non-bold, unlike Errorf's ✖) — e.g. "not signed in" or an offline environment. func (p *Printer) CrossLine(format string, a ...any) { - p.out(" %s %s\n", p.paint("✗", color.FgRed), fmt.Sprintf(format, a...)) + p.out(" %s %s\n", p.hue("✗", toneErrSoft), fmt.Sprintf(format, a...)) } // WarnLine prints a caution status line led by a yellow ⚠, single-spaced to // align with CheckLine/CrossLine (Warnf double-spaces for standalone warnings). func (p *Printer) WarnLine(format string, a ...any) { - p.out(" %s %s\n", p.paint("⚠", color.FgYellow), fmt.Sprintf(format, a...)) + p.out(" %s %s\n", p.hue("⚠", toneWarn), fmt.Sprintf(format, a...)) } // Detailf prints an indented, dim step-detail line — but ONLY in verbose mode @@ -204,7 +339,7 @@ func (p *Printer) Verbose() bool { return p.verbose } // it does NOT exit — surfacing the message is the UI's job; the command // still returns an *exitError so main() owns the process exit code. func (p *Printer) Errorf(format string, a ...any) { - p.out(" %s\n", p.paint("✖ "+fmt.Sprintf(format, a...), color.FgRed, color.Bold)) + p.out(" %s\n", p.hue("✖ "+fmt.Sprintf(format, a...), toneErr)) } // Hintf prints dim contextual help (e.g. the line under a prompt). @@ -217,7 +352,7 @@ func (p *Printer) Hintf(format string, a ...any) { // above the prompt. Distinct from Hintf (dim) — prompt guidance is meant // to be read, not skimmed past. func (p *Printer) PromptHint(format string, a ...any) { - p.out("\n %s\n", p.paint(fmt.Sprintf(format, a...), color.FgCyan)) + p.out("\n %s\n", p.hue(fmt.Sprintf(format, a...), toneAccent)) } // Newline emits a single blank line. Used to detach a closing line or @@ -232,24 +367,25 @@ func (p *Printer) PromptHeader(label string) { p.out("\n %s\n", p.paint(label, color.Bold, color.FgWhite)) } -// Section prints a bold section header preceded by a blank line. Used -// to group related Field rows (e.g. "Target cluster"). +// Section prints a section header preceded by a blank line — the screen's +// structural spine, so it's cyan + bold (the heading tone). Used to group +// related Field rows (e.g. "Target cluster", "Your secure environment"). func (p *Printer) Section(title string) { - p.out("\n %s\n", p.paint(title, color.Bold)) + p.out("\n %s\n", p.hue(title, toneHeading)) } // Field prints an aligned, dim-labelled key/value row beneath a // Section: " label: value". The label is padded to a fixed // width so values line up within a section. func (p *Printer) Field(label, value string) { - p.out(" %s %s\n", p.paint(fmt.Sprintf("%-14s", label+":"), color.Faint), value) + p.out(" %s %s\n", p.hue(fmt.Sprintf("%-14s", label+":"), toneLabel), value) } // Stat prints an aligned "label value" row with a dimmed, fixed-width label, // so a short block of them lines up. Unlike Field's compact 14-col key, Stat // fits full-sentence labels (e.g. the resources view's two lines). func (p *Printer) Stat(label, value string) { - p.out(" %s %s\n", p.paint(fmt.Sprintf("%-42s", label), color.Faint), value) + p.out(" %s %s\n", p.hue(fmt.Sprintf("%-42s", label), toneLabel), value) } // Action prints an imperative instruction row — a bold verb label and its value, diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 23256471..2c2cf741 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -88,7 +88,7 @@ func TestSectionAndField_Plain(t *testing.T) { } // TestNoColorEnv_DefaultsPlain exercises the NO_COLOR branch of -// autoColor: with it set, a freshly-constructed Printer stays plain. +// detectMode: with it set, a freshly-constructed Printer stays plain. // (t.Setenv restores the prior value when the test ends.) func TestNoColorEnv_DefaultsPlain(t *testing.T) { t.Setenv("NO_COLOR", "1")