diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7afd907..976f36bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,12 +3,16 @@ ## Setup ```sh +just dev-setup # enables the repository git hooks just setup # builds, installs to ~/.local/bin, reports missing host deps just dev # runs the TUI against a scratch STOAT_HOME ``` -`just setup` also installs the git hooks. A repository with a `stoat.toml` -declares its own VMs; run `stoat up` in it to build them. +Run `just dev-setup` once per clone. The `commit-msg` hook it enables strips +tool-attribution trailers, and the `pre-commit` hook runs the fast checks. + +A repository with a `stoat.toml` declares its own VMs. Run `stoat up` in it to +build them. Go 1.26 (pinned in `go.mod`), `just`, and for anything that boots a VM: KVM, `qemu-system-x86_64`, `qemu-img`, `ssh`. `stoat doctor` lists what is diff --git a/cmd/installer/flags.go b/cmd/installer/flags.go new file mode 100644 index 00000000..9eeadd8f --- /dev/null +++ b/cmd/installer/flags.go @@ -0,0 +1,36 @@ +package main + +import ( + "flag" + "fmt" + "io" +) + +// exitUsage is the exit code for a bad command line, which separates it from +// an installation that started and failed. +const exitUsage = 2 + +// parseFlags reads the installer's command line. It returns whether to run +// without the terminal UI. An unknown flag, a misplaced flag, or a positional +// argument is an error: the previous parser read os.Args[1] only, so +// `--no-tty` in any other position silently started the interactive UI. +func parseFlags(args []string, out io.Writer) (noTTY bool, err error) { + flags := flag.NewFlagSet("installer", flag.ContinueOnError) + flags.SetOutput(out) + flags.Usage = func() { + fmt.Fprintln(out, "usage: go run ./cmd/installer [--no-tty]") + fmt.Fprintln(out, "\nBuilds stoat from this clone and installs it to $PREFIX,") + fmt.Fprintln(out, "or to ~/.local/bin when PREFIX is unset.") + flags.PrintDefaults() + } + headless := flags.Bool("no-tty", false, "install without the terminal UI, for CI and scripts") + if err := flags.Parse(args); err != nil { + return false, err + } + if flags.NArg() > 0 { + fmt.Fprintln(out, "unexpected argument:", flags.Arg(0)) + flags.Usage() + return false, fmt.Errorf("unexpected argument: %s", flags.Arg(0)) + } + return *headless, nil +} diff --git a/cmd/installer/flags_test.go b/cmd/installer/flags_test.go new file mode 100644 index 00000000..fafa3ad7 --- /dev/null +++ b/cmd/installer/flags_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "io" + "strings" + "testing" +) + +func TestParseFlags(t *testing.T) { + cases := []struct { + name string + args []string + noTTY bool + wantErr bool + }{ + {name: "no arguments", args: nil}, + {name: "single dash", args: []string{"-no-tty"}, noTTY: true}, + {name: "double dash", args: []string{"--no-tty"}, noTTY: true}, + {name: "unknown flag", args: []string{"--headless"}, wantErr: true}, + {name: "positional argument", args: []string{"install"}, wantErr: true}, + {name: "flag after argument", args: []string{"install", "--no-tty"}, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + noTTY, err := parseFlags(tc.args, io.Discard) + if tc.wantErr { + if err == nil { + t.Fatalf("parseFlags(%q) = nil error, want an error", tc.args) + } + return + } + if err != nil { + t.Fatalf("parseFlags(%q): %v", tc.args, err) + } + if noTTY != tc.noTTY { + t.Errorf("parseFlags(%q) noTTY = %v, want %v", tc.args, noTTY, tc.noTTY) + } + }) + } +} + +func TestParseFlagsReportsTheBadArgument(t *testing.T) { + var out strings.Builder + if _, err := parseFlags([]string{"--headles"}, &out); err == nil { + t.Fatal("parseFlags accepted a misspelled flag") + } + if !strings.Contains(out.String(), "no-tty") { + t.Errorf("usage does not name the real flag:\n%s", out.String()) + } +} diff --git a/cmd/installer/main_linux.go b/cmd/installer/main_linux.go index 7a80afdd..bf23aeb7 100644 --- a/cmd/installer/main_linux.go +++ b/cmd/installer/main_linux.go @@ -13,6 +13,11 @@ import ( ) func run() int { + noTTY, err := parseFlags(os.Args[1:], os.Stderr) + if err != nil { + return exitUsage + } + repoDir, err := os.Getwd() if err != nil { fmt.Fprintln(os.Stderr, "cannot determine the working directory:", err) @@ -25,8 +30,7 @@ func run() int { return 1 } - // ponytail: --no-tty is the only flag; full flag parsing if more needed - if len(os.Args) > 1 && os.Args[1] == "--no-tty" { + if noTTY { return runHeadless(repoDir, home) } @@ -78,11 +82,6 @@ func runHeadless(repoDir, home string) int { return 1 } - if err := installer.InstallData(repoDir, home); err != nil { - fmt.Fprintln(os.Stderr, "error setting up ~/.stoat:", err) - return 1 - } - fmt.Printf("done: stoat %s at %s\n", version, binPath) if !installer.OnPath(dir, os.Getenv("PATH")) { diff --git a/cmd/installer/main_other.go b/cmd/installer/main_other.go index 750d905f..effab8e3 100644 --- a/cmd/installer/main_other.go +++ b/cmd/installer/main_other.go @@ -12,6 +12,9 @@ import ( // on other hosts so users can run read-only diagnostics, while native VM // operations remain unqualified there. func run() int { - fmt.Fprintln(os.Stderr, "stoat command is buildable for diagnostics on "+runtime.GOOS+"/"+runtime.GOARCH+", but native VM operations are not qualified there; source installation remains Linux-only.") + host := runtime.GOOS + "/" + runtime.GOARCH + fmt.Fprintln(os.Stderr, "The guided installer runs on Linux only. This host is "+host+".") + fmt.Fprintln(os.Stderr, "Run `just install` to build the diagnostic binary here.") + fmt.Fprintln(os.Stderr, "Native VM operations are not qualified on "+host+".") return 1 } diff --git a/internal/installer/build.go b/internal/installer/build.go index 561da83f..b76bed37 100644 --- a/internal/installer/build.go +++ b/internal/installer/build.go @@ -62,58 +62,6 @@ func (e *BuildError) Error() string { func (e *BuildError) Unwrap() error { return e.Err } -// InstallData creates ~/.stoat subdirectories and copies bundled recipes from -// the repo. Safe to call on reinstall: existing user files are not overwritten. -func InstallData(repoDir, home string) error { - root := filepath.Join(home, ".stoat") - for _, d := range []string{"recipes", "isos", "logs"} { - if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { - return err - } - } - // Copy bundled recipes from repo, skipping any the user already has. - srcDir := filepath.Join(repoDir, "internal", "recipes") - destDir := filepath.Join(root, "recipes") - entries, err := os.ReadDir(srcDir) - if err != nil { - return err - } - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".sh") { - continue - } - dst := filepath.Join(destDir, name) - if _, err := os.Stat(dst); err == nil { - continue // ponytail: don't clobber user edits - } - if err := copyFile(filepath.Join(srcDir, name), dst); err != nil { - return err - } - } - return nil -} - -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer func() { _ = in.Close() }() - out, err := os.Create(dst) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return err - } - return out.Close() -} - // Install copies srcPath into destDir as an executable, creating destDir if // needed. func Install(srcPath, destDir string) (string, error) { diff --git a/internal/installer/tui.go b/internal/installer/tui.go index 1e9f6a52..8ca47c11 100644 --- a/internal/installer/tui.go +++ b/internal/installer/tui.go @@ -1,6 +1,7 @@ package installer import ( + "image/color" "os" "path/filepath" "strings" @@ -18,18 +19,9 @@ import ( // defaultWidth applies until the first WindowSizeMsg arrives. It also floors // a terminal that reports an unusably narrow width. -const defaultWidth = 60 - -var ( - okStyle = lipgloss.NewStyle().Foreground(theme.Up) - warnStyle = lipgloss.NewStyle().Foreground(theme.Warn) - errStyle = lipgloss.NewStyle().Foreground(theme.Err) - accentStyle = lipgloss.NewStyle().Foreground(theme.Accent) - dimStyle = lipgloss.NewStyle().Foreground(theme.Dim) - - // cellStyle spaces the check table's columns. lipgloss/table measures cells - // ANSI-aware, so pre-colored status cells still align. - cellStyle = lipgloss.NewStyle().PaddingRight(2) +const ( + defaultWidth = 60 + defaultHeight = 24 ) // keys use key.Binding, not raw string comparison. This is the Bubbles idiom. @@ -43,18 +35,12 @@ var keys = struct { Install, Accept, Decline, Interrupt, Quit key.Binding }{ Install: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "install here")), - Accept: key.NewBinding(key.WithKeys("y", "Y", "enter"), key.WithHelp("y", "append it")), + Accept: key.NewBinding(key.WithKeys("y", "Y", "enter"), key.WithHelp("enter/y", "append it")), Decline: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "skip")), Interrupt: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")), Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), } -// helpModel returns a fresh help.Model instead of a shared package-level -// value. ShortHelpView never mutates it today, but a mutable package-level -// UI model is the kind of shared state that breaks later. help.New() is a -// small struct literal; building one per render costs nothing. -func helpModel() help.Model { return help.New() } - type phase int const ( @@ -94,9 +80,14 @@ type Model struct { // AppendRC runs, the build and install already succeeded, so the user // can finish the rc write by hand: this failure does not fail the whole // run. See Failed and done. - rcErr error - err error - width int + rcErr error + err error + width int + height int + // darkBackground follows Bubble Tea's background-colour report. It + // defaults dark because terminals that cannot answer the query are more + // commonly dark, and the shared Stoat palette was designed for that case. + darkBackground bool // cancelled is set when ctrl+c or q exits before the run reaches // phaseDone on its own. See Failed and done: it counts as a failure // only if binPath was still empty at that point. @@ -114,19 +105,22 @@ func New(repoDir, home, shell, pathEnv, prefixEnv string) Model { sp := spinner.New() sp.Spinner = spinner.Dot - sp.Style = accentStyle - - return Model{ - phase: phaseChecks, - input: in, - spin: sp, - repoDir: repoDir, - home: home, - shell: shell, - pathEnv: pathEnv, - dir: dir, - width: defaultWidth, + + m := Model{ + phase: phaseChecks, + input: in, + spin: sp, + repoDir: repoDir, + home: home, + shell: shell, + pathEnv: pathEnv, + dir: dir, + width: defaultWidth, + height: defaultHeight, + darkBackground: true, } + m.spin.Style = m.accentStyle() + return m } // Failed reports whether the installer stopped on an error, so main can pick @@ -143,7 +137,52 @@ func New(repoDir, home, shell, pathEnv, prefixEnv string) Model { func (m Model) Failed() bool { return m.err != nil || (m.cancelled && m.binPath == "") } func (m Model) Init() tea.Cmd { - return tea.Batch(m.spin.Tick, runChecksCmd()) + return tea.Batch(m.spin.Tick, runChecksCmd(), tea.RequestBackgroundColor) +} + +// The palette lives in internal/theme so the installer and the main TUI name +// one set of colours. Each style is built per render from the background the +// terminal reported. +func (m Model) styleFor(pick func(theme.Palette) color.Color) lipgloss.Style { + return lipgloss.NewStyle().Foreground(pick(theme.For(m.darkBackground))) +} + +func (m Model) accentStyle() lipgloss.Style { + return m.styleFor(func(p theme.Palette) color.Color { return p.Accent }) +} + +func (m Model) okStyle() lipgloss.Style { + return m.styleFor(func(p theme.Palette) color.Color { return p.Up }) +} + +func (m Model) warnStyle() lipgloss.Style { + return m.styleFor(func(p theme.Palette) color.Color { return p.Warn }) +} + +func (m Model) errStyle() lipgloss.Style { + return m.styleFor(func(p theme.Palette) color.Color { return p.Err }) +} + +func (m Model) dimStyle() lipgloss.Style { + return m.styleFor(func(p theme.Palette) color.Color { return p.Dim }) +} + +// helpModel uses Bubbles' width-aware help renderer with the installer's +// adaptive palette. Bubbles defaults to its dark palette, which is too faint +// on Stoat's dark background and has no way to follow a later background +// colour message without being restyled here. +func (m Model) helpModel() help.Model { + h := help.New() + h.SetWidth(m.width - 4) + h.Styles.ShortKey = m.accentStyle() + h.Styles.ShortDesc = m.dimStyle() + h.Styles.ShortSeparator = m.dimStyle() + h.Styles.Ellipsis = m.dimStyle() + return h +} + +func inset(n int, s string) string { + return lipgloss.NewStyle().MarginLeft(n).Render(s) } func runChecksCmd() tea.Cmd { @@ -168,7 +207,10 @@ func buildCmd(repoDir, version string) tea.Cmd { } } -func installCmd(src, destDir, repoDir, home string) tea.Cmd { +// installCmd copies the built binary into place. It creates no data root: +// every stoat command runs config.EnsureRoot, recipes.Install and keys.Ensure +// first, and those honour STOAT_HOME. +func installCmd(src, destDir string) tea.Cmd { return func() tea.Msg { // buildCmd's temp dir has done its job once the binary is copied out, // whether the copy succeeds or fails. It is removed here @@ -178,9 +220,6 @@ func installCmd(src, destDir, repoDir, home string) tea.Cmd { if err != nil { return errMsg{err: err} } - if err := InstallData(repoDir, home); err != nil { - return errMsg{err: err} - } return installedMsg{path: path} } } @@ -198,7 +237,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case builtMsg: - return m, installCmd(msg.tmpPath, m.dir, m.repoDir, m.home) + return m, installCmd(msg.tmpPath, m.dir) case installedMsg: m.binPath = msg.path @@ -215,6 +254,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.width < defaultWidth { m.width = defaultWidth } + m.height = msg.Height + return m, nil + + case tea.BackgroundColorMsg: + m.darkBackground = msg.IsDark() + m.spin.Style = m.accentStyle() return m, nil case spinner.TickMsg: @@ -311,11 +356,11 @@ func (m Model) cancel() (tea.Model, tea.Cmd) { // reading a `\`-continued paste treats leading whitespace before the // reopened quote as ordinary inter-token whitespace, not part of the // quoted value. The indent is invisible in what gets pasted. -func (m Model) rcLineLines(indent string) []string { - chunks := WrapRCLine(m.shell, m.dir, m.width-len(indent)) +func (m Model) rcLineLines(indent int) []string { + chunks := WrapRCLine(m.shell, m.dir, m.width-indent) lines := make([]string, len(chunks)) for i, c := range chunks { - lines[i] = indent + dimStyle.Render(c) + lines[i] = inset(indent, m.dimStyle().Render(c)) } return lines } @@ -331,22 +376,37 @@ func expandHome(p, home string) string { } func (m Model) View() tea.View { + s := m.render(true) + // Keep the transcript, but trade the six-line mark for a compact product + // label when the current terminal cannot show the active state beneath it. + // Reserving one row also leaves the final shell prompt from scrolling the + // first line away when Bubble Tea exits. + if m.height > 0 && lipgloss.Height(s) >= m.height { + s = m.render(false) + } + return tea.NewView(s) +} + +func (m Model) render(fullBanner bool) string { var blocks []string - blocks = append(blocks, accentStyle.Render(theme.BannerArt), "") + if fullBanner { + blocks = append(blocks, m.accentStyle().Render(theme.BannerArt), "") + } else { + blocks = append(blocks, inset(2, m.accentStyle().Bold(true).Render("stoat")), "") + } if len(m.checks) > 0 { blocks = append(blocks, - " checking host", + inset(2, "checking host"), m.checkTable(), "", - dimStyle.Render(" "+strings.Repeat("─", m.ruleWidth())), + inset(2, m.dimStyle().Render(strings.Repeat("─", m.ruleWidth()))), "", ) } - s := lipgloss.JoinVertical(lipgloss.Left, append(blocks, m.active())...) + "\n" - return tea.NewView(s) + return lipgloss.JoinVertical(lipgloss.Left, append(blocks, m.active())...) + "\n" } // checkTable renders the probe results as an aligned table. @@ -368,14 +428,36 @@ func (m Model) checkTable() string { BorderTop(false).BorderBottom(false). BorderLeft(false).BorderRight(false). BorderColumn(false).BorderRow(false).BorderHeader(false). - StyleFunc(func(_, _ int) lipgloss.Style { return cellStyle }) + StyleFunc(func(_, col int) lipgloss.Style { + s := lipgloss.NewStyle().PaddingRight(2) + if col == 0 { + s = s.PaddingLeft(2) + } + return s + }) for _, c := range m.checks { - t.Row(" "+status(c), checkLabel(c), c.Detail) + t.Row(m.status(c), checkLabel(c), c.Detail) } return t.Render() } +func (m Model) statusRow(status, label, detail string) string { + t := table.New(). + BorderTop(false).BorderBottom(false). + BorderLeft(false).BorderRight(false). + BorderColumn(false).BorderRow(false).BorderHeader(false). + StyleFunc(func(_, col int) lipgloss.Style { + s := lipgloss.NewStyle().PaddingRight(2) + if col == 0 { + s = s.PaddingLeft(2) + } + return s + }) + t.Row(status, label, detail) + return t.Render() +} + // repairProblems is the display subset of failed checks. hostcheck.Problems // intentionally excludes optional failures for readiness aggregation, while // the installer must still tell the user how to repair every missing tool. @@ -412,30 +494,40 @@ func (m Model) ruleWidth() int { func (m Model) active() string { switch m.phase { case phaseChecks: - return " " + m.spin.View() + " checking host" + return m.statusRow(m.spin.View(), "checking", "host") case phaseDir: + prompt := inset(2, lipgloss.JoinHorizontal(lipgloss.Left, + lipgloss.NewStyle().PaddingRight(1).Render("install to:"), + m.input.View(), + )) return lipgloss.JoinVertical(lipgloss.Left, - " install to: "+m.input.View(), + prompt, "", - " "+helpModel().ShortHelpView([]key.Binding{keys.Install, keys.Interrupt}), + inset(2, m.helpModel().ShortHelpView([]key.Binding{keys.Install, keys.Interrupt})), ) case phaseBuild: - return " " + m.spin.View() + " building " + m.version + return lipgloss.JoinVertical(lipgloss.Left, + m.statusRow(m.spin.View(), "building", m.version), + "", + inset(2, m.helpModel().ShortHelpView([]key.Binding{keys.Quit, keys.Interrupt})), + ) case phaseRC: lines := []string{ - " " + okStyle.Render("ok") + " installed " + m.binPath, + m.statusRow(m.okStyle().Render("ok"), "installed", m.binPath), "", - " " + m.dir + " is not on your PATH", - " append to " + m.rcPath + ":", + m.statusRow(m.warnStyle().Render("warn"), "PATH", m.dir+" is not on your PATH"), + inset(2, "append to "+m.rcPath+":"), } - lines = append(lines, m.rcLineLines(" ")...) + lines = append(lines, m.rcLineLines(4)...) lines = append(lines, "", - " append it? [Y/n]", - " "+helpModel().ShortHelpView([]key.Binding{keys.Accept, keys.Decline}), + inset(2, "append it? [Y/n]"), + inset(2, m.helpModel().ShortHelpView([]key.Binding{ + keys.Accept, keys.Decline, keys.Quit, keys.Interrupt, + })), ) return lipgloss.JoinVertical(lipgloss.Left, lines...) @@ -456,12 +548,12 @@ func (m Model) done() string { case m.cancelled && m.binPath == "": // Left before the build or install finished. Unlike every other // branch here, nothing was installed. Failed() keys on the same fact. - lines = []string{"", errStyle.Render("cancelled") + ": nothing was installed"} + lines = []string{"", m.errStyle().Render("cancelled") + ": nothing was installed"} case m.err != nil: - lines = []string{"", errStyle.Render("failed") + ": " + m.err.Error()} + lines = []string{"", m.errStyle().Render("failed") + ": " + m.err.Error()} default: lines = []string{ - " " + okStyle.Render("ok") + " installed " + m.binPath, + m.statusRow(m.okStyle().Render("ok"), "installed", m.binPath), "", "done: stoat " + m.version, } @@ -469,16 +561,16 @@ func (m Model) done() string { case m.rcAdded: lines = append(lines, "", - " added the PATH line to "+m.rcPath, - " open a new shell, or source it, to pick it up", + inset(2, "added the PATH line to "+m.rcPath), + inset(2, "open a new shell, or source it, to pick it up"), ) case m.rcErr != nil: lines = append(lines, "", - " "+warnStyle.Render("warn")+" could not write "+m.rcPath+": "+m.rcErr.Error(), - " add this line yourself:", + m.statusRow(m.warnStyle().Render("warn"), "PATH", "could not write "+m.rcPath+": "+m.rcErr.Error()), + inset(8, "add this line yourself:"), ) - lines = append(lines, m.rcLineLines(" ")...) + lines = append(lines, m.rcLineLines(10)...) case m.rcLine != "": // Declined. rcLine is set only once the rc prompt has shown (see // the installedMsg case in Update), so this branch is reachable @@ -487,9 +579,9 @@ func (m Model) done() string { // hand. lines = append(lines, "", - " skipped, add this yourself:", + inset(2, "skipped, add this yourself:"), ) - lines = append(lines, m.rcLineLines(" ")...) + lines = append(lines, m.rcLineLines(8)...) } } @@ -497,7 +589,7 @@ func (m Model) done() string { lines = append(lines, "", "before your first VM:") seen := map[string]bool{} for _, c := range problems { - lines = append(lines, "", " "+warnStyle.Render(checkLabel(c))+": "+c.Detail) + lines = append(lines, "", inset(2, m.warnStyle().Render(checkLabel(c))+": "+c.Detail)) for _, f := range c.Fix { // Fixes are deduplicated here, not in Check. Two checks // (qemu-img, qemu-system-x86_64) can share one package. The @@ -507,7 +599,7 @@ func (m Model) done() string { continue } seen[f] = true - lines = append(lines, " "+dimStyle.Render(f)) + lines = append(lines, inset(4, m.dimStyle().Render(f))) } } } @@ -515,9 +607,9 @@ func (m Model) done() string { return lipgloss.JoinVertical(lipgloss.Left, lines...) } -func status(c Check) string { +func (m Model) status(c Check) string { if c.OK { - return okStyle.Render("ok") + return m.okStyle().Render("ok") } - return warnStyle.Render("warn") + return m.warnStyle().Render("warn") } diff --git a/internal/installer/tui_test.go b/internal/installer/tui_test.go index 80f4753e..190ed004 100644 --- a/internal/installer/tui_test.go +++ b/internal/installer/tui_test.go @@ -550,13 +550,7 @@ func TestInstallCmdRemovesBuildTempDirOnSuccess(t *testing.T) { t.Fatal(err) } - // InstallData needs internal/recipes/ to exist in repoDir. - repoDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(repoDir, "internal", "recipes"), 0o755); err != nil { - t.Fatal(err) - } - - msg := installCmd(src, t.TempDir(), repoDir, t.TempDir())() + msg := installCmd(src, t.TempDir())() if _, ok := msg.(installedMsg); !ok { t.Fatalf("expected installedMsg, got %T: %+v", msg, msg) } @@ -572,7 +566,7 @@ func TestInstallCmdRemovesBuildTempDirOnFailure(t *testing.T) { } src := filepath.Join(tmp, "stoat") // never created, so Install's Open fails - msg := installCmd(src, t.TempDir(), t.TempDir(), t.TempDir())() + msg := installCmd(src, t.TempDir())() if _, ok := msg.(errMsg); !ok { t.Fatalf("expected errMsg for a missing src binary, got %T: %+v", msg, msg) } diff --git a/internal/theme/theme.go b/internal/theme/theme.go index f0f07584..fd2bc25b 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -12,17 +12,30 @@ package theme import ( + "image/color" + "charm.land/bubbles/v2/textinput" "charm.land/lipgloss/v2" ) +// The unsuffixed constants are the dark palette. Every one of them falls below +// 4.5:1 against a light terminal, measured against #F7F7F7: accent 2.70:1, up +// 2.36:1, warn 2.04:1, err 3.30:1. The Light constants are the same hues +// darkened until each one clears 4.5:1 there. const ( AccentHex = "#C98A5B" UpHex = "#7FB069" - DownHex = "#6C7086" + DownHex = "#868CAA" WarnHex = "#E0A458" ErrHex = "#D16969" - DimHex = "#7A7A7A" + DimHex = "#8B949E" + + AccentLightHex = "#7A3E12" + UpLightHex = "#386A20" + DownLightHex = "#4C5163" + WarnLightHex = "#7A4E00" + ErrLightHex = "#A12D2D" + DimLightHex = "#59636E" ) var ( @@ -34,6 +47,32 @@ var ( Dim = lipgloss.Color(DimHex) ) +// Palette is one complete set of stoat's colours. The fields are color.Color +// because lipgloss v2's Color is a function, so there is no lipgloss.Color +// type to name here. +type Palette struct { + Accent, Up, Down, Warn, Err, Dim color.Color +} + +// For returns the palette that reads on the terminal background the caller +// reports. A Bubble Tea program gets that flag from tea.BackgroundColorMsg's +// IsDark; a terminal that cannot answer the query is treated as dark, which is +// what the unsuffixed constants above assume. +// +// internal/tui still draws the dark palette unconditionally. Switching it to +// follow the reported background is separate work. +func For(isDark bool) Palette { + pick := lipgloss.LightDark(isDark) + return Palette{ + Accent: pick(lipgloss.Color(AccentLightHex), Accent), + Up: pick(lipgloss.Color(UpLightHex), Up), + Down: pick(lipgloss.Color(DownLightHex), Down), + Warn: pick(lipgloss.Color(WarnLightHex), Warn), + Err: pick(lipgloss.Color(ErrLightHex), Err), + Dim: pick(lipgloss.Color(DimLightHex), Dim), + } +} + // BannerArt is the mark stoat draws above both the main TUI and the installer // transcript. At 43 columns it fits under the installer's 60-column default. const BannerArt = `███████╗████████╗ ██████╗ █████╗ ████████╗ diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go new file mode 100644 index 00000000..ebd2a837 --- /dev/null +++ b/internal/theme/theme_test.go @@ -0,0 +1,79 @@ +package theme + +import ( + "fmt" + "math" + "testing" +) + +// darkBackground and lightBackground are the terminal backgrounds the palettes +// are measured against. They are the darkest and lightest values a common +// terminal theme uses, so a colour that clears 4.5:1 here clears it on the +// backgrounds between them. +const ( + darkBackground = "#1E1E1E" + lightBackground = "#F7F7F7" +) + +// wcagAA is the contrast ratio WCAG 2.1 requires for body text. +const wcagAA = 4.5 + +func channel(v uint8) float64 { + c := float64(v) / 255 + if c <= 0.03928 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) +} + +func luminance(t *testing.T, hex string) float64 { + t.Helper() + var r, g, b uint8 + if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err != nil { + t.Fatalf("parse %q: %v", hex, err) + } + return 0.2126*channel(r) + 0.7152*channel(g) + 0.0722*channel(b) +} + +func contrast(t *testing.T, fg, bg string) float64 { + t.Helper() + a, b := luminance(t, fg), luminance(t, bg) + return (math.Max(a, b) + 0.05) / (math.Min(a, b) + 0.05) +} + +func TestPalettesMeetWCAGAA(t *testing.T) { + cases := []struct { + name string + hex string + background string + }{ + {"accent dark", AccentHex, darkBackground}, + {"up dark", UpHex, darkBackground}, + {"down dark", DownHex, darkBackground}, + {"warn dark", WarnHex, darkBackground}, + {"err dark", ErrHex, darkBackground}, + {"dim dark", DimHex, darkBackground}, + {"accent light", AccentLightHex, lightBackground}, + {"up light", UpLightHex, lightBackground}, + {"down light", DownLightHex, lightBackground}, + {"warn light", WarnLightHex, lightBackground}, + {"err light", ErrLightHex, lightBackground}, + {"dim light", DimLightHex, lightBackground}, + } + for _, tc := range cases { + got := contrast(t, tc.hex, tc.background) + if got < wcagAA { + t.Errorf("%s: %s on %s is %.2f:1, want at least %.1f:1", tc.name, tc.hex, tc.background, got, wcagAA) + } + } +} + +func TestForSelectsByBackground(t *testing.T) { + dark, light := For(true), For(false) + if dark.Accent == light.Accent { + t.Error("For returns the same accent for a dark and a light background") + } + if dark.Accent != Accent { + t.Errorf("For(true).Accent = %v, want the package Accent %v", dark.Accent, Accent) + } +} diff --git a/justfile b/justfile index c892d88a..e50893aa 100644 --- a/justfile +++ b/justfile @@ -21,14 +21,19 @@ install: build # build and install stoat, interactively: checks the host too [group('build')] -setup: hooks +setup: go run ./cmd/installer # build and install stoat without a TTY: for CI and scripts [group('build')] -setup-headless: hooks +setup-headless: go run ./cmd/installer --no-tty +# set this clone up for contributing: enables the repository git hooks +[group('dev')] +dev-setup: hooks + @echo "run 'just setup' to build and install stoat itself" + # remove the installed binary: never touches ~/.stoat [group('build')] uninstall: