named color themes

This commit is contained in:
booleanmaybe 2026-04-13 12:30:24 -04:00
parent 9eee3ea019
commit 9e40a0f56b
12 changed files with 859 additions and 126 deletions

View file

@ -100,16 +100,15 @@ Backlog:
# Appearance settings # Appearance settings
appearance: appearance:
theme: auto # Theme: "auto" (detect from terminal), "dark", "light" theme: auto # Theme: "auto" (detect from terminal), "dark", "light",
# or a named theme: "dracula", "tokyo-night", "gruvbox-dark",
# "catppuccin-mocha", "solarized-dark", "nord", "monokai",
# "one-dark", "catppuccin-latte", "solarized-light",
# "gruvbox-light", "github-light"
gradientThreshold: 256 # Minimum terminal colors for gradient rendering gradientThreshold: 256 # Minimum terminal colors for gradient rendering
# Options: 16, 256, 16777216 (truecolor) # Options: 16, 256, 16777216 (truecolor)
# Gradients disabled if terminal has fewer colors # Gradients disabled if terminal has fewer colors
# Default: 256 (works well on most terminals) # Default: 256 (works well on most terminals)
codeBlock:
theme: dracula # Chroma syntax theme for code blocks
# Examples: "dracula", "monokai", "catppuccin-macchiato"
background: "#282a36" # Code block background color (hex or ANSI e.g. "236")
border: "#6272a4" # Code block border color (hex or ANSI e.g. "244")
# AI agent integration # AI agent integration
ai: ai:

BIN
assets/light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

View file

@ -2,10 +2,6 @@ package config
// Color and style definitions for the UI: gradients, unified Color values. // Color and style definitions for the UI: gradients, unified Color values.
import (
"github.com/gdamore/tcell/v2"
)
// Gradient defines a start and end RGB color for a gradient transition // Gradient defines a start and end RGB color for a gradient transition
type Gradient struct { type Gradient struct {
Start [3]int // R, G, B (0-255) Start [3]int // R, G, B (0-255)
@ -149,98 +145,13 @@ type Palette struct {
// Content area // Content area
ContentBackgroundColor Color // canvas background (transparent/default — inherits terminal bg) ContentBackgroundColor Color // canvas background (transparent/default — inherits terminal bg)
// Statusline (Nord palette) // Statusline
NordPolarNight1 Color // #2e3440 StatuslineDarkBg Color // darkest statusline background (accent foreground)
NordPolarNight2 Color // #3b4252 StatuslineMidBg Color // mid statusline background (info/error/fill)
NordPolarNight3 Color // #434c5e StatuslineBorderBg Color // statusline main background + deps editor background
NordSnowStorm1 Color // #d8dee9 StatuslineText Color // statusline primary text
NordFrostBlue Color // #5e81ac StatuslineAccent Color // statusline accent background
NordAuroraGreen Color // #a3be8c StatuslineOk Color // statusline info/success foreground
}
// DarkPalette returns the color palette for dark backgrounds.
func DarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ffff00"),
TextColor: NewColorHex("#ffffff"),
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#686868"),
SoftBorderColor: NewColorHex("#686868"),
SoftTextColor: NewColorHex("#b4b4b4"),
AccentColor: NewColor(tcell.ColorGreen),
ValueColor: NewColorHex("#8c92ac"),
InfoLabelColor: NewColorHex("#ffa500"),
SelectionBgColor: NewColorHex("#3a5f8a"),
AccentBlue: NewColorHex("#5fafff"),
SlateColor: NewColorHex("#5f6982"),
LogoDotColor: NewColorHex("#40e0d0"),
LogoShadeColor: NewColorHex("#4682b4"),
LogoBorderColor: NewColorHex("#324664"),
CaptionFallbackGradient: Gradient{
Start: [3]int{25, 25, 112},
End: [3]int{65, 105, 225},
},
DeepSkyBlue: NewColorRGB(0, 191, 255),
DeepPurple: NewColorRGB(134, 90, 214),
ContentBackgroundColor: DefaultColor(), // transparent — inherit terminal background
NordPolarNight1: NewColorHex("#2e3440"),
NordPolarNight2: NewColorHex("#3b4252"),
NordPolarNight3: NewColorHex("#434c5e"),
NordSnowStorm1: NewColorHex("#d8dee9"),
NordFrostBlue: NewColorHex("#5e81ac"),
NordAuroraGreen: NewColorHex("#a3be8c"),
}
}
// LightPalette returns the color palette for light backgrounds.
func LightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#0055dd"), // vivid blue — accents, focus markers, key bindings
TextColor: NewColor(tcell.ColorBlack),
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#808080"), // medium gray — de-emphasized text, placeholders
SoftBorderColor: NewColorHex("#d8dee9"), // light blue-gray — unselected box borders recede on light bg
SoftTextColor: NewColorHex("#404040"), // dark gray — secondary readable text
AccentColor: NewColorHex("#006400"), // dark green — labels
ValueColor: NewColorHex("#4a4e6a"), // dark cool gray — field values
InfoLabelColor: NewColorHex("#b85c00"), // darker orange — header view name
SelectionBgColor: NewColorHex("#b8d4f0"), // light blue — selection background
AccentBlue: NewColorHex("#0060c0"), // darker blue — action keys, points bar
SlateColor: NewColorHex("#7080a0"), // blue-gray — tag values, unfilled bar segments
LogoDotColor: NewColorHex("#20a090"), // darker turquoise
LogoShadeColor: NewColorHex("#3060a0"), // medium blue
LogoBorderColor: NewColorHex("#6080a0"), // lighter blue-gray (visible on light bg)
CaptionFallbackGradient: Gradient{
Start: [3]int{100, 140, 200},
End: [3]int{60, 100, 180},
},
DeepSkyBlue: NewColorRGB(0, 100, 180),
DeepPurple: NewColorRGB(90, 50, 160),
ContentBackgroundColor: DefaultColor(), // transparent — inherit terminal background
NordPolarNight1: NewColorHex("#eceff4"), // inverted: light background
NordPolarNight2: NewColorHex("#e5e9f0"),
NordPolarNight3: NewColorHex("#d8dee9"),
NordSnowStorm1: NewColorHex("#2e3440"), // inverted: dark text
NordFrostBlue: NewColorHex("#5e81ac"), // stays — good contrast on light
NordAuroraGreen: NewColorHex("#4c7a5a"), // darker green for light bg
}
}
// DefaultColors returns the default color configuration built from the dark palette.
func DefaultColors() *ColorConfig {
return ColorsFromPalette(DarkPalette())
} }
// darkenRGB returns a darkened version of an RGB triple. ratio 0 = no change, 1 = black. // darkenRGB returns a darkened version of an RGB triple. ratio 0 = no change, 1 = black.
@ -345,7 +256,7 @@ func ColorsFromPalette(p Palette) *ColorConfig {
HeaderActionViewLabelColor: p.MutedColor, HeaderActionViewLabelColor: p.MutedColor,
// Plugin-specific // Plugin-specific
DepsEditorBackground: p.NordPolarNight3, DepsEditorBackground: p.StatuslineBorderBg,
// Fallback solid colors // Fallback solid colors
FallbackTaskIDColor: p.DeepSkyBlue, FallbackTaskIDColor: p.DeepSkyBlue,
@ -357,15 +268,15 @@ func ColorsFromPalette(p Palette) *ColorConfig {
LogoBorderColor: p.LogoBorderColor, LogoBorderColor: p.LogoBorderColor,
// Statusline // Statusline
StatuslineBg: p.NordPolarNight3, StatuslineBg: p.StatuslineBorderBg,
StatuslineFg: p.NordSnowStorm1, StatuslineFg: p.StatuslineText,
StatuslineAccentBg: p.NordFrostBlue, StatuslineAccentBg: p.StatuslineAccent,
StatuslineAccentFg: p.NordPolarNight1, StatuslineAccentFg: p.StatuslineDarkBg,
StatuslineInfoFg: p.NordAuroraGreen, StatuslineInfoFg: p.StatuslineOk,
StatuslineInfoBg: p.NordPolarNight2, StatuslineInfoBg: p.StatuslineMidBg,
StatuslineErrorFg: p.HighlightColor, StatuslineErrorFg: p.HighlightColor,
StatuslineErrorBg: p.NordPolarNight2, StatuslineErrorBg: p.StatuslineMidBg,
StatuslineFillBg: p.NordPolarNight2, StatuslineFillBg: p.StatuslineMidBg,
} }
} }
@ -384,11 +295,7 @@ var UseWideGradients bool
// GetColors returns the global color configuration for the effective theme // GetColors returns the global color configuration for the effective theme
func GetColors() *ColorConfig { func GetColors() *ColorConfig {
if !colorsInitialized { if !colorsInitialized {
if GetEffectiveTheme() == "light" { globalColors = ColorsFromPalette(PaletteForTheme())
globalColors = ColorsFromPalette(LightPalette())
} else {
globalColors = ColorsFromPalette(DarkPalette())
}
colorsInitialized = true colorsInitialized = true
} }
return globalColors return globalColors

View file

@ -44,7 +44,7 @@ type Config struct {
// Appearance configuration // Appearance configuration
Appearance struct { Appearance struct {
Theme string `mapstructure:"theme"` // "dark", "light", "auto" Theme string `mapstructure:"theme"` // "auto", "dark", "light", or a named theme (see ThemeNames())
GradientThreshold int `mapstructure:"gradientThreshold"` // Minimum color count for gradients (16, 256, 16777216) GradientThreshold int `mapstructure:"gradientThreshold"` // Minimum color count for gradients (16, 256, 16777216)
CodeBlock struct { CodeBlock struct {
Theme string `mapstructure:"theme"` // chroma syntax theme (e.g. "dracula", "monokai") Theme string `mapstructure:"theme"` // chroma syntax theme (e.g. "dracula", "monokai")
@ -390,15 +390,12 @@ func GetGradientThreshold() int {
} }
// GetCodeBlockTheme returns the chroma syntax highlighting theme for code blocks. // GetCodeBlockTheme returns the chroma syntax highlighting theme for code blocks.
// defaults to "nord" (dark) or "github" (light) when not explicitly configured. // Defaults to the theme registry's chroma mapping when not explicitly configured.
func GetCodeBlockTheme() string { func GetCodeBlockTheme() string {
if t := viper.GetString("appearance.codeBlock.theme"); t != "" { if t := viper.GetString("appearance.codeBlock.theme"); t != "" {
return t return t
} }
if GetEffectiveTheme() == "light" { return ChromaThemeForEffective()
return "github"
}
return "nord"
} }
// GetCodeBlockBackground returns the background color for code blocks // GetCodeBlockBackground returns the background color for code blocks

582
config/palettes.go Normal file
View file

@ -0,0 +1,582 @@
package config
// Palette constructors for all built-in and named themes.
// Each function returns a Palette with canonical hex values from the theme's specification.
import (
"github.com/gdamore/tcell/v2"
)
// DarkPalette returns the color palette for dark backgrounds.
func DarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ffff00"),
TextColor: NewColorHex("#ffffff"),
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#686868"),
SoftBorderColor: NewColorHex("#686868"),
SoftTextColor: NewColorHex("#b4b4b4"),
AccentColor: NewColor(tcell.ColorGreen),
ValueColor: NewColorHex("#8c92ac"),
InfoLabelColor: NewColorHex("#ffa500"),
SelectionBgColor: NewColorHex("#3a5f8a"),
AccentBlue: NewColorHex("#5fafff"),
SlateColor: NewColorHex("#5f6982"),
LogoDotColor: NewColorHex("#40e0d0"),
LogoShadeColor: NewColorHex("#4682b4"),
LogoBorderColor: NewColorHex("#324664"),
CaptionFallbackGradient: Gradient{
Start: [3]int{25, 25, 112},
End: [3]int{65, 105, 225},
},
DeepSkyBlue: NewColorRGB(0, 191, 255),
DeepPurple: NewColorRGB(134, 90, 214),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#2e3440"),
StatuslineMidBg: NewColorHex("#3b4252"),
StatuslineBorderBg: NewColorHex("#434c5e"),
StatuslineText: NewColorHex("#d8dee9"),
StatuslineAccent: NewColorHex("#5e81ac"),
StatuslineOk: NewColorHex("#a3be8c"),
}
}
// LightPalette returns the color palette for light backgrounds.
func LightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#0055dd"),
TextColor: NewColor(tcell.ColorBlack),
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#808080"),
SoftBorderColor: NewColorHex("#b0b8c8"),
SoftTextColor: NewColorHex("#404040"),
AccentColor: NewColorHex("#006400"),
ValueColor: NewColorHex("#4a4e6a"),
InfoLabelColor: NewColorHex("#b85c00"),
SelectionBgColor: NewColorHex("#b8d4f0"),
AccentBlue: NewColorHex("#0060c0"),
SlateColor: NewColorHex("#7080a0"),
LogoDotColor: NewColorHex("#20a090"),
LogoShadeColor: NewColorHex("#3060a0"),
LogoBorderColor: NewColorHex("#6080a0"),
CaptionFallbackGradient: Gradient{
Start: [3]int{100, 140, 200},
End: [3]int{60, 100, 180},
},
DeepSkyBlue: NewColorRGB(0, 100, 180),
DeepPurple: NewColorRGB(90, 50, 160),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#eceff4"),
StatuslineMidBg: NewColorHex("#e5e9f0"),
StatuslineBorderBg: NewColorHex("#d8dee9"),
StatuslineText: NewColorHex("#2e3440"),
StatuslineAccent: NewColorHex("#5e81ac"),
StatuslineOk: NewColorHex("#4c7a5a"),
}
}
// DraculaPalette returns the Dracula theme palette.
// Ref: https://draculatheme.com/contribute
func DraculaPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ff79c6"), // pink
TextColor: NewColorHex("#f8f8f2"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#6272a4"), // comment
SoftBorderColor: NewColorHex("#44475a"), // current line
SoftTextColor: NewColorHex("#bfbfbf"),
AccentColor: NewColorHex("#50fa7b"), // green
ValueColor: NewColorHex("#bd93f9"), // purple
InfoLabelColor: NewColorHex("#ffb86c"), // orange
SelectionBgColor: NewColorHex("#44475a"),
AccentBlue: NewColorHex("#8be9fd"), // cyan
SlateColor: NewColorHex("#6272a4"), // comment
LogoDotColor: NewColorHex("#8be9fd"),
LogoShadeColor: NewColorHex("#bd93f9"),
LogoBorderColor: NewColorHex("#44475a"),
CaptionFallbackGradient: Gradient{
Start: [3]int{40, 42, 54},
End: [3]int{68, 71, 90},
},
DeepSkyBlue: NewColorHex("#8be9fd"),
DeepPurple: NewColorHex("#bd93f9"),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#21222c"),
StatuslineMidBg: NewColorHex("#282a36"),
StatuslineBorderBg: NewColorHex("#44475a"),
StatuslineText: NewColorHex("#f8f8f2"),
StatuslineAccent: NewColorHex("#bd93f9"),
StatuslineOk: NewColorHex("#50fa7b"),
}
}
// TokyoNightPalette returns the Tokyo Night theme palette.
// Ref: https://github.com/folke/tokyonight.nvim
func TokyoNightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#e0af68"), // yellow
TextColor: NewColorHex("#c0caf5"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#565f89"), // comment
SoftBorderColor: NewColorHex("#3b4261"),
SoftTextColor: NewColorHex("#a9b1d6"),
AccentColor: NewColorHex("#9ece6a"), // green
ValueColor: NewColorHex("#7aa2f7"), // blue
InfoLabelColor: NewColorHex("#ff9e64"), // orange
SelectionBgColor: NewColorHex("#283457"),
AccentBlue: NewColorHex("#7aa2f7"),
SlateColor: NewColorHex("#565f89"),
LogoDotColor: NewColorHex("#7dcfff"),
LogoShadeColor: NewColorHex("#7aa2f7"),
LogoBorderColor: NewColorHex("#3b4261"),
CaptionFallbackGradient: Gradient{
Start: [3]int{26, 27, 38},
End: [3]int{59, 66, 97},
},
DeepSkyBlue: NewColorHex("#7dcfff"),
DeepPurple: NewColorHex("#bb9af7"),
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#16161e"),
StatuslineMidBg: NewColorHex("#1a1b26"),
StatuslineBorderBg: NewColorHex("#24283b"),
StatuslineText: NewColorHex("#c0caf5"),
StatuslineAccent: NewColorHex("#7aa2f7"),
StatuslineOk: NewColorHex("#9ece6a"),
}
}
// GruvboxDarkPalette returns the Gruvbox Dark theme palette.
// Ref: https://github.com/morhetz/gruvbox
func GruvboxDarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#fabd2f"), // yellow
TextColor: NewColorHex("#ebdbb2"), // fg
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#928374"), // gray
SoftBorderColor: NewColorHex("#504945"), // bg2
SoftTextColor: NewColorHex("#bdae93"), // fg3
AccentColor: NewColorHex("#b8bb26"), // green
ValueColor: NewColorHex("#83a598"), // blue
InfoLabelColor: NewColorHex("#fe8019"), // orange
SelectionBgColor: NewColorHex("#504945"),
AccentBlue: NewColorHex("#83a598"),
SlateColor: NewColorHex("#665c54"), // bg3
LogoDotColor: NewColorHex("#8ec07c"), // aqua
LogoShadeColor: NewColorHex("#83a598"),
LogoBorderColor: NewColorHex("#3c3836"), // bg1
CaptionFallbackGradient: Gradient{
Start: [3]int{40, 40, 40},
End: [3]int{80, 73, 69},
},
DeepSkyBlue: NewColorHex("#83a598"),
DeepPurple: NewColorHex("#d3869b"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#1d2021"), // bg0_h
StatuslineMidBg: NewColorHex("#282828"), // bg0
StatuslineBorderBg: NewColorHex("#3c3836"), // bg1
StatuslineText: NewColorHex("#ebdbb2"),
StatuslineAccent: NewColorHex("#689d6a"), // dark aqua
StatuslineOk: NewColorHex("#b8bb26"),
}
}
// CatppuccinMochaPalette returns the Catppuccin Mocha theme palette.
// Ref: https://catppuccin.com/palette
func CatppuccinMochaPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#f9e2af"), // yellow
TextColor: NewColorHex("#cdd6f4"), // text
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#6c7086"), // overlay0
SoftBorderColor: NewColorHex("#45475a"), // surface0
SoftTextColor: NewColorHex("#bac2de"), // subtext1
AccentColor: NewColorHex("#a6e3a1"), // green
ValueColor: NewColorHex("#89b4fa"), // blue
InfoLabelColor: NewColorHex("#fab387"), // peach
SelectionBgColor: NewColorHex("#45475a"),
AccentBlue: NewColorHex("#89b4fa"),
SlateColor: NewColorHex("#585b70"), // surface2
LogoDotColor: NewColorHex("#94e2d5"), // teal
LogoShadeColor: NewColorHex("#89b4fa"),
LogoBorderColor: NewColorHex("#313244"), // surface0
CaptionFallbackGradient: Gradient{
Start: [3]int{30, 30, 46},
End: [3]int{69, 71, 90},
},
DeepSkyBlue: NewColorHex("#89dceb"), // sky
DeepPurple: NewColorHex("#cba6f7"), // mauve
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#11111b"), // crust
StatuslineMidBg: NewColorHex("#1e1e2e"), // base
StatuslineBorderBg: NewColorHex("#313244"), // surface0
StatuslineText: NewColorHex("#cdd6f4"),
StatuslineAccent: NewColorHex("#89b4fa"),
StatuslineOk: NewColorHex("#a6e3a1"),
}
}
// SolarizedDarkPalette returns the Solarized Dark theme palette.
// Ref: https://ethanschoonover.com/solarized/
func SolarizedDarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#b58900"), // yellow
TextColor: NewColorHex("#839496"), // base0
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#586e75"), // base01
SoftBorderColor: NewColorHex("#073642"), // base02
SoftTextColor: NewColorHex("#93a1a1"), // base1
AccentColor: NewColorHex("#859900"), // green
ValueColor: NewColorHex("#268bd2"), // blue
InfoLabelColor: NewColorHex("#cb4b16"), // orange
SelectionBgColor: NewColorHex("#073642"),
AccentBlue: NewColorHex("#268bd2"),
SlateColor: NewColorHex("#586e75"),
LogoDotColor: NewColorHex("#2aa198"), // cyan
LogoShadeColor: NewColorHex("#268bd2"),
LogoBorderColor: NewColorHex("#073642"),
CaptionFallbackGradient: Gradient{
Start: [3]int{0, 43, 54},
End: [3]int{7, 54, 66},
},
DeepSkyBlue: NewColorHex("#268bd2"),
DeepPurple: NewColorHex("#6c71c4"), // violet
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#002b36"), // base03
StatuslineMidBg: NewColorHex("#073642"), // base02
StatuslineBorderBg: NewColorHex("#073642"),
StatuslineText: NewColorHex("#839496"),
StatuslineAccent: NewColorHex("#268bd2"),
StatuslineOk: NewColorHex("#859900"),
}
}
// NordPalette returns the Nord theme palette.
// Ref: https://www.nordtheme.com/docs/colors-and-palettes
func NordPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#ebcb8b"), // nord13 — yellow
TextColor: NewColorHex("#eceff4"), // nord6 — snow storm
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#4c566a"), // nord3
SoftBorderColor: NewColorHex("#434c5e"), // nord2
SoftTextColor: NewColorHex("#d8dee9"), // nord4
AccentColor: NewColorHex("#a3be8c"), // nord14 — green
ValueColor: NewColorHex("#81a1c1"), // nord9 — blue
InfoLabelColor: NewColorHex("#d08770"), // nord12 — orange
SelectionBgColor: NewColorHex("#434c5e"),
AccentBlue: NewColorHex("#88c0d0"), // nord8 — frost cyan
SlateColor: NewColorHex("#4c566a"),
LogoDotColor: NewColorHex("#8fbcbb"), // nord7 — frost teal
LogoShadeColor: NewColorHex("#81a1c1"),
LogoBorderColor: NewColorHex("#3b4252"), // nord1
CaptionFallbackGradient: Gradient{
Start: [3]int{46, 52, 64},
End: [3]int{59, 66, 82},
},
DeepSkyBlue: NewColorHex("#88c0d0"),
DeepPurple: NewColorHex("#b48ead"), // nord15 — purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#2e3440"), // nord0
StatuslineMidBg: NewColorHex("#3b4252"), // nord1
StatuslineBorderBg: NewColorHex("#434c5e"), // nord2
StatuslineText: NewColorHex("#d8dee9"), // nord4
StatuslineAccent: NewColorHex("#5e81ac"), // nord10
StatuslineOk: NewColorHex("#a3be8c"),
}
}
// MonokaiPalette returns the Monokai theme palette.
// Ref: https://monokai.pro/
func MonokaiPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#e6db74"), // yellow
TextColor: NewColorHex("#f8f8f2"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#75715e"), // comment
SoftBorderColor: NewColorHex("#49483e"),
SoftTextColor: NewColorHex("#cfcfc2"),
AccentColor: NewColorHex("#a6e22e"), // green
ValueColor: NewColorHex("#66d9ef"), // cyan
InfoLabelColor: NewColorHex("#fd971f"), // orange
SelectionBgColor: NewColorHex("#49483e"),
AccentBlue: NewColorHex("#66d9ef"),
SlateColor: NewColorHex("#75715e"),
LogoDotColor: NewColorHex("#a6e22e"),
LogoShadeColor: NewColorHex("#66d9ef"),
LogoBorderColor: NewColorHex("#3e3d32"),
CaptionFallbackGradient: Gradient{
Start: [3]int{39, 40, 34},
End: [3]int{73, 72, 62},
},
DeepSkyBlue: NewColorHex("#66d9ef"),
DeepPurple: NewColorHex("#ae81ff"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#1e1f1c"),
StatuslineMidBg: NewColorHex("#272822"), // bg
StatuslineBorderBg: NewColorHex("#3e3d32"),
StatuslineText: NewColorHex("#f8f8f2"),
StatuslineAccent: NewColorHex("#66d9ef"),
StatuslineOk: NewColorHex("#a6e22e"),
}
}
// OneDarkPalette returns the Atom One Dark theme palette.
// Ref: https://github.com/Binaryify/OneDark-Pro
func OneDarkPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#e5c07b"), // yellow
TextColor: NewColorHex("#abb2bf"), // foreground
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#5c6370"), // comment
SoftBorderColor: NewColorHex("#3e4452"),
SoftTextColor: NewColorHex("#9da5b4"),
AccentColor: NewColorHex("#98c379"), // green
ValueColor: NewColorHex("#61afef"), // blue
InfoLabelColor: NewColorHex("#d19a66"), // orange
SelectionBgColor: NewColorHex("#3e4452"),
AccentBlue: NewColorHex("#61afef"),
SlateColor: NewColorHex("#5c6370"),
LogoDotColor: NewColorHex("#56b6c2"), // cyan
LogoShadeColor: NewColorHex("#61afef"),
LogoBorderColor: NewColorHex("#3b4048"),
CaptionFallbackGradient: Gradient{
Start: [3]int{40, 44, 52},
End: [3]int{62, 68, 82},
},
DeepSkyBlue: NewColorHex("#61afef"),
DeepPurple: NewColorHex("#c678dd"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#21252b"),
StatuslineMidBg: NewColorHex("#282c34"), // bg
StatuslineBorderBg: NewColorHex("#3b4048"),
StatuslineText: NewColorHex("#abb2bf"),
StatuslineAccent: NewColorHex("#61afef"),
StatuslineOk: NewColorHex("#98c379"),
}
}
// --- Light themes ---
// CatppuccinLattePalette returns the Catppuccin Latte (light) theme palette.
// Ref: https://catppuccin.com/palette
func CatppuccinLattePalette() Palette {
return Palette{
HighlightColor: NewColorHex("#df8e1d"), // yellow
TextColor: NewColorHex("#4c4f69"), // text
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#9ca0b0"), // overlay0
SoftBorderColor: NewColorHex("#ccd0da"), // surface0
SoftTextColor: NewColorHex("#5c5f77"), // subtext1
AccentColor: NewColorHex("#40a02b"), // green
ValueColor: NewColorHex("#1e66f5"), // blue
InfoLabelColor: NewColorHex("#fe640b"), // peach
SelectionBgColor: NewColorHex("#ccd0da"),
AccentBlue: NewColorHex("#1e66f5"),
SlateColor: NewColorHex("#acb0be"), // surface2
LogoDotColor: NewColorHex("#179299"), // teal
LogoShadeColor: NewColorHex("#1e66f5"),
LogoBorderColor: NewColorHex("#bcc0cc"), // surface1
CaptionFallbackGradient: Gradient{
Start: [3]int{239, 241, 245},
End: [3]int{204, 208, 218},
},
DeepSkyBlue: NewColorHex("#04a5e5"), // sky
DeepPurple: NewColorHex("#8839ef"), // mauve
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#eff1f5"), // base
StatuslineMidBg: NewColorHex("#e6e9ef"), // mantle
StatuslineBorderBg: NewColorHex("#dce0e8"), // crust
StatuslineText: NewColorHex("#4c4f69"),
StatuslineAccent: NewColorHex("#1e66f5"),
StatuslineOk: NewColorHex("#40a02b"),
}
}
// SolarizedLightPalette returns the Solarized Light theme palette.
// Ref: https://ethanschoonover.com/solarized/
func SolarizedLightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#b58900"), // yellow (same accent colors as dark)
TextColor: NewColorHex("#657b83"), // base00
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#93a1a1"), // base1
SoftBorderColor: NewColorHex("#eee8d5"), // base2
SoftTextColor: NewColorHex("#586e75"), // base01
AccentColor: NewColorHex("#859900"), // green
ValueColor: NewColorHex("#268bd2"), // blue
InfoLabelColor: NewColorHex("#cb4b16"), // orange
SelectionBgColor: NewColorHex("#eee8d5"),
AccentBlue: NewColorHex("#268bd2"),
SlateColor: NewColorHex("#93a1a1"),
LogoDotColor: NewColorHex("#2aa198"), // cyan
LogoShadeColor: NewColorHex("#268bd2"),
LogoBorderColor: NewColorHex("#eee8d5"),
CaptionFallbackGradient: Gradient{
Start: [3]int{253, 246, 227},
End: [3]int{238, 232, 213},
},
DeepSkyBlue: NewColorHex("#268bd2"),
DeepPurple: NewColorHex("#6c71c4"), // violet
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#fdf6e3"), // base3
StatuslineMidBg: NewColorHex("#eee8d5"), // base2
StatuslineBorderBg: NewColorHex("#eee8d5"),
StatuslineText: NewColorHex("#657b83"),
StatuslineAccent: NewColorHex("#268bd2"),
StatuslineOk: NewColorHex("#859900"),
}
}
// GruvboxLightPalette returns the Gruvbox Light theme palette.
// Ref: https://github.com/morhetz/gruvbox
func GruvboxLightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#b57614"), // dark yellow
TextColor: NewColorHex("#3c3836"), // fg (dark0_hard)
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#928374"), // gray
SoftBorderColor: NewColorHex("#d5c4a1"), // bg2
SoftTextColor: NewColorHex("#504945"), // fg3 (dark2)
AccentColor: NewColorHex("#79740e"), // dark green
ValueColor: NewColorHex("#076678"), // dark blue
InfoLabelColor: NewColorHex("#af3a03"), // dark orange
SelectionBgColor: NewColorHex("#d5c4a1"),
AccentBlue: NewColorHex("#076678"),
SlateColor: NewColorHex("#bdae93"), // bg3
LogoDotColor: NewColorHex("#427b58"), // dark aqua
LogoShadeColor: NewColorHex("#076678"),
LogoBorderColor: NewColorHex("#ebdbb2"), // bg1
CaptionFallbackGradient: Gradient{
Start: [3]int{251, 241, 199},
End: [3]int{235, 219, 178},
},
DeepSkyBlue: NewColorHex("#076678"),
DeepPurple: NewColorHex("#8f3f71"), // dark purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#fbf1c7"), // bg0
StatuslineMidBg: NewColorHex("#ebdbb2"), // bg1
StatuslineBorderBg: NewColorHex("#d5c4a1"), // bg2
StatuslineText: NewColorHex("#3c3836"),
StatuslineAccent: NewColorHex("#427b58"),
StatuslineOk: NewColorHex("#79740e"),
}
}
// GithubLightPalette returns the GitHub Light theme palette.
// Ref: https://github.com/primer/github-vscode-theme
func GithubLightPalette() Palette {
return Palette{
HighlightColor: NewColorHex("#0550ae"), // blue accent
TextColor: NewColorHex("#1f2328"), // fg.default
TransparentColor: DefaultColor(),
MutedColor: NewColorHex("#656d76"), // fg.muted
SoftBorderColor: NewColorHex("#d0d7de"), // border.default
SoftTextColor: NewColorHex("#424a53"),
AccentColor: NewColorHex("#116329"), // green
ValueColor: NewColorHex("#0969da"), // blue
InfoLabelColor: NewColorHex("#953800"), // orange
SelectionBgColor: NewColorHex("#ddf4ff"),
AccentBlue: NewColorHex("#0969da"),
SlateColor: NewColorHex("#8c959f"),
LogoDotColor: NewColorHex("#0969da"),
LogoShadeColor: NewColorHex("#0550ae"),
LogoBorderColor: NewColorHex("#d0d7de"),
CaptionFallbackGradient: Gradient{
Start: [3]int{255, 255, 255},
End: [3]int{246, 248, 250},
},
DeepSkyBlue: NewColorHex("#0969da"),
DeepPurple: NewColorHex("#8250df"), // purple
ContentBackgroundColor: DefaultColor(),
StatuslineDarkBg: NewColorHex("#ffffff"),
StatuslineMidBg: NewColorHex("#f6f8fa"), // canvas.subtle
StatuslineBorderBg: NewColorHex("#eaeef2"),
StatuslineText: NewColorHex("#1f2328"),
StatuslineAccent: NewColorHex("#0969da"),
StatuslineOk: NewColorHex("#116329"),
}
}

50
config/palettes_test.go Normal file
View file

@ -0,0 +1,50 @@
package config
import "testing"
func TestAllPalettesHaveNonDefaultCriticalFields(t *testing.T) {
for name, info := range themeRegistry {
p := info.Palette()
critical := map[string]Color{
"TextColor": p.TextColor,
"HighlightColor": p.HighlightColor,
"AccentColor": p.AccentColor,
"MutedColor": p.MutedColor,
"AccentBlue": p.AccentBlue,
"InfoLabelColor": p.InfoLabelColor,
}
for field, c := range critical {
if c.IsDefault() {
t.Errorf("theme %q: %s is default/transparent", name, field)
}
}
}
}
func TestLightPalettesHaveDarkText(t *testing.T) {
for name, info := range themeRegistry {
if !info.Light {
continue
}
p := info.Palette()
r, g, b := p.TextColor.RGB()
luminance := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
if luminance > 160 {
t.Errorf("light theme %q: TextColor luminance %.0f is too bright (expected dark text)", name, luminance)
}
}
}
func TestDarkPalettesHaveLightText(t *testing.T) {
for name, info := range themeRegistry {
if info.Light {
continue
}
p := info.Palette()
r, g, b := p.TextColor.RGB()
luminance := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
if luminance < 128 {
t.Errorf("dark theme %q: TextColor luminance %.0f is too dark (expected light text)", name, luminance)
}
}
}

84
config/themes.go Normal file
View file

@ -0,0 +1,84 @@
package config
// Theme registry: maps theme names to palette constructors, dark/light classification,
// chroma syntax theme, and navidown markdown renderer style.
import (
"log/slog"
"sort"
)
// ThemeInfo holds all metadata for a named theme.
type ThemeInfo struct {
Light bool // true = light base, false = dark base
ChromaTheme string // chroma syntax theme for code blocks
NavidownStyle string // navidown markdown renderer style name
Palette func() Palette // palette constructor
}
// themeRegistry maps theme names to their ThemeInfo.
// "dark" and "light" are the built-in base themes; named themes extend this.
var themeRegistry = map[string]ThemeInfo{
// built-in base themes
"dark": {Light: false, ChromaTheme: "nord", NavidownStyle: "dark", Palette: DarkPalette},
"light": {Light: true, ChromaTheme: "github", NavidownStyle: "light", Palette: LightPalette},
// named dark themes
"dracula": {Light: false, ChromaTheme: "dracula", NavidownStyle: "dracula", Palette: DraculaPalette},
"tokyo-night": {Light: false, ChromaTheme: "tokyonight-night", NavidownStyle: "tokyo-night", Palette: TokyoNightPalette},
"gruvbox-dark": {Light: false, ChromaTheme: "gruvbox", NavidownStyle: "dark", Palette: GruvboxDarkPalette},
"catppuccin-mocha": {Light: false, ChromaTheme: "catppuccin-mocha", NavidownStyle: "dark", Palette: CatppuccinMochaPalette},
"solarized-dark": {Light: false, ChromaTheme: "solarized-dark256", NavidownStyle: "dark", Palette: SolarizedDarkPalette},
"nord": {Light: false, ChromaTheme: "nord", NavidownStyle: "dark", Palette: NordPalette},
"monokai": {Light: false, ChromaTheme: "monokai", NavidownStyle: "dark", Palette: MonokaiPalette},
"one-dark": {Light: false, ChromaTheme: "onedark", NavidownStyle: "dark", Palette: OneDarkPalette},
// named light themes
"catppuccin-latte": {Light: true, ChromaTheme: "catppuccin-latte", NavidownStyle: "light", Palette: CatppuccinLattePalette},
"solarized-light": {Light: true, ChromaTheme: "solarized-light", NavidownStyle: "light", Palette: SolarizedLightPalette},
"gruvbox-light": {Light: true, ChromaTheme: "gruvbox-light", NavidownStyle: "light", Palette: GruvboxLightPalette},
"github-light": {Light: true, ChromaTheme: "github", NavidownStyle: "light", Palette: GithubLightPalette},
}
var defaultTheme = themeRegistry["dark"]
// lookupTheme returns the ThemeInfo for the effective theme.
// Logs a warning and returns the dark theme for unrecognized names.
func lookupTheme() ThemeInfo {
name := GetEffectiveTheme()
if info, ok := themeRegistry[name]; ok {
return info
}
slog.Warn("unknown theme, falling back to dark", "theme", name)
return defaultTheme
}
// IsLightTheme returns true if the effective theme has a light background.
func IsLightTheme() bool {
return lookupTheme().Light
}
// GetNavidownStyle returns the navidown markdown renderer style for the effective theme.
func GetNavidownStyle() string {
return lookupTheme().NavidownStyle
}
// PaletteForTheme returns the Palette for the effective theme.
func PaletteForTheme() Palette {
return lookupTheme().Palette()
}
// ChromaThemeForEffective returns the chroma syntax theme name for the effective theme.
func ChromaThemeForEffective() string {
return lookupTheme().ChromaTheme
}
// ThemeNames returns a sorted list of all registered theme names.
func ThemeNames() []string {
names := make([]string, 0, len(themeRegistry))
for name := range themeRegistry {
names = append(names, name)
}
sort.Strings(names)
return names
}

114
config/themes_test.go Normal file
View file

@ -0,0 +1,114 @@
package config
import (
"testing"
chromaStyles "github.com/alecthomas/chroma/v2/styles"
)
func TestThemeRegistryComplete(t *testing.T) {
names := ThemeNames()
if len(names) != 14 {
t.Fatalf("expected 14 themes, got %d: %v", len(names), names)
}
}
func TestThemeNamesAreSorted(t *testing.T) {
names := ThemeNames()
for i := 1; i < len(names); i++ {
if names[i] < names[i-1] {
t.Errorf("ThemeNames() not sorted: %q before %q", names[i-1], names[i])
}
}
}
func TestAllPalettesResolve(t *testing.T) {
for name, info := range themeRegistry {
// calling Palette() must not panic
p := info.Palette()
if p.TextColor.IsDefault() {
t.Errorf("theme %q: TextColor is default/transparent", name)
}
if p.HighlightColor.IsDefault() {
t.Errorf("theme %q: HighlightColor is default/transparent", name)
}
if p.AccentColor.IsDefault() {
t.Errorf("theme %q: AccentColor is default/transparent", name)
}
}
}
func TestIsLightThemeClassification(t *testing.T) {
expectedLight := map[string]bool{
"dark": false,
"light": true,
"dracula": false,
"tokyo-night": false,
"gruvbox-dark": false,
"catppuccin-mocha": false,
"solarized-dark": false,
"nord": false,
"monokai": false,
"one-dark": false,
"catppuccin-latte": true,
"solarized-light": true,
"gruvbox-light": true,
"github-light": true,
}
for name, wantLight := range expectedLight {
info, ok := themeRegistry[name]
if !ok {
t.Errorf("theme %q not in registry", name)
continue
}
if info.Light != wantLight {
t.Errorf("theme %q: Light = %v, want %v", name, info.Light, wantLight)
}
}
}
func TestUnknownThemeFallsToDark(t *testing.T) {
// simulate unknown theme by looking up directly in registry
_, ok := themeRegistry["nonexistent-theme"]
if ok {
t.Error("expected nonexistent-theme to not be in registry")
}
// lookupTheme() falls back to dark — verify via default
if defaultTheme.Light {
t.Error("default theme should be dark (Light=false)")
}
if defaultTheme.ChromaTheme != "nord" {
t.Errorf("default chroma theme = %q, want nord", defaultTheme.ChromaTheme)
}
}
func TestChromaThemesExist(t *testing.T) {
for name, info := range themeRegistry {
style := chromaStyles.Get(info.ChromaTheme)
if style == nil {
t.Errorf("theme %q: chroma theme %q not found in chroma registry", name, info.ChromaTheme)
}
}
}
func TestNavidownStylesValid(t *testing.T) {
// navidown supports these style names; unknown names fall back to "dark"
validNavidown := map[string]bool{
"dark": true, "light": true,
"dracula": true, "tokyo-night": true,
"pink": true, "ascii": true, "notty": true,
}
for name, info := range themeRegistry {
if !validNavidown[info.NavidownStyle] {
t.Errorf("theme %q: navidown style %q is not a known navidown style", name, info.NavidownStyle)
}
}
}
func TestChromaThemeForEffectiveNonEmpty(t *testing.T) {
for name, info := range themeRegistry {
if info.ChromaTheme == "" {
t.Errorf("theme %q: ChromaTheme is empty", name)
}
}
}

View file

@ -302,7 +302,7 @@ func InitColorAndGradientSupport(cfg *config.Config) *sysinfo.SystemInfo {
// which is invisible on light backgrounds. // which is invisible on light backgrounds.
colors := config.GetColors() colors := config.GetColors()
tview.Styles.PrimitiveBackgroundColor = colors.ContentBackgroundColor.TCell() tview.Styles.PrimitiveBackgroundColor = colors.ContentBackgroundColor.TCell()
if config.GetEffectiveTheme() == "light" { if config.IsLightTheme() {
tview.Styles.PrimaryTextColor = colors.ContentTextColor.TCell() tview.Styles.PrimaryTextColor = colors.ContentTextColor.TCell()
} }

View file

@ -36,7 +36,7 @@ func Run(input InputSpec) error {
// Set up image rendering for Kitty-compatible terminals // Set up image rendering for Kitty-compatible terminals
resolver := nav.NewImageResolver(input.SearchRoots) resolver := nav.NewImageResolver(input.SearchRoots)
resolver.SetDarkMode(config.GetEffectiveTheme() == "dark") resolver.SetDarkMode(!config.IsLightTheme())
imgMgr := navtview.NewImageManager(resolver, 8, 16) imgMgr := navtview.NewImageManager(resolver, 8, 16)
imgMgr.SetMaxRows(config.GetMaxImageRows()) imgMgr.SetMaxRows(config.GetMaxImageRows())
imgMgr.SetSupported(util.SupportsKittyGraphics()) imgMgr.SetSupported(util.SupportsKittyGraphics())

View file

@ -33,7 +33,7 @@ func NewViewFactory(taskStore store.Store) *ViewFactory {
// Configure image resolver with task directory as search root for relative image paths // Configure image resolver with task directory as search root for relative image paths
searchRoots := []string{config.GetTaskDir()} searchRoots := []string{config.GetTaskDir()}
resolver := nav.NewImageResolver(searchRoots) resolver := nav.NewImageResolver(searchRoots)
resolver.SetDarkMode(config.GetEffectiveTheme() == "dark") resolver.SetDarkMode(!config.IsLightTheme())
imgMgr := navtview.NewImageManager(resolver, 8, 16) imgMgr := navtview.NewImageManager(resolver, 8, 16)
imgMgr.SetMaxRows(config.GetMaxImageRows()) imgMgr.SetMaxRows(config.GetMaxImageRows())
imgMgr.SetSupported(util.SupportsKittyGraphics()) imgMgr.SetSupported(util.SupportsKittyGraphics())

View file

@ -36,7 +36,7 @@ func NewNavigableMarkdown(cfg NavigableMarkdownConfig) *NavigableMarkdown {
onStateChange: cfg.OnStateChange, onStateChange: cfg.OnStateChange,
} }
nm.viewer.SetAnsiConverter(navutil.NewAnsiConverter(true)) nm.viewer.SetAnsiConverter(navutil.NewAnsiConverter(true))
renderer := nav.NewANSIRendererWithStyle(config.GetEffectiveTheme()) renderer := nav.NewANSIRendererWithStyle(config.GetNavidownStyle())
if t := config.GetCodeBlockTheme(); t != "" { if t := config.GetCodeBlockTheme(); t != "" {
renderer = renderer.WithCodeTheme(t) renderer = renderer.WithCodeTheme(t)
} }