diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f80ed..57f2cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Themes: `theme: dracula` (or `nord`) picks a built-in palette, and `themes:` in + the config defines your own, naming only the tokens you want to change. An + invalid color stops startup with a message naming the theme, the token and + the value, because lipgloss renders a bad color as no color at all and would + otherwise fail silently; an unknown theme stops startup with a message + naming the theme and listing the ones available (#82). - The report screen now draws a per-day sparkline of the selected range, with idle days rendered as gaps rather than as low bars (#80). - Command palette (`ctrl+p`): a fuzzy action launcher that floats over the current screen, @@ -30,6 +36,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The report's empty-state box is drawn with the same border color as the table + frame, so a report with no hours no longer shows a differently-weighted frame + from one with hours (#138). - The report header ("Report — scope …") is now truncated when it no longer fits the terminal width instead of wrapping to a second line, with an ellipsis marking where it was cut (#143). diff --git a/README.it.md b/README.it.md index 6451572..ba7cc9b 100644 --- a/README.it.md +++ b/README.it.md @@ -478,6 +478,34 @@ billing: il mouse attivo, in quasi tutti i terminali tenere **Shift** mentre si trascina seleziona e copia comunque, oppure impostare questa chiave a `false` per restituire il mouse al terminale una volta per tutte. +- `theme` (opzionale): la tavolozza con cui la TUI si disegna. I nomi built-in sono + `default`, `dracula` e `nord`; puoi anche nominarne una tua tra le `themes` sotto. + Omettere la chiave usa `default`. Un nome sconosciuto ferma `clup` all'avvio + anziché ricadere in silenzio. + - **`dracula` e `nord` sono disegnate per un terminale scuro e sono spedite + fedelmente.** Su uno sfondo chiaro parti della TUI diventano difficili o + impossibili da leggere — misurato contro il bianco, il verde di dracula sta a + 1.37:1; misurata contro il testo scuro di default del terminale dipinto sopra + di lei, la zebra stripe di nord sta a 2.09:1. Entrambi ben sotto il floor di + 3:1. `default` è quella adattiva: cambia con lo sfondo del tuo terminale. +- `themes` (opzionale): le tue tavolozze, per nome. Un tema nomina solo i token + che cambia; il resto viene da `default`. I sei token sono `primary`, `accent`, + `muted`, `danger`, `success` e `subtle` (la zebra stripe del report). Un valore è + un colore singolo, usato su entrambi gli sfondi, oppure una coppia `{light: …, dark: …}`. + Un colore è un hex `#RGB`/`#RRGGBB` oppure un numero da 0 a 255. Qualunque altra + cosa blocca l'avvio con un messaggio che nomina il tema, il token e il valore — + e ogni tema che definisci viene controllato, non solo quello che hai selezionato. + - **Quota i tuoi valori hex.** In YAML un `#` senza virgolette inizia un + commento, così `muted: #fff` imposta `muted` a nulla. `muted: "#fff"` è il colore. + I numeri nudi non hanno bisogno di virgolette. + + ```yaml + theme: mine + themes: + mine: + accent: {light: "127", dark: "205"} + muted: "240" + ``` ### Come vengono calcolati gli importi fatturati diff --git a/README.md b/README.md index 2986982..fe718db 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,34 @@ billing: reporting takes the terminal's native text selection away: with it on, in most terminals holding **Shift** while dragging still selects and copies, or set this key to `false` to give the mouse back to the terminal for good. +- `theme` (optional): the palette the TUI renders with. Built-in names are + `default`, `dracula` and `nord`; you can also name one of your own from `themes` + below. Omitting the key uses `default`. An unknown name stops `clup` from starting + rather than silently falling back. + - **`dracula` and `nord` are designed for a dark terminal and are shipped + faithfully.** On a light background parts of the UI become hard or impossible + to read — measured against white, dracula's green sits at 1.37:1; measured + against the terminal's own dark text painted on top of it, nord's zebra + stripe sits at 2.09:1. Both are far below the 3:1 floor. `default` is the + adaptive one: it changes with your terminal background. +- `themes` (optional): your own palettes, by name. A theme names only the tokens it + changes; the rest come from `default`. The six tokens are `primary`, `accent`, + `muted`, `danger`, `success` and `subtle` (the report's zebra stripe). A value is + either one color, used on both backgrounds, or a `{light: …, dark: …}` pair. A + color is a `#RGB`/`#RRGGBB` hex or a number from 0 to 255. Anything else stops + startup with a message naming the theme, the token and the value — and every + theme you define is checked, not just the one you selected. + - **Quote your hex values.** In YAML an unquoted `#` starts a comment, so + `muted: #fff` sets `muted` to nothing at all. `muted: "#fff"` is the color. + Plain numbers need no quotes. + + ```yaml + theme: mine + themes: + mine: + accent: {light: "127", dark: "205"} + muted: "240" + ``` ### How billed amounts are computed diff --git a/docs/superpowers/plans/2026-08-01-custom-themes.md b/docs/superpowers/plans/2026-08-01-custom-themes.md new file mode 100644 index 0000000..a2a32e0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-custom-themes.md @@ -0,0 +1,1423 @@ +# Temi personalizzati — piano di implementazione + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** chiudere le caselle 1 e 3 della **#82** (tema da YAML, due temi +built-in) e il **punto 2 della #138** (colore del bordo di `th.Box`). + +**Architecture:** un package foglia nuovo, `internal/themes`, possiede la +tavolozza, i temi built-in, il formato YAML e la validazione. `internal/config` +ne tiene solo il grezzo, `internal/cli` risolve e fa fallire l'avvio su un tema +invalido, `internal/tui` riceve la tavolozza già risolta. + +**Tech Stack:** Go 1.26, lipgloss v1.1.0, termenv v0.16.0, yaml.v3. + +**Spec:** `docs/superpowers/specs/2026-08-01-custom-themes-design.md` + +## Global Constraints + +- Tutto ciò che vive nel repo è in **inglese**: codice, identificatori, + commenti, stringhe UI, nomi e messaggi dei test, messaggi di commit. Eccezioni: + `README.it.md`, `CONTRIBUTING.it.md` e i doc di design sotto + `docs/superpowers/`. +- **MAI** un trailer `Co-Authored-By`. Conventional Commits. +- `internal/report` e `internal/duration` restano **puri**: solo stdlib, nessun + I/O, nessun import di `internal/config`, `internal/clickup`, `internal/tui`. +- **`internal/themes` è foglia**: importa solo `lipgloss`, `termenv`, `yaml.v3` e + la stdlib. Non importa `config`, `tui`, `clickup`, `service`, e non fa I/O. +- **Mai chiamare l'API ClickUp vera.** Non esistono credenziali in questo + ambiente. Il comportamento di rete si esercita solo con `httptest`. +- Nessuna funzione di stile in produzione chiama `lipgloss.NewStyle()`. +- **Ogni numero e ogni sequenza di escape scritta in un commento va misurata + eseguendo il codice**, mai calcolata a mente. +- **Un test scritto contro un difetto non vale finché non lo si è visto fallire + contro quel difetto**, con il transcript allegato al report. +- I golden si rigenerano solo con `go test ./internal/tui -update`, mai a mano, e + si **guardano** dopo. In questa tranche si muovono **soltanto** `palette_dark` + e `palette_light`; qualunque altro golden si muova è un difetto da indagare, + non un golden da rigenerare. +- Gate prima di ogni commit, tutti e cinque, tutti puliti: + `gofmt -l .` · `go vet ./...` · + `go run honnef.co/go/tools/cmd/staticcheck@latest ./...` · `go build ./...` · + `go test ./... -race` + +--- + +### Task 1: il nucleo di `internal/themes` + +**Files:** +- Create: `internal/themes/themes.go` +- Create: `internal/themes/themes_test.go` + +**Interfaces:** +- Consuma: niente. +- Produce: `Palette`, `Value`, `Spec`, `TokenNames`, `validColor`, `shadeFor`, + `adaptive`, `subtle`. I task 2 e 3 costruiscono su questi. + +- [ ] **Step 1: scrivere i test che falliscono** + +`internal/themes/themes_test.go`: + +```go +package themes + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestValidColor(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + in string + ok bool + says string // a substring the error must contain, when it errors + }{ + {"#FF00AA", true, ""}, + {"#fff", true, ""}, + {"0", true, ""}, + {"255", true, ""}, + {"236", true, ""}, + // An unquoted #hex is a YAML comment, so it reaches validation as an + // empty string. Measured: `muted: #fff` decodes to Value{"",""}. The + // message has to say so, or the user hunts for a key they wrote right. + {"", false, "quotes"}, + {"bogus", false, "neither"}, + {"#GGGGGG", false, "non-hex"}, + {"#FF00A", false, "#RGB or #RRGGBB"}, + {"256", false, "0-255"}, + {"999", false, "0-255"}, + {"-1", false, "0-255"}, + } { + err := validColor(tc.in) + if tc.ok { + if err != nil { + t.Errorf("validColor(%q) = %v, want nil", tc.in, err) + } + continue + } + if err == nil { + t.Errorf("validColor(%q) = nil, want an error", tc.in) + continue + } + if !strings.Contains(err.Error(), tc.says) { + t.Errorf("validColor(%q) = %q, want it to mention %q", tc.in, err, tc.says) + } + } +} + +// shadeFor is what keeps the zebra stripe alive at 16 colors on a custom theme. +// The first two rows are the property that matters: they are the values the +// default palette had hand-written before this package existed, so the +// derivation must reproduce them exactly or the default theme changes color. +// Every number here was measured with termenv.ConvertToRGB + DistanceLab. +func TestShadeFor(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ in, want string }{ + {"236", "8"}, // #303030, d(8)=0.3372 d(7)=0.5784 + {"254", "7"}, // #E4E4E4, d(8)=0.3700 d(7)=0.1288 + {"#44475A", "8"}, // dracula Current Line + {"#3B4252", "8"}, // nord1 + {"#808080", "8"}, // ANSI 8 itself, distance 0 + {"#A0A0A0", "7"}, // just past the crossover + {"#000000", "8"}, + {"#FFFFFF", "7"}, + } { + if got := shadeFor(tc.in); got != tc.want { + t.Errorf("shadeFor(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestValueUnmarshalsBothForms(t *testing.T) { + t.Parallel() + var got Spec + src := "accent: {light: \"127\", dark: \"205\"}\nmuted: \"240\"\n" + if err := yaml.Unmarshal([]byte(src), &got); err != nil { + t.Fatal(err) + } + if got["accent"] != (Value{Light: "127", Dark: "205"}) { + t.Errorf("accent = %+v, want the pair", got["accent"]) + } + // A bare string means the same color on both backgrounds. + if got["muted"] != (Value{Light: "240", Dark: "240"}) { + t.Errorf("muted = %+v, want 240 on both sides", got["muted"]) + } +} + +// Without MarshalYAML a bare string comes back as a light/dark map, so the +// first thing that calls config.Save rewrites the user's file into a shape they +// did not choose. This test is the only thing that would notice. +func TestValueMarshalsABareStringBack(t *testing.T) { + t.Parallel() + out, err := yaml.Marshal(Spec{"muted": {Light: "240", Dark: "240"}}) + if err != nil { + t.Fatal(err) + } + if got, want := strings.TrimSpace(string(out)), `muted: "240"`; got != want { + t.Errorf("marshaled to %q, want %q", got, want) + } + pair, err := yaml.Marshal(Spec{"accent": {Light: "127", Dark: "205"}}) + if err != nil { + t.Fatal(err) + } + // A real pair stays a pair, AND keeps light before dark. Measured: a + // map[string]string marshals its keys alphabetically, so returning a map + // would silently reorder what the user wrote. + li := strings.Index(string(pair), "light:") + di := strings.Index(string(pair), "dark:") + if li < 0 || di < 0 { + t.Fatalf("a genuine pair must stay a pair, got %q", pair) + } + if li > di { + t.Errorf("marshaled %q — light must come before dark, as the user wrote it", pair) + } +} +``` + +- [ ] **Step 2: eseguirli e vederli fallire** + +Run: `go test ./internal/themes -v` +Expected: il package non esiste ancora — errore di build. + +- [ ] **Step 3: implementare** + +`internal/themes/themes.go`: + +```go +// Package themes owns the six semantic colors the TUI is built from, the +// built-in palettes, and the resolution of a user-written theme. +// +// It is a leaf by choice, not by necessity: internal/config could hold these +// types without an import cycle, since it imports no internal package of its +// own. They live here so that internal/config stays free of a rendering +// library, so that color tests run without internal/tui's TestMain pinning +// termenv.Ascii for the whole package, and so that validation stays a unit +// small enough to hold in one's head. +// +// The package name is plural because internal/tui already declares a type +// called `theme`, and Go forbids the same identifier in a file block (an +// import) and a package block (that type). An aliased import would compile; +// the plural avoids needing one at every import site. +package themes + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "gopkg.in/yaml.v3" +) + +// Palette is the six semantic colors the whole TUI is built from. It is the +// only place a color literal belongs: the TUI's styles derive from these. +// +// Subtle is a CompleteAdaptiveColor while the other five are AdaptiveColor +// because it is the one token whose automatic downconvert lands on the wrong +// color: at 16 colors there is no mid-grey, so the nearest-color search picks +// the background itself and the zebra stripe disappears. shadeFor names the +// 16-color value instead of letting termenv guess it. +type Palette struct { + Primary lipgloss.AdaptiveColor + Accent lipgloss.AdaptiveColor + Muted lipgloss.AdaptiveColor + Danger lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Subtle lipgloss.CompleteAdaptiveColor +} + +// TokenNames are the YAML keys a theme may set, in the order error messages +// list them. +var TokenNames = []string{"primary", "accent", "muted", "danger", "success", "subtle"} + +// Value is one token as written in YAML: either a bare color, meaning the same +// on both backgrounds, or a light/dark pair. +type Value struct{ Light, Dark string } + +// UnmarshalYAML accepts both forms. internal/clickup's tagName does the same +// for ClickUp's JSON, where a tag arrives as either a string or an object. +func (v *Value) UnmarshalYAML(n *yaml.Node) error { + if n.Kind == yaml.ScalarNode { + var s string + if err := n.Decode(&s); err != nil { + return err + } + v.Light, v.Dark = s, s + return nil + } + var pair struct{ Light, Dark string } + if err := n.Decode(&pair); err != nil { + return err + } + v.Light, v.Dark = pair.Light, pair.Dark + return nil +} + +// yamlPair is the marshaled form of a genuine light/dark pair. It is a struct +// rather than a map because a map[string]string marshals its keys +// alphabetically — measured — which would silently reorder a user's +// `{light: …, dark: …}` into `{dark: …, light: …}` on the first save. +type yamlPair struct { + Light string `yaml:"light"` + Dark string `yaml:"dark"` +} + +// MarshalYAML collapses an equal pair back to a bare string. Without it, +// config.Save would rewrite a user's `muted: "240"` as a light/dark map the +// first time anything saves the config. +func (v Value) MarshalYAML() (any, error) { + if v.Light == v.Dark { + return v.Light, nil + } + return yamlPair{Light: v.Light, Dark: v.Dark}, nil +} + +// Spec is one user-written theme: token name -> value. A theme names only the +// tokens it changes; the rest come from Default. +type Spec map[string]Value + +// validColor reports whether s is a color this package will accept: a #RGB or +// #RRGGBB hex, or a decimal index in 0-255. +// +// The range check is not defensive politeness. Measured: lipgloss renders an +// invalid color as no color at all — no error, no panic — so a typo produces a +// colorless TUI and no message; and it renders "999" as "\x1b[38;5;999m", a +// sequence no terminal understands. Nothing downstream will ever complain, so +// this is the only place a bad color can be caught. +func validColor(s string) error { + if s == "" { + // Measured: an unquoted #hex is a YAML comment, so `muted: #fff` + // arrives here as "". Saying only "empty color" would send the user + // looking for a key they wrote correctly. + return fmt.Errorf("empty color (an unquoted #hex is a YAML comment: write it in quotes)") + } + if h, ok := strings.CutPrefix(s, "#"); ok { + if len(h) != 3 && len(h) != 6 { + return fmt.Errorf("hex color %q must be #RGB or #RRGGBB", s) + } + if strings.TrimLeft(strings.ToLower(h), "0123456789abcdef") != "" { + return fmt.Errorf("hex color %q has a non-hex digit", s) + } + return nil + } + n, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("color %q is neither a #hex nor a number", s) + } + if n < 0 || n > 255 { + return fmt.Errorf("color index %q is outside 0-255", s) + } + return nil +} + +// shadeFor picks the 16-color background a Subtle value degrades to. +// +// There is no threshold constant, deliberately: at 16 colors the only two +// shades that are neither a hue nor the background are 8 (#808080) and 7 +// (#C0C0C0), so the rule is simply whichever of those two is perceptually +// closer. Measured, that reproduces the values the default palette carried +// before this package existed — 236 -> 8, 254 -> 7 — so moving the palette here +// does not change a single byte of what the default theme renders. +// +// Only ever called on a color validColor has already accepted. +func shadeFor(c string) string { + col := termenv.ConvertToRGB(termenv.TrueColor.Color(c)) + d8 := col.DistanceLab(termenv.ConvertToRGB(termenv.TrueColor.Color("8"))) + d7 := col.DistanceLab(termenv.ConvertToRGB(termenv.TrueColor.Color("7"))) + if d7 < d8 { + return "7" + } + return "8" +} + +// adaptive builds an ordinary two-background color. +func adaptive(light, dark string) lipgloss.AdaptiveColor { + return lipgloss.AdaptiveColor{Light: light, Dark: dark} +} + +// subtle builds the zebra background, naming its 16-color value per side. The +// 256-color index sits in the TrueColor slot on purpose, so that only the +// 16-color profile differs from what the automatic conversion would produce. +func subtle(light, dark string) lipgloss.CompleteAdaptiveColor { + return lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{TrueColor: light, ANSI256: light, ANSI: shadeFor(light)}, + Dark: lipgloss.CompleteColor{TrueColor: dark, ANSI256: dark, ANSI: shadeFor(dark)}, + } +} +``` + +- [ ] **Step 4: eseguirli e vederli passare** + +Run: `go test ./internal/themes -v` +Expected: PASS (4 test). + +- [ ] **Step 5: prova per mutazione, obbligatoria** + +Due mutazioni, una alla volta, ognuna con il transcript nel report: + +1. In `shadeFor`, invertire il confronto (`if d7 > d8`). `TestShadeFor` **deve** + fallire su `236` e `254`. +2. Cancellare `MarshalYAML`. `TestValueMarshalsABareStringBack` **deve** fallire. + +Ripristinare dopo ognuna e verificare con `git diff`. + +- [ ] **Step 6: gate + commit** + +```bash +gofmt -l . && go vet ./... && go run honnef.co/go/tools/cmd/staticcheck@latest ./... && go build ./... && go test ./... -race +git add internal/themes/ +git commit -m "feat(themes): add the leaf package that owns the palette (#82)" +``` + +--- + +### Task 2: i temi built-in e il registro + +**Files:** +- Create: `internal/themes/builtin.go` +- Create: `internal/themes/builtin_test.go` + +**Interfaces:** +- Consuma: `Palette`, `adaptive`, `subtle` dal Task 1. +- Produce: `Default()`, `Dracula()`, `Nord()`, `Names()`, e la mappa `builtins` + che il Task 3 consulta. + +**Contesto che il brief non può sapere.** I valori di `Default()` sono +esattamente quelli che `internal/tui/theme.go` ha oggi in `defaultPalette()`, e +il Task 5 cancellerà quella funzione. Non vanno «migliorati»: il test di questo +task esiste apposta per inchiodarli. + +- [ ] **Step 1: scrivere i test che falliscono** + +`internal/themes/builtin_test.go`: + +```go +package themes + +import ( + "io" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// Default must keep the exact values internal/tui carried before this package +// existed. Moving code must not change a color. +func TestDefaultKeepsTheShippedValues(t *testing.T) { + t.Parallel() + p := Default() + for _, tc := range []struct { + name string + got lipgloss.AdaptiveColor + light, dark string + }{ + {"Primary", p.Primary, "127", "205"}, + {"Accent", p.Accent, "127", "205"}, + {"Muted", p.Muted, "240", "240"}, + {"Danger", p.Danger, "124", "196"}, + {"Success", p.Success, "28", "42"}, + } { + if tc.got.Light != tc.light || tc.got.Dark != tc.dark { + t.Errorf("%s = {%q, %q}, want {%q, %q}", + tc.name, tc.got.Light, tc.got.Dark, tc.light, tc.dark) + } + } + want := lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{TrueColor: "254", ANSI256: "254", ANSI: "7"}, + Dark: lipgloss.CompleteColor{TrueColor: "236", ANSI256: "236", ANSI: "8"}, + } + if p.Subtle != want { + t.Errorf("Subtle = %+v, want %+v — shadeFor must reproduce the hand-picked slots", p.Subtle, want) + } +} + +// The four tokens that are unreadable on white differ between backgrounds; +// Muted must not, since 240 already clears 7:1 on white. +func TestDefaultIsAdaptive(t *testing.T) { + t.Parallel() + p := Default() + for _, tc := range []struct { + name string + c lipgloss.AdaptiveColor + }{{"Primary", p.Primary}, {"Accent", p.Accent}, {"Danger", p.Danger}, {"Success", p.Success}} { + if tc.c.Light == tc.c.Dark { + t.Errorf("%s is not adaptive: Light == Dark == %q", tc.name, tc.c.Dark) + } + } + if p.Muted.Light != p.Muted.Dark { + t.Errorf("Muted was adapted (%q/%q) but reads fine on both backgrounds", + p.Muted.Light, p.Muted.Dark) + } + if s := Default().Subtle; s.Light.ANSI256 == s.Dark.ANSI256 || s.Light.ANSI == s.Dark.ANSI { + t.Errorf("Subtle is not adaptive: light %+v, dark %+v", s.Light, s.Dark) + } +} + +// Measured: lipgloss renders an invalid color as no color at all, silently. A +// typo in a hex we ship would therefore produce a colorless token and no error +// anywhere. This is the only thing that would catch it. +func TestEveryBuiltinTokenRendersAColor(t *testing.T) { + t.Parallel() + for _, name := range Names() { + p, ok := builtins[name] + if !ok { + t.Fatalf("Names() returned %q, which is not in builtins", name) + } + pal := p() + for _, dark := range []bool{true, false} { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.TrueColor) + r.SetHasDarkBackground(dark) + for label, c := range map[string]lipgloss.TerminalColor{ + "Primary": pal.Primary, "Accent": pal.Accent, "Muted": pal.Muted, + "Danger": pal.Danger, "Success": pal.Success, "Subtle": pal.Subtle, + } { + if out := r.NewStyle().Foreground(c).Render("x"); out == "x" { + t.Errorf("%s/%s (dark=%v) rendered no escape at all: %q", name, label, dark, out) + } + } + } + } +} + +// The other half of the guard: Default must RENDER exactly like the palette +// internal/tui carried before this package existed, at every profile and on +// both backgrounds. Matching values alone would not prove it — a slot the +// renderer consults could still differ. +func TestDefaultRendersLikeTheShippedPalette(t *testing.T) { + t.Parallel() + // The palette as internal/tui/theme.go declared it before the move. + shipped := Palette{ + Primary: lipgloss.AdaptiveColor{Light: "127", Dark: "205"}, + Accent: lipgloss.AdaptiveColor{Light: "127", Dark: "205"}, + Muted: lipgloss.AdaptiveColor{Light: "240", Dark: "240"}, + Danger: lipgloss.AdaptiveColor{Light: "124", Dark: "196"}, + Success: lipgloss.AdaptiveColor{Light: "28", Dark: "42"}, + Subtle: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{TrueColor: "254", ANSI256: "254", ANSI: "7"}, + Dark: lipgloss.CompleteColor{TrueColor: "236", ANSI256: "236", ANSI: "8"}, + }, + } + got := Default() + profiles := []termenv.Profile{termenv.TrueColor, termenv.ANSI256, termenv.ANSI, termenv.Ascii} + for _, profile := range profiles { + for _, dark := range []bool{true, false} { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(profile) + r.SetHasDarkBackground(dark) + for _, tc := range []struct { + name string + a, b lipgloss.TerminalColor + }{ + {"Primary", got.Primary, shipped.Primary}, + {"Accent", got.Accent, shipped.Accent}, + {"Muted", got.Muted, shipped.Muted}, + {"Danger", got.Danger, shipped.Danger}, + {"Success", got.Success, shipped.Success}, + {"Subtle", got.Subtle, shipped.Subtle}, + } { + x := r.NewStyle().Foreground(tc.a).Render("x") + y := r.NewStyle().Foreground(tc.b).Render("x") + if x != y { + t.Errorf("profile=%v dark=%v %s: rendered %q, want %q", + profile, dark, tc.name, x, y) + } + } + } + } +} + +func TestNamesAreSortedAndComplete(t *testing.T) { + t.Parallel() + got := Names() + want := []string{"default", "dracula", "nord"} + if len(got) != len(want) { + t.Fatalf("Names() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Names() = %v, want %v (sorted)", got, want) + } + } +} +``` + +- [ ] **Step 2: eseguirli e vederli fallire** + +Run: `go test ./internal/themes -run 'TestDefault|TestEveryBuiltin|TestNames' -v` +Expected: FAIL in compilazione — `undefined: Default`, `builtins`, `Names`. + +- [ ] **Step 3: implementare** + +`internal/themes/builtin.go`: + +```go +package themes + +import ( + "maps" + "slices" + + "github.com/charmbracelet/lipgloss" +) + +// Default is the built-in palette. Dark keeps the xterm indices the TUI has +// always shipped; Light overrides only the tokens that are illegible on white, +// measured as WCAG contrast against #FFFFFF: +// +// 205 -> 127 205 (#FF5FAF) is ~1.9:1 on white; 127 (#AF00AF) is ~7.5:1 +// 196 -> 124 196 (#FF0000) is ~4:1, under the 4.5:1 floor; 124 (#AF0000) ~8:1 +// 42 -> 28 42 (#00D787) is ~1.8:1 on white; 28 (#008700) is ~6.5:1 +// +// Muted (240, #585858) is left alone: it already clears 7:1 on white. Adaptive +// means legible on both backgrounds, not different on both. +// +// Subtle is a background, judged by a different rule than the five foregrounds: +// 236 on dark and 254 on light are chosen so the DEFAULT FOREGROUND still +// clears contrast painted on top of them. A zebra stripe that swallows its own +// text is worse than no stripe at all. +func Default() Palette { + return Palette{ + Primary: adaptive("127", "205"), + Accent: adaptive("127", "205"), + Muted: adaptive("240", "240"), + Danger: adaptive("124", "196"), + Success: adaptive("28", "42"), + Subtle: subtle("254", "236"), + } +} + +// mono is a color that does not change with the terminal background. The two +// palettes below are built from it because both are designed for a dark +// terminal and are shipped faithfully: see the design doc's §2.4 for the +// measured contrast ratios on a light background, and the README for the +// warning users get. +func mono(c string) lipgloss.AdaptiveColor { return adaptive(c, c) } + +// Dracula is draculatheme.com's published palette, mapped onto the six tokens +// as Primary=Purple, Accent=Pink, Muted=Comment, Danger=Red, Success=Green, +// Subtle=Current Line. +func Dracula() Palette { + return Palette{ + Primary: mono("#BD93F9"), + Accent: mono("#FF79C6"), + Muted: mono("#6272A4"), + Danger: mono("#FF5555"), + Success: mono("#50FA7B"), + Subtle: subtle("#44475A", "#44475A"), + } +} + +// Nord is nordtheme.com's published palette, mapped onto the six tokens as +// Primary=nord8, Accent=nord9 (both Frost), Muted=nord3 and Subtle=nord1 (both +// Polar Night), Danger=nord11 and Success=nord14 (both Aurora). +func Nord() Palette { + return Palette{ + Primary: mono("#88C0D0"), + Accent: mono("#81A1C1"), + Muted: mono("#4C566A"), + Danger: mono("#BF616A"), + Success: mono("#A3BE8C"), + Subtle: subtle("#3B4252", "#3B4252"), + } +} + +// builtins is the registry Resolve consults. +var builtins = map[string]func() Palette{ + "default": Default, + "dracula": Dracula, + "nord": Nord, +} + +// Names lists the built-in themes, sorted, for error messages. +func Names() []string { + return slices.Sorted(maps.Keys(builtins)) +} +``` + +- [ ] **Step 4: eseguirli e vederli passare** + +Run: `go test ./internal/themes -v` +Expected: PASS (9 test). + +- [ ] **Step 5: prova per mutazione, obbligatoria** + +Due mutazioni, con transcript: + +1. Cambiare un carattere di un hex di `Dracula` in qualcosa di non valido (per + esempio `#BD93FG`). `TestEveryBuiltinTokenRendersAColor` **deve** fallire. È + la mutazione che dimostra che la trappola del §2.1 è davvero coperta. +2. In `Default()`, cambiare `subtle("254", "236")` in `subtle("254", "235")`. + **Devono** fallire sia `TestDefaultKeepsTheShippedValues` sia + `TestDefaultRendersLikeTheShippedPalette` — se solo il primo cade, il secondo + non sta rendendo quello che dice. + +Ripristinare dopo ognuna e verificare con `git diff`. + +- [ ] **Step 6: gate + commit** + +```bash +gofmt -l . && go vet ./... && go run honnef.co/go/tools/cmd/staticcheck@latest ./... && go build ./... && go test ./... -race +git add internal/themes/ +git commit -m "feat(themes): ship the dracula and nord palettes (#82)" +``` + +--- + +### Task 3: `Resolve` e i suoi errori + +**Files:** +- Create: `internal/themes/resolve.go` +- Create: `internal/themes/resolve_test.go` + +**Interfaces:** +- Consuma: `Palette`, `Spec`, `Value`, `TokenNames`, `validColor`, `adaptive`, + `subtle`, `builtins`, `Names`. +- Produce: `func Resolve(name string, custom map[string]Spec) (Palette, error)`. + Il Task 5 lo chiama da `internal/cli`. + +- [ ] **Step 1: scrivere i test che falliscono** + +`internal/themes/resolve_test.go`: + +```go +package themes + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestResolveBuiltins(t *testing.T) { + t.Parallel() + for _, name := range []string{"", "default", "dracula", "nord"} { + if _, err := Resolve(name, nil); err != nil { + t.Errorf("Resolve(%q, nil) = %v, want nil — an empty name means default", name, err) + } + } + got, err := Resolve("dracula", nil) + if err != nil { + t.Fatal(err) + } + if got.Primary != Dracula().Primary { + t.Errorf("Resolve(\"dracula\") did not return the dracula palette") + } +} + +// A theme names only what it changes; everything else comes from Default. +func TestResolveCustomInheritsTheRest(t *testing.T) { + t.Parallel() + got, err := Resolve("mine", map[string]Spec{ + "mine": {"accent": {Light: "1", Dark: "2"}}, + }) + if err != nil { + t.Fatal(err) + } + if want := (lipgloss.AdaptiveColor{Light: "1", Dark: "2"}); got.Accent != want { + t.Errorf("Accent = %+v, want %+v", got.Accent, want) + } + if got.Primary != Default().Primary { + t.Errorf("Primary = %+v, want the default %+v — unnamed tokens must be inherited", + got.Primary, Default().Primary) + } +} + +// A custom Subtle must still get its 16-color slot derived, or every custom +// theme silently undoes the zebra-stripe fix. +func TestResolveDerivesSubtleForACustomTheme(t *testing.T) { + t.Parallel() + got, err := Resolve("mine", map[string]Spec{ + "mine": {"subtle": {Light: "#E4E4E4", Dark: "#303030"}}, + }) + if err != nil { + t.Fatal(err) + } + if got.Subtle.Dark.ANSI != "8" || got.Subtle.Light.ANSI != "7" { + t.Errorf("Subtle ANSI slots = %q/%q, want 7/8", got.Subtle.Light.ANSI, got.Subtle.Dark.ANSI) + } +} + +// Every failure names the theme, the token and the value: an error that only +// says "invalid color" makes the user hunt through their config by hand. +func TestResolveErrors(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + theme string + custom map[string]Spec + says []string + }{ + { + name: "unknown theme lists what is available", + theme: "nope", + says: []string{"nope", "default", "dracula", "nord"}, + }, + { + name: "a custom theme may not shadow a built-in", + theme: "default", + custom: map[string]Spec{"dracula": {}}, + says: []string{"dracula", "built-in"}, + }, + { + name: "unknown token lists the valid ones", + theme: "mine", + custom: map[string]Spec{"mine": {"primry": {Light: "1", Dark: "1"}}}, + says: []string{"mine", "primry", "primary", "subtle"}, + }, + { + name: "invalid color names theme, token and value", + theme: "mine", + custom: map[string]Spec{"mine": {"accent": {Light: "bogus", Dark: "1"}}}, + says: []string{"mine", "accent", "bogus"}, + }, + { + name: "an out-of-range index is rejected", + theme: "mine", + custom: map[string]Spec{"mine": {"accent": {Light: "999", Dark: "1"}}}, + says: []string{"mine", "accent", "999", "0-255"}, + }, + { + name: "an empty half of a pair is rejected, and says why", + theme: "mine", + custom: map[string]Spec{"mine": {"accent": {Light: "", Dark: "1"}}}, + says: []string{"mine", "accent", "quotes"}, + }, + { + // A typo in a theme you are not using is still a typo. Selecting + // "default" here means only the all-themes validation can catch it. + name: "a bad color in a theme that is not selected still fails", + theme: "default", + custom: map[string]Spec{"unused": {"accent": {Light: "bogus", Dark: "1"}}}, + says: []string{"unused", "accent", "bogus"}, + }, + } { + _, err := Resolve(tc.theme, tc.custom) + if err == nil { + t.Errorf("%s: Resolve = nil error, want one", tc.name) + continue + } + for _, s := range tc.says { + if !strings.Contains(err.Error(), s) { + t.Errorf("%s: error %q does not mention %q", tc.name, err, s) + } + } + } +} +``` + +- [ ] **Step 2: eseguirli e vederli fallire** + +Run: `go test ./internal/themes -run TestResolve -v` +Expected: FAIL in compilazione — `undefined: Resolve`. + +- [ ] **Step 3: implementare** + +`internal/themes/resolve.go`: + +```go +package themes + +import ( + "fmt" + "slices" + "strings" +) + +// Resolve turns a configured theme name and the user's own themes into the +// palette the TUI renders with. +// +// Every failure is an error rather than a fallback, for the reason validColor's +// comment records: a bad color degrades invisibly — colorless text, or an +// escape sequence no terminal reads — so a silent fallback would leave the user +// staring at a wrong-looking TUI with nothing to go on. The same rule +// billing.rounding.increment follows. +func Resolve(name string, custom map[string]Spec) (Palette, error) { + // EVERY user theme is validated, not only the selected one: a typo in a + // theme you are not using today is still a typo, and the day you switch to + // it is the worst possible moment to find out. Sorted so that a config with + // two broken themes always reports the same one. + for _, n := range slices.Sorted(maps.Keys(custom)) { + if _, clash := builtins[n]; clash { + return Palette{}, fmt.Errorf("theme %q is built-in; choose another name for your own", n) + } + if err := validateSpec(n, custom[n]); err != nil { + return Palette{}, err + } + } + if name == "" { + name = "default" + } + if spec, ok := custom[name]; ok { + return applySpec(spec), nil + } + if p, ok := builtins[name]; ok { + return p(), nil + } + return Palette{}, fmt.Errorf("unknown theme %q; available: %s", + name, strings.Join(allNames(custom), ", ")) +} + +// allNames is every theme the user could have named, sorted, for the error +// above: the built-ins plus whatever they defined themselves. +func allNames(custom map[string]Spec) []string { + out := Names() + for n := range custom { + out = append(out, n) + } + slices.Sort(out) + return out +} + +// validateSpec checks one user theme's tokens and colors. Separate from +// applySpec because Resolve validates every theme but applies only one. +func validateSpec(name string, spec Spec) error { + for _, token := range slices.Sorted(maps.Keys(spec)) { + if !slices.Contains(TokenNames, token) { + return fmt.Errorf("theme %q: unknown token %q; valid tokens: %s", + name, token, strings.Join(TokenNames, ", ")) + } + v := spec[token] + for _, side := range []string{v.Light, v.Dark} { + if err := validColor(side); err != nil { + return fmt.Errorf("theme %q, token %q, value %q: %w", name, token, side, err) + } + } + } + return nil +} + +// applySpec starts from Default and overrides only the tokens the spec names. +// It assumes validateSpec has already passed. +func applySpec(spec Spec) Palette { + p := Default() + for token, v := range spec { + switch token { + case "primary": + p.Primary = adaptive(v.Light, v.Dark) + case "accent": + p.Accent = adaptive(v.Light, v.Dark) + case "muted": + p.Muted = adaptive(v.Light, v.Dark) + case "danger": + p.Danger = adaptive(v.Light, v.Dark) + case "success": + p.Success = adaptive(v.Light, v.Dark) + case "subtle": + p.Subtle = subtle(v.Light, v.Dark) + } + } + return p +} +``` + +Il blocco import di `resolve.go` è quindi `fmt`, `maps`, `slices`, `strings`. + +- [ ] **Step 4: eseguirli e vederli passare** + +Run: `go test ./internal/themes -v` +Expected: PASS (13 test). + +- [ ] **Step 5: prova per mutazione, obbligatoria** + +Tre mutazioni, una alla volta, con transcript: + +1. In `validateSpec`, togliere il ciclo `validColor`. I casi «invalid color», + «out-of-range» ed «empty half» di `TestResolveErrors` **devono** fallire. +2. In `applySpec`, far partire `p` da `Palette{}` invece che da `Default()`. + `TestResolveCustomInheritsTheRest` **deve** fallire. +3. In `Resolve`, validare solo il tema selezionato invece di tutti (spostare la + chiamata a `validateSpec` dentro il ramo `if spec, ok := custom[name]`). Il + caso «a bad color in a theme that is not selected» **deve** fallire, e gli + altri no. + +Ripristinare dopo ognuna e verificare con `git diff`. + +- [ ] **Step 6: gate + commit** + +```bash +gofmt -l . && go vet ./... && go run honnef.co/go/tools/cmd/staticcheck@latest ./... && go build ./... && go test ./... -race +git add internal/themes/ +git commit -m "feat(themes): resolve a configured theme, refusing bad input (#82)" +``` + +--- + +### Task 4: le chiavi `theme` e `themes` nel config + +**Files:** +- Modify: `internal/config/config.go` (struct `Config`) +- Test: `internal/config/config_test.go` + +**Interfaces:** +- Consuma: `themes.Spec` dal Task 1. +- Produce: `Config.Theme string`, `Config.Themes map[string]themes.Spec`. Il + Task 5 li legge da `internal/cli`. + +- [ ] **Step 1: scrivere i test che falliscono** + +In fondo a `internal/config/config_test.go` (import di +`"github.com/marcoarnulfo/clickup-cli/internal/themes"`): + +```go +// --- theme keys (#82) --- + +func TestThemeKeysRoundTrip(t *testing.T) { + isolateConfig(t) + in := Config{Token: "t", WorkspaceID: "1", Theme: "mine", Themes: map[string]themes.Spec{ + "mine": { + "muted": {Light: "240", Dark: "240"}, + "accent": {Light: "127", Dark: "205"}, + }, + }} + if err := Save(in); err != nil { + t.Fatal(err) + } + got, err := Load() + if err != nil { + t.Fatal(err) + } + if got.Theme != "mine" { + t.Errorf("Theme = %q, want %q", got.Theme, "mine") + } + if got.Themes["mine"]["muted"] != (themes.Value{Light: "240", Dark: "240"}) { + t.Errorf("muted = %+v, want 240 on both sides", got.Themes["mine"]["muted"]) + } + if got.Themes["mine"]["accent"] != (themes.Value{Light: "127", Dark: "205"}) { + t.Errorf("accent = %+v, want the pair", got.Themes["mine"]["accent"]) + } +} + +// A token the user wrote as a bare string must come back as a bare string: +// Save serializes the whole Config, so without Value.MarshalYAML every save +// would rewrite their file into a shape they did not choose. +func TestSaveKeepsABareColorBare(t *testing.T) { + isolateConfig(t) + if err := Save(Config{Token: "t", WorkspaceID: "1", Themes: map[string]themes.Spec{ + "mine": {"muted": {Light: "240", Dark: "240"}}, + }}); err != nil { + t.Fatal(err) + } + p, err := Path() + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `muted: "240"`) { + t.Fatalf("saved config did not keep the bare form:\n%s", raw) + } +} + +func TestThemeKeysAbsentAreZero(t *testing.T) { + isolateConfig(t) + if err := Save(Config{Token: "t", WorkspaceID: "1"}); err != nil { + t.Fatal(err) + } + p, err := Path() + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "theme") { + t.Fatalf("saved config mentions theme:\n%s", raw) + } +} +``` + +- [ ] **Step 2: eseguirli e vederli fallire** + +Run: `go test ./internal/config -run 'TestTheme|TestSaveKeeps' -v` +Expected: FAIL in compilazione — `unknown field Theme`. + +- [ ] **Step 3: implementare** + +In `internal/config/config.go`, dentro `Config`, sotto `Mouse`: + +```go + // Theme names the palette the TUI renders with: a built-in name, or one of + // the Themes below. Empty means the built-in default. The names are + // resolved by internal/themes, not here — this package holds the YAML as + // written and nothing more. + Theme string `yaml:"theme,omitempty"` + // Themes are the user's own palettes, by name. A theme names only the + // tokens it changes; internal/themes fills the rest from the default. + Themes map[string]themes.Spec `yaml:"themes,omitempty"` +``` + +più l'import di `"github.com/marcoarnulfo/clickup-cli/internal/themes"`. + +- [ ] **Step 4: eseguirli e vederli passare** + +Run: `go test ./internal/config -run 'TestTheme|TestSaveKeeps' -v` +Expected: PASS (3 test). + +- [ ] **Step 5: gate + commit** + +```bash +gofmt -l . && go vet ./... && go run honnef.co/go/tools/cmd/staticcheck@latest ./... && go build ./... && go test ./... -race +git add internal/config/ +git commit -m "feat(config): add the theme and themes keys (#82)" +``` + +--- + +### Task 5: la TUI riceve la tavolozza risolta + +**Files:** +- Modify: `internal/tui/theme.go` (cancella `palette` e `defaultPalette`) +- Modify: `internal/tui/app.go` (`New`) +- Modify: `internal/cli/cli.go` (`runTUI`, più la nuova `resolveTheme`) +- Modify: `internal/tui/theme_test.go`, `internal/tui/palette_test.go`, + `internal/tui/app_test.go`, `internal/tui/log_test.go`, + `internal/tui/report_test.go`, `internal/tui/demo_test.go`, + `internal/tui/golden_test.go`, `internal/tui/home_test.go`, + `internal/tui/palette_demo_test.go` +- Test: `internal/cli/cli_test.go` + +**Interfaces:** +- Consuma: `themes.Palette`, `themes.Default()`, `themes.Resolve` dai Task 1-3; + `config.Theme`/`config.Themes` dal Task 4. +- Produce: `func New(cfg config.Config, pal themes.Palette) Model`. + +**Contesto che il brief non può sapere.** + +1. `defaultPalette()` è referenziata in **11 punti**: la definizione + (`theme.go:86-87`), `app.go:210`, `palette_test.go:399`, e `theme_test.go` ×8 + (righe 19, 26, 50, 62, 97, 119, 149, 199). Tutte diventano `themes.Default()`, + tranne la definizione che sparisce. +2. **Due test di `theme_test.go` sono già stati riscritti nel Task 2 dentro + `internal/themes` e vanno cancellati qui**, non adattati: + `TestDefaultPaletteKeepsCurrentDarkColors` (riga ~24) è ora + `TestDefaultKeepsTheShippedValues`, e `TestPaletteIsAdaptive` (riga ~93) è ora + `TestDefaultIsAdaptive`. Tenerli in due package è duplicazione, non copertura. +3. Ogni altro test di `theme_test.go` resta dov'è: verifica come `newTheme` + costruisce gli **stili**, che è responsabilità di `internal/tui`. +4. **`New` ha 44 call site su 8 file**, misurati: `app_test.go` 33, + `demo_test.go` 3, `log_test.go` 2, `report_test.go` 2, `golden_test.go` 1, + `home_test.go` 1, `palette_demo_test.go` 1, `internal/cli/cli.go:57` 1. Nei + test si passa `themes.Default()`. + + **Non cercarli con `grep 'New(cfg)'`**: quella stringa ne trova nove. Le altre + chiamate sono `New(config.Config{…})` (34 nel solo `app_test.go`), + `New(demoConfig())` e `New(realCfg)`. La prima versione di questo piano diceva + «nove», e chi l'avesse seguita avrebbe lasciato `internal/tui` non compilabile + a metà del task. Il modo affidabile è compilare: `go build ./... && go vet ./...` + dopo il cambio di firma elenca ogni chiamata rimasta. + +- [ ] **Step 1: scrivere il test che fallisce** + +In `internal/cli/cli_test.go`: + +```go +// An unresolvable theme must stop the launch with an error the user can act on, +// not start a TUI with the wrong colors. The check is on resolveTheme, which is +// the production code path runTUI takes: runTUI itself blocks on a terminal and +// cannot be called from a test, the same reason programOptions was extracted. +func TestResolveThemeRejectsAnUnknownName(t *testing.T) { + t.Parallel() + _, err := resolveTheme(config.Config{Theme: "nope"}) + if err == nil { + t.Fatal("resolveTheme of an unknown theme = nil error, want one") + } + if !strings.Contains(err.Error(), "nope") { + t.Errorf("error %q does not name the theme the user asked for", err) + } + // The prefix is what tells the user which part of their config is at fault + // once Execute prints it as "error: …". + if !strings.HasPrefix(err.Error(), "theme:") { + t.Errorf("error %q is not prefixed with the config section it comes from", err) + } +} + +func TestResolveThemeAcceptsABuiltin(t *testing.T) { + t.Parallel() + if _, err := resolveTheme(config.Config{Theme: "dracula"}); err != nil { + t.Errorf("resolveTheme(dracula) = %v, want nil", err) + } + if _, err := resolveTheme(config.Config{}); err != nil { + t.Errorf("resolveTheme with no theme set = %v, want nil (the default)", err) + } +} +``` + +- [ ] **Step 2: eseguirli e vederli fallire** + +Run: `go test ./internal/cli -run TestResolveTheme -v` +Expected: FAIL in compilazione — `undefined: resolveTheme`. + +- [ ] **Step 3: implementare** + +In `internal/tui/theme.go`: cancellare il tipo `palette` e la funzione +`defaultPalette`, e cambiare la firma: + +```go +func newTheme(r *lipgloss.Renderer, p themes.Palette) theme { +``` + +Il corpo non cambia: i campi di `themes.Palette` hanno gli stessi nomi. + +In `internal/tui/app.go`: + +```go +func New(cfg config.Config, pal themes.Palette) Model { +``` + +e alla riga del campo `theme:` + +```go + theme: newTheme(lipgloss.DefaultRenderer(), pal), +``` + +In `internal/cli/cli.go`, una funzione nuova accanto a `programOptions`: + +```go +// resolveTheme builds the palette the TUI will render with, and refuses to +// start on a configuration it cannot honor. +// +// Extracted from runTUI for the same reason programOptions was: runTUI blocks +// on a terminal and cannot be called from a test, while the decision it makes +// can. Called before the program starts because this is the last place a +// configuration error still reaches stderr — Execute prints it as "error: …" — +// instead of appearing inside an already-running TUI. +func resolveTheme(cfg config.Config) (themes.Palette, error) { + pal, err := themes.Resolve(cfg.Theme, cfg.Themes) + if err != nil { + return themes.Palette{}, fmt.Errorf("theme: %w", err) + } + return pal, nil +} +``` + +e dentro `runTUI`, subito dopo `config.Load()`: + +```go + pal, err := resolveTheme(cfg) + if err != nil { + return err + } + p := tea.NewProgram(tui.New(cfg, pal), programOptions(cfg)...) +``` + +Nei test di `internal/tui`, sostituire `defaultPalette()` con `themes.Default()` +e `New(cfg)` con `New(cfg, themes.Default())`, e cancellare i due test elencati +nel punto 2 del contesto. + +- [ ] **Step 4: eseguire tutto** + +Run: `go test ./... -race` +Expected: PASS, e **nessun golden modificato** — questa task non cambia un +colore, sposta soltanto da dove arrivano. Verificare con +`git status --short internal/tui/testdata/`: deve essere vuoto. Se un golden si +muove, è un difetto: non rigenerarlo. + +- [ ] **Step 5: prova per mutazione, obbligatoria** + +Due mutazioni, con transcript: + +1. In `resolveTheme`, ignorare l'errore e ritornare `themes.Default(), nil`. + `TestResolveThemeRejectsAnUnknownName` **deve** fallire. +2. In `resolveTheme`, togliere il `fmt.Errorf("theme: %w", …)` e ritornare `err` + nudo. Il test **deve** fallire sull'asserzione del prefisso — se passa lo + stesso, quell'asserzione non serve a niente e va tolta o riscritta. + +Un limite da **dichiarare nel report** invece di nasconderlo: nessuno dei due +test prova che `runTUI` *chiami* `resolveTheme`, perché `runTUI` blocca su un +terminale. Mostrare con `git diff` che la chiamata c'è e che è l'unico percorso. + +- [ ] **Step 6: gate + commit** + +```bash +gofmt -l . && go vet ./... && go run honnef.co/go/tools/cmd/staticcheck@latest ./... && go build ./... && go test ./... -race +git add internal/tui/ internal/cli/ +git commit -m "refactor(tui): take the palette from internal/themes (#82)" +``` + +--- + +### Task 6: il bordo di `th.Box` (#138 punto 2) + +**Files:** +- Modify: `internal/tui/theme.go` (`newTheme`, campo `Box`) +- Modify: `internal/tui/testdata/palette_dark.golden`, + `internal/tui/testdata/palette_light.golden` (rigenerati, mai a mano) +- Test: `internal/tui/theme_test.go` + +**Interfaces:** nessuna nuova. + +- [ ] **Step 1: scrivere il test che fallisce** + +In `internal/tui/theme_test.go`: + +```go +// The report screen draws its table frame with th.Border and its empty-state +// box with th.Box. Until th.Box carried a border color the two frames rendered +// at different weights on the same screen, which is #138's second point: a user +// whose report has no hours saw a different frame from one whose report does. +func TestBoxAndBorderShareTheirColor(t *testing.T) { + t.Parallel() + for _, dark := range []bool{true, false} { + th := paletteTheme(dark) + if got, want := th.Box.GetBorderTopForeground(), th.Border.GetForeground(); got != want { + t.Errorf("dark=%v: Box border = %+v, Border = %+v — the two frames must weigh the same", + dark, got, want) + } + } +} +``` + +- [ ] **Step 2: eseguirlo e vederlo fallire** + +Run: `go test ./internal/tui -run TestBoxAndBorderShareTheirColor -v` +Expected: FAIL su entrambi i fondi. Misurato, i due valori a confronto sono +`lipgloss.NoColor{}` (quello che un bordo senza colore ritorna) contro +`lipgloss.AdaptiveColor{Light:"240", Dark:"240"}`. **Incollare il transcript.** + +- [ ] **Step 3: implementare** + +In `internal/tui/theme.go`, dentro `newTheme`: + +```go + Box: r.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.Muted).Padding(0, 1), +``` + +- [ ] **Step 4: rigenerare e GUARDARE i due golden** + +```bash +go test ./internal/tui -update +git status --short internal/tui/testdata/ +``` + +Expected: esattamente **due** file modificati, `palette_dark.golden` e +`palette_light.golden`. Se ne compaiono altri, fermarsi e riportare: sarebbe un +difetto dell'implementazione, non un golden stantio. + +Poi **aprire i due file e guardarli**: la riga `Box` deve portare +`\x1b[38;5;240m` sul bordo, e nient'altro deve essere cambiato. Riportare cosa +si è visto, non che si è eseguito il comando. + +- [ ] **Step 5: eseguire tutto** + +Run: `go test ./... -race` +Expected: PASS. + +- [ ] **Step 6: gate + commit** + +```bash +gofmt -l . && go vet ./... && go run honnef.co/go/tools/cmd/staticcheck@latest ./... && go build ./... && go test ./... -race +git add internal/tui/ +git commit -m "fix(tui): give the box border the same color as the table frame (#138)" +``` + +--- + +### Task 7: documentazione + +**Files:** +- Modify: `README.md` (elenco delle chiavi di config) +- Modify: `README.it.md` (stesso punto) +- Modify: `CHANGELOG.md` (sezione `## [Unreleased]`) + +**Interfaces:** nessuna. + +- [ ] **Step 1: `README.md`** + +Dopo il punto elenco `- \`mouse\` (optional): …`: + +```markdown +- `theme` (optional): the palette the TUI renders with. Built-in names are + `default`, `dracula` and `nord`; you can also name one of your own from `themes` + below. Omitting the key uses `default`. An unknown name stops `clup` from starting + rather than silently falling back. + - **`dracula` and `nord` are designed for a dark terminal and are shipped + faithfully.** On a light background parts of the UI become hard or impossible + to read — measured against white, dracula's green sits at 1.37:1 and nord's + zebra stripe at 2.09:1, both far below the 3:1 floor. `default` is the + adaptive one: it changes with your terminal background. +- `themes` (optional): your own palettes, by name. A theme names only the tokens it + changes; the rest come from `default`. The six tokens are `primary`, `accent`, + `muted`, `danger`, `success` and `subtle` (the report's zebra stripe). A value is + either one color, used on both backgrounds, or a `{light: …, dark: …}` pair. A + color is a `#RGB`/`#RRGGBB` hex or a number from 0 to 255. Anything else stops + startup with a message naming the theme, the token and the value — and every + theme you define is checked, not just the one you selected. + - **Quote your hex values.** In YAML an unquoted `#` starts a comment, so + `muted: #fff` sets `muted` to nothing at all. `muted: "#fff"` is the color. + Plain numbers need no quotes. + + ```yaml + theme: mine + themes: + mine: + accent: {light: "127", dark: "205"} + muted: "240" + ``` +``` + +- [ ] **Step 2: `README.it.md`** + +Lo stesso contenuto, in italiano, dopo il punto elenco `- \`mouse\` (opzionale): …`. +Mantenere i due file equivalenti nel significato, compresi i numeri misurati. + +- [ ] **Step 3: `CHANGELOG.md`** + +In `### Added` sotto `## [Unreleased]`: + +```markdown +- Themes: `theme: dracula` (or `nord`) picks a built-in palette, and `themes:` in + the config defines your own, naming only the tokens you want to change. An + unknown theme or an invalid color stops startup with a message naming the + theme, the token and the value, because lipgloss renders a bad color as no + color at all and would otherwise fail silently (#82). +``` + +In `### Fixed`: + +```markdown +- The report's empty-state box is drawn with the same border color as the table + frame, so a report with no hours no longer shows a differently-weighted frame + from one with hours (#138). +``` + +- [ ] **Step 4: rileggere quello che si è scritto** + +Aprire i tre file e **guardare** le sezioni modificate in contesto: che i punti +elenco stiano nella lista giusta, che l'indentazione combaci con i vicini, che il +blocco YAML sia annidato correttamente sotto il suo punto elenco, e che le due +voci di changelog siano nella sezione giusta. Riportare cosa si è visto. + +- [ ] **Step 5: gate + commit** + +```bash +gofmt -l . && go vet ./... && go run honnef.co/go/tools/cmd/staticcheck@latest ./... && go build ./... && go test ./... -race +git add README.md README.it.md CHANGELOG.md +git commit -m "docs: document the theme keys and the box border fix (#82, #138)" +``` + +--- + +## Note per il controllore + +- La **#82 non si chiude** con questa tranche: resta la casella 2, gli override + delle keybinding, che è la tranche successiva. Chiudere la issue qui sarebbe + sbagliato. +- La **#138 si chiude**: il punto 1 era già stato risolto dalla tranche D + (`reportNumWidths` misurato dal contenuto ha sostituito la costante), il punto + 2 è il Task 6. +- Parità demo-mode: `cli` risolve dal config vero e passa la tavolozza a `New`, + che sostituisce solo `cfg` con `demoConfig()`. Quindi `CLICKUP_DEMO=1` onora il + tema dell'utente. In CI, dove un config non esiste, resta il default e il GIF + del README non cambia. diff --git a/docs/superpowers/specs/2026-08-01-custom-themes-design.md b/docs/superpowers/specs/2026-08-01-custom-themes-design.md new file mode 100644 index 0000000..6ad58ef --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-custom-themes-design.md @@ -0,0 +1,432 @@ +# Temi personalizzati e registro dei temi (design) + +> v1.9. Chiude la **#82** limitatamente alle caselle 1 e 3 (tema da YAML, due +> temi built-in) e il **punto 2 della #138** (il colore del bordo di `th.Box`). +> La casella 2 della #82 — override delle keybinding — è una tranche a sé, +> perché tocca 51 binding e non condivide niente con i colori se non il file di +> configurazione. + +## 1. Obiettivo + +Far scegliere all'utente i colori della TUI: due tavolozze pronte, e la +possibilità di scriversene una nel proprio `config.yml`. Più il residuo della +#138: le due cornici della schermata report devono pesare uguale. + +Nessuna chiamata API nuova, nessuna schermata nuova, nessun tasto nuovo. + +## 2. Evidenza misurata + +Ogni numero e ogni sequenza di escape qui sotto viene dall'aver **eseguito** il +codice. È la regola che le tranche precedenti hanno dovuto imparare a caro +prezzo, e nella tranche D quattro numeri scritti a mano erano falsi. + +### 2.1 lipgloss non protesta mai + +È la misura che decide la forma della validazione. Reso su un renderer a profilo +`TrueColor`: + +| valore | risultato | +|---|---| +| `#FF00AA` | `\x1b[38;2;255;0;170m` | +| `#fff` | `\x1b[38;2;255;255;255m` (hex corto accettato) | +| `236` | `\x1b[38;5;236m` | +| `bogus` | **`x`** — nessun escape, nessun errore, nessun panico | +| `""` | **`x`** — idem | +| `#GGGGGG` | **`x`** — idem | +| `999` | **`\x1b[38;5;999m`** — accettato ed emesso | + +Due conseguenze, entrambe vincolanti: + +- **Un refuso in un tema produce una TUI scolorita e nessun messaggio.** La + validazione dobbiamo scriverla noi: lipgloss non ci dirà mai che un colore era + sbagliato. +- **Un indice fuori intervallo è peggio di un colore assente**: `999` non viene + respinto, viene *emesso*, e `\x1b[38;5;999m` è una sequenza che nessun + terminale interpreta. L'intervallo 0-255 va imposto da noi. + +### 2.2 Il raggio d'azione della #138.2 è due golden, non diciassette + +La #138 dice che dare un colore al bordo di `th.Box` «sposta i golden di ogni +schermata che lo usa — da qui la sua esclusione dalla #66». Misurato, +confrontando `th.Box.Render("Box")` con e senza `BorderForeground(p.Muted)`: + +| profilo | esito | +|---|---| +| **Ascii** | **byte-identico** su entrambi i fondi | +| ANSI | `\x1b[90m` sul bordo | +| TrueColor | `\x1b[38;5;240m` sul bordo | + +L'affermazione era vera quando è stata scritta e **non lo è più**: `TestMain` +(`internal/tui/golden_test.go:29`) fissa `termenv.Ascii` per tutto il package, ed +è arrivato nella tranche A della v1.9, cioè *dopo* la #66. Dei 17 golden che +contengono un bordo tondo, 15 girano sotto Ascii e non si muovono; si muovono +solo `palette_dark` e `palette_light`, che `paletteTheme` rende a `TrueColor` +(`theme_test.go:58-63`) e che includono `th.Box` fra i token campionati. + +### 2.3 Lo slot a 16 colori si può derivare + +La tranche precedente ha dato a `Subtle` un `CompleteAdaptiveColor` con il valore +a 16 colori scritto a mano (`8` su fondo scuro, `7` su chiaro), perché la +conversione automatica lo faceva sparire. Se un tema utente scrivesse un solo +valore per token, quel fix si disferebbe in silenzio su ogni tema +personalizzato. + +`termenv.ConvertToRGB` funziona sia sugli indici sia sugli hex, il che rende la +derivazione possibile. La regola scelta **non ha una soglia arbitraria**: fra le +due sole sfumature disponibili a 16 colori — `8` (#808080) e `7` (#C0C0C0) — si +prende quella percettivamente più vicina, con `DistanceLab` di go-colorful. +Misurato: + +| colore | d(8) | d(7) | scelta | +|---|---|---|---| +| `236` (#303030) | 0.3372 | 0.5784 | **8** | +| `254` (#E4E4E4) | 0.3700 | 0.1288 | **7** | +| `#44475A` (dracula) | 0.2602 | 0.4867 | **8** | +| `#3B4252` (nord) | 0.2776 | 0.5089 | **8** | +| `#808080` | 0.0000 | 0.2412 | 8 | +| `#A0A0A0` | 0.1228 | 0.1184 | 7 (il punto di scambio è qui intorno) | + +Le prime due righe sono la proprietà che conta: **la derivazione riproduce +esattamente i due valori scelti a mano ieri**. Il tema di default costruito così +rende byte per byte come quello di oggi, a ogni profilo, e un test lo inchioda — +se un giorno qualcuno cambia la regola, quel test cade prima dei golden. + +### 2.4 dracula e nord su fondo chiaro + +Rapporti di contrasto WCAG dei token proposti in §6, calcolati dalla luminanza +relativa lineare: + +| tema | token | vs nero | vs bianco | +|---|---|---|---| +| dracula | Primary `#BD93F9` | 8.71:1 | **2.41:1** | +| dracula | Accent `#FF79C6` | 8.80:1 | **2.39:1** | +| dracula | Muted `#6272A4` | **4.46:1** | 4.71:1 | +| dracula | Danger `#FF5555` | 6.68:1 | **3.14:1** | +| dracula | Success `#50FA7B` | 15.30:1 | **1.37:1** | +| nord | Primary `#88C0D0` | 10.50:1 | **2.00:1** | +| nord | Accent `#81A1C1` | 7.80:1 | **2.69:1** | +| nord | Muted `#4C566A` | **2.85:1** | 7.38:1 | +| nord | Danger `#BF616A` | 5.13:1 | **4.09:1** | +| nord | Success `#A3BE8C` | 10.30:1 | **2.04:1** | + +E il caso peggiore, che non è un testo ma uno sfondo: `Subtle` è la zebratura, +dipinta **sotto il colore di default del terminale**. + +| striscia | testo sopra, su terminale chiaro | +|---|---| +| dracula `#44475A` | **2.29:1** | +| nord `#3B4252` | **2.09:1** | + +Righe scure sotto testo scuro, sotto il minimo di 3:1 previsto persino per il +testo grande. Su un terminale chiaro questi due temi non sono soltanto infedeli: +parti della TUI **non si leggono**. + +Due numeri vanno detti anche per il fondo scuro, dove i temi sono a casa loro: +`Muted` sta a 4.46:1 (dracula) e 2.85:1 (nord). Sono i colori che quelle +tavolozze destinano ai commenti, cioè volutamente arretrati, e li spediamo così +per fedeltà — ma il README deve dire che il testo d'aiuto in `nord` è tenue, non +lasciarlo scoprire. + +**Questa è una conseguenza accettata, non un difetto da correggere.** La scelta è +la fedeltà: un tema chiamato `dracula` deve essere Dracula. La difesa è il +README, che porterà questi numeri e non un generico «potrebbero non stare bene». + +## 3. Architettura: un package nuovo, e perché + +> **Questa sezione è stata riscritta dopo una review.** La prima versione diceva +> che un package nuovo era *forzato*, perché mettere il tipo in +> `internal/config` avrebbe creato un ciclo. **È falso**, e la review l'ha +> dimostrato con il grafo delle dipendenze: `internal/config` non importa +> **nessun** package interno, e `internal/tui` importa `config`. Un tipo dentro +> `config` non chiude alcun ciclo. Il ciclo esisterebbe solo mettendolo in `tui`, +> perché allora `config` dovrebbe importare `tui` per deserializzare. L'errore +> vale la pena raccontarlo: stava per finire, in inglese, dentro il commento di +> un package. + +Il tipo che descrive un tema serve a `internal/config`, che lo deserializza +dallo YAML, e a `internal/tui`, che lo trasforma in stili lipgloss. Sta in un +terzo package **per scelta**, e le ragioni sono tre: + +- **`internal/config` resterebbe altrimenti legato a lipgloss e termenv.** Oggi + dipende da `yaml.v3` e dalla stdlib; farne il posto dove vivono i tipi colore + significherebbe farci entrare una libreria di rendering per un package il cui + lavoro è leggere un file. +- **La logica dei colori si testa meglio fuori dalla TUI.** + `internal/tui/golden_test.go:29` fissa `termenv.Ascii` per **tutto il + package**: qualunque test sui colori scritto lì dentro deve costruirsi un + renderer proprio per vedere un solo byte di escape. Un package a sé non ha + quel vincolo. +- **`internal/tui` è già il package più grande del repo.** La validazione e la + risoluzione dei temi sono un'unità che si tiene in testa da sola; metterla lì + la nasconderebbe. + +L'alternativa scartata — `Value`/`Spec` in `config` e `Palette`/`Resolve` in +`tui`, con `cli` che chiama `tui.ResolvePalette` — non ha cicli e non aggiunge +package. Costa però i tre punti qui sopra, e in più esporterebbe da `tui` due +identificatori che nessuno dentro `tui` usa. + +### 3.1 `internal/themes` + +Foglia; dipende da `lipgloss`, `termenv` e `yaml.v3` — quest'ultimo perché +`Value` porta il proprio `UnmarshalYAML` (§4), e il tipo deve saper leggere le +sue due forme da sé. Non fa I/O, non conosce la TUI, non conosce la +configurazione su disco. + +```go +package themes + +// Palette is the six semantic colors the whole TUI is built from. +type Palette struct { + Primary lipgloss.AdaptiveColor + Accent lipgloss.AdaptiveColor + Muted lipgloss.AdaptiveColor + Danger lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Subtle lipgloss.CompleteAdaptiveColor +} + +// Value is one token as written in YAML: a bare string, or a light/dark pair. +type Value struct{ Light, Dark string } + +// Spec is one user-written theme: token name -> value. +type Spec map[string]Value + +func Default() Palette +func Names() []string // built-in names, sorted +func Resolve(name string, custom map[string]Spec) (Palette, error) +``` + +**Il nome del package è `themes` al plurale per una ragione meccanica**, e va +scritto o sembrerà arbitrario fra sei mesi: `internal/tui` dichiara già un tipo +chiamato `theme` (`theme.go:30`), usato in **18 file** come `func …view(th theme)`. +Il nome di un package importato vive nel *file block* e quello del tipo nel +*package block*, e il Go spec vieta che lo stesso identificatore stia in +entrambi — verificato con un package di prova: `import ".../theme"` dentro un +package che dichiara `type theme` non compila, *«theme already declared through +import of package theme»*, anche quando import e tipo stanno in file diversi. + +Un import con alias compilerebbe, quindi il plurale non è l'unica via: è quella +che evita di dover scrivere un alias a ogni import. L'altra alternativa, +rinominare il tipo, costerebbe 18 file per una ragione che non è di questa +tranche. + +### 3.2 Chi chiama cosa + +- `internal/config` guadagna due campi grezzi: `Theme string` e + `Themes map[string]themes.Spec`. Non valida niente: tiene lo YAML com'è. +- `internal/cli` guadagna `resolveTheme(cfg) (themes.Palette, error)`, estratta + da `runTUI` per la stessa ragione per cui `programOptions` lo era già + (`cli.go:17-26`): `runTUI` blocca su un terminale e non è chiamabile da un + test, mentre la decisione sì. `runTUI` la chiama **prima** di costruire il + programma e ritorna l'errore — è l'ultimo punto in cui un errore di + configurazione può ancora finire su stderr invece che dentro una TUI già + avviata, ed è dove `Execute` lo stampa già come `error: …`. +- `internal/tui` cambia firma: `New(cfg config.Config, pal themes.Palette) Model`. + +**I call site di `New` sono 44, su 8 file**: `app_test.go` 33, `demo_test.go` 3, +`log_test.go` 2, `report_test.go` 2, `golden_test.go` 1, `home_test.go` 1, +`palette_demo_test.go` 1, `internal/cli/cli.go:57` 1, più la definizione in +`app.go`. + +> **Due note di metodo, perché il numero è stato sbagliato qui due volte.** +> +> La prima versione diceva «nove call site», perché avevo cercato la stringa +> letterale `New(cfg)`. Quella ricerca non trova `New(config.Config{…})`, né +> `New(demoConfig())`, né `New(realCfg)`. Un piano costruito su quel numero +> avrebbe lasciato `internal/tui` non compilabile a metà esecuzione. +> +> La correzione, però, dava `app_test.go` a 34, e sono **33**: il conteggio per +> file e il totale erano stati presi con due comandi diversi e non tornavano fra +> loro. Se ne è accorto l'implementer del Task 5, misurando prima di iniziare e +> segnalando la discrepanza invece di aggiustarla in silenzio. Il modo +> affidabile non è nessuno dei due grep: si cambia la firma e si lascia che sia +> il compilatore a elencare le chiamate rimaste. + +L'alternativa — lasciare `New(cfg)` e farle risolvere il tema da sola — vorrebbe +un ramo «non può succedere» per l'errore, dato che `cli` avrebbe già rifiutato di +partire. Un ramo irraggiungibile che nessun test può esercitare è peggio di una +firma con un argomento in più. + +**Demo mode**: `cli` risolve dal config vero e passa la tavolozza; `New` sostituisce +solo `cfg` con `demoConfig()`. Quindi `CLICKUP_DEMO=1` **onora il tema +dell'utente**, che è giusto — il tema è una preferenza di interfaccia, non un +dato. In CI, dove un config non esiste, resta il default e il GIF non cambia. + +## 4. Il formato YAML + +```yaml +theme: dracula + +themes: + mine: + accent: {light: "127", dark: "205"} # coppia: adattivo + muted: "240" # valore singolo: uguale su entrambi i fondi +``` + +- **`theme`** è il nome da usare. Assente o vuoto significa `default`. +- **`themes`** sono i temi dell'utente, per nome. +- **Un valore può essere una stringa o una coppia.** Una stringa sola vale per + entrambi i fondi. `Value` ha un `UnmarshalYAML` tollerante che accetta le due + forme, come `tagName` in `internal/clickup` fa già per il JSON di ClickUp. +- **`Value` ha anche un `MarshalYAML`, e non è un vezzo.** `config.Save` + serializza l'intera `Config`, quindi senza di esso un `muted: "240"` scritto + dall'utente tornerebbe su disco come `muted: {light: "240", dark: "240"}` alla + prima cosa che salva il config. `MarshalYAML` ricollassa a stringa quando i due + lati coincidono, così un salvataggio non riscrive il file dell'utente in una + forma che non ha scelto. **E per una coppia vera deve ritornare una struct con + i tag, non una mappa**: misurato, una `map[string]string` viene serializzata + con le chiavi in ordine alfabetico, cioè `dark:` prima di `light:` — un + riordino silenzioso di quello che l'utente aveva scritto. +- **Un tema dichiara solo ciò che cambia.** I token non nominati arrivano da + `Default()`. Il caso comune — «voglio solo un accento diverso» — sono due + righe, non sei. +- **I nomi dei token** sono `primary`, `accent`, `muted`, `danger`, `success`, + `subtle`: gli stessi sei campi di `Palette`, in minuscolo. +- **Un colore** è un hex (`#RGB` o `#RRGGBB`) oppure un indice decimale 0-255. + +## 5. Validazione + +Tutti errori **bloccanti**, con il precedente già stabilito da +`billing.rounding.increment` (`internal/service/pricing.go:71`): una +configurazione che non si riesce a interpretare ferma l'avvio invece di +degradare in silenzio. Qui la ragione è più forte del solito, perché §2.1 dice +che il degrado sarebbe *invisibile*: testo senza colore, o una sequenza che il +terminale ignora. + +| caso | messaggio | +|---|---| +| nome di tema sconosciuto | elenca i nomi disponibili, built-in e utente, ordinati | +| un tema utente chiamato come un built-in | «`dracula` is a built-in theme; choose another name» | +| token sconosciuto dentro un tema | elenca i sei token validi | +| colore non hex e non intero | nomina tema, token e valore | +| indice fuori da 0-255 | idem, con l'intervallo | +| valore vuoto (o metà coppia vuota) | idem | + +Il messaggio nomina sempre **tema, token e valore**: un errore che dice solo +«invalid color» costringe a cercarlo a mano in un config con sei temi. + +**Si validano tutti i temi dell'utente, non solo quello selezionato.** La prima +versione della spec non lo diceva, e il piano che ne era uscito controllava le +collisioni di nome su tutti e i colori solo su quello scelto — un refuso in un +tema non selezionato sarebbe rimasto invisibile fino al giorno in cui l'utente +lo avesse selezionato, che è il momento peggiore per scoprirlo. La regola è +uniforme: un `config.yml` che contiene un tema invalido è un config invalido. + +**Un hex non quotato è un commento YAML**, e va detto dentro il messaggio +d'errore. Misurato: `muted: #fff` non è un colore, è la chiave `muted` con +valore vuoto seguita da un commento, quindi arriva alla validazione come +stringa vuota. Un errore che dicesse soltanto «empty color» manderebbe l'utente +a cercare una chiave che ha scritto benissimo. Il messaggio per il valore vuoto +deve suggerire le virgolette. (Gli indici numerici non quotati invece +funzionano: `muted: 240` si decodifica correttamente.) + +## 6. I due temi built-in + +Valori dalle tavolozze ufficiali, mappati sui sei token: + +**dracula** (draculatheme.com) + +| token | sorgente Dracula | hex | +|---|---|---| +| Primary | Purple | `#BD93F9` | +| Accent | Pink | `#FF79C6` | +| Muted | Comment | `#6272A4` | +| Danger | Red | `#FF5555` | +| Success | Green | `#50FA7B` | +| Subtle | Current Line | `#44475A` | + +**nord** (nordtheme.com) + +| token | sorgente Nord | hex | +|---|---|---| +| Primary | nord8 (Frost) | `#88C0D0` | +| Accent | nord9 (Frost) | `#81A1C1` | +| Muted | nord3 (Polar Night) | `#4C566A` | +| Danger | nord11 (Aurora) | `#BF616A` | +| Success | nord14 (Aurora) | `#A3BE8C` | +| Subtle | nord1 (Polar Night) | `#3B4252` | + +Entrambi dichiarano **un valore solo per token**, quindi rendono identici su +fondo chiaro e scuro: è la fedeltà scelta in §2.4. `Primary` e `Accent` restano +vicini fra loro in entrambi i temi, esattamente come nel default, dove i due +token portano lo stesso valore. + +Il `Subtle` di entrambi deriva sull'`8` (§2.3), quindi la zebratura sopravvive a +16 colori anche con questi temi — sui terminali scuri per cui sono fatti. + +## 7. Il bordo di `th.Box` (#138 punto 2) + +```go +Box: r.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.Muted).Padding(0, 1), +``` + +Lo stesso colore di `th.Border`, che è ciò che rende la cornice della tabella del +report: dopo questa riga le due cornici della stessa schermata pesano uguale, che +è il difetto che la #138 descrive. Due golden da rigenerare (§2.2), **guardati** +dopo. + +## 8. Test + +- **Il test che porta il peso ha due metà.** La prima confronta i **valori** di + `Default()` con quelli che `internal/tui` porta oggi, letterali scritti a mano + nel test. La seconda **rende** ogni token a tutti e quattro i profili su + entrambi i fondi e confronta con la tavolozza di oggi ricostruita nel test, + byte per byte. Le due insieme dicono che spostare il codice non ha cambiato un + colore, e inchiodano la derivazione di §2.3: se la regola di vicinanza cambia, + `Subtle` smette di dare `8`/`7` e cadono entrambe. +- **La derivazione** ha una tabella sua, con i valori misurati in §2.3, compresi + i due dei temi built-in e il punto di scambio. +- **La validazione** ha una tabella di input cattivi, uno per riga di §5, e ogni + caso asserisce che l'errore **nomina il token e il valore** — non solo che un + errore c'è. Un test che si accontenta di `err != nil` passa anche con un + messaggio inutile. +- **Nessun token di nessun tema built-in può rendere vuoto.** È la trappola di + §2.1: un refuso in un hex scritto da noi non darebbe errore, darebbe testo + scolorito. Il test rende ogni token di ogni tema built-in a `TrueColor` e + pretende una sequenza di escape. +- **Il round-trip** di `theme:` e `themes:` su disco, con la forma stringa e la + forma coppia — e in particolare che **un valore scritto come stringa torni su + disco come stringa** dopo un `Save`. Senza `MarshalYAML` quel test fallisce, ed + è l'unico modo per accorgersi che stiamo riscrivendo il file dell'utente. +- **Ogni difetto ha un test visto fallire** contro il difetto, col transcript. In + particolare: la derivazione va vista fallire invertendo la scelta fra `8` e + `7`, e il bordo di `th.Box` va visto fallire togliendo `BorderForeground`. +- **Golden**: si muovono esattamente `palette_dark` e `palette_light`. Ogni altro + golden che si muove è un difetto dell'implementazione, non un golden stantio: + non va rigenerato, va indagato. +- Gate: `gofmt -l .`, `go vet ./...`, + `go run honnef.co/go/tools/cmd/staticcheck@latest ./...`, `go build ./...`, + `go test ./... -race`. + +## 9. Fuori scope (dichiarato) + +- **Override delle keybinding** (casella 2 della #82): tranche a sé. Tocca 51 + binding e non condivide con i colori nulla se non il file di configurazione. +- **Una directory di temi** (`~/.config/clup/themes/*.yml`): tutto sta in + `config.yml`. Una directory aggiunge scoperta di file, precedenze e nuovi + percorsi d'errore per un beneficio che nessuno ha chiesto. +- **Un selettore di tema dentro la TUI**: si sceglie dal config. Una schermata + per cambiare colori è una feature, non questa issue. +- **Adattare dracula e nord al fondo chiaro**: deciso in §2.4, con i numeri. +- **Il punto 1 della #138** (`reportNumWidth`): già chiuso dalla tranche D, che + ha sostituito la costante con `reportNumWidths` misurato dal contenuto. +- **Rinominare il tipo `theme` di `internal/tui`**: 18 file per una ragione che + non è di questa tranche (§3.1). + +## 10. Vincoli globali + +- `internal/report` e `internal/duration` restano **puri**: solo stdlib, nessun + I/O, nessun import di `internal/config`, `internal/clickup`, `internal/tui`. +- `internal/themes` è foglia: **non** importa `config`, `tui`, `clickup`, + `service`; non fa I/O. +- Nessuna funzione di stile in produzione chiama `lipgloss.NewStyle()`. +- Mai chiamare l'API ClickUp vera: il comportamento di rete si esercita solo con + `httptest`. +- Tutto ciò che vive nel repo è in **inglese** — codice, commenti, stringhe UI, + nomi e messaggi dei test, messaggi di commit. Eccezioni: `README.it.md`, + `CONTRIBUTING.it.md` e i doc di design sotto `docs/superpowers/`. +- **Ogni numero e ogni sequenza di escape scritta in un commento va misurata + eseguendo.** +- I golden si rigenerano solo con `go test ./internal/tui -update`, mai a mano, e + si **guardano** dopo. +- Conventional Commits. **MAI** `Co-Authored-By`. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 5e5abd4..13dd84d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/service" + "github.com/marcoarnulfo/clickup-cli/internal/themes" "github.com/marcoarnulfo/clickup-cli/internal/tui" "github.com/spf13/cobra" ) @@ -31,6 +32,22 @@ func programOptions(cfg config.Config) []tea.ProgramOption { return opts } +// resolveTheme builds the palette the TUI will render with, and refuses to +// start on a configuration it cannot honor. +// +// Extracted from runTUI for the same reason programOptions was: runTUI blocks +// on a terminal and cannot be called from a test, while the decision it makes +// can. Called before the program starts because this is the last place a +// configuration error still reaches stderr — Execute prints it as "error: …" +// — instead of appearing inside an already-running TUI. +func resolveTheme(cfg config.Config) (themes.Palette, error) { + pal, err := themes.Resolve(cfg.Theme, cfg.Themes) + if err != nil { + return themes.Palette{}, fmt.Errorf("theme: %w", err) + } + return pal, nil +} + // rootCmd builds the root command. Unexported so tests can exercise the // command wiring directly without going through Execute/os.Exit. func rootCmd() *cobra.Command { @@ -54,7 +71,11 @@ func runTUI(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("config: %w", err) } - p := tea.NewProgram(tui.New(cfg), programOptions(cfg)...) + pal, err := resolveTheme(cfg) + if err != nil { + return err + } + p := tea.NewProgram(tui.New(cfg, pal), programOptions(cfg)...) _, err = p.Run() return err } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4042012..8581682 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,6 +1,7 @@ package cli import ( + "strings" "testing" "github.com/marcoarnulfo/clickup-cli/internal/config" @@ -64,3 +65,33 @@ func TestProgramOptionsAddsOneWhenMouseIsEnabled(t *testing.T) { t.Errorf("programOptions: %d options with the mouse on, %d with it off; want exactly one more", on, off) } } + +// An unresolvable theme must stop the launch with an error the user can act on, +// not start a TUI with the wrong colors. The check is on resolveTheme, which is +// the production code path runTUI takes: runTUI itself blocks on a terminal and +// cannot be called from a test, the same reason programOptions was extracted. +func TestResolveThemeRejectsAnUnknownName(t *testing.T) { + t.Parallel() + _, err := resolveTheme(config.Config{Theme: "nope"}) + if err == nil { + t.Fatal("resolveTheme of an unknown theme = nil error, want one") + } + if !strings.Contains(err.Error(), "nope") { + t.Errorf("error %q does not name the theme the user asked for", err) + } + // The prefix is what tells the user which part of their config is at fault + // once Execute prints it as "error: …". + if !strings.HasPrefix(err.Error(), "theme:") { + t.Errorf("error %q is not prefixed with the config section it comes from", err) + } +} + +func TestResolveThemeAcceptsABuiltin(t *testing.T) { + t.Parallel() + if _, err := resolveTheme(config.Config{Theme: "dracula"}); err != nil { + t.Errorf("resolveTheme(dracula) = %v, want nil", err) + } + if _, err := resolveTheme(config.Config{}); err != nil { + t.Errorf("resolveTheme with no theme set = %v, want nil (the default)", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2bdc138..9bd7a93 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" + "github.com/marcoarnulfo/clickup-cli/internal/themes" "gopkg.in/yaml.v3" ) @@ -89,6 +90,15 @@ type Config struct { // native text selection away from the user: with it on, drag-to-select // needs Shift in most terminals. Mouse *bool `yaml:"mouse,omitempty"` + + // Theme names the palette the TUI renders with: a built-in name, or one of + // the Themes below. Empty means the built-in default. The names are + // resolved by internal/themes, not here — this package holds the YAML as + // written and nothing more. + Theme string `yaml:"theme,omitempty"` + // Themes are the user's own palettes, by name. A theme names only the + // tokens it changes; internal/themes fills the rest from the default. + Themes map[string]themes.Spec `yaml:"themes,omitempty"` } // Valid reports whether the config can be used to query the API. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0add5e6..80f3697 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,8 @@ import ( "reflect" "strings" "testing" + + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) // isolateConfig redirects the config to a temp dir on ALL platforms. @@ -613,3 +615,72 @@ func TestMouseEnabled(t *testing.T) { } } } + +// --- theme keys (#82) --- + +func TestThemeKeysRoundTrip(t *testing.T) { + isolateConfig(t) + in := Config{Token: "t", WorkspaceID: "1", Theme: "mine", Themes: map[string]themes.Spec{ + "mine": { + "muted": {Light: "240", Dark: "240"}, + "accent": {Light: "127", Dark: "205"}, + }, + }} + if err := Save(in); err != nil { + t.Fatal(err) + } + got, err := Load() + if err != nil { + t.Fatal(err) + } + if got.Theme != "mine" { + t.Errorf("Theme = %q, want %q", got.Theme, "mine") + } + if got.Themes["mine"]["muted"] != (themes.Value{Light: "240", Dark: "240"}) { + t.Errorf("muted = %+v, want 240 on both sides", got.Themes["mine"]["muted"]) + } + if got.Themes["mine"]["accent"] != (themes.Value{Light: "127", Dark: "205"}) { + t.Errorf("accent = %+v, want the pair", got.Themes["mine"]["accent"]) + } +} + +// A token the user wrote as a bare string must come back as a bare string: +// Save serializes the whole Config, so without Value.MarshalYAML every save +// would rewrite their file into a shape they did not choose. +func TestSaveKeepsABareColorBare(t *testing.T) { + isolateConfig(t) + if err := Save(Config{Token: "t", WorkspaceID: "1", Themes: map[string]themes.Spec{ + "mine": {"muted": {Light: "240", Dark: "240"}}, + }}); err != nil { + t.Fatal(err) + } + p, err := Path() + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `muted: "240"`) { + t.Fatalf("saved config did not keep the bare form:\n%s", raw) + } +} + +func TestThemeKeysAbsentAreZero(t *testing.T) { + isolateConfig(t) + if err := Save(Config{Token: "t", WorkspaceID: "1"}); err != nil { + t.Fatal(err) + } + p, err := Path() + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "theme") { + t.Fatalf("saved config mentions theme:\n%s", raw) + } +} diff --git a/internal/themes/builtin.go b/internal/themes/builtin.go new file mode 100644 index 0000000..0ac2d66 --- /dev/null +++ b/internal/themes/builtin.go @@ -0,0 +1,82 @@ +package themes + +import ( + "maps" + "slices" + + "github.com/charmbracelet/lipgloss" +) + +// Default is the built-in palette. Dark keeps the xterm indices the TUI has +// always shipped; Light overrides only the tokens that are illegible on white, +// measured as WCAG contrast against #FFFFFF: +// +// 205 -> 127 205 (#FF5FAF) is 2.80:1 on white; 127 (#AF00AF) is 6.10:1 +// 196 -> 124 196 (#FF0000) is 4.00:1, under the 4.5:1 floor; 124 (#AF0000) is 7.44:1 +// 42 -> 28 42 (#00D787) is 1.90:1 on white; 28 (#008700) is 4.70:1 — over the +// 4.5:1 floor, but not by much +// +// Muted (240, #585858) is left alone: it already clears 7.11:1 on white. +// Adaptive means legible on both backgrounds, not different on both. +// +// Subtle is a background, judged by a different rule than the five foregrounds: +// 236 on dark and 254 on light are chosen so the DEFAULT FOREGROUND still +// clears contrast painted on top of them. A zebra stripe that swallows its own +// text is worse than no stripe at all. +func Default() Palette { + return Palette{ + Primary: adaptive("127", "205"), + Accent: adaptive("127", "205"), + Muted: adaptive("240", "240"), + Danger: adaptive("124", "196"), + Success: adaptive("28", "42"), + Subtle: subtle("254", "236"), + } +} + +// mono is a color that does not change with the terminal background. The two +// palettes below are built from it because both are designed for a dark +// terminal and are shipped faithfully: see the design doc's §2.4 for the +// measured contrast ratios on a light background, and the README for the +// warning users get. +func mono(c string) lipgloss.AdaptiveColor { return adaptive(c, c) } + +// Dracula is draculatheme.com's published palette, mapped onto the six tokens +// as Primary=Purple, Accent=Pink, Muted=Comment, Danger=Red, Success=Green, +// Subtle=Current Line. +func Dracula() Palette { + return Palette{ + Primary: mono("#BD93F9"), + Accent: mono("#FF79C6"), + Muted: mono("#6272A4"), + Danger: mono("#FF5555"), + Success: mono("#50FA7B"), + Subtle: subtle("#44475A", "#44475A"), + } +} + +// Nord is nordtheme.com's published palette, mapped onto the six tokens as +// Primary=nord8, Accent=nord9 (both Frost), Muted=nord3 and Subtle=nord1 (both +// Polar Night), Danger=nord11 and Success=nord14 (both Aurora). +func Nord() Palette { + return Palette{ + Primary: mono("#88C0D0"), + Accent: mono("#81A1C1"), + Muted: mono("#4C566A"), + Danger: mono("#BF616A"), + Success: mono("#A3BE8C"), + Subtle: subtle("#3B4252", "#3B4252"), + } +} + +// builtins is the registry Resolve consults. +var builtins = map[string]func() Palette{ + "default": Default, + "dracula": Dracula, + "nord": Nord, +} + +// Names lists the built-in themes, sorted, for error messages. +func Names() []string { + return slices.Sorted(maps.Keys(builtins)) +} diff --git a/internal/themes/builtin_test.go b/internal/themes/builtin_test.go new file mode 100644 index 0000000..dffe6ed --- /dev/null +++ b/internal/themes/builtin_test.go @@ -0,0 +1,149 @@ +package themes + +import ( + "io" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// Default must keep the exact values internal/tui carried before this package +// existed. Moving code must not change a color. +func TestDefaultKeepsTheShippedValues(t *testing.T) { + t.Parallel() + p := Default() + for _, tc := range []struct { + name string + got lipgloss.AdaptiveColor + light, dark string + }{ + {"Primary", p.Primary, "127", "205"}, + {"Accent", p.Accent, "127", "205"}, + {"Muted", p.Muted, "240", "240"}, + {"Danger", p.Danger, "124", "196"}, + {"Success", p.Success, "28", "42"}, + } { + if tc.got.Light != tc.light || tc.got.Dark != tc.dark { + t.Errorf("%s = {%q, %q}, want {%q, %q}", + tc.name, tc.got.Light, tc.got.Dark, tc.light, tc.dark) + } + } + want := lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{TrueColor: "254", ANSI256: "254", ANSI: "7"}, + Dark: lipgloss.CompleteColor{TrueColor: "236", ANSI256: "236", ANSI: "8"}, + } + if p.Subtle != want { + t.Errorf("Subtle = %+v, want %+v — shadeFor must reproduce the hand-picked slots", p.Subtle, want) + } +} + +// The four tokens that are unreadable on white differ between backgrounds; +// Muted must not, since 240 already clears 7:1 on white. +func TestDefaultIsAdaptive(t *testing.T) { + t.Parallel() + p := Default() + for _, tc := range []struct { + name string + c lipgloss.AdaptiveColor + }{{"Primary", p.Primary}, {"Accent", p.Accent}, {"Danger", p.Danger}, {"Success", p.Success}} { + if tc.c.Light == tc.c.Dark { + t.Errorf("%s is not adaptive: Light == Dark == %q", tc.name, tc.c.Dark) + } + } + if p.Muted.Light != p.Muted.Dark { + t.Errorf("Muted was adapted (%q/%q) but reads fine on both backgrounds", + p.Muted.Light, p.Muted.Dark) + } + if s := Default().Subtle; s.Light.ANSI256 == s.Dark.ANSI256 || s.Light.ANSI == s.Dark.ANSI { + t.Errorf("Subtle is not adaptive: light %+v, dark %+v", s.Light, s.Dark) + } +} + +// Measured: lipgloss renders an invalid color as no color at all, silently. A +// typo in a hex we ship would therefore produce a colorless token and no error +// anywhere. This is the only thing that would catch it. +func TestEveryBuiltinTokenRendersAColor(t *testing.T) { + t.Parallel() + for _, name := range Names() { + p, ok := builtins[name] + if !ok { + t.Fatalf("Names() returned %q, which is not in builtins", name) + } + pal := p() + for _, dark := range []bool{true, false} { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.TrueColor) + r.SetHasDarkBackground(dark) + for label, c := range map[string]lipgloss.TerminalColor{ + "Primary": pal.Primary, "Accent": pal.Accent, "Muted": pal.Muted, + "Danger": pal.Danger, "Success": pal.Success, "Subtle": pal.Subtle, + } { + if out := r.NewStyle().Foreground(c).Render("x"); out == "x" { + t.Errorf("%s/%s (dark=%v) rendered no escape at all: %q", name, label, dark, out) + } + } + } + } +} + +// The other half of the guard: Default must RENDER exactly like the palette +// internal/tui carried before this package existed, at every profile and on +// both backgrounds. Matching values alone would not prove it — a slot the +// renderer consults could still differ. +func TestDefaultRendersLikeTheShippedPalette(t *testing.T) { + t.Parallel() + // The palette as internal/tui/theme.go declared it before the move. + shipped := Palette{ + Primary: lipgloss.AdaptiveColor{Light: "127", Dark: "205"}, + Accent: lipgloss.AdaptiveColor{Light: "127", Dark: "205"}, + Muted: lipgloss.AdaptiveColor{Light: "240", Dark: "240"}, + Danger: lipgloss.AdaptiveColor{Light: "124", Dark: "196"}, + Success: lipgloss.AdaptiveColor{Light: "28", Dark: "42"}, + Subtle: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{TrueColor: "254", ANSI256: "254", ANSI: "7"}, + Dark: lipgloss.CompleteColor{TrueColor: "236", ANSI256: "236", ANSI: "8"}, + }, + } + got := Default() + profiles := []termenv.Profile{termenv.TrueColor, termenv.ANSI256, termenv.ANSI, termenv.Ascii} + for _, profile := range profiles { + for _, dark := range []bool{true, false} { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(profile) + r.SetHasDarkBackground(dark) + for _, tc := range []struct { + name string + a, b lipgloss.TerminalColor + }{ + {"Primary", got.Primary, shipped.Primary}, + {"Accent", got.Accent, shipped.Accent}, + {"Muted", got.Muted, shipped.Muted}, + {"Danger", got.Danger, shipped.Danger}, + {"Success", got.Success, shipped.Success}, + {"Subtle", got.Subtle, shipped.Subtle}, + } { + x := r.NewStyle().Foreground(tc.a).Render("x") + y := r.NewStyle().Foreground(tc.b).Render("x") + if x != y { + t.Errorf("profile=%v dark=%v %s: rendered %q, want %q", + profile, dark, tc.name, x, y) + } + } + } + } +} + +func TestNamesAreSortedAndComplete(t *testing.T) { + t.Parallel() + got := Names() + want := []string{"default", "dracula", "nord"} + if len(got) != len(want) { + t.Fatalf("Names() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Names() = %v, want %v (sorted)", got, want) + } + } +} diff --git a/internal/themes/resolve.go b/internal/themes/resolve.go new file mode 100644 index 0000000..a1e8cf6 --- /dev/null +++ b/internal/themes/resolve.go @@ -0,0 +1,94 @@ +package themes + +import ( + "fmt" + "maps" + "slices" + "strings" +) + +// Resolve turns a configured theme name and the user's own themes into the +// palette the TUI renders with. +// +// Every failure is an error rather than a fallback, for the reason validColor's +// comment records: a bad color degrades invisibly — colorless text, or an +// escape sequence no terminal reads — so a silent fallback would leave the user +// staring at a wrong-looking TUI with nothing to go on. The same rule +// billing.rounding.increment follows. +func Resolve(name string, custom map[string]Spec) (Palette, error) { + // EVERY user theme is validated, not only the selected one: a typo in a + // theme you are not using today is still a typo, and the day you switch to + // it is the worst possible moment to find out. Sorted so that a config with + // two broken themes always reports the same one. + for _, n := range slices.Sorted(maps.Keys(custom)) { + if _, clash := builtins[n]; clash { + return Palette{}, fmt.Errorf("theme %q is built-in; choose another name for your own", n) + } + if err := validateSpec(n, custom[n]); err != nil { + return Palette{}, err + } + } + if name == "" { + name = "default" + } + if spec, ok := custom[name]; ok { + return applySpec(spec), nil + } + if p, ok := builtins[name]; ok { + return p(), nil + } + return Palette{}, fmt.Errorf("unknown theme %q; available: %s", + name, strings.Join(allNames(custom), ", ")) +} + +// allNames is every theme the user could have named, sorted, for the error +// above: the built-ins plus whatever they defined themselves. +func allNames(custom map[string]Spec) []string { + out := Names() + for n := range custom { + out = append(out, n) + } + slices.Sort(out) + return out +} + +// validateSpec checks one user theme's tokens and colors. Separate from +// applySpec because Resolve validates every theme but applies only one. +func validateSpec(name string, spec Spec) error { + for _, token := range slices.Sorted(maps.Keys(spec)) { + if !slices.Contains(TokenNames, token) { + return fmt.Errorf("theme %q: unknown token %q; valid tokens: %s", + name, token, strings.Join(TokenNames, ", ")) + } + v := spec[token] + for _, side := range []string{v.Light, v.Dark} { + if err := validColor(side); err != nil { + return fmt.Errorf("theme %q, token %q, value %q: %w", name, token, side, err) + } + } + } + return nil +} + +// applySpec starts from Default and overrides only the tokens the spec names. +// It assumes validateSpec has already passed. +func applySpec(spec Spec) Palette { + p := Default() + for token, v := range spec { + switch token { + case "primary": + p.Primary = adaptive(v.Light, v.Dark) + case "accent": + p.Accent = adaptive(v.Light, v.Dark) + case "muted": + p.Muted = adaptive(v.Light, v.Dark) + case "danger": + p.Danger = adaptive(v.Light, v.Dark) + case "success": + p.Success = adaptive(v.Light, v.Dark) + case "subtle": + p.Subtle = subtle(v.Light, v.Dark) + } + } + return p +} diff --git a/internal/themes/resolve_test.go b/internal/themes/resolve_test.go new file mode 100644 index 0000000..264e30e --- /dev/null +++ b/internal/themes/resolve_test.go @@ -0,0 +1,132 @@ +package themes + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestResolveBuiltins(t *testing.T) { + t.Parallel() + for _, name := range []string{"", "default", "dracula", "nord"} { + if _, err := Resolve(name, nil); err != nil { + t.Errorf("Resolve(%q, nil) = %v, want nil — an empty name means default", name, err) + } + } + got, err := Resolve("dracula", nil) + if err != nil { + t.Fatal(err) + } + if got.Primary != Dracula().Primary { + t.Errorf("Resolve(\"dracula\") did not return the dracula palette") + } +} + +// A theme names only what it changes; everything else comes from Default. +func TestResolveCustomInheritsTheRest(t *testing.T) { + t.Parallel() + got, err := Resolve("mine", map[string]Spec{ + "mine": {"accent": {Light: "1", Dark: "2"}}, + }) + if err != nil { + t.Fatal(err) + } + if want := (lipgloss.AdaptiveColor{Light: "1", Dark: "2"}); got.Accent != want { + t.Errorf("Accent = %+v, want %+v", got.Accent, want) + } + if got.Primary != Default().Primary { + t.Errorf("Primary = %+v, want the default %+v — unnamed tokens must be inherited", + got.Primary, Default().Primary) + } +} + +// A custom Subtle must still get its 16-color slot derived, or every custom +// theme silently undoes the zebra-stripe fix. +func TestResolveDerivesSubtleForACustomTheme(t *testing.T) { + t.Parallel() + got, err := Resolve("mine", map[string]Spec{ + "mine": {"subtle": {Light: "#E4E4E4", Dark: "#303030"}}, + }) + if err != nil { + t.Fatal(err) + } + if got.Subtle.Dark.ANSI != "8" || got.Subtle.Light.ANSI != "7" { + t.Errorf("Subtle ANSI slots = %q/%q, want 7/8", got.Subtle.Light.ANSI, got.Subtle.Dark.ANSI) + } +} + +// Every failure names the theme, the token and the value: an error that only +// says "invalid color" makes the user hunt through their config by hand. +func TestResolveErrors(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + theme string + custom map[string]Spec + says []string + }{ + { + name: "unknown theme lists what is available", + theme: "nope", + says: []string{"nope", "default", "dracula", "nord"}, + }, + { + name: "a custom theme may not shadow a built-in", + theme: "default", + custom: map[string]Spec{"dracula": {}}, + says: []string{"dracula", "built-in"}, + }, + { + name: "unknown token lists the valid ones", + theme: "mine", + custom: map[string]Spec{"mine": {"primry": {Light: "1", Dark: "1"}}}, + says: []string{"mine", "primry", "primary", "subtle"}, + }, + { + name: "invalid color names theme, token and value", + theme: "mine", + custom: map[string]Spec{"mine": {"accent": {Light: "bogus", Dark: "1"}}}, + says: []string{"mine", "accent", "bogus"}, + }, + { + // The Light half being valid must not let a bad Dark half slip + // through: both sides of a pair are validated independently. + name: "invalid dark half names theme, token and value", + theme: "mine", + custom: map[string]Spec{"mine": {"accent": {Light: "1", Dark: "bogus"}}}, + says: []string{"mine", "accent", "bogus"}, + }, + { + name: "an out-of-range index is rejected", + theme: "mine", + custom: map[string]Spec{"mine": {"accent": {Light: "999", Dark: "1"}}}, + says: []string{"mine", "accent", "999", "0-255"}, + }, + { + name: "an empty half of a pair is rejected, and says why", + theme: "mine", + custom: map[string]Spec{"mine": {"accent": {Light: "", Dark: "1"}}}, + says: []string{"mine", "accent", "quotes"}, + }, + { + // A typo in a theme you are not using is still a typo. Selecting + // "default" here means only the all-themes validation can catch it. + name: "a bad color in a theme that is not selected still fails", + theme: "default", + custom: map[string]Spec{"unused": {"accent": {Light: "bogus", Dark: "1"}}}, + says: []string{"unused", "accent", "bogus"}, + }, + } { + _, err := Resolve(tc.theme, tc.custom) + if err == nil { + t.Errorf("%s: Resolve = nil error, want one", tc.name) + continue + } + for _, s := range tc.says { + if !strings.Contains(err.Error(), s) { + t.Errorf("%s: error %q does not mention %q", tc.name, err, s) + } + } + } +} diff --git a/internal/themes/themes.go b/internal/themes/themes.go new file mode 100644 index 0000000..5855aa6 --- /dev/null +++ b/internal/themes/themes.go @@ -0,0 +1,161 @@ +// Package themes owns the six semantic colors the TUI is built from, the +// built-in palettes, and the resolution of a user-written theme. +// +// It is a leaf by choice, not by necessity: internal/config could hold these +// types without an import cycle — config would then import no internal +// package of its own. They live here so that internal/config stays free of a +// rendering library, so that color tests run without internal/tui's TestMain +// pinning termenv.Ascii for the whole package, and so that validation stays a +// unit small enough to hold in one's head. +// +// The package name is plural because internal/tui already declares a type +// called `theme`, and Go forbids the same identifier in a file block (an +// import) and a package block (that type). An aliased import would compile; +// the plural avoids needing one at every import site. +package themes + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "gopkg.in/yaml.v3" +) + +// Palette is the six semantic colors the whole TUI is built from. It is the +// only place a color literal belongs: the TUI's styles derive from these. +// +// Subtle is a CompleteAdaptiveColor while the other five are AdaptiveColor +// because it is the one token whose automatic downconvert lands on the wrong +// color: at 16 colors there is no mid-grey, so the nearest-color search picks +// the background itself and the zebra stripe disappears. shadeFor names the +// 16-color value instead of letting termenv guess it. +type Palette struct { + Primary lipgloss.AdaptiveColor + Accent lipgloss.AdaptiveColor + Muted lipgloss.AdaptiveColor + Danger lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Subtle lipgloss.CompleteAdaptiveColor +} + +// TokenNames are the YAML keys a theme may set, in the order error messages +// list them. +var TokenNames = []string{"primary", "accent", "muted", "danger", "success", "subtle"} + +// Value is one token as written in YAML: either a bare color, meaning the same +// on both backgrounds, or a light/dark pair. +type Value struct{ Light, Dark string } + +// UnmarshalYAML accepts both forms. internal/clickup's tagName does the same +// for ClickUp's JSON, where a tag arrives as either a string or an object. +func (v *Value) UnmarshalYAML(n *yaml.Node) error { + if n.Kind == yaml.ScalarNode { + var s string + if err := n.Decode(&s); err != nil { + return err + } + v.Light, v.Dark = s, s + return nil + } + var pair struct{ Light, Dark string } + if err := n.Decode(&pair); err != nil { + return err + } + v.Light, v.Dark = pair.Light, pair.Dark + return nil +} + +// yamlPair is the marshaled form of a genuine light/dark pair. It is a struct +// rather than a map because a map[string]string marshals its keys +// alphabetically — measured — which would silently reorder a user's +// `{light: …, dark: …}` into `{dark: …, light: …}` on the first save. +type yamlPair struct { + Light string `yaml:"light"` + Dark string `yaml:"dark"` +} + +// MarshalYAML collapses an equal pair back to a bare string. Without it, +// config.Save would rewrite a user's `muted: "240"` as a light/dark map the +// first time anything saves the config. +func (v Value) MarshalYAML() (any, error) { + if v.Light == v.Dark { + return v.Light, nil + } + return yamlPair(v), nil +} + +// Spec is one user-written theme: token name -> value. A theme names only the +// tokens it changes; the rest come from Default. +type Spec map[string]Value + +// validColor reports whether s is a color this package will accept: a #RGB or +// #RRGGBB hex, or a decimal index in 0-255. +// +// The range check is not defensive politeness. Measured: lipgloss renders an +// invalid color as no color at all — no error, no panic — so a typo produces a +// colorless TUI and no message; and it renders "999" as "\x1b[38;5;999m", a +// sequence no terminal understands. Nothing downstream will ever complain, so +// this is the only place a bad color can be caught. +func validColor(s string) error { + if s == "" { + // Measured: an unquoted #hex is a YAML comment, so `muted: #fff` + // arrives here as "". Saying only "empty color" would send the user + // looking for a key they wrote correctly. + return fmt.Errorf("empty color (an unquoted #hex is a YAML comment: write it in quotes)") + } + if h, ok := strings.CutPrefix(s, "#"); ok { + if len(h) != 3 && len(h) != 6 { + return fmt.Errorf("hex color %q must be #RGB or #RRGGBB", s) + } + if strings.TrimLeft(strings.ToLower(h), "0123456789abcdef") != "" { + return fmt.Errorf("hex color %q has a non-hex digit", s) + } + return nil + } + n, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("color %q is neither a #hex nor a number", s) + } + if n < 0 || n > 255 { + return fmt.Errorf("color index %q is outside 0-255", s) + } + return nil +} + +// shadeFor picks the 16-color background a Subtle value degrades to. +// +// There is no threshold constant, deliberately: at 16 colors the only two +// shades that are neither a hue nor the background are 8 (#808080) and 7 +// (#C0C0C0), so the rule is simply whichever of those two is perceptually +// closer. Measured, that reproduces the values the default palette carried +// before this package existed — 236 -> 8, 254 -> 7 — so moving the palette here +// does not change a single byte of what the default theme renders. +// +// Only ever called on a color validColor has already accepted. +func shadeFor(c string) string { + col := termenv.ConvertToRGB(termenv.TrueColor.Color(c)) + d8 := col.DistanceLab(termenv.ConvertToRGB(termenv.TrueColor.Color("8"))) + d7 := col.DistanceLab(termenv.ConvertToRGB(termenv.TrueColor.Color("7"))) + if d7 < d8 { + return "7" + } + return "8" +} + +// adaptive builds an ordinary two-background color. +func adaptive(light, dark string) lipgloss.AdaptiveColor { + return lipgloss.AdaptiveColor{Light: light, Dark: dark} +} + +// subtle builds the zebra background, naming its 16-color value per side. The +// 256-color index sits in the TrueColor slot on purpose, so that only the +// 16-color profile differs from what the automatic conversion would produce. +func subtle(light, dark string) lipgloss.CompleteAdaptiveColor { + return lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{TrueColor: light, ANSI256: light, ANSI: shadeFor(light)}, + Dark: lipgloss.CompleteColor{TrueColor: dark, ANSI256: dark, ANSI: shadeFor(dark)}, + } +} diff --git a/internal/themes/themes_test.go b/internal/themes/themes_test.go new file mode 100644 index 0000000..7872549 --- /dev/null +++ b/internal/themes/themes_test.go @@ -0,0 +1,116 @@ +package themes + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestValidColor(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + in string + ok bool + says string // a substring the error must contain, when it errors + }{ + {"#FF00AA", true, ""}, + {"#fff", true, ""}, + {"0", true, ""}, + {"255", true, ""}, + {"236", true, ""}, + // An unquoted #hex is a YAML comment, so it reaches validation as an + // empty string. Measured: `muted: #fff` decodes to Value{"",""}. The + // message has to say so, or the user hunts for a key they wrote right. + {"", false, "quotes"}, + {"bogus", false, "neither"}, + {"#GGGGGG", false, "non-hex"}, + {"#FF00A", false, "#RGB or #RRGGBB"}, + {"256", false, "0-255"}, + {"999", false, "0-255"}, + {"-1", false, "0-255"}, + } { + err := validColor(tc.in) + if tc.ok { + if err != nil { + t.Errorf("validColor(%q) = %v, want nil", tc.in, err) + } + continue + } + if err == nil { + t.Errorf("validColor(%q) = nil, want an error", tc.in) + continue + } + if !strings.Contains(err.Error(), tc.says) { + t.Errorf("validColor(%q) = %q, want it to mention %q", tc.in, err, tc.says) + } + } +} + +// shadeFor is what keeps the zebra stripe alive at 16 colors on a custom theme. +// The first two rows are the property that matters: they are the values the +// default palette had hand-written before this package existed, so the +// derivation must reproduce them exactly or the default theme changes color. +// Every number here was measured with termenv.ConvertToRGB + DistanceLab. +func TestShadeFor(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ in, want string }{ + {"236", "8"}, // #303030, d(8)=0.3372 d(7)=0.5784 + {"254", "7"}, // #E4E4E4, d(8)=0.3700 d(7)=0.1288 + {"#44475A", "8"}, // dracula Current Line + {"#3B4252", "8"}, // nord1 + {"#808080", "8"}, // ANSI 8 itself, distance 0 + {"#A0A0A0", "7"}, // just past the crossover + {"#000000", "8"}, + {"#FFFFFF", "7"}, + } { + if got := shadeFor(tc.in); got != tc.want { + t.Errorf("shadeFor(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestValueUnmarshalsBothForms(t *testing.T) { + t.Parallel() + var got Spec + src := "accent: {light: \"127\", dark: \"205\"}\nmuted: \"240\"\n" + if err := yaml.Unmarshal([]byte(src), &got); err != nil { + t.Fatal(err) + } + if got["accent"] != (Value{Light: "127", Dark: "205"}) { + t.Errorf("accent = %+v, want the pair", got["accent"]) + } + // A bare string means the same color on both backgrounds. + if got["muted"] != (Value{Light: "240", Dark: "240"}) { + t.Errorf("muted = %+v, want 240 on both sides", got["muted"]) + } +} + +// Without MarshalYAML a bare string comes back as a light/dark map, so the +// first thing that calls config.Save rewrites the user's file into a shape they +// did not choose. This test is the only thing that would notice. +func TestValueMarshalsABareStringBack(t *testing.T) { + t.Parallel() + out, err := yaml.Marshal(Spec{"muted": {Light: "240", Dark: "240"}}) + if err != nil { + t.Fatal(err) + } + if got, want := strings.TrimSpace(string(out)), `muted: "240"`; got != want { + t.Errorf("marshaled to %q, want %q", got, want) + } + pair, err := yaml.Marshal(Spec{"accent": {Light: "127", Dark: "205"}}) + if err != nil { + t.Fatal(err) + } + // A real pair stays a pair, AND keeps light before dark. Measured: a + // map[string]string marshals its keys alphabetically, so returning a map + // would silently reorder what the user wrote. + li := strings.Index(string(pair), "light:") + di := strings.Index(string(pair), "dark:") + if li < 0 || di < 0 { + t.Fatalf("a genuine pair must stay a pair, got %q", pair) + } + if li > di { + t.Errorf("marshaled %q — light must come before dark, as the user wrote it", pair) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index bbf6015..2e8e8d3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -15,6 +15,7 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/report" "github.com/marcoarnulfo/clickup-cli/internal/service" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) type screen int @@ -194,8 +195,11 @@ type Model struct { browserContents map[string]browserSpaceContents } -// New builds the root model from the config. -func New(cfg config.Config) Model { +// New builds the root model from the config and the already-resolved palette +// it renders with. Resolving the palette is the caller's job (internal/cli's +// resolveTheme): by the time New runs, a bad theme name or color has already +// been turned into a startup error, so New itself never fails on it. +func New(cfg config.Config, pal themes.Palette) Model { demo := demoEnabled() if demo { cfg = demoConfig() @@ -207,7 +211,7 @@ func New(cfg config.Config) Model { preset: report.PresetThisMonth, client: clickup.New(cfg.Token), now: time.Now, - theme: newTheme(lipgloss.DefaultRenderer(), defaultPalette()), + theme: newTheme(lipgloss.DefaultRenderer(), pal), } // Best-effort default so range/label display works before the first report // build; a genuinely invalid configured zone is caught and surfaced by diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index b965c06..de7fb7c 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -20,6 +20,7 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/export" "github.com/marcoarnulfo/clickup-cli/internal/report" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) // TestDefaultYearMonthFollowsLoc pins the fix for "New's default year/month @@ -104,7 +105,7 @@ func TestLoadEntriesUsesGivenRange(t *testing.T) { } func TestSetupIgnoresKeysWhileLoading(t *testing.T) { - m := New(config.Config{}) + m := New(config.Config{}, themes.Default()) for _, r := range "tok" { u, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) m = u.(Model) @@ -125,7 +126,7 @@ func TestSetupIgnoresKeysWhileLoading(t *testing.T) { } func TestSetupRejectsInvalidRate(t *testing.T) { - m := New(config.Config{}) + m := New(config.Config{}, themes.Default()) m.setup.step = stepRate m.setup.input = newNumberInput("") m.setup.input.SetValue("abc") @@ -319,14 +320,14 @@ func TestReloadEntriesCmdIgnoresSelectionInMeScope(t *testing.T) { } func TestNewStartsInSetupWhenInvalid(t *testing.T) { - m := New(config.Config{}) + m := New(config.Config{}, themes.Default()) if m.screen != screenSetup { t.Fatalf("invalid config should start in setup, got %v", m.screen) } } func TestNewStartsInHomeWhenValid(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) if m.screen != screenHome { t.Fatalf("valid config should start in home, got %v", m.screen) } @@ -352,7 +353,7 @@ func TestNewInDemoModeBuildsClientFromDemoToken(t *testing.T) { defer srv.Close() realCfg := config.Config{Token: "REAL-SECRET-TOKEN", WorkspaceID: "1"} - m := New(realCfg) + m := New(realCfg, themes.Default()) if !m.demo { t.Fatal("expected m.demo = true with CLICKUP_DEMO set") } @@ -371,7 +372,7 @@ func TestNewInDemoModeBuildsClientFromDemoToken(t *testing.T) { } func TestErrMsgSwitchesToErrorScreen(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) updated, _ := m.Update(errMsg{err: errTest}) mm := updated.(Model) if mm.screen != screenError { @@ -382,7 +383,7 @@ func TestErrMsgSwitchesToErrorScreen(t *testing.T) { // #38: a retryable error originating from Home must send the user back to // Home with an inline message, instead of the dead-end error screen. func TestRetryableErrOnHomeStaysHome(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) updated, _ := m.Update(retryableErrMsg{origin: screenHome, err: errors.New("boom")}) mm := updated.(Model) if mm.screen != screenHome { @@ -395,7 +396,7 @@ func TestRetryableErrOnHomeStaysHome(t *testing.T) { // #38: a 401 must still relaunch the setup wizard, regardless of origin. func TestRetryableErrUnauthorizedGoesToSetup(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) updated, _ := m.Update(retryableErrMsg{origin: screenHome, err: fmt.Errorf("x: %w", clickup.ErrUnauthorized)}) mm := updated.(Model) if mm.screen != screenSetup { @@ -404,7 +405,7 @@ func TestRetryableErrUnauthorizedGoesToSetup(t *testing.T) { } func TestEntriesMsgBuildsReportAndShowsReportScreen(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 updated, _ := m.Update(entriesMsg{entries: []report.TimeEntry{}}) mm := updated.(Model) @@ -422,7 +423,7 @@ func TestEntriesMsgBuildsReportAndShowsReportScreen(t *testing.T) { // day-grouping are computed in Rome — never a mix of Rome and UTC. func TestEntriesMsgThreadsConfiguredTimezoneThroughRangeAndBuild(t *testing.T) { cfg := config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR", Timezone: "Europe/Rome"} - m := New(cfg) + m := New(cfg, themes.Default()) m.year, m.month = 2026, 7 m.preset = report.PresetThisMonth m.report.GroupBy = report.GroupByDay // carried across the reload by entriesMsg @@ -459,7 +460,7 @@ func TestEntriesMsgThreadsConfiguredTimezoneThroughRangeAndBuild(t *testing.T) { func TestEntriesMsgWithBadRoundingRoutesToErrorScreen(t *testing.T) { cfg := config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR"} cfg.Billing.Rounding.Increment = "not-a-duration" - m := New(cfg) + m := New(cfg, themes.Default()) m.year, m.month = 2026, 7 updated, _ := m.Update(entriesMsg{entries: []report.TimeEntry{}}) mm := updated.(Model) @@ -476,7 +477,7 @@ func TestEntriesMsgWithBadRoundingRoutesToErrorScreen(t *testing.T) { // — never silently fall back to time.Local or UTC. func TestEntriesMsgWithBadTimezoneRoutesToErrorScreen(t *testing.T) { cfg := config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR", Timezone: "Not/AZone"} - m := New(cfg) + m := New(cfg, themes.Default()) m.year, m.month = 2026, 7 updated, _ := m.Update(entriesMsg{entries: []report.TimeEntry{}}) mm := updated.(Model) @@ -489,7 +490,7 @@ func TestEntriesMsgWithBadTimezoneRoutesToErrorScreen(t *testing.T) { } func TestQuitKey(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) if cmd == nil { t.Fatal("q should return a quit command") @@ -501,7 +502,7 @@ func TestQuitKey(t *testing.T) { // key.Matches(msg, defaultKeys().ForceQuit)): it must quit even on a screen // where 'q' itself does not (screenLog — see TestQuitBindingPerScreen). func TestCtrlCForceQuits(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) m.screen = screenLog _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) if cmd == nil { @@ -510,7 +511,7 @@ func TestCtrlCForceQuits(t *testing.T) { } func TestSetupTokenStepAcceptsInput(t *testing.T) { - m := New(config.Config{}) + m := New(config.Config{}, themes.Default()) // type a character in the token field updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) mm := updated.(Model) @@ -526,7 +527,7 @@ type testErr struct{} func (*testErr) Error() string { return "boom" } func TestReportCycleGroupBy(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 updated, _ := m.Update(entriesMsg{entries: []report.TimeEntry{ {TaskName: "A", ListName: "L", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -556,7 +557,7 @@ func TestEntriesMsgMemberGroupingDoesNotLeakIntoMeScope(t *testing.T) { } func TestHomeChangesMonthAndScope(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) m.year, m.month = 2026, 7 m.home = newHome() @@ -581,7 +582,7 @@ func TestExportWritesFile(t *testing.T) { defer os.Chdir(oldwd) os.Chdir(dir) - m := New(config.Config{Token: "t", WorkspaceID: "1", Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 jStart := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) m.report = report.Report{Start: jStart, End: jStart.AddDate(0, 1, 0), DefaultCurrency: "EUR", @@ -601,7 +602,7 @@ func TestExportWritesFile(t *testing.T) { } func TestHomeEnterStartsLoading(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) m.home = newHome() updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) mm := updated.(Model) @@ -614,7 +615,7 @@ func TestHomeEnterStartsLoading(t *testing.T) { } func TestRatesScreenOpensFromReport(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Client Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -624,7 +625,7 @@ func TestRatesScreenOpensFromReport(t *testing.T) { u, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) m = u.(Model) if m.screen != screenRates { - t.Fatalf("p dalla vista report deve aprire screenRates, got %v", m.screen) + t.Fatalf("p from the report view should open screenRates, got %v", m.screen) } if len(m.ratesScreen.rows) != 1 || m.ratesScreen.rows[0].name != "Client Z" { t.Fatalf("wrong rate rows: %+v", m.ratesScreen.rows) @@ -637,7 +638,7 @@ func TestRatesScreenEditSaveRecomputes(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("CLICKUP_TOKEN", "") - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: 2 * time.Hour, Billable: true}, @@ -686,7 +687,7 @@ func TestRatesScreenSaveWithBadRoundingIsRejected(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("CLICKUP_TOKEN", "") - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: 2 * time.Hour, Billable: true}, @@ -710,7 +711,7 @@ func TestRatesScreenSaveWithBadRoundingIsRejected(t *testing.T) { } func TestRatesScreenEscCancelsEdit(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -736,7 +737,7 @@ func TestRatesScreenEscCancelsEdit(t *testing.T) { } func TestRatesScreenInvalidRateStaysEditing(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -764,7 +765,7 @@ func TestRatesScreenInvalidRateStaysEditing(t *testing.T) { } func TestRatesScreenRejectsNonNumericInput(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -783,7 +784,7 @@ func TestRatesScreenRejectsNonNumericInput(t *testing.T) { } func TestRatesScreenEscDiscardsAndReturns(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -816,7 +817,7 @@ func TestRatesScreenDropsOverrideEqualToDefault(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("CLICKUP_TOKEN", "") - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -960,7 +961,7 @@ func TestRatesScreenSaveErrorStaysOnScreen(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", f) // ...or from here (Linux); a file => MkdirAll fails t.Setenv("CLICKUP_TOKEN", "") - m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Rate: 30, Currency: "EUR"}, themes.Default()) m.year, m.month = 2026, 7 entries := []report.TimeEntry{ {ListID: "55", ListName: "Z", Start: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Duration: time.Hour, Billable: true}, @@ -1177,7 +1178,7 @@ func TestStatusEnrichUnauthorizedReturnsErrMsg(t *testing.T) { // Fix 3: 'q' must not quit while typing a custom date on the range screen — // it has free-text inputs, so a stray 'q' shouldn't kill the whole session. func TestQuitKeyDoesNotQuitWhileEditingCustomRange(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) m.screen = screenRange m.rangeScreen = newRange(report.PresetThisMonth) m.rangeScreen.editing = true @@ -1246,7 +1247,7 @@ func TestSpacesMsgWarmsCacheAndUpdatesWhenOnBrowser(t *testing.T) { // newWithClock is a test helper that builds a Model with an injected clock. func newWithClock(cfg config.Config, now func() time.Time) Model { - m := New(cfg) + m := New(cfg, themes.Default()) m.now = now // Re-derive the default month from the injected clock the same way New // does, so the helper cannot re-encode the local-calendar bug @@ -1302,7 +1303,7 @@ func TestExportInvoiceWritesDistinctFile(t *testing.T) { Buckets: []report.Bucket{{Label: "A", Key: "l1", Hours: 1}}, TotalHours: 1} for i, f := range exportFormats { - m := New(config.Config{Token: "t", WorkspaceID: "1", Currency: "EUR"}) + m := New(config.Config{Token: "t", WorkspaceID: "1", Currency: "EUR"}, themes.Default()) m.report = r m.export = newExport(r) m.export.idx = i @@ -1322,7 +1323,7 @@ func TestExportInvoiceWritesDistinctFile(t *testing.T) { } func TestUpdateAvailableMsgSetsLatestVersion(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) next, _ := m.Update(updateAvailableMsg{latest: "v1.8.0"}) got := next.(Model) if got.latestVersion != "v1.8.0" { @@ -1341,7 +1342,7 @@ func TestUpdateAvailableMsgSetsLatestVersion(t *testing.T) { // sub-command's message is one of those demo-safe kinds, never an // update-check message. func TestInitDoesNotCheckInDemoMode(t *testing.T) { - m := New(config.Config{}) + m := New(config.Config{}, themes.Default()) m.demo = true cmd := m.Init() if cmd == nil { diff --git a/internal/tui/demo_test.go b/internal/tui/demo_test.go index fd29a0d..636e828 100644 --- a/internal/tui/demo_test.go +++ b/internal/tui/demo_test.go @@ -7,11 +7,12 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/report" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) func TestDemoModeSkipsSetup(t *testing.T) { t.Setenv("CLICKUP_DEMO", "1") - m := New(config.Config{}) // empty config: without demo it would go to setup + m := New(config.Config{}, themes.Default()) // empty config: without demo it would go to setup if !m.demo { t.Fatal("expected m.demo = true with CLICKUP_DEMO set") } @@ -166,7 +167,7 @@ func TestDemoEntriesHaveBillableMix(t *testing.T) { // percentage. func TestDemoReportShowsBillingDepth(t *testing.T) { t.Setenv("CLICKUP_DEMO", "1") - m := New(config.Config{}) + m := New(config.Config{}, themes.Default()) m.year, m.month = 2026, time.July msg := m.reloadEntriesCmd(screenHome)() @@ -227,7 +228,7 @@ func TestDemoModeBillingEditorNeverWritesConfig(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("CLICKUP_DEMO", "1") - m := New(config.Config{}) + m := New(config.Config{}, themes.Default()) m.entries = demoEntries(demoJuly2026()) m.ratesScreen = newRates(m.entries, m.cfg) m.screen = screenRates diff --git a/internal/tui/golden_test.go b/internal/tui/golden_test.go index 3e5c2a9..d084e3c 100644 --- a/internal/tui/golden_test.go +++ b/internal/tui/golden_test.go @@ -15,6 +15,7 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/clickup" "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/report" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) var updateGolden = flag.Bool("update", false, "rewrite testdata/*.golden files") @@ -59,7 +60,7 @@ var goldenFixedTime = time.Date(2026, time.July, 15, 10, 30, 0, 0, time.UTC) // goldenModel is a Model with a frozen clock and a fixed configuration. func goldenModel() Model { - m := New(config.Config{Token: "t", WorkspaceID: "team1", Currency: "EUR", Rate: 50}) + m := New(config.Config{Token: "t", WorkspaceID: "team1", Currency: "EUR", Rate: 50}, themes.Default()) m.now = func() time.Time { return goldenFixedTime } m.loc = time.UTC m.year, m.month = 2026, time.July diff --git a/internal/tui/home_test.go b/internal/tui/home_test.go index 13167b0..210f155 100644 --- a/internal/tui/home_test.go +++ b/internal/tui/home_test.go @@ -9,6 +9,7 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/clickup" "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/report" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) func TestHomeFOpensMembersInTeam(t *testing.T) { @@ -181,7 +182,7 @@ func TestHomeMembersNote(t *testing.T) { } func TestHomeShowsUpdateNotice(t *testing.T) { - m := New(config.Config{Token: "t", WorkspaceID: "1"}) + m := New(config.Config{Token: "t", WorkspaceID: "1"}, themes.Default()) m.latestVersion = "v1.8.0" out := m.View() if !strings.Contains(out, "v1.8.0") { diff --git a/internal/tui/log_test.go b/internal/tui/log_test.go index 31720bc..b3a4533 100644 --- a/internal/tui/log_test.go +++ b/internal/tui/log_test.go @@ -12,6 +12,7 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/report" "github.com/marcoarnulfo/clickup-cli/internal/service" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) func keyMsg(s string) tea.KeyMsg { @@ -30,12 +31,12 @@ func keyMsg(s string) tea.KeyMsg { // timer indicator and its 'c' key handling. func newTestModel() Model { cfg := config.Config{Token: "t", WorkspaceID: "team1", Currency: "EUR", Rate: 40} - return New(cfg) + return New(cfg, themes.Default()) } func newTestModelOnReport() Model { cfg := config.Config{Token: "t", WorkspaceID: "team1", Currency: "EUR", Rate: 40} - m := New(cfg) + m := New(cfg, themes.Default()) m.screen = screenReport // Report is only ever reached from Home: seed that parent so pop() // (esc/m/s and everything built on top of this fixture) lands where the diff --git a/internal/tui/palette_demo_test.go b/internal/tui/palette_demo_test.go index b809d30..5af6a65 100644 --- a/internal/tui/palette_demo_test.go +++ b/internal/tui/palette_demo_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/marcoarnulfo/clickup-cli/internal/clickup" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) // Demo mode must offer the same palette, and open it without any I/O: the demo @@ -14,7 +15,7 @@ import ( // from racing TestMain's os.Unsetenv of the same variable. func TestPaletteWorksInDemoMode(t *testing.T) { t.Setenv("CLICKUP_DEMO", "1") - m := New(demoConfig()) + m := New(demoConfig(), themes.Default()) m.entries = goldenEntries() // A non-empty Status is load-bearing for the "Go to filters" assertion // below: demoStatusEnrichCmd echoes each entry's own Status, and diff --git a/internal/tui/palette_test.go b/internal/tui/palette_test.go index 7cc2d50..d72a0aa 100644 --- a/internal/tui/palette_test.go +++ b/internal/tui/palette_test.go @@ -13,6 +13,7 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/clickup" "github.com/marcoarnulfo/clickup-cli/internal/fuzzy" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) func openPaletteOn(m Model) Model { return m.openPalette() } @@ -396,7 +397,7 @@ func colorTheme() theme { r := lipgloss.NewRenderer(io.Discard) r.SetColorProfile(termenv.ANSI) r.SetHasDarkBackground(true) - return newTheme(r, defaultPalette()) + return newTheme(r, themes.Default()) } func TestPaletteHighlightUsesTheAccentStyle(t *testing.T) { diff --git a/internal/tui/report_test.go b/internal/tui/report_test.go index d51ee85..f6ea2b7 100644 --- a/internal/tui/report_test.go +++ b/internal/tui/report_test.go @@ -11,6 +11,7 @@ import ( "github.com/marcoarnulfo/clickup-cli/internal/clickup" "github.com/marcoarnulfo/clickup-cli/internal/config" "github.com/marcoarnulfo/clickup-cli/internal/report" + "github.com/marcoarnulfo/clickup-cli/internal/themes" ) // TestReportKeyLabels pins the exact label set report.go's updateReport @@ -159,7 +160,7 @@ func TestReportViewEmptyReportShowsNoHoursAndSplit(t *testing.T) { func TestReportViewShowsSummaryAndBillableSplit(t *testing.T) { cfg := config.Config{Token: "t", WorkspaceID: "1", Rate: 10, Currency: "EUR"} cfg.Billing.Currencies = map[string]string{"eur-list": "EUR", "usd-list": "USD"} - m := New(cfg) + m := New(cfg, themes.Default()) m.year, m.month = 2026, 7 start := time.Date(2026, time.July, 1, 9, 0, 0, 0, time.UTC) entries := []report.TimeEntry{ @@ -218,7 +219,7 @@ func TestReportBOpensBudgetView(t *testing.T) { cfg := config.Config{Token: "t", WorkspaceID: "1", Currency: "EUR", Rate: 50} cfg.Billing.Budgets = map[string]float64{"list-1": 100} cfg.Billing.Currencies = map[string]string{"list-1": "EUR"} - m := New(cfg) + m := New(cfg, themes.Default()) m.screen = screenReport m.entries = []report.TimeEntry{ {ListID: "list-1", ListName: "Website", Start: time.Date(2026, time.July, 1, 9, 0, 0, 0, time.UTC), diff --git a/internal/tui/testdata/palette_dark.golden b/internal/tui/testdata/palette_dark.golden index fe403e2..beff0df 100644 --- a/internal/tui/testdata/palette_dark.golden +++ b/internal/tui/testdata/palette_dark.golden @@ -4,8 +4,8 @@ Help Help Err Err OK OK Accent Accent -Box ╭─────╮ -│ Box │ -╰─────╯ +Box ╭─────╮ +│ Box │ +╰─────╯ Border Border Zebra Zebra diff --git a/internal/tui/testdata/palette_light.golden b/internal/tui/testdata/palette_light.golden index 603d99e..7b87e57 100644 --- a/internal/tui/testdata/palette_light.golden +++ b/internal/tui/testdata/palette_light.golden @@ -4,8 +4,8 @@ Help Help Err Err OK OK Accent Accent -Box ╭─────╮ -│ Box │ -╰─────╯ +Box ╭─────╮ +│ Box │ +╰─────╯ Border Border Zebra Zebra diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 33c39f1..4f6cb11 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -1,32 +1,16 @@ package tui -import "github.com/charmbracelet/lipgloss" - -// palette holds the semantic colors the whole TUI is built from. It is the -// only place a color literal belongs: styles derive from these, and a future -// user-supplied theme (#82) will override these six values, not the styles. -type palette struct { - Primary lipgloss.AdaptiveColor // titles and headings - Accent lipgloss.AdaptiveColor // selection, highlighted values - Muted lipgloss.AdaptiveColor // help lines and secondary text - Danger lipgloss.AdaptiveColor // errors - Success lipgloss.AdaptiveColor // confirmations - - // Subtle is the only token whose automatic downconvert lands on the wrong - // color, so it names a value per profile instead of letting termenv pick - // the nearest one. The other five stay AdaptiveColor: measured, their - // nearest-color conversion already arrives where it should, and making - // them explicit would be five times the surface for no visible change. - Subtle lipgloss.CompleteAdaptiveColor // zebra row background -} +import ( + "github.com/charmbracelet/lipgloss" + "github.com/marcoarnulfo/clickup-cli/internal/themes" +) // theme is the styled surface the views render through. It travels as an // explicit argument rather than living in package state, so no view can render // with a half-built theme and tests can hold two themes at once. // -// The palette is deliberately not kept as a field: nothing reads it in this -// tranche, and a write-only field is dead weight. It comes back when a -// user-supplied theme (#82) needs to be inspected. +// The palette itself is deliberately not kept as a field: nothing reads it +// back out of theme, and a write-only field is dead weight. type theme struct { Title lipgloss.Style Help lipgloss.Style @@ -43,7 +27,7 @@ type theme struct { // newTheme builds the styles for a palette on a specific renderer. Production // passes lipgloss.DefaultRenderer(); tests pass a renderer with a pinned color // profile and background so output is deterministic. -func newTheme(r *lipgloss.Renderer, p palette) theme { +func newTheme(r *lipgloss.Renderer, p themes.Palette) theme { // Resolve the terminal background now, while we still own the terminal. // lipgloss otherwise resolves AdaptiveColor lazily, at the first Render, // by querying the terminal over OSC-11 — by then bubbletea's input reader @@ -57,53 +41,10 @@ func newTheme(r *lipgloss.Renderer, p palette) theme { Err: r.NewStyle().Foreground(p.Danger).Bold(true), OK: r.NewStyle().Foreground(p.Success), Accent: r.NewStyle().Foreground(p.Accent), - Box: r.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1), + Box: r.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.Muted).Padding(0, 1), Header: r.NewStyle().Bold(true), Cell: r.NewStyle(), Border: r.NewStyle().Foreground(p.Muted), Zebra: r.NewStyle().Background(p.Subtle), } } - -// defaultPalette is the built-in palette. Dark keeps the xterm indices the TUI -// has always shipped, so a dark terminal renders exactly as before and a user's -// customized 256-color palette is still honored (a hex triple would override -// it). Light overrides only the tokens that are illegible on white, measured as -// WCAG contrast against #FFFFFF: -// -// 205 -> 127 205 (#FF5FAF) is ~1.9:1 on white; 127 (#AF00AF) is ~7.5:1 -// 196 -> 124 196 (#FF0000) is ~4:1, under the 4.5:1 floor; 124 (#AF0000) ~8:1 -// 42 -> 28 42 (#00D787) is ~1.8:1 on white; 28 (#008700) is ~6.5:1 -// -// Muted (240, #585858) is left alone: it already clears 7:1 on white. Adaptive -// means legible on both backgrounds, not different on both. -// -// Subtle is a background, so it is judged by a different rule than the five -// foregrounds above: 236 (#303030) on dark and 254 (#E4E4E4) on light are -// chosen so the DEFAULT FOREGROUND still clears contrast when painted on top -// of them, not so they contrast with the terminal's own background. A zebra -// stripe that swallows its own text is worse than no stripe at all. -func defaultPalette() palette { - return palette{ - Primary: lipgloss.AdaptiveColor{Light: "127", Dark: "205"}, - Accent: lipgloss.AdaptiveColor{Light: "127", Dark: "205"}, - Muted: lipgloss.AdaptiveColor{Light: "240", Dark: "240"}, - Danger: lipgloss.AdaptiveColor{Light: "124", Dark: "196"}, - Success: lipgloss.AdaptiveColor{Light: "28", Dark: "42"}, - - // The 256-color index sits in the TrueColor slot on purpose, so only - // the 16-color profile moves: measured, TrueColor and ANSI256 stay - // byte-identical to the AdaptiveColor this replaces. At 16 colors the - // only choices that are a shade rather than a hue are 0/8 (black, - // bright black) and 7/15 (white, bright white); on a dark background 0 - // and 15 are the background and the text, leaving 8, and on a light one - // the same argument leaves 7. Indices 0-15 belong to the user's - // terminal theme, so the exact contrast is not knowable from here — - // this is the best choice by construction, not a measured ratio like - // the five foregrounds above. - Subtle: lipgloss.CompleteAdaptiveColor{ - Light: lipgloss.CompleteColor{TrueColor: "254", ANSI256: "254", ANSI: "7"}, - Dark: lipgloss.CompleteColor{TrueColor: "236", ANSI256: "236", ANSI: "8"}, - }, - } -} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go index d24877d..c64ccfb 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/charmbracelet/lipgloss" + "github.com/marcoarnulfo/clickup-cli/internal/themes" "github.com/muesli/termenv" ) @@ -16,29 +17,7 @@ func testTheme(dark bool) theme { r := lipgloss.NewRenderer(io.Discard) r.SetColorProfile(termenv.Ascii) r.SetHasDarkBackground(dark) - return newTheme(r, defaultPalette()) -} - -// The default palette must keep today's colors on a dark background, so the -// refactor is a no-op for the terminals most users are on. -func TestDefaultPaletteKeepsCurrentDarkColors(t *testing.T) { - t.Parallel() - p := defaultPalette() - for _, tc := range []struct { - name string - got string - want string - }{ - {"Primary", p.Primary.Dark, "205"}, - {"Accent", p.Accent.Dark, "205"}, - {"Muted", p.Muted.Dark, "240"}, - {"Danger", p.Danger.Dark, "196"}, - {"Success", p.Success.Dark, "42"}, - } { - if tc.got != tc.want { - t.Errorf("%s dark = %q, want %q", tc.name, tc.got, tc.want) - } - } + return newTheme(r, themes.Default()) } // A theme built on a private renderer must not disturb the package default. @@ -47,7 +26,7 @@ func TestThemeRendererIsIsolated(t *testing.T) { before := lipgloss.ColorProfile() r := lipgloss.NewRenderer(io.Discard) r.SetColorProfile(termenv.TrueColor) - _ = newTheme(r, defaultPalette()) + _ = newTheme(r, themes.Default()) if after := lipgloss.ColorProfile(); after != before { t.Errorf("building a theme changed the default color profile: %v -> %v", before, after) } @@ -59,7 +38,7 @@ func paletteTheme(dark bool) theme { r := lipgloss.NewRenderer(io.Discard) r.SetColorProfile(termenv.TrueColor) r.SetHasDarkBackground(dark) - return newTheme(r, defaultPalette()) + return newTheme(r, themes.Default()) } // paletteSample renders one labeled line per style, so a single golden pins @@ -89,38 +68,6 @@ func TestGoldenPaletteLight(t *testing.T) { golden(t, "palette_light", paletteSample(paletteTheme(false))) } -// The four tokens that are unreadable on white must differ between -// backgrounds. Muted must NOT: 240 already clears 7:1 on white, so changing it -// would be churn dressed up as accessibility. -func TestPaletteIsAdaptive(t *testing.T) { - t.Parallel() - p := defaultPalette() - for _, tc := range []struct { - name string - c lipgloss.AdaptiveColor - }{ - {"Primary", p.Primary}, {"Accent", p.Accent}, - {"Danger", p.Danger}, {"Success", p.Success}, - } { - if tc.c.Light == tc.c.Dark { - t.Errorf("%s is not adaptive: Light == Dark == %q", tc.name, tc.c.Dark) - } - } - if p.Muted.Light != p.Muted.Dark { - t.Errorf("Muted was adapted (%q/%q) but reads fine on both backgrounds", - p.Muted.Light, p.Muted.Dark) - } - - // Subtle is checked separately: it is a CompleteAdaptiveColor, so it does - // not fit the AdaptiveColor loop above. The requirement is the same one — - // light and dark must differ — carried over to the two slots that survived - // the type change. (The TrueColor slot holds the same 256-color index as - // ANSI256 by design, so checking it too would assert the same thing twice.) - if s := defaultPalette().Subtle; s.Light.ANSI256 == s.Dark.ANSI256 || s.Light.ANSI == s.Dark.ANSI { - t.Errorf("Subtle is not adaptive: light %+v, dark %+v", s.Light, s.Dark) - } -} - // NO_COLOR is honored by termenv inside lipgloss's renderer; this pins that // contract so a future lipgloss bump cannot silently break it. // @@ -146,7 +93,7 @@ func TestNoColorProducesNoEscapes(t *testing.T) { t.Setenv("NO_COLOR", "1") r := lipgloss.NewRenderer(io.Discard, termenv.WithUnsafe()) r.SetColorProfile(r.Output().EnvColorProfile()) - th := newTheme(r, defaultPalette()) + th := newTheme(r, themes.Default()) out := paletteSample(th) if strings.Contains(out, "\x1b") { t.Errorf("NO_COLOR=1 still produced escape sequences:\n%q", out) @@ -196,7 +143,7 @@ func TestZebraSurvivesSixteenColors(t *testing.T) { r := lipgloss.NewRenderer(io.Discard) r.SetColorProfile(tc.profile) r.SetHasDarkBackground(tc.dark) - got := newTheme(r, defaultPalette()).Zebra.Render("x") + got := newTheme(r, themes.Default()).Zebra.Render("x") if tc.want == "" { if want := r.NewStyle().Background(old).Render("x"); got != want { @@ -211,6 +158,32 @@ func TestZebraSurvivesSixteenColors(t *testing.T) { } } +// The report screen draws its table frame with th.Border and its empty-state +// box with th.Box. Until th.Box carried a border color the two frames rendered +// at different weights on the same screen, which is #138's second point: a user +// whose report has no hours saw a different frame from one whose report does. +func TestBoxAndBorderShareTheirColor(t *testing.T) { + t.Parallel() + for _, dark := range []bool{true, false} { + th := paletteTheme(dark) + want := th.Border.GetForeground() + for _, side := range []struct { + name string + got lipgloss.TerminalColor + }{ + {"top", th.Box.GetBorderTopForeground()}, + {"right", th.Box.GetBorderRightForeground()}, + {"bottom", th.Box.GetBorderBottomForeground()}, + {"left", th.Box.GetBorderLeftForeground()}, + } { + if side.got != want { + t.Errorf("dark=%v: Box border %s = %+v, Border = %+v — the two frames must weigh the same", + dark, side.name, side.got, want) + } + } + } +} + // CLICOLOR_FORCE is the force-color variable termenv implements (FORCE_COLOR, // the npm convention, is deliberately not supported). func TestCliColorForceKeepsColor(t *testing.T) {