diff --git a/CHANGELOG.md b/CHANGELOG.md index 3755047e..313f70a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,10 +131,12 @@ tagged. Until then, source builds report the version `dev`. GitHub issue/PR templates. - Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move and applies on select (Esc reverts). -- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`, - `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and - `solarized-light` — selectable via `/theme `, `--theme `, or `ZERO_THEME`. Every palette - is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility. +- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`, + `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, + `solarized-light`, `dune`, and `neon` — selectable via `/theme `, `--theme `, or + `ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally + audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light + theme was reworked for legibility. - `--theme ` flag for the TUI, accepting `auto` or any registered theme (previously only the `ZERO_THEME` env var existed). - "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`, diff --git a/README.md b/README.md index 86ce5d13..2fdc8ea5 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ manifest. | Control | Effect | |---|---| | `NO_COLOR=` | disables color output | -| `ZERO_THEME=` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) | +| `ZERO_THEME=` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) | | `--theme ` | selects the TUI theme from the CLI (same names) | | `/theme` | opens the theme picker inside the TUI (live preview; `/theme ` switches directly) | | `ZERO_NO_FADE=1` | disables streaming fade animation | @@ -350,6 +350,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero - [Install](docs/INSTALL.md) - [Update flow](docs/UPDATE.md) +- [Themes](docs/THEMES.md) - [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) - [Specialists](docs/SPECIALISTS.md) - [GitHub Action](docs/GITHUB_ACTION.md) diff --git a/README_ZH.md b/README_ZH.md index 014697ef..bab0968f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -252,7 +252,7 @@ zero update 检查更新版本 | 控制 | 效果 | |---|---| | `NO_COLOR=<任意值>` | 禁用颜色输出 | -| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) | +| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) | | `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) | | `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) | | `ZERO_NO_FADE=1` | 禁用流式淡入动画 | @@ -279,6 +279,7 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero - [安装](docs/INSTALL.md) - [更新流程](docs/UPDATE.md) +- [主题](docs/THEMES.md) - [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md) - [专家](docs/SPECIALISTS.md) - [GitHub Action](docs/GITHUB_ACTION.md) diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 00000000..d5d14d16 --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,39 @@ +# TUI Themes + +Zero's TUI ships a set of built-in color themes. Pick one with `--theme `, +the `ZERO_THEME` environment variable, or the `/theme ` command while +running. `auto` (the default) follows the terminal's detected background. + +Run `/theme` with no argument to open a picker: move through the list to +live-preview each theme, press Enter to apply the highlighted one, or Esc to +cancel and restore the previously active theme. Run `/theme list` to print +the active theme and the registered names without opening the picker. + +## Dune (`dune`) + +A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft +amber accent. + +## Neon (`neon`) + +A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent. + +## Adding a theme + +Every theme is a `palette` (a table of color hex tokens) plus one entry in +`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in +`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style` +set every renderer reads from the active `zeroTheme`. Adding a theme means +adding a new `palette{...}` literal, a `themeRegistry` entry, and test +coverage for the new palette (see below). + +Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic +WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the +selected-row band for every entry. The rendered-surface invariants beyond +those (permission surfaces, selected-row secondary text, diff gutters, and +the xterm-256 downsampling checks) are asserted per palette, not against the +whole registry: `TestExtendedThemeContrastInvariants` and +`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new +theme must be added to those tests (or given equivalent palette-specific +assertions), or CI can stay green while its permission, selected-row, and +diff surfaces ship unreadable. diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 7230a5f1..054dfdd1 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -333,6 +333,38 @@ var everforestPalette = palette{ cardPerm: "#96896b", } +// neonPalette is a neon-on-black color scheme: pitch-black surface with +// neon green ink and a cyan accent. +var neonPalette = palette{ + panel: "#050b06", + promptBg: "#0c180d", + line: "#1c3820", + line2: "#2c5230", + ink: "#c9ffd2", + muted: "#80db8f", + faint: "#6eca7d", + faintest: "#74c468", // brightened from #58af69 so line numbers quantize to #87d75f and stay AA on the xterm-green addBg (#005f00); still dimmer than faint, keeping the ramp monotonic + accent: "#00e5c8", + green: "#39ff6a", + red: "#ff4d6d", + amber: "#f4ff3a", + blue: "#22e0ff", + gitAdd: "#4fdc6a", + gitDel: "#ff6f80", + addBg: "#083c10", // quantizes to xterm green #005f00 instead of the same gray as delBg, keeping add/del rows distinct on 256-color terminals + delBg: "#3c0810", // quantizes to xterm red #5f0000 (see addBg) + addBgWord: "#147828", // quantizes to xterm green #008700, distinct from both addBg's #005f00 and delBgWord's red + delBgWord: "#74202e", // quantizes to xterm red #870000 (see addBgWord) + permBg: "#2a2a0c", + selBg: "#123a1e", + addInk: "#ecffdc", // quantizes to #ffffd7, which keeps AA on addBgWord's xterm #008700 (the old #c8ffcf quantized to #d7ffd7 at 4.29:1) + delInk: "#ffd0d6", + onAccent: "#001410", + cardRun: "#1f8a6e", + cardErr: "#9a4042", // raised from #8a2f42 for the 3:1 non-text border threshold against the panel (2.43:1 before), holding after xterm-256 quantization too + cardPerm: "#8a8a1f", +} + // lightPalette is dark-on-light: a warm cream surface (so cards lift off the // terminal page, which Zero never paints) with near-black ink and an olive-lime // accent that keeps the brand identity while clearing AA on the light panel. The @@ -401,6 +433,38 @@ var solarizedLightPalette = palette{ cardPerm: "#c4ae63", } +// dunePalette is a warm sand-and-cream color scheme: sand/cream surface, +// charcoal ink, and a soft amber accent. +var dunePalette = palette{ + panel: "#f2e9d8", + promptBg: "#e9dcbf", + line: "#d9c7a3", + line2: "#c2a97c", + ink: "#2b241a", + muted: "#473e32", + faint: "#554a3a", + faintest: "#655648", + accent: "#724028", // darkened from #8f5215 for AA on selBg (5.46:1) that also survives ANSI-256 downsampling (quantizes to #444444, 6.47:1 on quantized selBg; the previous #7c4712 quantized to #875f00 at 3.81:1) + green: "#38572a", + red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling (true 6.57:1, 256 7.86:1) + amber: "#6d4600", + blue: "#2f5680", // darkened from #3d6a9e for AA on selBg (was 3.61:1, now 4.90:1) + gitAdd: "#38572a", + gitDel: "#963328", + addBg: "#dcecd0", + delBg: "#f5dbd5", + addBgWord: "#b9dc9e", + delBgWord: "#eebba9", + permBg: "#f0dfae", + selBg: "#e0cf98", + addInk: "#264018", + delInk: "#5c1810", + onAccent: "#fdf6ea", + cardRun: "#b08a4a", + cardErr: "#b57560", + cardPerm: "#c2a04a", +} + // themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme // token (lowercase, kebab), Label is the picker display text, and IsDark groups the // picker (Dark/Light sections) and drives which built-in `auto` resolves to. @@ -426,8 +490,10 @@ var themeRegistry = []themeEntry{ {Name: "solarized-dark", Label: "Solarized Dark", Palette: solarizedDarkPalette, IsDark: true}, {Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true}, {Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true}, + {Name: "neon", Label: "Neon", Palette: neonPalette, IsDark: true}, {Name: "light", Label: "light", Palette: lightPalette, IsDark: false}, {Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false}, + {Name: "dune", Label: "Dune", Palette: dunePalette, IsDark: false}, } // themeByName indexes the registry by lowercased name for O(1) lookup. Built as a diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index cb8e0ef9..d586fdde 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "math" "strconv" "strings" @@ -261,6 +262,225 @@ func TestHandleThemeCommand(t *testing.T) { } } +func TestNewThemePresetsWired(t *testing.T) { + neon, ok := lookupTheme("neon") + if !ok { + t.Fatal("theme 'neon' is not registered") + } + if !neon.IsDark { + t.Error("theme 'neon' should be marked as dark") + } + + dune, ok := lookupTheme("dune") + if !ok { + t.Fatal("theme 'dune' is not registered") + } + if dune.IsDark { + t.Error("theme 'dune' should be marked as light") + } + + for _, name := range []string{"neon", "dune"} { + if !validThemeMode(name) { + t.Errorf("%q should be a valid --theme/ZERO_THEME value", name) + } + } + + if !contains(themeModes, "neon") || !contains(themeModes, "dune") { + t.Errorf("themeModes = %v, want it to include neon and dune (the /theme picker list)", themeModes) + } +} + +// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and +// applyTheme must actually swap zeroTheme to the resolved preset's own palette, +// not silently fall back to a built-in. +func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) { + defer applyTheme(themeDark, true) + + if got := resolveThemeMode("dune", ""); got != themeMode("dune") { + t.Fatalf(`resolveThemeMode("dune", "") = %q, want "dune"`, got) + } + if got := resolveThemeMode("", "neon"); got != themeMode("neon") { + t.Fatalf(`resolveThemeMode("", "neon") = %q, want "neon"`, got) + } + + applyTheme(themeMode("dune"), true) + if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, dunePalette.ink) { + t.Error("applying \"dune\" did not swap zeroTheme to the dune palette") + } + + applyTheme(themeMode("neon"), true) + if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, neonPalette.ink) { + t.Error("applying \"neon\" did not swap zeroTheme to the neon palette") + } +} + +func TestExtendedThemeContrastInvariants(t *testing.T) { + // Skip validation for old built-in themes if they have established, non-compliant palettes, + // but enforce strict compliance on the newly introduced 'neon' and 'dune' themes. + for _, entry := range themeRegistry { + if entry.Name != "neon" && entry.Name != "dune" { + continue + } + name, pal := entry.Name, entry.Palette + + // Finding 1: Permission and status/success surfaces + if r := wcagRatio(t, pal.amber, pal.permBg); r < 4.5 { + t.Errorf("%s: amber on permBg contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.onAccent, pal.amber); r < 4.5 { + t.Errorf("%s: onAccent on amber contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.green, pal.panel); r < 4.5 { + t.Errorf("%s: green on panel contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.amber, pal.panel); r < 4.5 { + t.Errorf("%s: amber on panel contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.red, pal.panel); r < 4.5 { + t.Errorf("%s: red on panel contrast %.2f < 4.5", name, r) + } + + // Finding 2: Selected row secondary text + if r := wcagRatio(t, pal.faint, pal.selBg); r < 4.5 { + t.Errorf("%s: faint on selBg contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.faintest, pal.selBg); r < 4.5 { + t.Errorf("%s: faintest on selBg contrast %.2f < 4.5", name, r) + } + + // Finding 3: Diff gutter pairings + if r := wcagRatio(t, pal.faintest, pal.addBg); r < 4.5 { + t.Errorf("%s: faintest on addBg contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.faintest, pal.delBg); r < 4.5 { + t.Errorf("%s: faintest on delBg contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.green, pal.addBg); r < 4.5 { + t.Errorf("%s: green on addBg contrast %.2f < 4.5", name, r) + } + if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 { + t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r) + } + } +} + +// hexChannels splits a #rrggbb token into its 8-bit channels. +func hexChannels(t *testing.T, hexColor string) (int, int, int) { + t.Helper() + h := strings.TrimPrefix(hexColor, "#") + v, err := strconv.ParseUint(h, 16, 32) + if err != nil || len(h) != 6 { + t.Fatalf("bad hex %q", hexColor) + } + return int((v >> 16) & 0xff), int((v >> 8) & 0xff), int(v & 0xff) +} + +// xterm256Hex returns the nearest xterm-256 color (the 6x6x6 cube plus the +// 24-step grayscale ramp, by squared RGB distance): how a terminal without +// truecolor support downsamples the palette's hex tokens before rendering. +func xterm256Hex(t *testing.T, hexColor string) string { + t.Helper() + r, g, b := hexChannels(t, hexColor) + levels := []int{0, 95, 135, 175, 215, 255} + bestR, bestG, bestB := 0, 0, 0 + bestDistance := math.MaxFloat64 + try := func(cr, cg, cb int) { + d := float64((r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb)) + if d < bestDistance { + bestDistance, bestR, bestG, bestB = d, cr, cg, cb + } + } + for _, cr := range levels { + for _, cg := range levels { + for _, cb := range levels { + try(cr, cg, cb) + } + } + } + for i := 0; i < 24; i++ { + gray := 8 + 10*i + try(gray, gray, gray) + } + return fmt.Sprintf("#%02x%02x%02x", bestR, bestG, bestB) +} + +// Hex-level AA does not guarantee the rendered pairs hold on a 256-color +// terminal, which quantizes every token to its nearest xterm entry first. +// Guard the pairs that regressed: Dune's selected-row affordances (accent +// caret/favorite star and blue local-model dot over selBg via onSel) and +// Neon's diff bands, whose previous values all quantized to the same grays. +func TestExtendedThemeANSI256Contrast(t *testing.T) { + palettes := map[string]palette{} + for _, entry := range themeRegistry { + palettes[entry.Name] = entry.Palette + } + q := func(hexColor string) string { return xterm256Hex(t, hexColor) } + + dune := palettes["dune"] + for _, pair := range []struct{ name, fg, bg string }{ + {"accent on selBg", dune.accent, dune.selBg}, + {"blue on selBg", dune.blue, dune.selBg}, + {"faintest on selBg", dune.faintest, dune.selBg}, + {"ink on selBg", dune.ink, dune.selBg}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 { + t.Errorf("dune: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg)) + } + } + + neon := palettes["neon"] + greenish := func(hexColor string) bool { + r, g, b := hexChannels(t, hexColor) + return g > r && g > b + } + reddish := func(hexColor string) bool { + r, g, b := hexChannels(t, hexColor) + return r > g && r > b + } + if q(neon.addBg) == q(neon.delBg) || !greenish(q(neon.addBg)) || !reddish(q(neon.delBg)) { + t.Errorf("neon: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s", + neon.addBg, q(neon.addBg), neon.delBg, q(neon.delBg)) + } + if q(neon.addBgWord) == q(neon.delBgWord) || !greenish(q(neon.addBgWord)) || !reddish(q(neon.delBgWord)) { + t.Errorf("neon: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s", + neon.addBgWord, q(neon.addBgWord), neon.delBgWord, q(neon.delBgWord)) + } + if q(neon.addBgWord) == q(neon.addBg) { + t.Errorf("neon: changed span is indistinguishable from its add row after quantization (both %s)", q(neon.addBg)) + } + if q(neon.delBgWord) == q(neon.delBg) { + t.Errorf("neon: changed span is indistinguishable from its del row after quantization (both %s)", q(neon.delBg)) + } + if r := wcagRatio(t, q(neon.green), q(neon.addBg)); r < 4.5 { + t.Errorf("neon: green on addBg = %.2f < 4.5 after quantization", r) + } + if r := wcagRatio(t, q(neon.red), q(neon.delBg)); r < 4.5 { + t.Errorf("neon: red on delBg = %.2f < 4.5 after quantization", r) + } + // The two rendered diff-content pairs a 256-color terminal actually + // shows: line numbers (faintest on addBg/delBg) and highlighted changed + // spans (addInk/delInk on their word bands). + for _, pair := range []struct{ name, fg, bg string }{ + {"faintest on addBg", neon.faintest, neon.addBg}, + {"faintest on delBg", neon.faintest, neon.delBg}, + {"addInk on addBgWord", neon.addInk, neon.addBgWord}, + {"delInk on delBgWord", neon.delInk, neon.delBgWord}, + } { + if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 { + t.Errorf("neon: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg)) + } + } + // Error-card borders are non-text UI components: they need 3:1 against + // the panel (WCAG 1.4.11) to be distinguishable at all, in both truecolor + // and 256-color renderings. + if r := wcagRatio(t, neon.cardErr, neon.panel); r < 3.0 { + t.Errorf("neon: cardErr border on panel = %.2f < 3.0", r) + } + if r := wcagRatio(t, q(neon.cardErr), q(neon.panel)); r < 3.0 { + t.Errorf("neon: cardErr border on panel = %.2f < 3.0 after quantization", r) + } +} + func mustR(t *testing.T, hex string) uint32 { t.Helper() r, _, _, _ := lipgloss.Color(hex).RGBA()