feat: themes (#285) (#294)

Drew Smirnoff created

Change summary

config/config.go                |   3 
docs/docs/Configuration.md      |   2 
docs/docs/Features/Themes.md    |  69 +++++++++
docs/docs/Features/UI.md        |   2 
main.go                         |   6 
screenshots/theme_settings.tape |  48 ++++++
theme/theme.go                  | 248 +++++++++++++++++++++++++++++++++++
tui/choice.go                   |   3 
tui/drafts.go                   |   5 
tui/email_view.go               |   7 
tui/inbox.go                    |   3 
tui/settings.go                 | 209 ++++++++++++++++++++++++++--
tui/styles.go                   |   5 
tui/theme.go                    | 119 ++++++++++++++++
view/html.go                    |  41 +++--
15 files changed, 723 insertions(+), 47 deletions(-)

Detailed changes

config/config.go 🔗

@@ -46,6 +46,7 @@ type Config struct {
 	Accounts      []Account     `json:"accounts"`
 	DisableImages bool          `json:"disable_images,omitempty"`
 	HideTips      bool          `json:"hide_tips,omitempty"`
+	Theme         string        `json:"theme,omitempty"`
 	MailingLists  []MailingList `json:"mailing_lists,omitempty"`
 }
 
@@ -190,6 +191,7 @@ func LoadConfig() (*Config, error) {
 		Accounts      []rawAccount  `json:"accounts"`
 		DisableImages bool          `json:"disable_images,omitempty"`
 		HideTips      bool          `json:"hide_tips,omitempty"`
+		Theme         string        `json:"theme,omitempty"`
 		MailingLists  []MailingList `json:"mailing_lists,omitempty"`
 	}
 
@@ -220,6 +222,7 @@ func LoadConfig() (*Config, error) {
 
 	config.DisableImages = raw.DisableImages
 	config.HideTips = raw.HideTips
+	config.Theme = raw.Theme
 	config.MailingLists = raw.MailingLists
 	for _, rawAcc := range raw.Accounts {
 		acc := Account{

docs/docs/Configuration.md 🔗

@@ -36,6 +36,7 @@ Configuration is stored in `~/.config/matcha/config.json`.
       "addresses": ["alice@example.com", "bob@example.com"]
     }
   ],
+  "theme": "Matcha",
   "disable_images": true,
   "hide_tips": true
 }
@@ -46,3 +47,4 @@ Configuration is stored in `~/.config/matcha/config.json`.
 - **Drafts**: `~/.config/matcha/drafts/`
 - **Email Cache**: `~/.config/matcha/cache.json`
 - **Contacts**: `~/.config/matcha/contacts.json`
+- **Custom Themes**: `~/.config/matcha/themes/*.json`

docs/docs/Features/Themes.md 🔗

@@ -0,0 +1,69 @@
+# Themes
+
+Matcha supports color themes to personalize the look of your terminal email client. Choose from built-in themes or create your own.
+
+## Changing the Theme
+
+Go to **Settings > Theme** to browse available themes. A live preview panel on the right shows how each theme looks as you navigate the list. Press `enter` to apply the selected theme.
+
+Your selection is saved to `config.json` and persists across sessions.
+
+![Theme Settings](../assets/theme_settings.png)
+
+## Built-in Themes
+
+| Theme | Description |
+|-------|-------------|
+| **Matcha** | The default green theme |
+| **Rose** | Soft pink and rose accents |
+| **Lavender** | Purple and violet tones |
+| **Ocean** | Cool blue palette |
+| **Peach** | Warm orange and peach tones |
+| **Catppuccin Mocha** | Based on the popular [Catppuccin](https://catppuccin.com/) color scheme |
+
+## Custom Themes
+
+You can create your own themes by adding JSON files to `~/.config/matcha/themes/`. Each `.json` file in that directory will appear in the theme picker.
+
+### Example Custom Theme
+
+Create a file like `~/.config/matcha/themes/dracula.json`:
+
+```json
+{
+  "name": "Dracula",
+  "accent": "#BD93F9",
+  "accent_dark": "#6272A4",
+  "accent_text": "#F8F8F2",
+  "secondary": "#6272A4",
+  "subtle_text": "#6272A4",
+  "muted_text": "#6272A4",
+  "dim_text": "#F8F8F2",
+  "danger": "#FF5555",
+  "warning": "#FFB86C",
+  "tip": "#F1FA8C",
+  "link": "#8BE9FD",
+  "directory": "#BD93F9",
+  "contrast": "#282A36"
+}
+```
+
+### Color Properties
+
+| Property | Used For |
+|----------|----------|
+| `accent` | Selected items, focused elements, primary highlights |
+| `accent_dark` | Borders, title backgrounds |
+| `accent_text` | Text on accent-colored backgrounds |
+| `secondary` | Help text, blurred/unfocused elements |
+| `subtle_text` | List headers, hints |
+| `muted_text` | Dates, timestamps |
+| `dim_text` | Sender names, secondary info |
+| `danger` | Delete confirmations, errors |
+| `warning` | Update notifications |
+| `tip` | Contextual tips |
+| `link` | Hyperlinks in email content |
+| `directory` | Directory names in the file picker |
+| `contrast` | Text on accent-colored backgrounds (e.g. active folder) |
+
+Colors can be specified as hex values (`#BD93F9`) or ANSI 256-color codes (`42`).

docs/docs/Features/UI.md 🔗

@@ -4,7 +4,7 @@ Matcha features a modern terminal interface built for efficiency and aesthetics.
 
 ## Key Features
 
-- **🎨 Beautiful TUI**: Clean, modern terminal interface built with Bubble Tea.
+- **🎨 Beautiful TUI**: Clean, modern, customizable terminal interface built with Bubble Tea.
 - **⌨️ Vim-like Keybindings**: Efficient keyboard navigation (`j/k`, `h/l`, etc.).
 - **📱 Responsive Design**: Adapts to your terminal window size.
 - **🎯 Focus Management**: Clear visual indication of focused elements.

main.go 🔗

@@ -23,6 +23,7 @@ import (
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 	"github.com/floatpane/matcha/sender"
+	"github.com/floatpane/matcha/theme"
 	"github.com/floatpane/matcha/tui"
 	"github.com/google/uuid"
 	"github.com/yuin/goldmark"
@@ -1975,6 +1976,11 @@ func main() {
 	}
 
 	cfg, err := config.LoadConfig()
+	if err == nil && cfg.Theme != "" {
+		theme.SetTheme(cfg.Theme)
+	}
+	tui.RebuildStyles()
+
 	var initialModel *mainModel
 	if err != nil {
 		initialModel = newInitialModel(nil)

screenshots/theme_settings.tape 🔗

@@ -0,0 +1,48 @@
+# Screenshot: Theme Settings
+# Shows the theme picker with Rose selected and a live preview
+
+Output screenshots/theme_settings.gif
+
+Set FontSize 14
+Set FontFamily "JetBrainsMono Nerd Font"
+Set Width 1400
+Set Height 800
+Set Theme "Catppuccin Mocha"
+Set Padding 20
+Set WindowBar Colorful
+Set WindowBarSize 40
+Set BorderRadius 10
+
+Hide
+Type "go run ."
+Enter
+Show
+
+Sleep 2s
+
+# Navigate to Settings (last item in menu)
+# Inbox -> Compose -> [Drafts if present] -> Settings
+Down
+Sleep 300ms
+Down
+Sleep 300ms
+Down
+Sleep 300ms
+Down
+Sleep 300ms
+Enter
+
+Sleep 1s
+
+# Navigate to Theme (2nd option in settings)
+Down
+Sleep 300ms
+Enter
+
+Sleep 1s
+
+# Navigate to Rose (2nd theme in list)
+Down
+Sleep 500ms
+
+Screenshot screenshots/theme_settings.png

theme/theme.go 🔗

@@ -0,0 +1,248 @@
+package theme
+
+import (
+	"encoding/json"
+	"image/color"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+)
+
+// Theme defines the color palette for the application.
+type Theme struct {
+	Name       string      `json:"name"`
+	Accent     color.Color `json:"-"`
+	AccentDark color.Color `json:"-"`
+	AccentText color.Color `json:"-"`
+	Secondary  color.Color `json:"-"`
+	SubtleText color.Color `json:"-"`
+	MutedText  color.Color `json:"-"`
+	DimText    color.Color `json:"-"`
+	Danger     color.Color `json:"-"`
+	Warning    color.Color `json:"-"`
+	Tip        color.Color `json:"-"`
+	Link       color.Color `json:"-"`
+	Directory  color.Color `json:"-"`
+	Contrast   color.Color `json:"-"`
+}
+
+// themeJSON is the JSON-serializable form of Theme using string color values.
+type themeJSON struct {
+	Name       string `json:"name"`
+	Accent     string `json:"accent"`
+	AccentDark string `json:"accent_dark"`
+	AccentText string `json:"accent_text"`
+	Secondary  string `json:"secondary"`
+	SubtleText string `json:"subtle_text"`
+	MutedText  string `json:"muted_text"`
+	DimText    string `json:"dim_text"`
+	Danger     string `json:"danger"`
+	Warning    string `json:"warning"`
+	Tip        string `json:"tip"`
+	Link       string `json:"link"`
+	Directory  string `json:"directory"`
+	Contrast   string `json:"contrast"`
+}
+
+func themeFromJSON(j themeJSON) Theme {
+	return Theme{
+		Name:       j.Name,
+		Accent:     lipgloss.Color(j.Accent),
+		AccentDark: lipgloss.Color(j.AccentDark),
+		AccentText: lipgloss.Color(j.AccentText),
+		Secondary:  lipgloss.Color(j.Secondary),
+		SubtleText: lipgloss.Color(j.SubtleText),
+		MutedText:  lipgloss.Color(j.MutedText),
+		DimText:    lipgloss.Color(j.DimText),
+		Danger:     lipgloss.Color(j.Danger),
+		Warning:    lipgloss.Color(j.Warning),
+		Tip:        lipgloss.Color(j.Tip),
+		Link:       lipgloss.Color(j.Link),
+		Directory:  lipgloss.Color(j.Directory),
+		Contrast:   lipgloss.Color(j.Contrast),
+	}
+}
+
+// Built-in themes
+
+var Matcha = Theme{
+	Name:       "Matcha",
+	Accent:     lipgloss.Color("42"),
+	AccentDark: lipgloss.Color("#25A065"),
+	AccentText: lipgloss.Color("#FFFDF5"),
+	Secondary:  lipgloss.Color("240"),
+	SubtleText: lipgloss.Color("241"),
+	MutedText:  lipgloss.Color("243"),
+	DimText:    lipgloss.Color("250"),
+	Danger:     lipgloss.Color("196"),
+	Warning:    lipgloss.Color("208"),
+	Tip:        lipgloss.Color("214"),
+	Link:       lipgloss.Color("#9BC4FF"),
+	Directory:  lipgloss.Color("34"),
+	Contrast:   lipgloss.Color("#000000"),
+}
+
+var Rose = Theme{
+	Name:       "Rose",
+	Accent:     lipgloss.Color("#E8729B"),
+	AccentDark: lipgloss.Color("#B5547A"),
+	AccentText: lipgloss.Color("#FFFDF5"),
+	Secondary:  lipgloss.Color("240"),
+	SubtleText: lipgloss.Color("241"),
+	MutedText:  lipgloss.Color("243"),
+	DimText:    lipgloss.Color("250"),
+	Danger:     lipgloss.Color("196"),
+	Warning:    lipgloss.Color("208"),
+	Tip:        lipgloss.Color("214"),
+	Link:       lipgloss.Color("#9BC4FF"),
+	Directory:  lipgloss.Color("#E8729B"),
+	Contrast:   lipgloss.Color("#000000"),
+}
+
+var Lavender = Theme{
+	Name:       "Lavender",
+	Accent:     lipgloss.Color("#B4A7D6"),
+	AccentDark: lipgloss.Color("#8E7CC3"),
+	AccentText: lipgloss.Color("#FFFDF5"),
+	Secondary:  lipgloss.Color("240"),
+	SubtleText: lipgloss.Color("241"),
+	MutedText:  lipgloss.Color("243"),
+	DimText:    lipgloss.Color("250"),
+	Danger:     lipgloss.Color("196"),
+	Warning:    lipgloss.Color("208"),
+	Tip:        lipgloss.Color("214"),
+	Link:       lipgloss.Color("#9BC4FF"),
+	Directory:  lipgloss.Color("#B4A7D6"),
+	Contrast:   lipgloss.Color("#000000"),
+}
+
+var Ocean = Theme{
+	Name:       "Ocean",
+	Accent:     lipgloss.Color("#5B9BD5"),
+	AccentDark: lipgloss.Color("#3A7BBF"),
+	AccentText: lipgloss.Color("#FFFDF5"),
+	Secondary:  lipgloss.Color("240"),
+	SubtleText: lipgloss.Color("241"),
+	MutedText:  lipgloss.Color("243"),
+	DimText:    lipgloss.Color("250"),
+	Danger:     lipgloss.Color("196"),
+	Warning:    lipgloss.Color("208"),
+	Tip:        lipgloss.Color("214"),
+	Link:       lipgloss.Color("#9BC4FF"),
+	Directory:  lipgloss.Color("#5B9BD5"),
+	Contrast:   lipgloss.Color("#000000"),
+}
+
+var Peach = Theme{
+	Name:       "Peach",
+	Accent:     lipgloss.Color("#FAB387"),
+	AccentDark: lipgloss.Color("#E0956E"),
+	AccentText: lipgloss.Color("#1E1E2E"),
+	Secondary:  lipgloss.Color("240"),
+	SubtleText: lipgloss.Color("241"),
+	MutedText:  lipgloss.Color("243"),
+	DimText:    lipgloss.Color("250"),
+	Danger:     lipgloss.Color("#F38BA8"),
+	Warning:    lipgloss.Color("#F9E2AF"),
+	Tip:        lipgloss.Color("#F9E2AF"),
+	Link:       lipgloss.Color("#89B4FA"),
+	Directory:  lipgloss.Color("#FAB387"),
+	Contrast:   lipgloss.Color("#1E1E2E"),
+}
+
+var CatppuccinMocha = Theme{
+	Name:       "Catppuccin Mocha",
+	Accent:     lipgloss.Color("#89B4FA"),
+	AccentDark: lipgloss.Color("#74C7EC"),
+	AccentText: lipgloss.Color("#1E1E2E"),
+	Secondary:  lipgloss.Color("#6C7086"),
+	SubtleText: lipgloss.Color("#7F849C"),
+	MutedText:  lipgloss.Color("#9399B2"),
+	DimText:    lipgloss.Color("#BAC2DE"),
+	Danger:     lipgloss.Color("#F38BA8"),
+	Warning:    lipgloss.Color("#FAB387"),
+	Tip:        lipgloss.Color("#F9E2AF"),
+	Link:       lipgloss.Color("#89DCEB"),
+	Directory:  lipgloss.Color("#89B4FA"),
+	Contrast:   lipgloss.Color("#1E1E2E"),
+}
+
+// BuiltinThemes lists all built-in themes in display order.
+var BuiltinThemes = []Theme{
+	Matcha,
+	Rose,
+	Lavender,
+	Ocean,
+	Peach,
+	CatppuccinMocha,
+}
+
+// ActiveTheme is the currently active theme used for styling.
+var ActiveTheme = Matcha
+
+// SetTheme sets the active theme by name. Returns true if found.
+// It searches built-in themes first, then custom themes.
+func SetTheme(name string) bool {
+	if name == "" {
+		ActiveTheme = Matcha
+		return true
+	}
+	for _, t := range BuiltinThemes {
+		if strings.EqualFold(t.Name, name) {
+			ActiveTheme = t
+			return true
+		}
+	}
+	// Try custom themes
+	custom := LoadCustomThemes()
+	for _, t := range custom {
+		if strings.EqualFold(t.Name, name) {
+			ActiveTheme = t
+			return true
+		}
+	}
+	return false
+}
+
+// AllThemes returns all available themes (built-in + custom).
+func AllThemes() []Theme {
+	all := make([]Theme, len(BuiltinThemes))
+	copy(all, BuiltinThemes)
+	all = append(all, LoadCustomThemes()...)
+	return all
+}
+
+// LoadCustomThemes loads custom themes from ~/.config/matcha/themes/*.json.
+func LoadCustomThemes() []Theme {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return nil
+	}
+	themesDir := filepath.Join(home, ".config", "matcha", "themes")
+	entries, err := os.ReadDir(themesDir)
+	if err != nil {
+		return nil
+	}
+
+	var themes []Theme
+	for _, entry := range entries {
+		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
+			continue
+		}
+		data, err := os.ReadFile(filepath.Join(themesDir, entry.Name()))
+		if err != nil {
+			continue
+		}
+		var j themeJSON
+		if err := json.Unmarshal(data, &j); err != nil {
+			continue
+		}
+		if j.Name == "" {
+			j.Name = strings.TrimSuffix(entry.Name(), ".json")
+		}
+		themes = append(themes, themeFromJSON(j))
+	}
+	return themes
+}

tui/choice.go 🔗

@@ -8,6 +8,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/theme"
 )
 
 // Styles defined locally to avoid import issues.
@@ -127,7 +128,7 @@ func (m Choice) View() tea.View {
 
 	// If we detected an update, show a short message under the header.
 	if m.UpdateAvailable {
-		updateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Padding(0, 1)
+		updateStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Warning).Padding(0, 1)
 		cur := m.CurrentVersion
 		if cur == "" {
 			cur = "unknown"

tui/drafts.go 🔗

@@ -10,6 +10,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/theme"
 )
 
 // draftItem represents a draft in the list
@@ -85,7 +86,7 @@ func NewDrafts(drafts []config.Draft) *Drafts {
 
 	l := list.New(items, list.NewDefaultDelegate(), 0, 0)
 	l.Title = "Drafts"
-	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+	l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
 	l.SetShowStatusBar(true)
 	l.SetFilteringEnabled(true)
 	l.SetStatusBarItemName("draft", "drafts")
@@ -198,7 +199,7 @@ func (m *Drafts) View() tea.View {
 
 	if len(m.drafts) == 0 {
 		emptyMsg := lipgloss.NewStyle().
-			Foreground(lipgloss.Color("240")).
+			Foreground(theme.ActiveTheme.Secondary).
 			Render("No drafts saved.\n\nPress esc to go back.")
 		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg))
 	}

tui/email_view.go 🔗

@@ -10,6 +10,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/fetcher"
+	"github.com/floatpane/matcha/theme"
 	"github.com/floatpane/matcha/view"
 )
 
@@ -243,12 +244,12 @@ func (m *EmailView) View() tea.View {
 
 	smimeStatus := ""
 	if m.isEncrypted {
-		smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: 🔒 Encrypted]")
+		smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
 	} else if m.isSMIME {
 		if m.smimeTrusted {
-			smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: ✅ Trusted]")
+			smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
 		} else {
-			smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(" [S/MIME: ❌ Untrusted]")
+			smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
 		}
 	}
 

tui/inbox.go 🔗

@@ -12,6 +12,7 @@ import (
 	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
+	"github.com/floatpane/matcha/theme"
 )
 
 var (
@@ -314,7 +315,7 @@ func (m *Inbox) updateList() {
 	l.Title = m.getTitle()
 	l.SetShowStatusBar(true)
 	l.SetFilteringEnabled(true)
-	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+	l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
 	l.Styles.PaginationStyle = paginationStyle
 	l.Styles.HelpStyle = inboxHelpStyle
 	l.SetStatusBarItemName("email", "emails")

tui/settings.go 🔗

@@ -8,6 +8,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/theme"
 )
 
 var (
@@ -27,6 +28,7 @@ const (
 	SettingsAccounts
 	SettingsMailingLists
 	SettingsSMIMEConfig
+	SettingsTheme
 )
 
 // Settings displays the settings screen.
@@ -91,6 +93,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyPressMsg:
 		if m.state == SettingsMain {
 			return m.updateMain(msg)
+		} else if m.state == SettingsTheme {
+			return m.updateTheme(msg)
 		} else if m.state == SettingsMailingLists {
 			return m.updateMailingLists(msg)
 		} else if m.state == SettingsSMIMEConfig {
@@ -120,8 +124,8 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			m.cursor--
 		}
 	case "down", "j":
-		// Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature, 3: Contextual Tips, 4: Mailing Lists
-		if m.cursor < 4 {
+		// Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Mailing Lists
+		if m.cursor < 5 {
 			m.cursor++
 		}
 	case "enter":
@@ -130,19 +134,29 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			m.state = SettingsAccounts
 			m.cursor = 0
 			return m, nil
-		case 1: // Image Display
+		case 1: // Theme
+			m.state = SettingsTheme
+			// Position cursor on the currently active theme
+			themes := theme.AllThemes()
+			m.cursor = 0
+			for i, t := range themes {
+				if t.Name == theme.ActiveTheme.Name {
+					m.cursor = i
+					break
+				}
+			}
+			return m, nil
+		case 2: // Image Display
 			m.cfg.DisableImages = !m.cfg.DisableImages
-			// Save config immediately
 			_ = config.SaveConfig(m.cfg)
 			return m, nil
-		case 2: // Edit Signature
+		case 3: // Edit Signature
 			return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
-		case 3: // Contextual Tips
+		case 4: // Contextual Tips
 			m.cfg.HideTips = !m.cfg.HideTips
-			// Save config immediately
 			_ = config.SaveConfig(m.cfg)
 			return m, nil
-		case 4: // Mailing Lists
+		case 5: // Mailing Lists
 			m.state = SettingsMailingLists
 			m.cursor = 0
 			return m, nil
@@ -324,6 +338,8 @@ func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
 func (m *Settings) View() tea.View {
 	if m.state == SettingsMain {
 		return tea.NewView(m.viewMain())
+	} else if m.state == SettingsTheme {
+		return tea.NewView(m.viewTheme())
 	} else if m.state == SettingsMailingLists {
 		return tea.NewView(m.viewMailingLists())
 	} else if m.state == SettingsSMIMEConfig {
@@ -345,47 +361,56 @@ func (m *Settings) viewMain() string {
 	}
 	b.WriteString("\n")
 
-	// Option 1: Image Display
+	// Option 1: Theme
+	themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
+	if m.cursor == 1 {
+		b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
+	} else {
+		b.WriteString(accountItemStyle.Render("  " + themeText))
+	}
+	b.WriteString("\n")
+
+	// Option 2: Image Display
 	status := "ON"
 	if m.cfg.DisableImages {
 		status = "OFF"
 	}
 	text := fmt.Sprintf("Image Display: %s", status)
-	if m.cursor == 1 {
+	if m.cursor == 2 {
 		b.WriteString(selectedAccountItemStyle.Render("> " + text))
 	} else {
 		b.WriteString(accountItemStyle.Render("  " + text))
 	}
 	b.WriteString("\n")
 
-	// Option 2: Edit Signature
+	// Option 3: Edit Signature
 	sigText := "Edit Signature"
 	if config.HasSignature() {
 		sigText = "Edit Signature (configured)"
 	}
-	if m.cursor == 2 {
+	if m.cursor == 3 {
 		b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
 	} else {
 		b.WriteString(accountItemStyle.Render("  " + sigText))
 	}
 	b.WriteString("\n")
 
-	// Option 3: Contextual Tips
+	// Option 4: Contextual Tips
 	tipsStatus := "ON"
 	if m.cfg.HideTips {
 		tipsStatus = "OFF"
 	}
 	tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
-	if m.cursor == 3 {
+	if m.cursor == 4 {
 		b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
 	} else {
 		b.WriteString(accountItemStyle.Render("  " + tipsText))
 	}
 	b.WriteString("\n")
 
-	// Option 4: Mailing Lists
+	// Option 5: Mailing Lists
 	mailingListsText := "Mailing Lists"
-	if m.cursor == 4 {
+	if m.cursor == 5 {
 		b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
 	} else {
 		b.WriteString(accountItemStyle.Render("  " + mailingListsText))
@@ -398,12 +423,14 @@ func (m *Settings) viewMain() string {
 		case 0:
 			tip = "Manage your connected email accounts."
 		case 1:
-			tip = "Toggle displaying images in emails."
+			tip = "Choose a color theme for the application."
 		case 2:
-			tip = "Configure the signature appended to your outgoing emails."
+			tip = "Toggle displaying images in emails."
 		case 3:
-			tip = "Toggle displaying helpful contextual tips like this one."
+			tip = "Configure the signature appended to your outgoing emails."
 		case 4:
+			tip = "Toggle displaying helpful contextual tips like this one."
+		case 5:
 			tip = "Manage groups of email addresses to quickly send to multiple people."
 		}
 		if tip != "" {
@@ -630,6 +657,150 @@ func (m *Settings) viewMailingLists() string {
 	return docStyle.Render(mainContent + helpView)
 }
 
+func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	themes := theme.AllThemes()
+
+	switch msg.String() {
+	case "up", "k":
+		if m.cursor > 0 {
+			m.cursor--
+		}
+	case "down", "j":
+		if m.cursor < len(themes)-1 {
+			m.cursor++
+		}
+	case "enter":
+		if m.cursor < len(themes) {
+			selected := themes[m.cursor]
+			theme.SetTheme(selected.Name)
+			RebuildStyles()
+			m.cfg.Theme = selected.Name
+			_ = config.SaveConfig(m.cfg)
+		}
+		m.state = SettingsMain
+		m.cursor = 1 // Return to Theme option
+		return m, nil
+	case "esc":
+		m.state = SettingsMain
+		m.cursor = 1 // Return to Theme option
+		return m, nil
+	}
+	return m, nil
+}
+
+func (m *Settings) viewTheme() string {
+	themes := theme.AllThemes()
+
+	// Build left panel: theme list
+	var left strings.Builder
+	left.WriteString(titleStyle.Render("Theme") + "\n\n")
+
+	for i, t := range themes {
+		isActive := t.Name == theme.ActiveTheme.Name
+
+		label := t.Name
+		if isActive {
+			label += " (active)"
+		}
+
+		if m.cursor == i {
+			left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
+		} else {
+			left.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", label)))
+		}
+		left.WriteString("\n")
+	}
+
+	left.WriteString("\n")
+	if !m.cfg.HideTips {
+		left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
+	}
+
+	// Build right panel: theme preview
+	var previewTheme theme.Theme
+	if m.cursor < len(themes) {
+		previewTheme = themes[m.cursor]
+	} else {
+		previewTheme = theme.ActiveTheme
+	}
+	preview := renderThemePreview(previewTheme, m.width)
+
+	// Join panels side by side
+	leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
+	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, "  ", preview)
+
+	helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
+
+	if m.height > 0 {
+		currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
+		gap := m.height - currentHeight
+		if gap > 0 {
+			content += strings.Repeat("\n", gap)
+		}
+	} else {
+		content += "\n\n"
+	}
+
+	return docStyle.Render(content + "\n" + helpView)
+}
+
+// renderThemePreview renders a small mockup showing how a theme looks.
+func renderThemePreview(t theme.Theme, maxWidth int) string {
+	previewWidth := maxWidth - 38 // 30 for left panel + padding/margins
+	if previewWidth < 30 {
+		previewWidth = 30
+	}
+	if previewWidth > 60 {
+		previewWidth = 60
+	}
+
+	accent := lipgloss.NewStyle().Foreground(t.Accent)
+	accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+	secondary := lipgloss.NewStyle().Foreground(t.Secondary)
+	muted := lipgloss.NewStyle().Foreground(t.MutedText)
+	dim := lipgloss.NewStyle().Foreground(t.DimText)
+	danger := lipgloss.NewStyle().Foreground(t.Danger)
+	warn := lipgloss.NewStyle().Foreground(t.Warning)
+	tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
+	link := lipgloss.NewStyle().Foreground(t.Link)
+	title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
+	activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
+	activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
+
+	var b strings.Builder
+
+	b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
+
+	// Fake inbox tabs
+	b.WriteString(activeTab.Render("Inbox") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Drafts") + "\n")
+	b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
+
+	// Fake email list
+	b.WriteString(accentBold.Render("> ") + dim.Render("Alice  ") + accent.Render("Meeting tomorrow") + "  " + muted.Render("2m ago") + "\n")
+	b.WriteString("  " + dim.Render("Bob    ") + secondary.Render("Re: Project update") + "  " + muted.Render("1h ago") + "\n")
+	b.WriteString("  " + dim.Render("Carol  ") + secondary.Render("Quick question") + "    " + muted.Render("3h ago") + "\n\n")
+
+	// Folder sidebar sample
+	b.WriteString(accentBold.Render("Folders") + "\n")
+	b.WriteString(activeFolder.Render(" INBOX ") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Trash") + "\n\n")
+
+	// Status indicators
+	b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
+	b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
+	b.WriteString(warn.Render("Update available: v2.0") + "\n")
+	b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
+	b.WriteString(link.Render("https://example.com") + "\n")
+
+	box := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.AccentDark).
+		Padding(1, 2).
+		Width(previewWidth).
+		Render(b.String())
+
+	return box
+}
+
 // UpdateConfig updates the configuration (used when accounts are deleted).
 func (m *Settings) UpdateConfig(cfg *config.Config) {
 	m.cfg = cfg

tui/styles.go 🔗

@@ -6,6 +6,7 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/theme"
 )
 
 // ASCII logo for Matcha displayed during loading screens
@@ -57,7 +58,7 @@ type Status struct {
 func NewStatus(msg string) Status {
 	s := spinner.New()
 	s.Spinner = spinner.Dot
-	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+	s.Style = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent)
 	return Status{spinner: s, message: msg}
 }
 
@@ -70,7 +71,7 @@ func (m Status) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m Status) View() tea.View {
-	logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+	logoStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent)
 	styledLogo := logoStyle.Render(asciiLogo)
 
 	spinnerLine := fmt.Sprintf("   %s %s", m.spinner.View(), m.message)

tui/theme.go 🔗

@@ -0,0 +1,119 @@
+package tui
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/theme"
+)
+
+// RebuildStyles updates all package-level style variables to match the active theme.
+// This must be called after theme.SetTheme() and at startup.
+func RebuildStyles() {
+	t := theme.ActiveTheme
+
+	// styles.go
+	DialogBoxStyle = lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.AccentDark).
+		Padding(1, 0).
+		BorderTop(true).
+		BorderLeft(true).
+		BorderRight(true).
+		BorderBottom(true)
+
+	HelpStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+	TipStyle = lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
+	SuccessStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+	InfoStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+
+	H1Style = lipgloss.NewStyle().
+		Foreground(t.Accent).
+		Bold(true).
+		Align(lipgloss.Center)
+
+	H2Style = lipgloss.NewStyle().
+		Foreground(t.Accent).
+		Bold(false).
+		Align(lipgloss.Center)
+
+	// choice.go
+	titleStyle = lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
+	logoStyle = lipgloss.NewStyle().Foreground(t.Accent)
+	listHeader = lipgloss.NewStyle().Foreground(t.SubtleText).PaddingBottom(1)
+	itemStyle = lipgloss.NewStyle().PaddingLeft(2)
+	selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent)
+
+	// settings.go
+	accountItemStyle = lipgloss.NewStyle().PaddingLeft(2)
+	selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent)
+	accountEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+	dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
+	settingsFocusedStyle = lipgloss.NewStyle().Foreground(t.Accent)
+	settingsBlurredStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+
+	// composer.go
+	suggestionStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+	suggestionBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Secondary).Padding(0, 1)
+	focusedStyle = lipgloss.NewStyle().Foreground(t.Accent)
+	blurredStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+	noStyle = lipgloss.NewStyle()
+	helpStyle = lipgloss.NewStyle().Foreground(t.SubtleText)
+	focusedButton = focusedStyle.Render("[ Send ]")
+	blurredButton = blurredStyle.Render("[ Send ]")
+	emailRecipientStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+	attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary)
+	fromSelectorStyle = lipgloss.NewStyle().Foreground(t.Accent)
+	smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary)
+
+	// inbox.go
+	tabStyle = lipgloss.NewStyle().Padding(0, 2)
+	activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(t.Accent).Bold(true).Underline(true)
+	tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
+	dateStyle = lipgloss.NewStyle().Foreground(t.MutedText)
+	senderStyle = lipgloss.NewStyle().Foreground(t.DimText).Bold(true)
+
+	// folder_inbox.go
+	sidebarStyle = lipgloss.NewStyle().
+		Width(sidebarWidth).
+		BorderStyle(lipgloss.NormalBorder()).
+		BorderRight(true).
+		PaddingRight(1).
+		PaddingLeft(1)
+	sidebarTitleStyle = lipgloss.NewStyle().
+		Foreground(t.Accent).
+		Bold(true).
+		PaddingBottom(1)
+	folderStyle = lipgloss.NewStyle().
+		PaddingLeft(1).
+		PaddingRight(1)
+	activeFolderStyle = lipgloss.NewStyle().
+		PaddingLeft(1).
+		PaddingRight(1).
+		Background(t.Accent).
+		Foreground(t.Contrast).
+		Bold(true)
+	moveOverlayStyle = lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.AccentDark).
+		Padding(1, 2)
+	moveOverlayTitleStyle = lipgloss.NewStyle().
+		Foreground(t.Accent).
+		Bold(true).
+		PaddingBottom(1)
+	moveItemStyle = lipgloss.NewStyle().
+		PaddingLeft(1)
+	moveSelectedItemStyle = lipgloss.NewStyle().
+		PaddingLeft(1).
+		Foreground(t.Accent).
+		Bold(true)
+
+	// filepicker.go
+	filePickerItemStyle = lipgloss.NewStyle().PaddingLeft(2)
+	filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent)
+	directoryStyle = lipgloss.NewStyle().Foreground(t.Directory)
+	fileSizeStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+
+	// trash_archive.go
+	mailboxTabStyle = lipgloss.NewStyle().Padding(0, 3)
+	activeMailboxTabStyle = lipgloss.NewStyle().Padding(0, 3).Foreground(t.Accent).Bold(true).Underline(true)
+}

view/html.go 🔗

@@ -20,12 +20,15 @@ import (
 
 	"charm.land/lipgloss/v2"
 	"github.com/PuerkitoBio/goquery"
+	"github.com/floatpane/matcha/theme"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/renderer/html"
 	"golang.org/x/sys/unix"
 )
 
-var linkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9BC4FF")) // Cyan
+func linkStyle() lipgloss.Style {
+	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
+}
 
 // getTerminalCellSize returns the height of a terminal cell in pixels.
 // It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
@@ -143,14 +146,14 @@ func hyperlink(url, text string) string {
 
 	if supported {
 		// Use OSC 8 hyperlink sequence for supported terminals
-		return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle.Render(text))
+		return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
 	} else {
 		// Fallback to plain text format for unsupported terminals
 		// Use HTML-encoded angle brackets to prevent HTML parser from treating them as tags
 		if text == url {
-			return fmt.Sprintf("&lt;%s&gt;", linkStyle.Render(url))
+			return fmt.Sprintf("&lt;%s&gt;", linkStyle().Render(url))
 		}
-		return fmt.Sprintf("%s &lt;%s&gt;", linkStyle.Render(text), linkStyle.Render(url))
+		return fmt.Sprintf("%s &lt;%s&gt;", linkStyle().Render(text), linkStyle().Render(url))
 	}
 }
 
@@ -771,7 +774,7 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 		if hyperlinkSupported() {
 			s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
 		} else {
-			s.ReplaceWithHtml(fmt.Sprintf("\n %s \n", linkStyle.Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
+			s.ReplaceWithHtml(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
 		}
 	})
 
@@ -829,16 +832,18 @@ func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bod
 	return bodyStyle.Render(text), placements, nil
 }
 
-// quoteBoxStyle is the style for the quoted reply box border
-var quoteBoxStyle = lipgloss.NewStyle().
-	Border(lipgloss.RoundedBorder()).
-	BorderForeground(lipgloss.Color("240")).
-	Padding(0, 1).
-	Foreground(lipgloss.Color("240"))
+func quoteBoxStyle() lipgloss.Style {
+	return lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(theme.ActiveTheme.Secondary).
+		Padding(0, 1).
+		Foreground(theme.ActiveTheme.Secondary)
+}
 
-// quoteHeaderStyle is the style for the header line in the quote box
-var quoteHeaderStyle = lipgloss.NewStyle().
-	Foreground(lipgloss.Color("240"))
+func quoteHeaderStyle() lipgloss.Style {
+	return lipgloss.NewStyle().
+		Foreground(theme.ActiveTheme.Secondary)
+}
 
 // styleQuotedReplies detects quoted reply sections and styles them in a box
 func styleQuotedReplies(text string) string {
@@ -950,11 +955,11 @@ func renderQuoteBox(from, date string, lines []string) string {
 	var header string
 	if from != "" || date != "" {
 		if from != "" && date != "" {
-			header = quoteHeaderStyle.Render(from + "  " + date)
+			header = quoteHeaderStyle().Render(from + "  " + date)
 		} else if from != "" {
-			header = quoteHeaderStyle.Render(from)
+			header = quoteHeaderStyle().Render(from)
 		} else {
-			header = quoteHeaderStyle.Render(date)
+			header = quoteHeaderStyle().Render(date)
 		}
 	}
 
@@ -969,5 +974,5 @@ func renderQuoteBox(from, date string, lines []string) string {
 		boxContent = content
 	}
 
-	return quoteBoxStyle.Render(boxContent)
+	return quoteBoxStyle().Render(boxContent)
 }