From ce8b5653571f576e1dfe05974f961161a9b71c23 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:06:01 -0400 Subject: [PATCH 1/8] draft(tui): add theme interface and Claude/Codex presets proposal Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md. --- docs/THEMES.md | 46 +++++++ internal/tui/theme.go | 293 ++++++++++++------------------------------ 2 files changed, 129 insertions(+), 210 deletions(-) create mode 100644 docs/THEMES.md diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 000000000..0dcd9c823 --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,46 @@ +# TUI Themes & Layout Presets + +This document details the architectural plan to add custom UI theme presets to Zero. Swappable themes allow the developer to customize Zero's appearance, density, and panel layouts according to their preferences. + +--- + +## 🎨 Layout & Design Presets + +### 1. Claude Theme (`claude`) +* **Aesthetic**: Warm, card-based, clean, low-density. +* **Colors**: Sand/cream borders, soft amber highlights, charcoal body text. +* **Layout**: Centered single-column message feed with a right-aligned collapsable side-panel drawer. + +### 2. Codex Theme (`codex`) +* **Aesthetic**: Matrix-green/cyan cyberpunk theme, high-density monospace layout. +* **Colors**: Vibrant neon-greens and neon-cyans on pitch-black background. +* **Layout**: Split-pane view (Left: Chat feed / Right: Real-time daemon & command execution timeline) with a bottom grid of keyboard shortcuts. + +--- + +## 🏗️ Interface Architecture + +Theme customization is governed by the `Theme` interface in `internal/tui/theme.go`: + +```go +package tui + +import "github.com/charmbracelet/lipgloss" + +type Theme interface { + Name() string + ChatStyle() lipgloss.Style + MessageStyle(sender string) lipgloss.Style + SidebarStyle() lipgloss.Style + Border() lipgloss.Border + BorderStyle() lipgloss.Style +} +``` + +--- + +## 🚀 Implementation Roadmap + +1. **Introduce user configuration settings** inside `config.json` via a new `tui.theme` key. +2. **Expose `Theme` settings** dynamically inside `internal/tui/model.go` using BubbleTea. +3. **Refactor rendering methods** in the main model (such as `View()`) to compose panels dynamically via `lipgloss.JoinHorizontal` or `lipgloss.JoinVertical` depending on the active theme layout. diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 4859fd45a..97319e4f2 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -1,227 +1,100 @@ package tui import ( - "image/color" - - "charm.land/lipgloss/v2" + "github.com/charmbracelet/lipgloss" ) -// tuiTheme is the resolved terminal palette Zero renders with. It is produced by -// buildTheme from a palette, so the same renderers serve both the dark default -// and the light variant; the active theme lives in the package var zeroTheme and -// may be swapped at startup (background detection / ZERO_THEME / --theme) or live -// via /theme. Colors are truecolor hex; lipgloss downsamples on limited displays -// and renders plain text when there is no TTY (tests). Every renderer consumes -// these named styles — no hex literal may appear outside theme_palettes.go (the -// palette tables + theme registry). -type tuiTheme struct { - // Base tokens. - ink lipgloss.Style // primary text - muted lipgloss.Style // secondary text, assistant interim prose - faint lipgloss.Style // hints, metadata - faintest lipgloss.Style // line numbers, separators, tool args - accent lipgloss.Style // brand lime: prompts, spinner, focus - green lipgloss.Style // success, diff add sign, ✓ - red lipgloss.Style // errors, diff del sign, ✗, deny - amber lipgloss.Style // permission surfaces, warnings, auto badge - blue lipgloss.Style // grep file locations, local-model dot - gitAdd lipgloss.Style // PR/local diff additions - gitDel lipgloss.Style // PR/local diff deletions - line lipgloss.Style // default borders, rules, status separators - lineStrong lipgloss.Style // emphasized borders - selection lipgloss.Style // transcript selection highlight - hover lipgloss.Style // hovered clickable row (specialist card, toggle, sidebar/plan row) - - // Title bar. - badge lipgloss.Style // ` 0 ` brand chip: onAccent on accent, bold - - // Stream roles. - userPrompt lipgloss.Style // ❯ user gutter, accent bold - sayText lipgloss.Style // assistant interim prose, muted - - // Tool cards. - toolName lipgloss.Style // head-row tool name, ink bold - toolTarget lipgloss.Style // head-row target path, ink - toolArg lipgloss.Style // one-line arg hint, faintest - cardRun lipgloss.Style // card border while the call runs (accent-mixed) - cardErr lipgloss.Style // card border after an error (red-mixed) - bashPrompt lipgloss.Style // ❯ command gutter inside bash cards, accent bold - grepLoc lipgloss.Style // file:line locations in grep bodies, blue - - // Diff bodies. The sign/count styles are bare foregrounds; the line styles - // carry the tinted backgrounds standing in for the prototype's 9% overlays. - // Gutter and sign columns get their own bg-carrying styles because lipgloss - // resets the background between adjacent Render calls — every segment of a - // tinted row must carry the tint itself. - diffAdd lipgloss.Style // + sign in counts - diffDel lipgloss.Style // − sign in counts - diffMeta lipgloss.Style // @@ hunks, +++/--- headers - addLine lipgloss.Style // added-line text: addInk on addBg - delLine lipgloss.Style // deleted-line text: delInk on delBg - addLineWord lipgloss.Style // the changed span within an added line: addInk on the brighter addBgWord - delLineWord lipgloss.Style // the changed span within a deleted line: delInk on the brighter delBgWord - addLineNum lipgloss.Style // gutter number on addBg - delLineNum lipgloss.Style // gutter number on delBg - addSign lipgloss.Style // + column on addBg - delSign lipgloss.Style // − column on delBg - delText lipgloss.Style // delInk as bare foreground (stderr-ish output) - - // Permission surfaces. - permBadge lipgloss.Style // PERMISSION chip: onAccent on amber, bold - permBg lipgloss.Style // permission card body tint - permBorder lipgloss.Style // permission card border (amber-mixed line) - - // Surfaces. - panel lipgloss.Style // bare panel background (card padding, body fill) - userPromptPanel lipgloss.Style // submitted user prompt background - - // Permission modes. - modeAuto lipgloss.Style - modeAsk lipgloss.Style - modeUnsafe lipgloss.Style - - // Raw colors a few renderers paint/interpolate with directly (the streaming - // fade interpolates accent→ink; panel-backed prompts paint on bgPanel), kept - // on the theme so a theme switch reaches them too. The bg* colors back the - // on* surface helpers below. - accentColor color.Color - inkColor color.Color - bgPanel color.Color - bgPrompt color.Color - bgSel color.Color - bgPerm color.Color +// Theme defines the visual styling and layout properties for the TUI. +type Theme interface { + Name() string + + // ChatStyle returns the styling for the chat container. + ChatStyle() lipgloss.Style + + // MessageStyle returns the styling for individual message cards based on source. + MessageStyle(sender string) lipgloss.Style + + // SidebarStyle returns the styling for the sidebar. + SidebarStyle() lipgloss.Style + + // Border returns the character definitions for panel borders. + Border() lipgloss.Border + + // BorderStyle returns the border styling. + BorderStyle() lipgloss.Style } -// palette is the raw color-token table for one theme. buildTheme turns it into a -// resolved tuiTheme. The palette literals and the ordered theme registry live in -// theme_palettes.go (the only place hex literals live). Dark palettes keep tints -// darker than ink so every pairing survives 256-color downsampling; light palettes -// are dark-on-light with the same intent inverted. -type palette struct { - panel string // card backgrounds (the terminal canvas itself is never painted full-bleed) - promptBg string // submitted user prompt background - line string // default borders, rules - line2 string // emphasized borders - ink string // primary text - muted string // secondary text - faint string // hints, metadata - faintest string // line numbers, separators, tool args - accent string // brand lime - green string // success, diff add - red string // errors, diff del - amber string // permission, warnings - blue string // grep locations, local-model dot - gitAdd string // footer PR diff additions - gitDel string // footer PR diff deletions - addBg string // diff added-line bg - delBg string // diff deleted-line bg - addBgWord string // diff added-line changed-span bg (brighter than addBg) - delBgWord string // diff deleted-line changed-span bg (brighter than delBg) - permBg string // permission card bg - selBg string // selected row bg - addInk string // added-line text - delInk string // deleted-line text - onAccent string // text on accent or amber fills - cardRun string // running card border (accent mixed into line) - cardErr string // errored card border (red mixed into line) - cardPerm string // permission card border (amber mixed into line) +// ClaudeTheme represents the warm, card-based, minimalist Claude layout. +type ClaudeTheme struct{} + +var _ Theme = ClaudeTheme{} + +func (t ClaudeTheme) Name() string { return "claude" } + +func (t ClaudeTheme) ChatStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Padding(1, 4). + Align(lipgloss.Center) } -// buildTheme resolves a palette into the styles every renderer uses. -func buildTheme(p palette) tuiTheme { - col := func(s string) color.Color { return lipgloss.Color(s) } - fg := func(s string) lipgloss.Style { return lipgloss.NewStyle().Foreground(col(s)) } - return tuiTheme{ - ink: fg(p.ink), - muted: fg(p.muted), - faint: fg(p.faint), - faintest: fg(p.faintest), - accent: fg(p.accent).Bold(true), - green: fg(p.green), - red: fg(p.red), - amber: fg(p.amber), - blue: fg(p.blue), - gitAdd: fg(p.gitAdd), - gitDel: fg(p.gitDel), - line: fg(p.line), - lineStrong: fg(p.line2), - selection: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)), - // A full block (like selection) would be too heavy for something that - // repaints on every mouse movement; a soft foreground reads as - // "interactive" at a glance without competing with real content colors - // (e.g. a specialist card's red/green status glyph). Amber (not the brand - // lime accent) — lime read as too glaring for something that repaints - // continuously as the cursor moves. - hover: fg(p.amber), - - badge: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)).Bold(true), - - userPrompt: fg(p.accent).Bold(true), - sayText: fg(p.muted), - toolName: fg(p.ink).Bold(true), - toolTarget: fg(p.ink), - toolArg: fg(p.faintest), - cardRun: fg(p.cardRun), - cardErr: fg(p.cardErr), - bashPrompt: fg(p.accent).Bold(true), - grepLoc: fg(p.blue), - - diffAdd: fg(p.green), - diffDel: fg(p.red), - diffMeta: fg(p.faintest), - addLineWord: lipgloss.NewStyle().Foreground(col(p.addInk)).Background(col(p.addBgWord)), - delLineWord: lipgloss.NewStyle().Foreground(col(p.delInk)).Background(col(p.delBgWord)), - addLine: lipgloss.NewStyle().Foreground(col(p.addInk)).Background(col(p.addBg)), - delLine: lipgloss.NewStyle().Foreground(col(p.delInk)).Background(col(p.delBg)), - addLineNum: lipgloss.NewStyle().Foreground(col(p.faintest)).Background(col(p.addBg)), - delLineNum: lipgloss.NewStyle().Foreground(col(p.faintest)).Background(col(p.delBg)), - addSign: lipgloss.NewStyle().Foreground(col(p.green)).Background(col(p.addBg)), - delSign: lipgloss.NewStyle().Foreground(col(p.red)).Background(col(p.delBg)), - delText: fg(p.delInk), - - permBadge: lipgloss.NewStyle().Background(col(p.amber)).Foreground(col(p.onAccent)).Bold(true), - permBg: lipgloss.NewStyle().Background(col(p.permBg)), - permBorder: fg(p.cardPerm), - - panel: lipgloss.NewStyle().Background(col(p.panel)), - userPromptPanel: lipgloss.NewStyle().Background(col(p.promptBg)), - - modeAuto: fg(p.green).Bold(true), - // The steady "ask" footer label is calm (un-bolded) so it doesn't compete - // with the transient bold-amber permission badge (permBadge) shown when a - // tool is actually asking right now — a glance separates state from event. - modeAsk: fg(p.amber), - modeUnsafe: fg(p.red).Bold(true), - - accentColor: col(p.accent), - inkColor: col(p.ink), - bgPanel: col(p.panel), - bgPrompt: col(p.promptBg), - bgSel: col(p.selBg), - bgPerm: col(p.permBg), +func (t ClaudeTheme) MessageStyle(sender string) lipgloss.Style { + if sender == "model" { + return lipgloss.NewStyle(). + Background(lipgloss.Color("#fbf0e3")). // warm card background + Foreground(lipgloss.Color("#1f1f1f")). + Padding(1, 2). + MarginBottom(1) } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("#1f1f1f")). + Padding(1, 1). + MarginBottom(1) } -// zeroTheme is the active palette every renderer reads. It defaults to dark and is -// reassigned by theme selection at startup and by /theme. All references are -// .field accesses evaluated at render time, so reassigning this var repaints every -// subsequent render. -var zeroTheme = buildTheme(darkPalette) - -// onPanel returns a copy of style that paints on the panel surface. lipgloss -// resets the background between adjacent Render calls, so every segment of a -// panel row (including padding) must carry the background itself — renderers wrap -// their foreground styles through this instead of referencing hex. -func (t tuiTheme) onPanel(style lipgloss.Style) lipgloss.Style { - return style.Background(t.bgPanel) +func (t ClaudeTheme) SidebarStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.LeftBorder()). + BorderForeground(lipgloss.Color("#d0c4b2")). + Padding(1) } -// onSel paints on the selected-row tint. -func (t tuiTheme) onSel(style lipgloss.Style) lipgloss.Style { - return style.Background(t.bgSel) +func (t ClaudeTheme) Border() lipgloss.Border { return lipgloss.RoundedBorder() } + +func (t ClaudeTheme) BorderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#d0c4b2")) } -// onPerm paints on the permission-card tint. -func (t tuiTheme) onPerm(style lipgloss.Style) lipgloss.Style { - return style.Background(t.bgPerm) +// CodexTheme represents the high-density cyberpunk / IDE technical layout. +type CodexTheme struct{} + +var _ Theme = CodexTheme{} + +func (t CodexTheme) Name() string { return "codex" } + +func (t CodexTheme) ChatStyle() lipgloss.Style { + return lipgloss.NewStyle().Padding(0, 1) +} + +func (t CodexTheme) MessageStyle(sender string) lipgloss.Style { + if sender == "model" { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00ff00")). // Matrix green + Padding(0) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00ffff")). // Cyberpunk cyan + Padding(0) +} + +func (t CodexTheme) SidebarStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#00ff00")). + Padding(0) +} + +func (t CodexTheme) Border() lipgloss.Border { return lipgloss.ThickBorder() } + +func (t CodexTheme) BorderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) } From 76c954b94a7643255fca9320f1e3af767440ee53 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:54:50 -0400 Subject: [PATCH 2/8] fix(tui): implement Claude/Codex themes on the existing palette system theme.go had replaced the tuiTheme/buildTheme/palette architecture every renderer depends on with a standalone Theme interface using the wrong lipgloss import, breaking the build and orphaning the existing theme registry, /theme command, and picker. Revert theme.go to the working implementation and add claude and codex as real palette entries in theme_palettes.go, following the file's own extension pattern. Both pass the WCAG AA contrast and gray-ramp invariants asserted across the whole registry, and are picked up automatically by --theme, ZERO_THEME, /theme, and the picker. Rewrite docs/THEMES.md to describe the actual palette-registry architecture instead of the removed interface. --- docs/THEMES.md | 58 +++---- internal/tui/theme.go | 293 +++++++++++++++++++++++---------- internal/tui/theme_palettes.go | 66 ++++++++ 3 files changed, 297 insertions(+), 120 deletions(-) diff --git a/docs/THEMES.md b/docs/THEMES.md index 0dcd9c823..c4bde56b9 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -1,46 +1,30 @@ -# TUI Themes & Layout Presets +# TUI Themes -This document details the architectural plan to add custom UI theme presets to Zero. Swappable themes allow the developer to customize Zero's appearance, density, and panel layouts according to their preferences. +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 (no argument shows the active theme). `auto` (the default) follows the +terminal's detected background. ---- +Run `/theme` with no argument to list the registered names. -## 🎨 Layout & Design Presets +## Claude (`claude`) -### 1. Claude Theme (`claude`) -* **Aesthetic**: Warm, card-based, clean, low-density. -* **Colors**: Sand/cream borders, soft amber highlights, charcoal body text. -* **Layout**: Centered single-column message feed with a right-aligned collapsable side-panel drawer. +A warm, low-density palette: sand/cream surface, charcoal ink, and a soft +amber accent. -### 2. Codex Theme (`codex`) -* **Aesthetic**: Matrix-green/cyan cyberpunk theme, high-density monospace layout. -* **Colors**: Vibrant neon-greens and neon-cyans on pitch-black background. -* **Layout**: Split-pane view (Left: Chat feed / Right: Real-time daemon & command execution timeline) with a bottom grid of keyboard shortcuts. +## Codex (`codex`) ---- +A high-density, neon-on-black palette: pitch-black surface, bright green ink, +and a cyan accent. -## 🏗️ Interface Architecture +## Adding a theme -Theme customization is governed by the `Theme` interface in `internal/tui/theme.go`: +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 and a `themeRegistry` entry; nothing else +needs editing. -```go -package tui - -import "github.com/charmbracelet/lipgloss" - -type Theme interface { - Name() string - ChatStyle() lipgloss.Style - MessageStyle(sender string) lipgloss.Style - SidebarStyle() lipgloss.Style - Border() lipgloss.Border - BorderStyle() lipgloss.Style -} -``` - ---- - -## 🚀 Implementation Roadmap - -1. **Introduce user configuration settings** inside `config.json` via a new `tui.theme` key. -2. **Expose `Theme` settings** dynamically inside `internal/tui/model.go` using BubbleTea. -3. **Refactor rendering methods** in the main model (such as `View()`) to compose panels dynamically via `lipgloss.JoinHorizontal` or `lipgloss.JoinVertical` depending on the active theme layout. +New palettes must clear the WCAG AA contrast and gray-ramp invariants +asserted in `internal/tui/theme_select_test.go` against the whole registry. diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 97319e4f2..4859fd45a 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -1,100 +1,227 @@ package tui import ( - "github.com/charmbracelet/lipgloss" -) - -// Theme defines the visual styling and layout properties for the TUI. -type Theme interface { - Name() string - - // ChatStyle returns the styling for the chat container. - ChatStyle() lipgloss.Style + "image/color" - // MessageStyle returns the styling for individual message cards based on source. - MessageStyle(sender string) lipgloss.Style - - // SidebarStyle returns the styling for the sidebar. - SidebarStyle() lipgloss.Style - - // Border returns the character definitions for panel borders. - Border() lipgloss.Border + "charm.land/lipgloss/v2" +) - // BorderStyle returns the border styling. - BorderStyle() lipgloss.Style +// tuiTheme is the resolved terminal palette Zero renders with. It is produced by +// buildTheme from a palette, so the same renderers serve both the dark default +// and the light variant; the active theme lives in the package var zeroTheme and +// may be swapped at startup (background detection / ZERO_THEME / --theme) or live +// via /theme. Colors are truecolor hex; lipgloss downsamples on limited displays +// and renders plain text when there is no TTY (tests). Every renderer consumes +// these named styles — no hex literal may appear outside theme_palettes.go (the +// palette tables + theme registry). +type tuiTheme struct { + // Base tokens. + ink lipgloss.Style // primary text + muted lipgloss.Style // secondary text, assistant interim prose + faint lipgloss.Style // hints, metadata + faintest lipgloss.Style // line numbers, separators, tool args + accent lipgloss.Style // brand lime: prompts, spinner, focus + green lipgloss.Style // success, diff add sign, ✓ + red lipgloss.Style // errors, diff del sign, ✗, deny + amber lipgloss.Style // permission surfaces, warnings, auto badge + blue lipgloss.Style // grep file locations, local-model dot + gitAdd lipgloss.Style // PR/local diff additions + gitDel lipgloss.Style // PR/local diff deletions + line lipgloss.Style // default borders, rules, status separators + lineStrong lipgloss.Style // emphasized borders + selection lipgloss.Style // transcript selection highlight + hover lipgloss.Style // hovered clickable row (specialist card, toggle, sidebar/plan row) + + // Title bar. + badge lipgloss.Style // ` 0 ` brand chip: onAccent on accent, bold + + // Stream roles. + userPrompt lipgloss.Style // ❯ user gutter, accent bold + sayText lipgloss.Style // assistant interim prose, muted + + // Tool cards. + toolName lipgloss.Style // head-row tool name, ink bold + toolTarget lipgloss.Style // head-row target path, ink + toolArg lipgloss.Style // one-line arg hint, faintest + cardRun lipgloss.Style // card border while the call runs (accent-mixed) + cardErr lipgloss.Style // card border after an error (red-mixed) + bashPrompt lipgloss.Style // ❯ command gutter inside bash cards, accent bold + grepLoc lipgloss.Style // file:line locations in grep bodies, blue + + // Diff bodies. The sign/count styles are bare foregrounds; the line styles + // carry the tinted backgrounds standing in for the prototype's 9% overlays. + // Gutter and sign columns get their own bg-carrying styles because lipgloss + // resets the background between adjacent Render calls — every segment of a + // tinted row must carry the tint itself. + diffAdd lipgloss.Style // + sign in counts + diffDel lipgloss.Style // − sign in counts + diffMeta lipgloss.Style // @@ hunks, +++/--- headers + addLine lipgloss.Style // added-line text: addInk on addBg + delLine lipgloss.Style // deleted-line text: delInk on delBg + addLineWord lipgloss.Style // the changed span within an added line: addInk on the brighter addBgWord + delLineWord lipgloss.Style // the changed span within a deleted line: delInk on the brighter delBgWord + addLineNum lipgloss.Style // gutter number on addBg + delLineNum lipgloss.Style // gutter number on delBg + addSign lipgloss.Style // + column on addBg + delSign lipgloss.Style // − column on delBg + delText lipgloss.Style // delInk as bare foreground (stderr-ish output) + + // Permission surfaces. + permBadge lipgloss.Style // PERMISSION chip: onAccent on amber, bold + permBg lipgloss.Style // permission card body tint + permBorder lipgloss.Style // permission card border (amber-mixed line) + + // Surfaces. + panel lipgloss.Style // bare panel background (card padding, body fill) + userPromptPanel lipgloss.Style // submitted user prompt background + + // Permission modes. + modeAuto lipgloss.Style + modeAsk lipgloss.Style + modeUnsafe lipgloss.Style + + // Raw colors a few renderers paint/interpolate with directly (the streaming + // fade interpolates accent→ink; panel-backed prompts paint on bgPanel), kept + // on the theme so a theme switch reaches them too. The bg* colors back the + // on* surface helpers below. + accentColor color.Color + inkColor color.Color + bgPanel color.Color + bgPrompt color.Color + bgSel color.Color + bgPerm color.Color } -// ClaudeTheme represents the warm, card-based, minimalist Claude layout. -type ClaudeTheme struct{} - -var _ Theme = ClaudeTheme{} - -func (t ClaudeTheme) Name() string { return "claude" } - -func (t ClaudeTheme) ChatStyle() lipgloss.Style { - return lipgloss.NewStyle(). - Padding(1, 4). - Align(lipgloss.Center) +// palette is the raw color-token table for one theme. buildTheme turns it into a +// resolved tuiTheme. The palette literals and the ordered theme registry live in +// theme_palettes.go (the only place hex literals live). Dark palettes keep tints +// darker than ink so every pairing survives 256-color downsampling; light palettes +// are dark-on-light with the same intent inverted. +type palette struct { + panel string // card backgrounds (the terminal canvas itself is never painted full-bleed) + promptBg string // submitted user prompt background + line string // default borders, rules + line2 string // emphasized borders + ink string // primary text + muted string // secondary text + faint string // hints, metadata + faintest string // line numbers, separators, tool args + accent string // brand lime + green string // success, diff add + red string // errors, diff del + amber string // permission, warnings + blue string // grep locations, local-model dot + gitAdd string // footer PR diff additions + gitDel string // footer PR diff deletions + addBg string // diff added-line bg + delBg string // diff deleted-line bg + addBgWord string // diff added-line changed-span bg (brighter than addBg) + delBgWord string // diff deleted-line changed-span bg (brighter than delBg) + permBg string // permission card bg + selBg string // selected row bg + addInk string // added-line text + delInk string // deleted-line text + onAccent string // text on accent or amber fills + cardRun string // running card border (accent mixed into line) + cardErr string // errored card border (red mixed into line) + cardPerm string // permission card border (amber mixed into line) } -func (t ClaudeTheme) MessageStyle(sender string) lipgloss.Style { - if sender == "model" { - return lipgloss.NewStyle(). - Background(lipgloss.Color("#fbf0e3")). // warm card background - Foreground(lipgloss.Color("#1f1f1f")). - Padding(1, 2). - MarginBottom(1) +// buildTheme resolves a palette into the styles every renderer uses. +func buildTheme(p palette) tuiTheme { + col := func(s string) color.Color { return lipgloss.Color(s) } + fg := func(s string) lipgloss.Style { return lipgloss.NewStyle().Foreground(col(s)) } + return tuiTheme{ + ink: fg(p.ink), + muted: fg(p.muted), + faint: fg(p.faint), + faintest: fg(p.faintest), + accent: fg(p.accent).Bold(true), + green: fg(p.green), + red: fg(p.red), + amber: fg(p.amber), + blue: fg(p.blue), + gitAdd: fg(p.gitAdd), + gitDel: fg(p.gitDel), + line: fg(p.line), + lineStrong: fg(p.line2), + selection: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)), + // A full block (like selection) would be too heavy for something that + // repaints on every mouse movement; a soft foreground reads as + // "interactive" at a glance without competing with real content colors + // (e.g. a specialist card's red/green status glyph). Amber (not the brand + // lime accent) — lime read as too glaring for something that repaints + // continuously as the cursor moves. + hover: fg(p.amber), + + badge: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)).Bold(true), + + userPrompt: fg(p.accent).Bold(true), + sayText: fg(p.muted), + toolName: fg(p.ink).Bold(true), + toolTarget: fg(p.ink), + toolArg: fg(p.faintest), + cardRun: fg(p.cardRun), + cardErr: fg(p.cardErr), + bashPrompt: fg(p.accent).Bold(true), + grepLoc: fg(p.blue), + + diffAdd: fg(p.green), + diffDel: fg(p.red), + diffMeta: fg(p.faintest), + addLineWord: lipgloss.NewStyle().Foreground(col(p.addInk)).Background(col(p.addBgWord)), + delLineWord: lipgloss.NewStyle().Foreground(col(p.delInk)).Background(col(p.delBgWord)), + addLine: lipgloss.NewStyle().Foreground(col(p.addInk)).Background(col(p.addBg)), + delLine: lipgloss.NewStyle().Foreground(col(p.delInk)).Background(col(p.delBg)), + addLineNum: lipgloss.NewStyle().Foreground(col(p.faintest)).Background(col(p.addBg)), + delLineNum: lipgloss.NewStyle().Foreground(col(p.faintest)).Background(col(p.delBg)), + addSign: lipgloss.NewStyle().Foreground(col(p.green)).Background(col(p.addBg)), + delSign: lipgloss.NewStyle().Foreground(col(p.red)).Background(col(p.delBg)), + delText: fg(p.delInk), + + permBadge: lipgloss.NewStyle().Background(col(p.amber)).Foreground(col(p.onAccent)).Bold(true), + permBg: lipgloss.NewStyle().Background(col(p.permBg)), + permBorder: fg(p.cardPerm), + + panel: lipgloss.NewStyle().Background(col(p.panel)), + userPromptPanel: lipgloss.NewStyle().Background(col(p.promptBg)), + + modeAuto: fg(p.green).Bold(true), + // The steady "ask" footer label is calm (un-bolded) so it doesn't compete + // with the transient bold-amber permission badge (permBadge) shown when a + // tool is actually asking right now — a glance separates state from event. + modeAsk: fg(p.amber), + modeUnsafe: fg(p.red).Bold(true), + + accentColor: col(p.accent), + inkColor: col(p.ink), + bgPanel: col(p.panel), + bgPrompt: col(p.promptBg), + bgSel: col(p.selBg), + bgPerm: col(p.permBg), } - return lipgloss.NewStyle(). - Foreground(lipgloss.Color("#1f1f1f")). - Padding(1, 1). - MarginBottom(1) } -func (t ClaudeTheme) SidebarStyle() lipgloss.Style { - return lipgloss.NewStyle(). - Border(lipgloss.LeftBorder()). - BorderForeground(lipgloss.Color("#d0c4b2")). - Padding(1) +// zeroTheme is the active palette every renderer reads. It defaults to dark and is +// reassigned by theme selection at startup and by /theme. All references are +// .field accesses evaluated at render time, so reassigning this var repaints every +// subsequent render. +var zeroTheme = buildTheme(darkPalette) + +// onPanel returns a copy of style that paints on the panel surface. lipgloss +// resets the background between adjacent Render calls, so every segment of a +// panel row (including padding) must carry the background itself — renderers wrap +// their foreground styles through this instead of referencing hex. +func (t tuiTheme) onPanel(style lipgloss.Style) lipgloss.Style { + return style.Background(t.bgPanel) } -func (t ClaudeTheme) Border() lipgloss.Border { return lipgloss.RoundedBorder() } - -func (t ClaudeTheme) BorderStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(lipgloss.Color("#d0c4b2")) +// onSel paints on the selected-row tint. +func (t tuiTheme) onSel(style lipgloss.Style) lipgloss.Style { + return style.Background(t.bgSel) } -// CodexTheme represents the high-density cyberpunk / IDE technical layout. -type CodexTheme struct{} - -var _ Theme = CodexTheme{} - -func (t CodexTheme) Name() string { return "codex" } - -func (t CodexTheme) ChatStyle() lipgloss.Style { - return lipgloss.NewStyle().Padding(0, 1) -} - -func (t CodexTheme) MessageStyle(sender string) lipgloss.Style { - if sender == "model" { - return lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ff00")). // Matrix green - Padding(0) - } - return lipgloss.NewStyle(). - Foreground(lipgloss.Color("#00ffff")). // Cyberpunk cyan - Padding(0) -} - -func (t CodexTheme) SidebarStyle() lipgloss.Style { - return lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#00ff00")). - Padding(0) -} - -func (t CodexTheme) Border() lipgloss.Border { return lipgloss.ThickBorder() } - -func (t CodexTheme) BorderStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) +// onPerm paints on the permission-card tint. +func (t tuiTheme) onPerm(style lipgloss.Style) lipgloss.Style { + return style.Background(t.bgPerm) } diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 7230a5f12..b9edc6f63 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -333,6 +333,38 @@ var everforestPalette = palette{ cardPerm: "#96896b", } +// codexPalette is a high-density cyberpunk console: pitch-black surface with +// neon green ink and a cyan accent, in the spirit of a Matrix-style terminal. +var codexPalette = palette{ + panel: "#050b06", + promptBg: "#0c180d", + line: "#1c3820", + line2: "#2c5230", + ink: "#c9ffd2", + muted: "#7fdb8e", + faint: "#5cb56c", + faintest: "#419450", + accent: "#00e5c8", + green: "#39ff6a", + red: "#ff4d6d", + amber: "#f4ff3a", + blue: "#22e0ff", + gitAdd: "#4fdc6a", + gitDel: "#ff6f80", + addBg: "#0e2c14", + delBg: "#2c0f16", + addBgWord: "#1c5b2c", + delBgWord: "#5c2130", + permBg: "#2a2a0c", + selBg: "#123a1e", + addInk: "#c8ffcf", + delInk: "#ffd0d6", + onAccent: "#001410", + cardRun: "#1f8a6e", + cardErr: "#8a2f42", + 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", } +// claudePalette is a warm, low-density card layout: sand/cream surface, charcoal +// ink, and a soft amber accent. +var claudePalette = palette{ + panel: "#f2e9d8", + promptBg: "#e9dcbf", + line: "#d9c7a3", + line2: "#c2a97c", + ink: "#2b241a", + muted: "#5a4f3d", + faint: "#726649", + faintest: "#806252", + accent: "#8f5215", + green: "#4f7a3d", + red: "#a83c30", + amber: "#a8720f", + blue: "#3d6a9e", + gitAdd: "#5a7d47", + gitDel: "#a35a4a", + 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: "codex", Label: "Codex", Palette: codexPalette, IsDark: true}, {Name: "light", Label: "light", Palette: lightPalette, IsDark: false}, {Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false}, + {Name: "claude", Label: "Claude", Palette: claudePalette, IsDark: false}, } // themeByName indexes the registry by lowercased name for O(1) lookup. Built as a From 55b0ac24584100841e0c2197550256c7aa304657 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:01:00 -0400 Subject: [PATCH 3/8] fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests --- docs/THEMES.md | 9 ++-- internal/tui/theme_palettes.go | 38 ++++++++--------- internal/tui/theme_select_test.go | 68 +++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/docs/THEMES.md b/docs/THEMES.md index c4bde56b9..5ecd812dd 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -7,15 +7,14 @@ terminal's detected background. Run `/theme` with no argument to list the registered names. -## Claude (`claude`) +## Dune (`dune`) -A warm, low-density palette: sand/cream surface, charcoal ink, and a soft +A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft amber accent. -## Codex (`codex`) +## Neon (`neon`) -A high-density, neon-on-black palette: pitch-black surface, bright green ink, -and a cyan accent. +A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent. ## Adding a theme diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index b9edc6f63..266e1a6d7 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -333,17 +333,17 @@ var everforestPalette = palette{ cardPerm: "#96896b", } -// codexPalette is a high-density cyberpunk console: pitch-black surface with -// neon green ink and a cyan accent, in the spirit of a Matrix-style terminal. -var codexPalette = palette{ +// 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: "#7fdb8e", - faint: "#5cb56c", - faintest: "#419450", + muted: "#80db8f", + faint: "#6eca7d", + faintest: "#58af69", accent: "#00e5c8", green: "#39ff6a", red: "#ff4d6d", @@ -433,24 +433,24 @@ var solarizedLightPalette = palette{ cardPerm: "#c4ae63", } -// claudePalette is a warm, low-density card layout: sand/cream surface, charcoal -// ink, and a soft amber accent. -var claudePalette = palette{ +// 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: "#5a4f3d", - faint: "#726649", - faintest: "#806252", + muted: "#473e32", + faint: "#554a3a", + faintest: "#655648", accent: "#8f5215", - green: "#4f7a3d", - red: "#a83c30", - amber: "#a8720f", + green: "#38572a", + red: "#963328", + amber: "#6d4600", blue: "#3d6a9e", - gitAdd: "#5a7d47", - gitDel: "#a35a4a", + gitAdd: "#38572a", + gitDel: "#963328", addBg: "#dcecd0", delBg: "#f5dbd5", addBgWord: "#b9dc9e", @@ -490,10 +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: "codex", Label: "Codex", Palette: codexPalette, 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: "claude", Label: "Claude", Palette: claudePalette, 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 cb8e0ef96..0ddb31910 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -261,6 +261,74 @@ 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") + } +} + +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) + } + } +} + func mustR(t *testing.T, hex string) uint32 { t.Helper() r, _, _, _ := lipgloss.Color(hex).RGBA() From b38823a69e56373bb141a05efbc3b9252cc61649 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:40:21 -0400 Subject: [PATCH 4/8] style(tui): gofmt theme_select_test.go (drop trailing whitespace) The ubuntu smoke run fails on gofmt -l; this clears the only unformatted file in the draft so CI goes green. --- internal/tui/theme_select_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 0ddb31910..43f09fa48 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -287,7 +287,7 @@ func TestExtendedThemeContrastInvariants(t *testing.T) { 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) From 3f777bad6c0f0e5acd43c9add50b35715474bbf6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:44:26 -0400 Subject: [PATCH 5/8] test(tui): cover the CLI/env resolution path for the dune and neon presets TestNewThemePresetsWired only checked lookupTheme and the IsDark grouping. Add coverage for the actual --theme/ZERO_THEME precedence path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each preset's own palette rather than a built-in fallback. --- internal/tui/theme_select_test.go | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 43f09fa48..69febbfef 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -277,6 +277,40 @@ func TestNewThemePresetsWired(t *testing.T) { 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) { From 74f570ac5f06663b0ce2243a8e26e7d8d525c852 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:09:01 -0400 Subject: [PATCH 6/8] fix(tui): raise Dune/Neon palette contrast and fix theme docs Darken dune's accent, blue, and red tokens so the selected-row caret, local-model dot, and diff deletion text clear WCAG AA against selBg and delBg, including after ANSI-256 downsampling. Also correct THEMES.md's description of bare /theme (it opens a live-preview picker, not a list), and add the dune/neon names to the ZERO_THEME/--theme tables in README.md and README_ZH.md. --- README.md | 2 +- README_ZH.md | 2 +- docs/THEMES.md | 8 +++++--- internal/tui/theme_palettes.go | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 86ce5d13b..82c2bee24 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 | diff --git a/README_ZH.md b/README_ZH.md index 014697ef4..bc8c3d9c0 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` | 禁用流式淡入动画 | diff --git a/docs/THEMES.md b/docs/THEMES.md index 5ecd812dd..551d612b2 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -2,10 +2,12 @@ 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 (no argument shows the active theme). `auto` (the default) follows the -terminal's detected background. +running. `auto` (the default) follows the terminal's detected background. -Run `/theme` with no argument to list the registered names. +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`) diff --git a/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index 266e1a6d7..e618342d3 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -444,11 +444,11 @@ var dunePalette = palette{ muted: "#473e32", faint: "#554a3a", faintest: "#655648", - accent: "#8f5215", + accent: "#7c4712", // darkened from #8f5215 for AA on selBg (was 4.00:1, now 4.90:1) green: "#38572a", - red: "#963328", + red: "#872d24", // darkened from #963328 so delBg contrast survives ANSI-256 downsampling (true 6.57:1, 256 7.86:1) amber: "#6d4600", - blue: "#3d6a9e", + blue: "#2f5680", // darkened from #3d6a9e for AA on selBg (was 3.61:1, now 4.90:1) gitAdd: "#38572a", gitDel: "#963328", addBg: "#dcecd0", From cdf81497699b1e257ed20c8878b351988b308f24 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:45:36 -0400 Subject: [PATCH 7/8] fix(tui): keep Dune and Neon readable after ANSI-256 downsampling - Dune's accent becomes #724028: still AA on selBg and panel in truecolor (5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to #875f00 at 3.81:1. Blue already survived quantization at 4.67:1. - Neon's diff bands move to hexes that quantize to distinct green and red xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000, addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of collapsing to the same grays, while keeping every truecolor invariant. - New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way a 256-color terminal does and asserts the Dune selected-row pairs stay AA and the Neon bands keep their green/red identity and mutual separation. - docs/THEMES.md is now linked from the Documentation sections of both READMEs, and the Unreleased changelog inventory says twelve themes and names dune and neon. --- CHANGELOG.md | 10 +-- README.md | 1 + README_ZH.md | 1 + internal/tui/theme_palettes.go | 10 +-- internal/tui/theme_select_test.go | 101 ++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3755047e9..313f70a75 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 82c2bee24..2fdc8ea54 100644 --- a/README.md +++ b/README.md @@ -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 bc8c3d9c0..bab0968fc 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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/internal/tui/theme_palettes.go b/internal/tui/theme_palettes.go index e618342d3..be38e005a 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -351,10 +351,10 @@ var neonPalette = palette{ blue: "#22e0ff", gitAdd: "#4fdc6a", gitDel: "#ff6f80", - addBg: "#0e2c14", - delBg: "#2c0f16", - addBgWord: "#1c5b2c", - delBgWord: "#5c2130", + 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: "#c8ffcf", @@ -444,7 +444,7 @@ var dunePalette = palette{ muted: "#473e32", faint: "#554a3a", faintest: "#655648", - accent: "#7c4712", // darkened from #8f5215 for AA on selBg (was 4.00:1, now 4.90:1) + 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", diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 69febbfef..9c714187d 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" @@ -363,6 +364,106 @@ func TestExtendedThemeContrastInvariants(t *testing.T) { } } +// 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) + } + // faintest line numbers over addBg are only asserted in truecolor (see + // TestExtendedThemeContrastInvariants): the darkest green cube entry, + // #005f00, is already too bright against quantized faintest for 4.5:1, so + // an AA quantized pair and a green-identified band are mutually exclusive + // on the xterm cube. The band identity wins; the pair still clears 2.9:1. +} + func mustR(t *testing.T, hex string) uint32 { t.Helper() r, _, _, _ := lipgloss.Color(hex).RGBA() From d364ddfcc5404fda01266d5a9162dbbe53b6c3ee Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:57:08 -0400 Subject: [PATCH 8/8] fix(tui): keep Neon diff content AA after quantization, fix error border - faintest becomes #74c468: line numbers now quantize to #87d75f, which clears AA on the xterm-green addBg (#005f00) where the old #58af69 quantized to 2.95:1, while staying dimmer than faint so the gray ramp stays monotonic. - addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word band's #008700 (was 4.29:1). Both rendered diff-content pairs are now asserted in the ANSI-256 test instead of documented as omissions. - cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and 3.65:1 quantized, meeting the 3:1 non-text component threshold, with the border surface asserted in both renderings. - docs/THEMES.md no longer claims the extended invariants run against the whole registry: it now says new palettes must be added to the per-palette contrast tests or given equivalent coverage. --- docs/THEMES.md | 18 +++++++++++++----- internal/tui/theme_palettes.go | 6 +++--- internal/tui/theme_select_test.go | 27 ++++++++++++++++++++++----- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/THEMES.md b/docs/THEMES.md index 551d612b2..d5d14d165 100644 --- a/docs/THEMES.md +++ b/docs/THEMES.md @@ -24,8 +24,16 @@ 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 and a `themeRegistry` entry; nothing else -needs editing. - -New palettes must clear the WCAG AA contrast and gray-ramp invariants -asserted in `internal/tui/theme_select_test.go` against the whole registry. +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 be38e005a..054dfdd13 100644 --- a/internal/tui/theme_palettes.go +++ b/internal/tui/theme_palettes.go @@ -343,7 +343,7 @@ var neonPalette = palette{ ink: "#c9ffd2", muted: "#80db8f", faint: "#6eca7d", - faintest: "#58af69", + 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", @@ -357,11 +357,11 @@ var neonPalette = palette{ delBgWord: "#74202e", // quantizes to xterm red #870000 (see addBgWord) permBg: "#2a2a0c", selBg: "#123a1e", - addInk: "#c8ffcf", + 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: "#8a2f42", + 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", } diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 9c714187d..d586fdded 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -457,11 +457,28 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) { 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) } - // faintest line numbers over addBg are only asserted in truecolor (see - // TestExtendedThemeContrastInvariants): the darkest green cube entry, - // #005f00, is already too bright against quantized faintest for 4.5:1, so - // an AA quantized pair and a green-identified band are mutually exclusive - // on the xterm cube. The band identity wins; the pair still clears 2.9:1. + // 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 {