From 65adeccd563c9f9a1687edf290f10ee3c9a17904 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 00:58:12 +0200 Subject: [PATCH 1/8] docs(superpowers): spec and plan for the accessibility base (#74) --- .../plans/2026-08-01-accessibility-base.md | 701 ++++++++++++++++++ .../2026-08-01-accessibility-base-design.md | 293 ++++++++ 2 files changed, 994 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-accessibility-base.md create mode 100644 docs/superpowers/specs/2026-08-01-accessibility-base-design.md diff --git a/docs/superpowers/plans/2026-08-01-accessibility-base.md b/docs/superpowers/plans/2026-08-01-accessibility-base.md new file mode 100644 index 0000000..7d39483 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-accessibility-base.md @@ -0,0 +1,701 @@ +# Accessibility base — 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 la **#74** — la rotella del mouse scorre le liste, e la +zebratura del report sopravvive su un terminale a 16 colori. + +**Architecture:** la rotella viene tradotta in un `tea.KeyMsg` su/giù e rigiocata +dentro l'instradamento che già esiste (`updateOverlay` / `routeKey`), così ogni +schermata la eredita senza modifiche. Il colore della zebratura passa da +`AdaptiveColor` (conversione automatica) a `CompleteAdaptiveColor` (un valore +dichiarato per profilo) **solo** per il token `Subtle`. + +**Tech Stack:** Go 1.26, bubbletea v1.3.10, lipgloss v1.1.0, termenv v0.16.0. + +**Spec:** `docs/superpowers/specs/2026-08-01-accessibility-base-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. Unica + eccezione: i doc di design sotto `docs/superpowers/`. +- **MAI** un trailer `Co-Authored-By` in un messaggio di commit. +- Conventional Commits. +- `internal/report` e `internal/duration` restano **puri**: nessun I/O, solo + stdlib, nessun import di `internal/config`, `internal/clickup`, `internal/tui`. +- **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()`: gli + stili vengono dal `theme` costruito su un renderer iniettato. +- **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 averli rigenerati. +- 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: la chiave di config `mouse` + +**Files:** +- Modify: `internal/config/config.go` (struct `Config`, accanto a `UpdateCheck`) +- Test: `internal/config/config_test.go` (in fondo, nuova sezione) + +**Interfaces:** +- Consuma: niente. +- Produce: `Config.Mouse *bool` e `func (c Config) MouseEnabled() bool`. Il + Task 3 chiama `MouseEnabled()`. + +- [ ] **Step 1: scrivere i test che falliscono** + +In fondo a `internal/config/config_test.go`: + +```go +// --- optional mouse key (#74) --- + +func TestMouseAbsentIsNil(t *testing.T) { + // A config file without the key must load as nil — meaning "enabled". + // With a plain bool the absent key would decode as false, and mouse + // support would be born disabled in every config written so far. + isolateConfig(t) + if err := Save(Config{Token: "t", WorkspaceID: "1"}); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Mouse != nil { + t.Fatalf("Mouse = %v, want nil for an absent key", *cfg.Mouse) + } +} + +func TestMouseFalseRoundTrips(t *testing.T) { + isolateConfig(t) + no := false + if err := Save(Config{Token: "t", WorkspaceID: "1", Mouse: &no}); err != nil { + t.Fatal(err) + } + got, err := Load() + if err != nil { + t.Fatal(err) + } + if got.Mouse == nil || *got.Mouse { + t.Fatalf("Mouse = %v, want an explicit false", got.Mouse) + } +} + +func TestMouseNilIsNotWrittenToDisk(t *testing.T) { + // omitempty matters: without it Save writes "mouse: null" into every + // config file it touches. + 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), "mouse") { + t.Fatalf("saved config mentions mouse:\n%s", raw) + } +} + +func TestMouseEnabled(t *testing.T) { + t.Parallel() + yes, no := true, false + for _, tc := range []struct { + name string + in *bool + want bool + }{ + {"absent", nil, true}, + {"explicit true", &yes, true}, + {"explicit false", &no, false}, + } { + if got := (Config{Mouse: tc.in}).MouseEnabled(); got != tc.want { + t.Errorf("%s: MouseEnabled() = %v, want %v", tc.name, got, tc.want) + } + } +} +``` + +- [ ] **Step 2: eseguirli e vederli fallire** + +Run: `go test ./internal/config -run 'TestMouse' -v` +Expected: FAIL in compilazione — `unknown field Mouse` e +`cfg.MouseEnabled undefined`. + +- [ ] **Step 3: implementare** + +In `internal/config/config.go`, dentro `Config`, subito sotto `UpdateCheck`: + +```go + // Mouse enables mouse reporting, which turns the wheel into scrolling. + // nil means enabled, for the same reason as UpdateCheck above: a plain + // bool would make an absent key decode as false and silently disable a + // default-on feature in every config written before this field existed. + // + // The escape hatch exists because mouse reporting takes the terminal's + // native text selection away from the user: with it on, drag-to-select + // needs Shift in most terminals. + Mouse *bool `yaml:"mouse,omitempty"` +``` + +E, accanto a `Valid()`: + +```go +// MouseEnabled reports whether the TUI should turn on mouse reporting. +func (c Config) MouseEnabled() bool { return c.Mouse == nil || *c.Mouse } +``` + +- [ ] **Step 4: eseguirli e vederli passare** + +Run: `go test ./internal/config -run 'TestMouse' -v` +Expected: PASS (4 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/config.go internal/config/config_test.go +git commit -m "feat(config): add the optional mouse key (#74)" +``` + +--- + +### Task 2: la rotella scorre + +**Files:** +- Create: `internal/tui/mouse.go` +- Create: `internal/tui/mouse_test.go` +- Modify: `internal/tui/app.go` (type-switch di `Update`, accanto a `case tea.KeyMsg`) + +**Interfaces:** +- Consuma: `routeKey`, `updateOverlay`, `Model.overlay` (già esistenti). +- Produce: `func wheelKey(msg tea.MouseMsg) (tea.KeyMsg, bool)`. + +**Contesto che il brief non può sapere.** `keyMsgFor` (`internal/tui/actions.go:149`) +esiste già e costruisce `KeyMsg` sintetici, ma **non va usata né allargata**: il +suo commento dichiara l'insieme chiuso di proposito, per la command palette. +`wheelKey` è una funzione separata con la propria motivazione. + +- [ ] **Step 1: scrivere i test che falliscono** + +Nuovo file `internal/tui/mouse_test.go`: + +```go +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestWheelKey(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + in tea.MouseMsg + want tea.KeyType + ok bool + }{ + {"wheel up", tea.MouseMsg{Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress}, tea.KeyUp, true}, + {"wheel down", tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}, tea.KeyDown, true}, + {"wheel left", tea.MouseMsg{Button: tea.MouseButtonWheelLeft, Action: tea.MouseActionPress}, 0, false}, + {"wheel right", tea.MouseMsg{Button: tea.MouseButtonWheelRight, Action: tea.MouseActionPress}, 0, false}, + {"left click", tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}, 0, false}, + {"release", tea.MouseMsg{Button: tea.MouseButtonNone, Action: tea.MouseActionRelease}, 0, false}, + {"motion", tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion}, 0, false}, + } { + got, ok := wheelKey(tc.in) + if ok != tc.ok { + t.Errorf("%s: ok = %v, want %v", tc.name, ok, tc.ok) + continue + } + if ok && got.Type != tc.want { + t.Errorf("%s: key type = %v, want %v", tc.name, got.Type, tc.want) + } + } +} + +// The wheel must reach the active screen through the same path a key takes, so +// every screen that already handles up/down scrolls without being modified. +func TestWheelScrollsTheActiveScreen(t *testing.T) { + m := filtersScreenFixture() + if m.filtersScreen.row != 0 { + t.Fatalf("fixture starts at row %d, want 0", m.filtersScreen.row) + } + + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + m = u.(Model) + if m.filtersScreen.row != 1 { + t.Fatalf("row = %d after one wheel-down notch, want 1", m.filtersScreen.row) + } + + u, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress}) + m = u.(Model) + if m.filtersScreen.row != 0 { + t.Fatalf("row = %d after wheel-up, want back to 0", m.filtersScreen.row) + } +} + +// A click carries no meaning yet (#74 ships the wheel only), and must not be +// mistaken for a keystroke by the screen underneath. +func TestClickChangesNothing(t *testing.T) { + m := filtersScreenFixture() + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if got := u.(Model).filtersScreen.row; got != 0 { + t.Errorf("row = %d after a click, want 0 — clicks are ignored", got) + } +} + +// With an overlay open the overlay owns the wheel, exactly as it owns the +// keyboard: the screen underneath must not scroll behind it. +func TestWheelGoesToTheOverlayWhenOneIsOpen(t *testing.T) { + m := openPaletteOn(newTestModelOnReport()) + m.height = 24 + if len(m.palette.items) < 2 { + t.Fatalf("the fixture has %d actions; this test needs at least 2", len(m.palette.items)) + } + + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + got := u.(Model) + if got.palette.idx != 1 { + t.Errorf("palette idx = %d after wheel-down, want 1", got.palette.idx) + } +} +``` + +- [ ] **Step 2: eseguirli e vederli fallire** + +Run: `go test ./internal/tui -run 'TestWheel|TestClick' -v` +Expected: FAIL in compilazione — `undefined: wheelKey`. + +- [ ] **Step 3: implementare** + +Nuovo file `internal/tui/mouse.go`: + +```go +package tui + +import tea "github.com/charmbracelet/bubbletea" + +// wheelKey translates a wheel notch into the arrow key it stands for, and +// reports false for every other mouse event. +// +// Going through a synthetic KeyMsg is what makes the wheel work on every +// screen without touching any of them: the message is replayed through the +// same routing a real key takes, so each screen's existing cursor handling — +// and the tests that cover it — apply unchanged. actions.go does the same for +// the command palette. +// +// It deliberately does not go through keyMsgFor: that function's key set is +// closed on purpose for the palette's benefit (see its doc comment), and +// widening it for a different caller would betray that. +// +// There is no check on msg.Action, and it would be dead code: measured against +// bubbletea v1.3.10, a wheel notch always arrives as a single press. The SGR +// parser excludes IsWheel() from the release branch (mouse.go:186-191, with +// the comment "Wheel buttons don't have release events"), and the X10 parser +// assigns a release only in the ordinary-button branch and never applies the +// motion bit to a wheel event (mouse.go:249-257). +// +// Horizontal wheel and the side buttons are dropped: nothing in the TUI scrolls +// sideways, and a key that means nothing is worse than no key at all. +func wheelKey(msg tea.MouseMsg) (tea.KeyMsg, bool) { + switch msg.Button { + case tea.MouseButtonWheelUp: + return tea.KeyMsg{Type: tea.KeyUp}, true + case tea.MouseButtonWheelDown: + return tea.KeyMsg{Type: tea.KeyDown}, true + } + return tea.KeyMsg{}, false +} +``` + +In `internal/tui/app.go`, dentro il type-switch di `Update`, **subito sotto il +blocco `case tea.KeyMsg:`** (che finisce con `return m.routeKey(msg)`): + +```go + case tea.MouseMsg: + k, ok := wheelKey(msg) + if !ok { + return m, nil + } + // The global bindings the KeyMsg case checks first — ForceQuit, Quit, + // Help, Palette — are all keystrokes, so a wheel notch cannot match + // any of them and they are skipped. The overlay check is not optional: + // with the palette open the wheel must move its selection, not scroll + // the screen behind it. + if m.overlay != overlayNone { + return m.updateOverlay(k) + } + return m.routeKey(k) +``` + +- [ ] **Step 4: eseguirli e vederli passare** + +Run: `go test ./internal/tui -run 'TestWheel|TestClick' -v` +Expected: PASS (4 test). + +- [ ] **Step 5: prova per mutazione, obbligatoria** + +Due mutazioni, ognuna eseguita e con il transcript nel report: + +1. Togliere le tre righe `if m.overlay != overlayNone { ... }` dal caso nuovo. + `TestWheelGoesToTheOverlayWhenOneIsOpen` **deve** fallire. Se passa lo stesso, + il test non prova quello che dice e va riscritto. +2. In `wheelKey`, far ritornare `true` anche per `MouseButtonLeft`. + `TestClickChangesNothing` **deve** fallire. + +Ripristinare entrambe le mutazioni prima di proseguire, e verificare con +`git diff` che l'albero sia tornato quello di prima. + +- [ ] **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/mouse.go internal/tui/mouse_test.go internal/tui/app.go +git commit -m "feat(tui): scroll lists with the mouse wheel (#74)" +``` + +--- + +### Task 3: accendere il mouse nel programma + +**Files:** +- Modify: `internal/cli/cli.go` (`runTUI`) +- Test: `internal/cli/cli_test.go` + +**Interfaces:** +- Consuma: `config.Config.MouseEnabled()` dal Task 1. +- Produce: `func programOptions(cfg config.Config) []tea.ProgramOption`. + +- [ ] **Step 1: scrivere il test che fallisce** + +In fondo a `internal/cli/cli_test.go` (aggiungere gli import +`"github.com/marcoarnulfo/clickup-cli/internal/config"`): + +```go +// tea.ProgramOption is an opaque function: this test can prove that an extra +// option is appended when the mouse is on, not WHICH option it is. That is the +// honest limit of what is checkable here — the real coverage of the decision +// lives on config.MouseEnabled, which is pure. This test exists to catch the +// forgotten append, and nothing more. +func TestProgramOptionsAddsOneWhenMouseIsEnabled(t *testing.T) { + t.Parallel() + no := false + off := len(programOptions(config.Config{Mouse: &no})) + on := len(programOptions(config.Config{})) + if on != off+1 { + t.Errorf("programOptions: %d options with the mouse on, %d with it off; want exactly one more", on, off) + } +} +``` + +- [ ] **Step 2: eseguirlo e vederlo fallire** + +Run: `go test ./internal/cli -run TestProgramOptions -v` +Expected: FAIL in compilazione — `undefined: programOptions`. + +- [ ] **Step 3: implementare** + +In `internal/cli/cli.go`: + +```go +// programOptions builds the bubbletea options for a config. Extracted from +// runTUI so the mouse decision is reachable from a test: runTUI itself blocks +// on a terminal. +// +// WithMouseCellMotion (DEC mode 1002) and not WithMouseAllMotion (1003): the +// latter reports pointer motion even with no button held, a stream of messages +// this TUI would discard. Neither is "wheel only" — no such DEC mode exists — +// which is why enabling the wheel costs the terminal's native text selection, +// and why config.Mouse exists to turn it back off. +func programOptions(cfg config.Config) []tea.ProgramOption { + opts := []tea.ProgramOption{tea.WithAltScreen()} + if cfg.MouseEnabled() { + opts = append(opts, tea.WithMouseCellMotion()) + } + return opts +} +``` + +e in `runTUI` sostituire la riga 40 con: + +```go + p := tea.NewProgram(tui.New(cfg), programOptions(cfg)...) +``` + +- [ ] **Step 4: eseguirlo e vederlo passare** + +Run: `go test ./internal/cli -run TestProgramOptions -v` +Expected: PASS. + +- [ ] **Step 5: prova per mutazione** + +Togliere l'`if cfg.MouseEnabled()` (appendere sempre). Il test **deve** +fallire. Ripristinare 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/cli/cli.go internal/cli/cli_test.go +git commit -m "feat(cli): enable mouse reporting unless the config turns it off (#74)" +``` + +--- + +### Task 4: la zebratura sopravvive a 16 colori + +**Files:** +- Modify: `internal/tui/theme.go` (campo `Subtle` della `palette`, e + `defaultPalette`) +- Modify: `internal/tui/theme_test.go` (`TestPaletteIsAdaptive`, che **non + compila più**, + il test nuovo) + +**Interfaces:** +- Consuma: niente. +- Produce: `palette.Subtle` di tipo `lipgloss.CompleteAdaptiveColor`. + +**Due cose che il brief non può sapere, e che vanno gestite in questo task:** + +1. `TestPaletteIsAdaptive` (`theme_test.go:93`) itera uno slice di + `lipgloss.AdaptiveColor` e ci mette dentro `Subtle`. Cambiare il tipo del + campo **rompe la compilazione del package di test**. `Subtle` va tolto da + quel loop e messo in una propria asserzione, che deve continuare a + verificare la stessa cosa: chiaro e scuro devono differire. +2. `TestGoldenPaletteDark` / `TestGoldenPaletteLight` rendono con + `termenv.TrueColor` (`theme_test.go:58-63`). Il cambiamento è misurato come + byte-identico a TrueColor, quindi **quei due golden non devono muoversi**. Se + si muovono, gli slot sono sbagliati: non rigenerarli, correggere il codice. + +- [ ] **Step 1: scrivere il test che fallisce** + +In `internal/tui/theme_test.go`, accanto a `TestZebraCarriesBackground`: + +```go +// The zebra stripe is the one palette token whose automatic downconvert lands +// on the wrong color. Measured, with Subtle as a plain AdaptiveColor: 236 +// becomes "\x1b[40m" (black) on a dark background and 254 becomes "\x1b[107m" +// (bright white) on a light one — on a normal terminal, exactly the background +// color, so the stripe disappears. CompleteAdaptiveColor names the 16-color +// value instead of letting the nearest-color search pick it. +// +// The second half of this test is the one that keeps the change surgical: at +// TrueColor and ANSI256 the output must stay byte-identical to what the old +// AdaptiveColor produced. Putting a hex triple in the TrueColor slot would +// break that half — and would render as 227;227;227, not 228, because of a +// rounding step in lipgloss's hex path. +func TestZebraSurvivesSixteenColors(t *testing.T) { + t.Parallel() + old := lipgloss.AdaptiveColor{Light: "254", Dark: "236"} + for _, tc := range []struct { + name string + dark bool + profile termenv.Profile + want string // "" means: identical to the old AdaptiveColor + }{ + {"dark 16-color", true, termenv.ANSI, "\x1b[100m"}, + {"light 16-color", false, termenv.ANSI, "\x1b[47m"}, + {"dark truecolor", true, termenv.TrueColor, ""}, + {"light truecolor", false, termenv.TrueColor, ""}, + {"dark 256-color", true, termenv.ANSI256, ""}, + {"light 256-color", false, termenv.ANSI256, ""}, + } { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(tc.profile) + r.SetHasDarkBackground(tc.dark) + got := newTheme(r, defaultPalette()).Zebra.Render("x") + + if tc.want == "" { + if want := r.NewStyle().Background(old).Render("x"); got != want { + t.Errorf("%s: Zebra = %q, want %q — this profile must not move", + tc.name, got, want) + } + continue + } + if !strings.Contains(got, tc.want) { + t.Errorf("%s: Zebra = %q, want it to contain %q", tc.name, got, tc.want) + } + } +} +``` + +- [ ] **Step 2: eseguirlo e vederlo fallire** + +Run: `go test ./internal/tui -run TestZebraSurvivesSixteenColors -v` +Expected: FAIL su `dark 16-color` (`Zebra = "\x1b[40mx\x1b[0m"`, voleva +`"\x1b[100m"`) e su `light 16-color` (`"\x1b[107m…"`, voleva `"\x1b[47m"`). I +quattro casi con `want == ""` passano già: è giusto così, sono la guardia. + +**Allegare questo transcript al report:** è la prova che il test vede il +difetto. + +- [ ] **Step 3: implementare** + +In `internal/tui/theme.go`, il campo: + +```go + // 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 +``` + +e in `defaultPalette`, al posto della riga `Subtle:` attuale: + +```go + // 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"}, + }, +``` + +In `theme_test.go`, togliere `{"Subtle", p.Subtle}` dal loop di +`TestPaletteIsAdaptive` e aggiungere in fondo alla stessa funzione: + +```go + // 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 — and it is checked on the two profiles that + // actually render a stripe. + 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) + } +``` + +- [ ] **Step 4: eseguire tutto il package** + +Run: `go test ./internal/tui -v -run 'TestZebra|TestPalette|TestGoldenPalette'` +Expected: PASS ovunque. + +Poi la suite intera: `go test ./... -race`. +Expected: PASS, e **nessun golden modificato**. Verificare con +`git status --short internal/tui/testdata/`: deve essere vuoto. Se un golden è +cambiato, è un difetto dell'implementazione, non del golden — non rigenerarlo. + +- [ ] **Step 5: prova per mutazione, obbligatoria** + +Rimettere `Subtle: lipgloss.AdaptiveColor{Light: "254", Dark: "236"}` (con il +tipo del campo riportato ad `AdaptiveColor`). +`TestZebraSurvivesSixteenColors` **deve** fallire sui due casi a 16 colori. +Ripristinare 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/tui/theme.go internal/tui/theme_test.go +git commit -m "fix(tui): keep the zebra stripe visible on a 16-color terminal (#74)" +``` + +--- + +### Task 5: documentazione + +**Files:** +- Modify: `README.md` (elenco delle chiavi di config, dopo `update_check` alla + riga ~438) +- Modify: `README.it.md` (stesso punto, riga ~467) +- Modify: `CHANGELOG.md` (sezione `## [Unreleased]`) + +**Interfaces:** nessuna. + +- [ ] **Step 1: `README.md`** + +Subito dopo il punto elenco `- \`update_check\` (optional): …`: + +```markdown +- `mouse` (optional): set to `false` to turn off mouse reporting. Omitting the key + (or setting `true`) leaves it enabled, which makes the wheel scroll lists. Mouse + reporting takes the terminal's native text selection away: with it on, hold + **Shift** while dragging to select and copy, or set this key to `false` to give + the mouse back to the terminal for good. +``` + +- [ ] **Step 2: `README.it.md`** + +Subito dopo `- \`update_check\` (opzionale): …`: + +```markdown +- `mouse` (opzionale): impostare `false` per disattivare il supporto mouse. + Omettere la chiave (o impostare `true`) lo lascia attivo, e la rotella scorre le + liste. Il mouse tracking toglie al terminale la selezione nativa del testo: con + il mouse attivo, tenere **Shift** mentre si trascina per selezionare e copiare, + oppure impostare questa chiave a `false` per restituire il mouse al terminale + una volta per tutte. +``` + +- [ ] **Step 3: `CHANGELOG.md`** + +In `### Added` sotto `## [Unreleased]`: + +```markdown +- The mouse wheel scrolls every list screen, and the command palette when it is + open. Mouse reporting can be turned off with `mouse: false` in the config, + which gives the terminal its native text selection back (#74). +``` + +In `### Fixed`: + +```markdown +- The report's zebra stripe no longer disappears on a 16-color terminal, where + the automatic downconvert of its 256-color grey landed on the background color + itself (#74). +``` + +- [ ] **Step 4: rileggere quello che si è scritto** + +Aprire i tre file e **guardare** le sezioni modificate in contesto: che il +punto elenco stia nella lista giusta, che l'indentazione combaci con i vicini, +che le due voci di changelog siano nella sezione giusta. + +- [ ] **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 mouse key and the 16-color zebra fix (#74)" +``` + +--- + +## Note per il controllore + +- La #74 si chiude quando entrambe le caselle rimaste — «Mouse support» e + «truecolor→256→16 downconvert» — sono spuntate. Le altre due erano già chiuse + dalla tranche A. +- La casella «downconvert» si chiude con il Task 4 **e con la misura**: la + conversione automatica funzionava già per cinque tinte su sei, e la spec lo + documenta. Nel chiudere la issue vale la pena scriverlo, così nessuno cerca + una pipeline di conversione che non esiste. +- Parità demo-mode: gratis. La rotella passa per lo stesso `Update` del modello + vero, e `CLICKUP_DEMO=1` non cambia né il tema né l'instradamento. diff --git a/docs/superpowers/specs/2026-08-01-accessibility-base-design.md b/docs/superpowers/specs/2026-08-01-accessibility-base-design.md new file mode 100644 index 0000000..06477fa --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-accessibility-base-design.md @@ -0,0 +1,293 @@ +# Accessibility base: rotella del mouse e degradazione a 16 colori (design) + +> v1.9. Chiude la **#74** — le due caselle rimaste aperte: supporto mouse e +> downconvert truecolor→256→16. Le altre due (`NO_COLOR`, `SetColorProfile`) +> erano già state chiuse dalla tranche A. + +## 1. Obiettivo + +«Rispettare la cittadinanza del terminale», nelle parole della issue. Due +interventi indipendenti fra loro, tenuti in una spec sola perché sono entrambi +piccoli e riguardano lo stesso tema: comportarsi bene su terminali che non sono +quello dello sviluppatore. + +Nessuna chiamata API nuova, nessuna schermata nuova. + +## 2. Evidenza misurata + +Ogni numero e ogni sequenza di escape qui sotto viene dall'aver **eseguito** il +codice, non dall'averlo letto. È la regola che le tranche precedenti hanno dovuto +imparare a caro prezzo: in tranche D quattro numeri scritti a mano erano falsi, e +uno era finito in un commento pubblico su una issue. + +### 2.1 Il downconvert esiste già + +termenv converte da solo, e lo fa bene. Misurato rendendo ogni tinta di +`defaultPalette()` su un renderer con profilo fissato: + +| tinta | sorgente | TrueColor | ANSI256 | ANSI (16) | +|---|---|---|---|---| +| Primary dark | `205` | `38;5;205` | `38;5;205` | `95` (magenta brillante) | +| Primary light | `127` | `38;5;127` | `38;5;127` | `35` (magenta) | +| Muted | `240` | `38;5;240` | `38;5;240` | `90` (nero brillante) | +| Danger dark | `196` | `38;5;196` | `38;5;196` | `91` (rosso brillante) | +| Danger light | `124` | `38;5;124` | `38;5;124` | `31` (rosso) | +| Success dark | `42` | `38;5;42` | `38;5;42` | `92` (verde brillante) | +| Success light | `28` | `38;5;28` | `38;5;28` | `32` (verde) | +| **Subtle dark** | `236` | `48;5;236` | `48;5;236` | **`40` (sfondo nero)** | +| **Subtle light** | `254` | `48;5;254` | `48;5;254` | **`107` (sfondo bianco brillante)** | + +E lo stesso vale partendo da un hex, che è quello che i temi YAML della #82 +permetteranno: `#FF5FAF` → `38;2;255;95;175` a TrueColor → `38;5;205` a 256 → +`95` a 16. **Non c'è una pipeline di conversione da costruire.** + +C'è un difetto solo, ed è nelle due righe in grassetto: la conversione sceglie il +colore *più vicino*, e a 16 colori il grigio medio non esiste. `236` (#303030) +finisce sul nero e `254` (#E4E4E4) sul bianco brillante — cioè, su un terminale +normale, **esattamente il colore dello sfondo**. La zebratura della tabella del +report sparisce su entrambi i fondi. + +### 2.2 `CompleteAdaptiveColor` risolve, e con quale costo + +`lipgloss.CompleteAdaptiveColor` dichiara un valore per profilo invece di +lasciar convertire. Misurato mettendo l'**indice 256 anche nello slot TrueColor**, +confrontato con l'`AdaptiveColor{Light: "254", Dark: "236"}` di oggi: + +| fondo | profilo | oggi | con Complete | identico? | +|---|---|---|---|---| +| scuro | TrueColor | `48;5;236` | `48;5;236` | **sì** | +| scuro | ANSI256 | `48;5;236` | `48;5;236` | **sì** | +| scuro | ANSI | `40` | `100` | no — è il punto | +| scuro | Ascii | *(niente)* | *(niente)* | **sì** | +| chiaro | TrueColor | `48;5;254` | `48;5;254` | **sì** | +| chiaro | ANSI256 | `48;5;254` | `48;5;254` | **sì** | +| chiaro | ANSI | `107` | `47` | no — è il punto | +| chiaro | Ascii | *(niente)* | *(niente)* | **sì** | + +Tre profili su quattro non si muovono di un byte. Questo è il motivo per cui +lo slot TrueColor porta un indice e non un hex: un hex avrebbe cambiato anche il +profilo truecolor senza guadagnare niente, e con un trabocchetto in più — +misurato, lipgloss rende `#E4E4E4` come `48;2;227;227;227`, non `228`, per un +arrotondamento nel percorso hex→float→uint8 di go-colorful. Invisibile +all'occhio, letale per un test che asserisse la tripletta esatta. + +**La trappola vera di `CompleteColor`, e vale soprattutto per la #82.** Non +converte niente: è una tabella di lookup. Misurato, un +`CompleteColor{TrueColor: "#303030"}` con gli altri due slot vuoti rende il testo +**senza alcun colore** sia a 256 sia a 16 colori. Se i temi YAML accetteranno un +valore solo per tinta, quel valore non va salvato come `CompleteColor`, o il +colore sparisce su ogni terminale non truecolor. + +### 2.3 Perché 8 e 7, e cosa non possiamo sapere + +A 16 colori le uniche scelte che non sono una *tinta* (rosso, verde, blu…) sono +le quattro sfumature: 0 nero, 8 nero brillante, 7 bianco, 15 bianco brillante. Su +fondo scuro 0 e 15 sono lo sfondo o il testo; resta **8**. Su fondo chiaro 15 è +lo sfondo e 0 il testo; resta **7**. + +Va detto onestamente: gli indici 0-15 sono il tema del terminale dell'utente, non +un colore che conosciamo. Il contrasto esatto della striscia **non è +conoscibile** da qui — su xterm l'8 è #808080, ma un tema qualsiasi può metterlo +altrove. La scelta è quindi la migliore possibile per costruzione (l'unica +sfumatura che non coincide né con il fondo né con il testo), non una scelta +verificata contro una soglia WCAG come quelle di `defaultPalette()`. + +### 2.4 Il mouse oggi non esiste + +Nessun `tea.MouseMsg` in tutto il repo, e `internal/cli/cli.go:40` costruisce il +programma con `tea.WithAltScreen()` e basta. + +Ma l'idioma per farlo funzionare c'è già: `internal/tui/actions.go:61` fa +eseguire alla command palette le azioni di schermata costruendo un `tea.KeyMsg` +sintetico e **rigiocandolo dentro `routeKey`**. Una rotella tradotta in su/giù e +mandata per la stessa strada eredita il cursore di ogni schermata, e i test che +lo coprono, senza toccarne nessuna. + +**Misurato dal sorgente di bubbletea v1.3.10**, e non assunto: gli eventi rotella +portano sempre `Action == MouseActionPress`. Il parser SGR lo dice esplicitamente +(`mouse.go:186`, *«Wheel buttons don't have release events»*, e la condizione a +188 esclude `IsWheel()` dal ramo di rilascio); il parser X10 assegna il rilascio +solo al ramo dei pulsanti normali (`mouse.go:249-253`) e non applica il bit di +motion agli eventi rotella (`mouse.go:257`). **Una tacca produce esattamente un +messaggio**: non serve filtrare sull'`Action`, e non c'è doppio scatto. + +## 3. Gli interventi + +### 3.1 La rotella diventa su/giù (#74, casella «mouse») + +Un file nuovo, `internal/tui/mouse.go`, con una funzione pura: + +```go +func wheelKey(msg tea.MouseMsg) (tea.KeyMsg, bool) +``` + +`MouseButtonWheelUp` → `tea.KeyMsg{Type: tea.KeyUp}`, `MouseButtonWheelDown` → +`tea.KeyMsg{Type: tea.KeyDown}`, **tutto il resto** — click, motion, rotella +orizzontale, pulsanti laterali — → `false`. + +Non passa da `keyMsgFor`: quella funzione ha un insieme chiuso di proposito +(`actions.go:140-148`) perché serve alla palette, e allargarlo per un chiamante +diverso ne tradirebbe il commento. `wheelKey` costruisce i suoi due messaggi e si +porta dietro la propria motivazione. + +In `app.go`, dentro il type-switch di `Update`, un caso che ricalca +l'instradamento dei tasti **meno le scorciatoie globali**: + +```go +case tea.MouseMsg: + k, ok := wheelKey(msg) + if !ok { + return m, nil + } + if m.overlay != overlayNone { + return m.updateOverlay(k) + } + return m.routeKey(k) +``` + +ForceQuit, Quit, `?` e `ctrl+p` non sono controllati perché una rotella non può +corrispondervi: sono tutti tasti. Il controllo sull'overlay invece resta, ed è +nello stesso ordine del ramo `tea.KeyMsg` (`app.go:662-668`), perché con la +palette aperta la rotella deve muovere la selezione della palette e non la +schermata sotto. + +**Una tacca muove una riga.** Nessuna accelerazione, nessun moltiplicatore: la +misura in §2.4 dice che il terminale manda già un messaggio per tacca, quindi +qualunque fattore sarebbe una preferenza travestita da costante. + +La conseguenza voluta: la rotella funziona **da subito su ogni schermata che già +gestisce su/giù** — report, entries, filtri, rates, list browser, members, range, +e la palette in overlay — senza un modello di geometria e senza modificare una +sola `updateX`. + +### 3.2 La chiave `mouse` nel config (#74, cittadinanza) + +```go +Mouse *bool `yaml:"mouse,omitempty"` + +func (c Config) MouseEnabled() bool { return c.Mouse == nil || *c.Mouse } +``` + +Puntatore per la ragione già documentata su `UpdateCheck` (`config.go:78-81`): +con un `bool` semplice una chiave assente decodificherebbe come `false`, e ogni +config scritto prima che il campo esistesse spegnerebbe di soppiatto una feature +accesa di default. + +In `internal/cli/cli.go`: + +```go +opts := []tea.ProgramOption{tea.WithAltScreen()} +if cfg.MouseEnabled() { + opts = append(opts, tea.WithMouseCellMotion()) +} +p := tea.NewProgram(tui.New(cfg), opts...) +``` + +`WithMouseCellMotion` (modo 1002) e non `WithMouseAllMotion` (1003): il secondo +riporta ogni movimento del puntatore anche senza pulsanti premuti, un fiume di +messaggi che butteremmo via subito. Nessuno dei due è «solo rotella»: quel modo +DEC non esiste. È per questo che il costo esiste ed è dichiarato — vedi §5. + +### 3.3 La zebratura sopravvive a 16 colori (#74, casella «downconvert») + +In `theme.go`, il campo `Subtle` della `palette` cambia tipo: + +```go +Subtle lipgloss.CompleteAdaptiveColor // zebra row background +``` + +```go +Subtle: lipgloss.CompleteAdaptiveColor{ + Light: lipgloss.CompleteColor{TrueColor: "254", ANSI256: "254", ANSI: "7"}, + Dark: lipgloss.CompleteColor{TrueColor: "236", ANSI256: "236", ANSI: "8"}, +}, +``` + +Le altre cinque tinte **restano `AdaptiveColor` e non si toccano**: la tabella in +§2.1 dice che la loro conversione automatica arriva già dove deve. Rendere +esplicito anche il resto sarebbe cinque volte più superficie per zero cambiamenti +misurabili. + +Il tipo della `palette` diventa quindi eterogeneo — cinque `AdaptiveColor` e una +`CompleteAdaptiveColor`. È voluto, e il commento accanto al campo deve dire +perché, così la #82 non lo uniforma per estetica: `Subtle` è l'unica tinta che a +16 colori ha bisogno di una sfumatura che la conversione automatica non sa +trovare. + +## 4. Test + +- **Il test che porta il peso** è sul tema, e ha due metà. Con un renderer a + profilo `termenv.ANSI`, `th.Zebra.Render(...)` deve contenere `\x1b[100m` su + fondo scuro e `\x1b[47m` su fondo chiaro. E con `termenv.TrueColor` e + `termenv.ANSI256` deve restare **byte per byte identico** a quello che + produrrebbe `lipgloss.AdaptiveColor{Light: "254", Dark: "236"}`. La seconda + metà è la guardia che tiene chirurgico il cambiamento: senza, nessuno si + accorge se qualcuno «migliora» gli slot mettendoci un hex. +- **`wheelKey`**: tabella su su/giù/sinistra/destra, click sinistro, motion. +- **Instradamento**: la rotella muove il cursore su una schermata a lista; con la + palette aperta muove la selezione della palette e non la schermata sotto; un + click non cambia niente. +- **Config**: `MouseEnabled()` per `nil`/`true`/`false`, e il round-trip su disco + di `mouse: false`. +- **Ogni difetto ha un test visto fallire** contro il difetto, col transcript. In + particolare il test del tema va visto fallire rimettendo `AdaptiveColor`, e il + test dell'instradamento con overlay va visto fallire togliendo il controllo + su `m.overlay`. +- **Golden**: attesi immutati, perché `TestMain` fissa `termenv.Ascii` e la + tabella in §2.2 dice che sotto Ascii lo sfondo non esce comunque. **Da + verificare eseguendo la suite, non da assumere**; se qualcuno si muove, si + rigenera con `go test ./internal/tui -update` e si **guarda**. +- Gate: `gofmt -l .`, `go vet ./...`, + `go run honnef.co/go/tools/cmd/staticcheck@latest ./...`, `go build ./...`, + `go test ./... -race`. + +### 4.1 Un limite dichiarato invece che nascosto + +`tea.ProgramOption` è una funzione opaca: un test su `cli.go` può provare che +**viene aggiunta un'opzione in più** quando il mouse è acceso, non *quale* +opzione. La copertura vera sta su `MouseEnabled()`, che è puro e testabile a +fondo. Il test su `cli.go` esiste per intercettare l'`append` dimenticato, e il +suo commento deve dire esattamente questo — un test che promettesse di più +sarebbe un test che passa per la ragione sbagliata. + +## 5. Il costo, dichiarato + +Abilitare il mouse tracking fa smettere al terminale di gestire la **selezione +nativa del testo**: gli eventi vanno all'applicazione. Su un'app da cui si +copiano importi non è un dettaglio. + +Due vie d'uscita, ed è per questo che la §3.2 esiste: + +- **momentanea**: Shift+trascina, che quasi tutti i terminali riservano alla + selezione locale proprio per questo caso; +- **permanente**: `mouse: false` nel config. + +Entrambe vanno nei README, in inglese e in italiano, insieme alla chiave nuova. + +## 6. Fuori scope (dichiarato) + +- **Click e hit-testing.** Muovere il cursore sulla riga puntata richiede che + ogni schermata dichiari dove comincia il contenuto e quanto è alta una riga: + oggi le view producono stringhe, non geometria. È un modello nuovo, e va + progettato insieme al design system (#82/#138), non incastrato qui. +- **Toggle del mouse a runtime.** Shift+trascina copre il caso momentaneo e la + chiave di config quello permanente; un terzo meccanismo non aggiunge un caso + d'uso. +- **Rotella orizzontale** e pulsanti laterali: `wheelKey` li scarta + esplicitamente. +- **Valori per-profilo sulle altre cinque tinte**: misurato che non servono + (§2.1). +- **`FORCE_COLOR`**: già escluso dalla issue stessa, con la sua motivazione. + +## 7. Vincoli globali + +- `internal/report` e `internal/duration` restano **puri**. +- 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. Eccezione: i doc di design qui + sotto `docs/superpowers/`. +- **Ogni numero e ogni sequenza di escape scritta in un commento va misurata + eseguendo.** +- Conventional Commits. **MAI** `Co-Authored-By`. From f35b6bfe9f035a2a6e667b83efa0bbfb6f0dc014 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 01:11:41 +0200 Subject: [PATCH 2/8] docs(superpowers): fold the spec+plan review into the design (#74) --- .../plans/2026-08-01-accessibility-base.md | 138 +++++++++++++++--- .../2026-08-01-accessibility-base-design.md | 66 ++++++++- 2 files changed, 174 insertions(+), 30 deletions(-) diff --git a/docs/superpowers/plans/2026-08-01-accessibility-base.md b/docs/superpowers/plans/2026-08-01-accessibility-base.md index 7d39483..f320364 100644 --- a/docs/superpowers/plans/2026-08-01-accessibility-base.md +++ b/docs/superpowers/plans/2026-08-01-accessibility-base.md @@ -184,13 +184,27 @@ git commit -m "feat(config): add the optional mouse key (#74)" - Modify: `internal/tui/app.go` (type-switch di `Update`, accanto a `case tea.KeyMsg`) **Interfaces:** -- Consuma: `routeKey`, `updateOverlay`, `Model.overlay` (già esistenti). -- Produce: `func wheelKey(msg tea.MouseMsg) (tea.KeyMsg, bool)`. - -**Contesto che il brief non può sapere.** `keyMsgFor` (`internal/tui/actions.go:149`) -esiste già e costruisce `KeyMsg` sintetici, ma **non va usata né allargata**: il -suo commento dichiara l'insieme chiuso di proposito, per la command palette. -`wheelKey` è una funzione separata con la propria motivazione. +- Consuma: `routeKey`, `updateOverlay`, `Model.overlay`, `Model.screen`, + `Model.entriesScreen.mode`, la costante `entriesConfirmDelete` (tutti già + esistenti). +- Produce: `func wheelKey(msg tea.MouseMsg) (tea.KeyMsg, bool)` e + `func (m Model) anyKeyIsAnAnswer() bool`. + +**Contesto che il brief non può sapere.** + +1. `keyMsgFor` (`internal/tui/actions.go:149`) esiste già e costruisce `KeyMsg` + sintetici, ma **non va usata né allargata**: il suo commento dichiara + l'insieme chiuso di proposito, per la command palette. `wheelKey` è una + funzione separata con la propria motivazione. +2. Le fixture dei test esistono già nel package e vanno riusate, non riscritte: + `filtersScreenFixture()` (`filters_test.go:18`), `newTestModel()` + (`log_test.go:31`), `newTestModelOnReport()` (`log_test.go:36`), + `openPaletteOn()` (`palette_test.go:18`), `browserWithEntries()` + (`entries_delete_test.go:13`) e l'helper `keyMsg(...)`. +3. I valori attesi dai test sono stati **misurati** su queste fixture prima di + scrivere il piano: filters parte a `row=0` e va a 1 con una tacca; la palette + ha 18 azioni e `idx` va da 0 a 1. Non serve impostare `m.height`: + `paletteRows(0)` ritorna già `paletteMaxRows`. - [ ] **Step 1: scrivere i test che falliscono** @@ -255,11 +269,22 @@ func TestWheelScrollsTheActiveScreen(t *testing.T) { // A click carries no meaning yet (#74 ships the wheel only), and must not be // mistaken for a keystroke by the screen underneath. +// +// The cursor is moved off row 0 first, and deliberately: asserting "row 0 +// before, row 0 after" would also pass if a click were translated into an UP +// key, because at row 0 the cursor is already at the top and cannot move. From +// row 1 the test sees both mistranslations. func TestClickChangesNothing(t *testing.T) { m := filtersScreenFixture() - u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) - if got := u.(Model).filtersScreen.row; got != 0 { - t.Errorf("row = %d after a click, want 0 — clicks are ignored", got) + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + m = u.(Model) + if m.filtersScreen.row != 1 { + t.Fatalf("setup: row = %d after one wheel-down, want 1", m.filtersScreen.row) + } + + u, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if got := u.(Model).filtersScreen.row; got != 1 { + t.Errorf("row = %d after a click, want it to stay 1 — clicks are ignored", got) } } @@ -267,7 +292,6 @@ func TestClickChangesNothing(t *testing.T) { // keyboard: the screen underneath must not scroll behind it. func TestWheelGoesToTheOverlayWhenOneIsOpen(t *testing.T) { m := openPaletteOn(newTestModelOnReport()) - m.height = 24 if len(m.palette.items) < 2 { t.Fatalf("the fixture has %d actions; this test needs at least 2", len(m.palette.items)) } @@ -278,12 +302,47 @@ func TestWheelGoesToTheOverlayWhenOneIsOpen(t *testing.T) { t.Errorf("palette idx = %d after wheel-down, want 1", got.palette.idx) } } + +// The error screen dismisses itself on any key. A wheel notch is not an answer +// to it: with mouse reporting on and a trackpad's inertia, one stray notch +// would wipe an error message before it had been read. +func TestWheelDoesNotDismissTheErrorScreen(t *testing.T) { + m := newTestModel() + m.screen = screenError + m.err = errors.New("boom") + + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + if got := u.(Model).screen; got != screenError { + t.Errorf("screen = %v after a wheel notch, want it to stay on the error screen", got) + } +} + +// Same rule, second context: the delete confirmation answers "any key but y" +// with a cancel, and a wheel notch must not be that answer. +func TestWheelDoesNotCancelTheDeleteConfirmation(t *testing.T) { + m := browserWithEntries(newTestModel(), + report.TimeEntry{ID: "e1", TaskName: "Fix", UserID: 1, Start: time.Now()}) + u, _ := m.Update(keyMsg("x")) + m = u.(Model) + if m.entriesScreen.mode != entriesConfirmDelete { + t.Fatalf("setup: mode = %v, want the confirm dialog open", m.entriesScreen.mode) + } + + u, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + if got := u.(Model).entriesScreen.mode; got != entriesConfirmDelete { + t.Errorf("mode = %v after a wheel notch, want the confirm dialog still open", got) + } +} ``` +Gli import di `mouse_test.go` diventano quindi `errors`, `testing`, `time`, `tea` +e `github.com/marcoarnulfo/clickup-cli/internal/report`. + - [ ] **Step 2: eseguirli e vederli fallire** Run: `go test ./internal/tui -run 'TestWheel|TestClick' -v` -Expected: FAIL in compilazione — `undefined: wheelKey`. +Expected: FAIL in compilazione — `undefined: wheelKey` (e, una volta creato il +file, `undefined: anyKeyIsAnAnswer`). - [ ] **Step 3: implementare** @@ -325,6 +384,24 @@ func wheelKey(msg tea.MouseMsg) (tea.KeyMsg, bool) { } return tea.KeyMsg{}, false } + +// anyKeyIsAnAnswer reports whether the active context treats every keystroke as +// an answer to a question rather than as a movement. A wheel notch is not an +// answer, so it is dropped there instead of being replayed. +// +// Measured, these are the two contexts where replaying a notch does damage: on +// screenError any key resets to Home (app.go), so a stray notch wipes an error +// message before it has been read; in entriesConfirmDelete any key but "y" +// cancels (entries.go), so a notch dismisses the confirmation. They are the +// same two places where the app already had to leave "?" unassigned. +// +// The name states the property rather than the mechanism on purpose: it gives +// the list an admission rule — a context belongs here if and only if every key +// is an answer in it — instead of leaving it to rot as a list of special cases. +func (m Model) anyKeyIsAnAnswer() bool { + return m.screen == screenError || + (m.screen == screenEntries && m.entriesScreen.mode == entriesConfirmDelete) +} ``` In `internal/tui/app.go`, dentro il type-switch di `Update`, **subito sotto il @@ -340,30 +417,42 @@ blocco `case tea.KeyMsg:`** (che finisce con `return m.routeKey(msg)`): // Help, Palette — are all keystrokes, so a wheel notch cannot match // any of them and they are skipped. The overlay check is not optional: // with the palette open the wheel must move its selection, not scroll - // the screen behind it. + // the screen behind it. It comes before the gate below because an + // overlay owns the wheel the way it owns the keyboard, whatever screen + // is underneath. if m.overlay != overlayNone { return m.updateOverlay(k) } + if m.anyKeyIsAnAnswer() { + return m, nil + } return m.routeKey(k) ``` - [ ] **Step 4: eseguirli e vederli passare** Run: `go test ./internal/tui -run 'TestWheel|TestClick' -v` -Expected: PASS (4 test). +Expected: PASS (6 test). - [ ] **Step 5: prova per mutazione, obbligatoria** -Due mutazioni, ognuna eseguita e con il transcript nel report: +Quattro mutazioni, una alla volta, ognuna eseguita e con il transcript nel +report. Ognuna nomina il test che **deve** fallire: se passa lo stesso, il test +non prova quello che dice e va riscritto, non aggirato. -1. Togliere le tre righe `if m.overlay != overlayNone { ... }` dal caso nuovo. - `TestWheelGoesToTheOverlayWhenOneIsOpen` **deve** fallire. Se passa lo stesso, - il test non prova quello che dice e va riscritto. -2. In `wheelKey`, far ritornare `true` anche per `MouseButtonLeft`. - `TestClickChangesNothing` **deve** fallire. +1. Togliere le tre righe `if m.overlay != overlayNone { ... }` dal caso nuovo → + `TestWheelGoesToTheOverlayWhenOneIsOpen` deve fallire. +2. Togliere le tre righe `if m.anyKeyIsAnAnswer() { ... }` → + `TestWheelDoesNotDismissTheErrorScreen` **e** + `TestWheelDoesNotCancelTheDeleteConfirmation` devono fallire entrambi. +3. In `wheelKey`, aggiungere `case tea.MouseButtonLeft:` al ramo che ritorna + `tea.KeyUp` — cioè far tradurre il click in **su**, che è la svista da copia + più probabile → `TestClickChangesNothing` deve fallire. +4. In `wheelKey`, scambiare `KeyUp` e `KeyDown` → + `TestWheelScrollsTheActiveScreen` deve fallire. -Ripristinare entrambe le mutazioni prima di proseguire, e verificare con -`git diff` che l'albero sia tornato quello di prima. +Ripristinare dopo ogni mutazione, e alla fine verificare con `git diff` che +l'albero sia tornato esattamente quello di prima. - [ ] **Step 6: gate + commit** @@ -585,8 +674,9 @@ In `theme_test.go`, togliere `{"Subtle", p.Subtle}` dal loop di ```go // 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 — and it is checked on the two profiles that - // actually render a stripe. + // 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) } diff --git a/docs/superpowers/specs/2026-08-01-accessibility-base-design.md b/docs/superpowers/specs/2026-08-01-accessibility-base-design.md index 06477fa..9e97bb8 100644 --- a/docs/superpowers/specs/2026-08-01-accessibility-base-design.md +++ b/docs/superpowers/specs/2026-08-01-accessibility-base-design.md @@ -81,9 +81,16 @@ colore sparisce su ogni terminale non truecolor. ### 2.3 Perché 8 e 7, e cosa non possiamo sapere A 16 colori le uniche scelte che non sono una *tinta* (rosso, verde, blu…) sono -le quattro sfumature: 0 nero, 8 nero brillante, 7 bianco, 15 bianco brillante. Su -fondo scuro 0 e 15 sono lo sfondo o il testo; resta **8**. Su fondo chiaro 15 è -lo sfondo e 0 il testo; resta **7**. +le quattro sfumature: 0 nero, 8 nero brillante, 7 bianco, 15 bianco brillante. + +Su fondo scuro cadono 0 (è lo sfondo) e 15 (è il testo), e restano **due** +candidati, 7 e 8 — non uno solo. Il criterio che sceglie fra i due è che la +striscia deve restare **vicina alla sfumatura che sostituisce**: il 236 di oggi è +#303030, un grigio scuro, e l'8 è la sua controparte a 16 colori, mentre il 7 è +un grigio chiaro che su molti temi coincide con il colore di default del testo — +una striscia che si confonde col testo è peggio di una che si confonde con lo +sfondo. Specularmente su fondo chiaro cadono 15 e 0, restano 7 e 8, e il 254 +(#E4E4E4, grigio chiarissimo) porta al **7**. Va detto onestamente: gli indici 0-15 sono il tema del terminale dell'utente, non un colore che conosciamo. Il contrasto esatto della striscia **non è @@ -103,6 +110,30 @@ sintetico e **rigiocandolo dentro `routeKey`**. Una rotella tradotta in su/giù mandata per la stessa strada eredita il cursore di ogni schermata, e i test che lo coprono, senza toccarne nessuna. +### 2.5 Due contesti in cui una tacca non deve arrivare + +Rigiocare la rotella come su/giù la fa arrivare anche dove **qualunque tasto è +una risposta**, non un movimento di cursore. Misurato mandando un `KeyDown` +dentro `routeKey` su ogni schermata, due casi fanno danno: + +| contesto | cosa succede con un `KeyDown` | perché è un problema | +|---|---|---| +| `screenError` | `resetTo(screenHome)` (`app.go:953-960`) | una tacca accidentale **chiude il messaggio d'errore** prima che sia stato letto | +| `entriesConfirmDelete` | `mode` torna a `entriesList` (`entries.go:262-266`) | una tacca **annulla la conferma di cancellazione** | + +Il secondo è dal lato sicuro — la cancellazione richiede `y`, e una rotella non +la conferma mai — ma resta sorprendente. Il primo no: con il mouse tracking +attivo e l'inerzia di un trackpad, perdere un errore per una tacca di troppo +succederà davvero. + +Sono esattamente i due contesti in cui il codebase ha già dovuto disattivare a +mano il tasto `?` (`app.go:675-678` li elenca fra i posti dove `keysFor(m).Help` +resta non assegnata). La proprietà che li accomuna non è la schermata: è che +**ogni tasto è una risposta a una domanda**, e una tacca di rotella non è una +risposta. + +### 2.6 La forma degli eventi rotella + **Misurato dal sorgente di bubbletea v1.3.10**, e non assunto: gli eventi rotella portano sempre `Action == MouseActionPress`. Il parser SGR lo dice esplicitamente (`mouse.go:186`, *«Wheel buttons don't have release events»*, e la condizione a @@ -142,6 +173,9 @@ case tea.MouseMsg: if m.overlay != overlayNone { return m.updateOverlay(k) } + if m.anyKeyIsAnAnswer() { + return m, nil + } return m.routeKey(k) ``` @@ -149,7 +183,21 @@ ForceQuit, Quit, `?` e `ctrl+p` non sono controllati perché una rotella non pu corrispondervi: sono tutti tasti. Il controllo sull'overlay invece resta, ed è nello stesso ordine del ramo `tea.KeyMsg` (`app.go:662-668`), perché con la palette aperta la rotella deve muovere la selezione della palette e non la -schermata sotto. +schermata sotto. E viene **prima** del cancello di §2.5: un overlay possiede la +rotella come possiede la tastiera, qualunque cosa ci sia sotto. + +Il cancello è un predicato con un criterio di ammissione dichiarato, non un +elenco di schermate: + +```go +func (m Model) anyKeyIsAnAnswer() bool +``` + +Vero per `screenError` e per `screenEntries` in `entriesConfirmDelete`, cioè per +i due contesti misurati in §2.5. Il nome dice la proprietà e non il meccanismo, +di proposito: è quello che dà alla lista un criterio per crescere — un contesto +nuovo ci entra se e solo se ogni tasto vi è una risposta — invece di lasciarla +marcire come elenco di casi speciali. **Una tacca muove una riga.** Nessuna accelerazione, nessun moltiplicatore: la misura in §2.4 dice che il terminale manda già un messaggio per tacca, quindi @@ -225,8 +273,14 @@ trovare. accorge se qualcuno «migliora» gli slot mettendoci un hex. - **`wheelKey`**: tabella su su/giù/sinistra/destra, click sinistro, motion. - **Instradamento**: la rotella muove il cursore su una schermata a lista; con la - palette aperta muove la selezione della palette e non la schermata sotto; un - click non cambia niente. + palette aperta muove la selezione della palette e non la schermata sotto; non + chiude la schermata d'errore e non annulla la conferma di cancellazione (§2.5); + un click non cambia niente. +- **Il test sul click parte da un cursore diverso da zero.** Asserire «riga 0 + prima, riga 0 dopo» lo farebbe passare anche se il click venisse tradotto in + *su*: a riga 0 il cursore è già in cima e non si muove comunque. Va portato + prima a riga 1 con una tacca, e lì il click deve lasciarlo a 1 — così il test + vede sia click→giù sia click→su. - **Config**: `MouseEnabled()` per `nil`/`true`/`false`, e il round-trip su disco di `mouse: false`. - **Ogni difetto ha un test visto fallire** contro il difetto, col transcript. In From 8ba3a5c4c51ddc224f5df589b99b339705d3a759 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 01:13:58 +0200 Subject: [PATCH 3/8] feat(config): add the optional mouse key (#74) --- internal/config/config.go | 13 ++++++ internal/config/config_test.go | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index f77b94d..2bdc138 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,6 +79,16 @@ type Config struct { // enabled: a plain bool would make an absent key decode as false and // disable the check in every config written before this field existed. UpdateCheck *bool `yaml:"update_check,omitempty"` + + // Mouse enables mouse reporting, which turns the wheel into scrolling. + // nil means enabled, for the same reason as UpdateCheck above: a plain + // bool would make an absent key decode as false and silently disable a + // default-on feature in every config written before this field existed. + // + // The escape hatch exists because mouse reporting takes the terminal's + // native text selection away from the user: with it on, drag-to-select + // needs Shift in most terminals. + Mouse *bool `yaml:"mouse,omitempty"` } // Valid reports whether the config can be used to query the API. @@ -86,6 +96,9 @@ func (c Config) Valid() bool { return c.Token != "" && c.WorkspaceID != "" } +// MouseEnabled reports whether the TUI should turn on mouse reporting. +func (c Config) MouseEnabled() bool { return c.Mouse == nil || *c.Mouse } + // configPath and legacyConfigPath are injectable so tests can point them at // a t.TempDir() without touching the real user config. They default to // os.UserConfigDir()/clup/config.yml (macOS: "~/Library/Application diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f9ce7af..0add5e6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -541,3 +541,75 @@ func TestUpdateCheckNilIsNotWrittenToDisk(t *testing.T) { t.Fatalf("saved config mentions update_check:\n%s", raw) } } + +// --- optional mouse key (#74) --- + +func TestMouseAbsentIsNil(t *testing.T) { + // A config file without the key must load as nil — meaning "enabled". + // With a plain bool the absent key would decode as false, and mouse + // support would be born disabled in every config written so far. + isolateConfig(t) + if err := Save(Config{Token: "t", WorkspaceID: "1"}); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Mouse != nil { + t.Fatalf("Mouse = %v, want nil for an absent key", *cfg.Mouse) + } +} + +func TestMouseFalseRoundTrips(t *testing.T) { + isolateConfig(t) + no := false + if err := Save(Config{Token: "t", WorkspaceID: "1", Mouse: &no}); err != nil { + t.Fatal(err) + } + got, err := Load() + if err != nil { + t.Fatal(err) + } + if got.Mouse == nil || *got.Mouse { + t.Fatalf("Mouse = %v, want an explicit false", got.Mouse) + } +} + +func TestMouseNilIsNotWrittenToDisk(t *testing.T) { + // omitempty matters: without it Save writes "mouse: null" into every + // config file it touches. + 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), "mouse") { + t.Fatalf("saved config mentions mouse:\n%s", raw) + } +} + +func TestMouseEnabled(t *testing.T) { + t.Parallel() + yes, no := true, false + for _, tc := range []struct { + name string + in *bool + want bool + }{ + {"absent", nil, true}, + {"explicit true", &yes, true}, + {"explicit false", &no, false}, + } { + if got := (Config{Mouse: tc.in}).MouseEnabled(); got != tc.want { + t.Errorf("%s: MouseEnabled() = %v, want %v", tc.name, got, tc.want) + } + } +} From 81a1f87da23a86c58b6c116c462d72b38dc012ba Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 01:21:41 +0200 Subject: [PATCH 4/8] feat(tui): scroll lists with the mouse wheel (#74) --- internal/tui/app.go | 20 ++++++ internal/tui/mouse.go | 53 ++++++++++++++++ internal/tui/mouse_test.go | 125 +++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 internal/tui/mouse.go create mode 100644 internal/tui/mouse_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index bf4418b..bbf6015 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -685,6 +685,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m.routeKey(msg) + case tea.MouseMsg: + k, ok := wheelKey(msg) + if !ok { + return m, nil + } + // The global bindings the KeyMsg case checks first — ForceQuit, Quit, + // Help, Palette — are all keystrokes, so a wheel notch cannot match + // any of them and they are skipped. The overlay check is not optional: + // with the palette open the wheel must move its selection, not scroll + // the screen behind it. It comes before the gate below because an + // overlay owns the wheel the way it owns the keyboard, whatever screen + // is underneath. + if m.overlay != overlayNone { + return m.updateOverlay(k) + } + if m.anyKeyIsAnAnswer() { + return m, nil + } + return m.routeKey(k) + case errMsg: m.err = msg.err // Invalid/revoked token: relaunch the setup wizard (spec §8). diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go new file mode 100644 index 0000000..da313eb --- /dev/null +++ b/internal/tui/mouse.go @@ -0,0 +1,53 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +// wheelKey translates a wheel notch into the arrow key it stands for, and +// reports false for every other mouse event. +// +// Going through a synthetic KeyMsg is what makes the wheel work on every +// screen without touching any of them: the message is replayed through the +// same routing a real key takes, so each screen's existing cursor handling — +// and the tests that cover it — apply unchanged. actions.go does the same for +// the command palette. +// +// It deliberately does not go through keyMsgFor: that function's key set is +// closed on purpose for the palette's benefit (see its doc comment), and +// widening it for a different caller would betray that. +// +// There is no check on msg.Action, and it would be dead code: measured against +// bubbletea v1.3.10, a wheel notch always arrives as a single press. The SGR +// parser excludes IsWheel() from the release branch (mouse.go:186-191, with +// the comment "Wheel buttons don't have release events"), and the X10 parser +// assigns a release only in the ordinary-button branch and never applies the +// motion bit to a wheel event (mouse.go:249-257). +// +// Horizontal wheel and the side buttons are dropped: nothing in the TUI scrolls +// sideways, and a key that means nothing is worse than no key at all. +func wheelKey(msg tea.MouseMsg) (tea.KeyMsg, bool) { + switch msg.Button { + case tea.MouseButtonWheelUp: + return tea.KeyMsg{Type: tea.KeyUp}, true + case tea.MouseButtonWheelDown: + return tea.KeyMsg{Type: tea.KeyDown}, true + } + return tea.KeyMsg{}, false +} + +// anyKeyIsAnAnswer reports whether the active context treats every keystroke as +// an answer to a question rather than as a movement. A wheel notch is not an +// answer, so it is dropped there instead of being replayed. +// +// Measured, these are the two contexts where replaying a notch does damage: on +// screenError any key resets to Home (app.go), so a stray notch wipes an error +// message before it has been read; in entriesConfirmDelete any key but "y" +// cancels (entries.go), so a notch dismisses the confirmation. They are the +// same two places where the app already had to leave "?" unassigned. +// +// The name states the property rather than the mechanism on purpose: it gives +// the list an admission rule — a context belongs here if and only if every key +// is an answer in it — instead of leaving it to rot as a list of special cases. +func (m Model) anyKeyIsAnAnswer() bool { + return m.screen == screenError || + (m.screen == screenEntries && m.entriesScreen.mode == entriesConfirmDelete) +} diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go new file mode 100644 index 0000000..f1831c6 --- /dev/null +++ b/internal/tui/mouse_test.go @@ -0,0 +1,125 @@ +package tui + +import ( + "errors" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/marcoarnulfo/clickup-cli/internal/report" +) + +func TestWheelKey(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + in tea.MouseMsg + want tea.KeyType + ok bool + }{ + {"wheel up", tea.MouseMsg{Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress}, tea.KeyUp, true}, + {"wheel down", tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}, tea.KeyDown, true}, + {"wheel left", tea.MouseMsg{Button: tea.MouseButtonWheelLeft, Action: tea.MouseActionPress}, 0, false}, + {"wheel right", tea.MouseMsg{Button: tea.MouseButtonWheelRight, Action: tea.MouseActionPress}, 0, false}, + {"left click", tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}, 0, false}, + {"release", tea.MouseMsg{Button: tea.MouseButtonNone, Action: tea.MouseActionRelease}, 0, false}, + {"motion", tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion}, 0, false}, + } { + got, ok := wheelKey(tc.in) + if ok != tc.ok { + t.Errorf("%s: ok = %v, want %v", tc.name, ok, tc.ok) + continue + } + if ok && got.Type != tc.want { + t.Errorf("%s: key type = %v, want %v", tc.name, got.Type, tc.want) + } + } +} + +// The wheel must reach the active screen through the same path a key takes, so +// every screen that already handles up/down scrolls without being modified. +func TestWheelScrollsTheActiveScreen(t *testing.T) { + m := filtersScreenFixture() + if m.filtersScreen.row != 0 { + t.Fatalf("fixture starts at row %d, want 0", m.filtersScreen.row) + } + + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + m = u.(Model) + if m.filtersScreen.row != 1 { + t.Fatalf("row = %d after one wheel-down notch, want 1", m.filtersScreen.row) + } + + u, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress}) + m = u.(Model) + if m.filtersScreen.row != 0 { + t.Fatalf("row = %d after wheel-up, want back to 0", m.filtersScreen.row) + } +} + +// A click carries no meaning yet (#74 ships the wheel only), and must not be +// mistaken for a keystroke by the screen underneath. +// +// The cursor is moved off row 0 first, and deliberately: asserting "row 0 +// before, row 0 after" would also pass if a click were translated into an UP +// key, because at row 0 the cursor is already at the top and cannot move. From +// row 1 the test sees both mistranslations. +func TestClickChangesNothing(t *testing.T) { + m := filtersScreenFixture() + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + m = u.(Model) + if m.filtersScreen.row != 1 { + t.Fatalf("setup: row = %d after one wheel-down, want 1", m.filtersScreen.row) + } + + u, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + if got := u.(Model).filtersScreen.row; got != 1 { + t.Errorf("row = %d after a click, want it to stay 1 — clicks are ignored", got) + } +} + +// With an overlay open the overlay owns the wheel, exactly as it owns the +// keyboard: the screen underneath must not scroll behind it. +func TestWheelGoesToTheOverlayWhenOneIsOpen(t *testing.T) { + m := openPaletteOn(newTestModelOnReport()) + if len(m.palette.items) < 2 { + t.Fatalf("the fixture has %d actions; this test needs at least 2", len(m.palette.items)) + } + + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + got := u.(Model) + if got.palette.idx != 1 { + t.Errorf("palette idx = %d after wheel-down, want 1", got.palette.idx) + } +} + +// The error screen dismisses itself on any key. A wheel notch is not an answer +// to it: with mouse reporting on and a trackpad's inertia, one stray notch +// would wipe an error message before it had been read. +func TestWheelDoesNotDismissTheErrorScreen(t *testing.T) { + m := newTestModel() + m.screen = screenError + m.err = errors.New("boom") + + u, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + if got := u.(Model).screen; got != screenError { + t.Errorf("screen = %v after a wheel notch, want it to stay on the error screen", got) + } +} + +// Same rule, second context: the delete confirmation answers "any key but y" +// with a cancel, and a wheel notch must not be that answer. +func TestWheelDoesNotCancelTheDeleteConfirmation(t *testing.T) { + m := browserWithEntries(newTestModel(), + report.TimeEntry{ID: "e1", TaskName: "Fix", UserID: 1, Start: time.Now()}) + u, _ := m.Update(keyMsg("x")) + m = u.(Model) + if m.entriesScreen.mode != entriesConfirmDelete { + t.Fatalf("setup: mode = %v, want the confirm dialog open", m.entriesScreen.mode) + } + + u, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}) + if got := u.(Model).entriesScreen.mode; got != entriesConfirmDelete { + t.Errorf("mode = %v after a wheel notch, want the confirm dialog still open", got) + } +} From e12e90050c66d5a985623514f5bcad15dd1b2563 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 01:28:12 +0200 Subject: [PATCH 5/8] feat(cli): enable mouse reporting unless the config turns it off (#74) --- internal/cli/cli.go | 19 ++++++++++++++++++- internal/cli/cli_test.go | 21 ++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 5f379e6..5e5abd4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -14,6 +14,23 @@ import ( "github.com/spf13/cobra" ) +// programOptions builds the bubbletea options for a config. Extracted from +// runTUI so the mouse decision is reachable from a test: runTUI itself blocks +// on a terminal. +// +// WithMouseCellMotion (DEC mode 1002) and not WithMouseAllMotion (1003): the +// latter reports pointer motion even with no button held, a stream of messages +// this TUI would discard. Neither is "wheel only" — no such DEC mode exists — +// which is why enabling the wheel costs the terminal's native text selection, +// and why config.Mouse exists to turn it back off. +func programOptions(cfg config.Config) []tea.ProgramOption { + opts := []tea.ProgramOption{tea.WithAltScreen()} + if cfg.MouseEnabled() { + opts = append(opts, tea.WithMouseCellMotion()) + } + return opts +} + // rootCmd builds the root command. Unexported so tests can exercise the // command wiring directly without going through Execute/os.Exit. func rootCmd() *cobra.Command { @@ -37,7 +54,7 @@ func runTUI(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("config: %w", err) } - p := tea.NewProgram(tui.New(cfg), tea.WithAltScreen()) + p := tea.NewProgram(tui.New(cfg), programOptions(cfg)...) _, err = p.Run() return err } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index de255c5..4231897 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,6 +1,10 @@ package cli -import "testing" +import ( + "testing" + + "github.com/marcoarnulfo/clickup-cli/internal/config" +) // TestRootCmdRejectsUnknownArgs verifies the pinned cobra.NoArgs wiring: an // unexpected positional argument must be rejected before anything (like the @@ -35,3 +39,18 @@ func TestRootCmdSettings(t *testing.T) { t.Error("RunE is nil, want the TUI-launching function") } } + +// tea.ProgramOption is an opaque function: this test can prove that an extra +// option is appended when the mouse is on, not WHICH option it is. That is the +// honest limit of what is checkable here — the real coverage of the decision +// lives on config.MouseEnabled, which is pure. This test exists to catch the +// forgotten append, and nothing more. +func TestProgramOptionsAddsOneWhenMouseIsEnabled(t *testing.T) { + t.Parallel() + no := false + off := len(programOptions(config.Config{Mouse: &no})) + on := len(programOptions(config.Config{})) + if on != off+1 { + t.Errorf("programOptions: %d options with the mouse on, %d with it off; want exactly one more", on, off) + } +} From 24b67700364edb201efb74584001c237d2df361d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 01:35:03 +0200 Subject: [PATCH 6/8] fix(tui): keep the zebra stripe visible on a 16-color terminal (#74) Subtle downconverts through termenv's nearest-color search like the other five palette tokens, but at 16 colors there is no mid-grey: 236 lands on plain black and 254 on bright white, exactly the terminal background, so the zebra row vanishes. Give Subtle an explicit CompleteAdaptiveColor with the 256-color index kept in the TrueColor slot, so only the ANSI (16-color) profile moves and TrueColor/ANSI256 stay byte-identical to today. --- internal/tui/theme.go | 24 ++++++++++++++-- internal/tui/theme_test.go | 56 +++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 5247230..33c39f1 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -11,7 +11,13 @@ type palette struct { Muted lipgloss.AdaptiveColor // help lines and secondary text Danger lipgloss.AdaptiveColor // errors Success lipgloss.AdaptiveColor // confirmations - Subtle lipgloss.AdaptiveColor // zebra row background + + // 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 } // theme is the styled surface the views render through. It travels as an @@ -84,6 +90,20 @@ func defaultPalette() palette { Muted: lipgloss.AdaptiveColor{Light: "240", Dark: "240"}, Danger: lipgloss.AdaptiveColor{Light: "124", Dark: "196"}, Success: lipgloss.AdaptiveColor{Light: "28", Dark: "42"}, - Subtle: lipgloss.AdaptiveColor{Light: "254", Dark: "236"}, + + // 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 836a551..d24877d 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -101,7 +101,6 @@ func TestPaletteIsAdaptive(t *testing.T) { }{ {"Primary", p.Primary}, {"Accent", p.Accent}, {"Danger", p.Danger}, {"Success", p.Success}, - {"Subtle", p.Subtle}, } { if tc.c.Light == tc.c.Dark { t.Errorf("%s is not adaptive: Light == Dark == %q", tc.name, tc.c.Dark) @@ -111,6 +110,15 @@ func TestPaletteIsAdaptive(t *testing.T) { 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 @@ -157,6 +165,52 @@ func TestZebraCarriesBackground(t *testing.T) { } } +// The zebra stripe is the one palette token whose automatic downconvert lands +// on the wrong color. Measured, with Subtle as a plain AdaptiveColor: 236 +// becomes "\x1b[40m" (black) on a dark background and 254 becomes "\x1b[107m" +// (bright white) on a light one — on a normal terminal, exactly the background +// color, so the stripe disappears. CompleteAdaptiveColor names the 16-color +// value instead of letting the nearest-color search pick it. +// +// The second half of this test is the one that keeps the change surgical: at +// TrueColor and ANSI256 the output must stay byte-identical to what the old +// AdaptiveColor produced. Putting a hex triple in the TrueColor slot would +// break that half — and would render as 227;227;227, not 228, because of a +// rounding step in lipgloss's hex path. +func TestZebraSurvivesSixteenColors(t *testing.T) { + t.Parallel() + old := lipgloss.AdaptiveColor{Light: "254", Dark: "236"} + for _, tc := range []struct { + name string + dark bool + profile termenv.Profile + want string // "" means: identical to the old AdaptiveColor + }{ + {"dark 16-color", true, termenv.ANSI, "\x1b[100m"}, + {"light 16-color", false, termenv.ANSI, "\x1b[47m"}, + {"dark truecolor", true, termenv.TrueColor, ""}, + {"light truecolor", false, termenv.TrueColor, ""}, + {"dark 256-color", true, termenv.ANSI256, ""}, + {"light 256-color", false, termenv.ANSI256, ""}, + } { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(tc.profile) + r.SetHasDarkBackground(tc.dark) + got := newTheme(r, defaultPalette()).Zebra.Render("x") + + if tc.want == "" { + if want := r.NewStyle().Background(old).Render("x"); got != want { + t.Errorf("%s: Zebra = %q, want %q — this profile must not move", + tc.name, got, want) + } + continue + } + if !strings.Contains(got, tc.want) { + t.Errorf("%s: Zebra = %q, want it to contain %q", tc.name, got, tc.want) + } + } +} + // CLICOLOR_FORCE is the force-color variable termenv implements (FORCE_COLOR, // the npm convention, is deliberately not supported). func TestCliColorForceKeepsColor(t *testing.T) { From 065b4d9deaf76fc7029d1cb20afa8db686e2c8bb Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 01:42:15 +0200 Subject: [PATCH 7/8] docs: document the mouse key and the 16-color zebra fix (#74) --- CHANGELOG.md | 6 ++++++ README.it.md | 6 ++++++ README.md | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5bfad..29f80ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 offering both that screen's commands and one-step navigation to any other (#71). - An overlay compositor: `View()` can now layer a floating box over a screen instead of replacing it (#59). +- The mouse wheel scrolls every list screen, and the command palette when it is + open. Mouse reporting can be turned off with `mouse: false` in the config, + which gives the terminal its native text selection back (#74). ### Changed @@ -55,6 +58,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and statuses (#28). - Task and entry tags are now parsed correctly whether ClickUp returns them as tag objects or as plain strings — the two documented shapes (#28). +- The report's zebra stripe no longer disappears on a 16-color terminal, where + the automatic downconvert of its 256-color grey landed on the background color + itself (#74). ## [1.9.0] - 2026-07-28 diff --git a/README.it.md b/README.it.md index 2d3b595..8d42e6d 100644 --- a/README.it.md +++ b/README.it.md @@ -467,6 +467,12 @@ billing: - `update_check` (opzionale): impostare `false` per disattivare il controllo aggiornamenti descritto più sotto. Omettere la chiave (o impostare `true`) lo lascia attivo. +- `mouse` (opzionale): impostare `false` per disattivare il supporto mouse. + Omettere la chiave (o impostare `true`) lo lascia attivo, e la rotella scorre le + liste. Il mouse tracking toglie al terminale la selezione nativa del testo: con + il mouse attivo, tenere **Shift** mentre si trascina per selezionare e copiare, + oppure impostare questa chiave a `false` per restituire il mouse al terminale + una volta per tutte. ### Come vengono calcolati gli importi fatturati diff --git a/README.md b/README.md index f3c37c2..57143e3 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,11 @@ billing: any other value rounds each entry individually. - `update_check` (optional): set to `false` to turn off the update check described below. Omitting the key (or setting `true`) leaves it enabled. +- `mouse` (optional): set to `false` to turn off mouse reporting. Omitting the key + (or setting `true`) leaves it enabled, which makes the wheel scroll lists. Mouse + reporting takes the terminal's native text selection away: with it on, hold + **Shift** while dragging to select and copy, or set this key to `false` to give + the mouse back to the terminal for good. ### How billed amounts are computed From a9ed2f3abe4b8cbf7acd511b0058551146f4e855 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 1 Aug 2026 02:03:17 +0200 Subject: [PATCH 8/8] fix(tui): close the gaps from the accessibility-base whole-branch review (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final review of feat/accessibility-base found six issues, each verified by running something: - cli_test.go: TestProgramOptionsAddsOneWhenMouseIsEnabled only checked the difference in option count, so deleting tea.WithAltScreen() from programOptions still passed (0 vs 1 is still a difference of one). Pin the mouse-off case to exactly one option first. - mouse_test.go: TestWheelKey never pinned wheelKey's "ignore msg.Action" decision — its two negative rows used non-wheel buttons, not a non-press wheel action. Add a wheel-down-while-moving row so reintroducing an Action check fails the suite instead of passing unnoticed. - mouse.go: the doc comment cited mouse.go:249-257 as evidence the X10 parser never applies the motion bit to a wheel event, but line 257 is only the comment; the condition is at 258. Widen the range to 249-258. - README.md / README.it.md: restore the "in most terminals" / "in quasi tutti i terminali" hedge on the Shift-drag text-selection workaround, matching config.go's own hedge (Terminal.app uses Fn, not Shift). - docs/superpowers/specs/2026-08-01-accessibility-base-design.md: §3.1 wrongly listed "report" among the screens that scroll with the wheel on day one; reportModel has no cursor or scroll offset and updateReport has no up/down case. Drop it from the list and note why. - README.md / README.it.md: the wheel and its text-selection cost were only documented in Configuration; add a line in the TUI commands section (the wheel isn't a key.Binding, so `?` help can't mention it either). --- README.it.md | 11 ++++++++--- README.md | 11 ++++++++--- .../specs/2026-08-01-accessibility-base-design.md | 9 ++++++--- internal/cli/cli_test.go | 10 ++++++++++ internal/tui/mouse.go | 2 +- internal/tui/mouse_test.go | 6 ++++++ 6 files changed, 39 insertions(+), 10 deletions(-) diff --git a/README.it.md b/README.it.md index 8d42e6d..6451572 100644 --- a/README.it.md +++ b/README.it.md @@ -173,6 +173,11 @@ Nelle schermate setup, rates, range, browser liste, log ore e voci ore non è pr `q` per uscire, per evitare di premerlo per errore mentre si sta scrivendo (un token, una tariffa, una nota, un ID task, ...): usa `Ctrl+C`. +La rotella del mouse scorre anche ogni schermata a lista e la command palette; non +essendo un tasto, non può comparire nella tabella qui sopra né nel footer di `?`. +Imposta `mouse: false` in [Configurazione](#configurazione) per disattivare il +supporto mouse e riavere la selezione nativa del testo del terminale. + #### Schermata Impostazioni di fatturazione Dalla schermata del report, premendo `p` si apre la schermata **Impostazioni di @@ -470,9 +475,9 @@ billing: - `mouse` (opzionale): impostare `false` per disattivare il supporto mouse. Omettere la chiave (o impostare `true`) lo lascia attivo, e la rotella scorre le liste. Il mouse tracking toglie al terminale la selezione nativa del testo: con - il mouse attivo, tenere **Shift** mentre si trascina per selezionare e copiare, - oppure impostare questa chiave a `false` per restituire il mouse al terminale - una volta per tutte. + 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. ### Come vengono calcolati gli importi fatturati diff --git a/README.md b/README.md index 57143e3..2986982 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,11 @@ The setup, rates, range, list browser, log-hours and time-entries screens have n `q`-to-quit, to avoid pressing it by mistake while typing (a token, a rate, a note, a task ID, …): use `Ctrl+C`. +The mouse wheel also scrolls every list screen and the command palette; since it +isn't a key, it can't appear in the table above or the `?` footer. Set `mouse: false` +in [Configuration](#configuration) to turn mouse reporting off and get the terminal's +native text selection back. + #### Billing settings screen From the report screen, pressing `p` opens the **Billing settings** screen, with four @@ -439,9 +444,9 @@ billing: below. Omitting the key (or setting `true`) leaves it enabled. - `mouse` (optional): set to `false` to turn off mouse reporting. Omitting the key (or setting `true`) leaves it enabled, which makes the wheel scroll lists. Mouse - reporting takes the terminal's native text selection away: with it on, hold - **Shift** while dragging to select and copy, or set this key to `false` to give - the mouse back to the terminal for good. + 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. ### How billed amounts are computed diff --git a/docs/superpowers/specs/2026-08-01-accessibility-base-design.md b/docs/superpowers/specs/2026-08-01-accessibility-base-design.md index 9e97bb8..8294170 100644 --- a/docs/superpowers/specs/2026-08-01-accessibility-base-design.md +++ b/docs/superpowers/specs/2026-08-01-accessibility-base-design.md @@ -204,9 +204,12 @@ misura in §2.4 dice che il terminale manda già un messaggio per tacca, quindi qualunque fattore sarebbe una preferenza travestita da costante. La conseguenza voluta: la rotella funziona **da subito su ogni schermata che già -gestisce su/giù** — report, entries, filtri, rates, list browser, members, range, -e la palette in overlay — senza un modello di geometria e senza modificare una -sola `updateX`. +gestisce su/giù** — entries, filtri, rates, list browser, members, range, e la +palette in overlay — senza un modello di geometria e senza modificare una sola +`updateX`. Il report resta fuori da questo elenco: `reportModel` +(`internal/tui/report.go`) non ha né cursore né offset di scroll, e +`updateReport` non ha un caso su/giù, quindi lì una tacca di rotella oggi non +fa nulla. ### 3.2 La chiave `mouse` nel config (#74, cittadinanza) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4231897..4042012 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -49,6 +49,16 @@ func TestProgramOptionsAddsOneWhenMouseIsEnabled(t *testing.T) { t.Parallel() no := false off := len(programOptions(config.Config{Mouse: &no})) + + // Pins tea.WithAltScreen() being present even with the mouse off. Without + // this, dropping WithAltScreen from programOptions entirely still passes + // the difference assertion below (0 vs 1 is still a difference of one), + // so the suite would stay green while the TUI silently stopped using the + // alt screen. + if off != 1 { + t.Errorf("programOptions with the mouse off = %d options, want exactly 1 (tea.WithAltScreen())", off) + } + on := len(programOptions(config.Config{})) if on != off+1 { t.Errorf("programOptions: %d options with the mouse on, %d with it off; want exactly one more", on, off) diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index da313eb..ee3b935 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -20,7 +20,7 @@ import tea "github.com/charmbracelet/bubbletea" // parser excludes IsWheel() from the release branch (mouse.go:186-191, with // the comment "Wheel buttons don't have release events"), and the X10 parser // assigns a release only in the ordinary-button branch and never applies the -// motion bit to a wheel event (mouse.go:249-257). +// motion bit to a wheel event (mouse.go:249-258). // // Horizontal wheel and the side buttons are dropped: nothing in the TUI scrolls // sideways, and a key that means nothing is worse than no key at all. diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index f1831c6..2630a27 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -19,6 +19,12 @@ func TestWheelKey(t *testing.T) { }{ {"wheel up", tea.MouseMsg{Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress}, tea.KeyUp, true}, {"wheel down", tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress}, tea.KeyDown, true}, + // Pins the "ignore Action entirely" decision documented on wheelKey: + // this row does not describe an event bubbletea actually emits (a + // wheel notch always arrives as a press, per the doc comment); it + // exists so that adding an Action check back to wheelKey — the exact + // inverse of that decision — fails a test instead of passing unnoticed. + {"wheel down while moving", tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionMotion}, tea.KeyDown, true}, {"wheel left", tea.MouseMsg{Button: tea.MouseButtonWheelLeft, Action: tea.MouseActionPress}, 0, false}, {"wheel right", tea.MouseMsg{Button: tea.MouseButtonWheelRight, Action: tea.MouseActionPress}, 0, false}, {"left click", tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}, 0, false},