From e200c4c95ae4506fc66a5f26335dccf657ca7ef4 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 25 Nov 2025 19:04:57 -0700 Subject: [PATCH] feat(tui): add WCAG-compliant light theme Add --light flag to enable light theme with accessible colors. Make theme colors WCAG AA compliant (4.5:1 contrast minimum). Add theme-aware markdown and diff colors to support both dark and light backgrounds. Assisted-by: Claude Opus 4.5 via Crush --- internal/cmd/root.go | 10 ++ internal/tui/styles/charmtone.go | 45 +++++++++ internal/tui/styles/light.go | 121 +++++++++++++++++++++++++ internal/tui/styles/theme.go | 151 +++++++++++++++++++++---------- 4 files changed, 278 insertions(+), 49 deletions(-) create mode 100644 internal/tui/styles/light.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 32905323dd9747d4bef112631265b25ef1ce0745..64b3ebaadf7aceb3ab9accef0002608052455ff1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -21,6 +21,7 @@ import ( "git.secluded.site/crush/internal/projects" "git.secluded.site/crush/internal/stringext" "git.secluded.site/crush/internal/tui" + "git.secluded.site/crush/internal/tui/styles" "git.secluded.site/crush/internal/version" "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/fang" @@ -35,6 +36,7 @@ func init() { rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory") rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory") rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug") + rootCmd.PersistentFlags().BoolP("light", "l", false, "Use light theme") rootCmd.Flags().BoolP("help", "h", false, "Help") rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)") @@ -177,9 +179,17 @@ func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) { func setupApp(cmd *cobra.Command) (*app.App, error) { debug, _ := cmd.Flags().GetBool("debug") yolo, _ := cmd.Flags().GetBool("yolo") + light, _ := cmd.Flags().GetBool("light") dataDir, _ := cmd.Flags().GetString("data-dir") ctx := cmd.Context() + // Set light theme if requested. + if light { + if err := styles.DefaultManager().SetTheme("light"); err != nil { + slog.Warn("Failed to set light theme", "error", err) + } + } + cwd, err := ResolveCwd(cmd) if err != nil { return nil, err diff --git a/internal/tui/styles/charmtone.go b/internal/tui/styles/charmtone.go index 44508e5a24e68ea0507af0f2649ddc372711104d..938fd70d1f0e4b462417b73c5fed0095b6aab4f2 100644 --- a/internal/tui/styles/charmtone.go +++ b/internal/tui/styles/charmtone.go @@ -56,6 +56,51 @@ func NewCharmtoneTheme() *Theme { RedDark: charmtone.Sriracha, RedLight: charmtone.Salmon, Cherry: charmtone.Cherry, + + // Markdown colors (dark theme). + MdText: charmtone.Smoke, + MdHeading: charmtone.Malibu, + MdH6: charmtone.Guac, + MdHRule: charmtone.Charcoal, + MdLink: charmtone.Zinc, + MdLinkText: charmtone.Guac, + MdImage: charmtone.Cheeky, + MdImageText: charmtone.Squid, + MdCodeFg: charmtone.Coral, + MdCodeBg: charmtone.Charcoal, + MdCodeBlockFg: charmtone.Charcoal, + MdCodeBlockBg: charmtone.Charcoal, + MdComment: charmtone.Oyster, + MdKeyword: charmtone.Malibu, + MdKeywordAlt: charmtone.Pony, + MdKeywordType: charmtone.Guppy, + MdOperator: charmtone.Salmon, + MdPunctuation: charmtone.Zest, + MdName: charmtone.Smoke, + MdNameBuiltin: charmtone.Cheeky, + MdNameTag: charmtone.Mauve, + MdNameAttr: charmtone.Hazy, + MdNameClass: charmtone.Salt, + MdNameDecorator: charmtone.Citron, + MdNameFunc: charmtone.Guac, + MdNumber: charmtone.Julep, + MdString: charmtone.Cumin, + MdStringEscape: charmtone.Bok, + MdDeleted: charmtone.Coral, + MdInserted: charmtone.Guac, + MdSubheading: charmtone.Squid, + MdError: charmtone.Butter, + MdErrorBg: charmtone.Sriracha, + + // Diff colors (dark theme). + DiffInsertFg: lipgloss.Color("#629657"), + DiffInsertBg: lipgloss.Color("#2b322a"), + DiffInsertSymBg: lipgloss.Color("#323931"), + DiffInsertCodeBg: lipgloss.Color("#323931"), + DiffDeleteFg: lipgloss.Color("#a45c59"), + DiffDeleteBg: lipgloss.Color("#312929"), + DiffDeleteSymBg: lipgloss.Color("#383030"), + DiffDeleteCodeBg: lipgloss.Color("#383030"), } // Text selection. diff --git a/internal/tui/styles/light.go b/internal/tui/styles/light.go new file mode 100644 index 0000000000000000000000000000000000000000..1d029eff98cdbbb6e57e1de7428dc0d51427b502 --- /dev/null +++ b/internal/tui/styles/light.go @@ -0,0 +1,121 @@ +package styles + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" +) + +func NewLightTheme() *Theme { + t := &Theme{ + Name: "light", + IsDark: false, + + Primary: charmtone.Charple, + Secondary: charmtone.Dolly, + Tertiary: charmtone.Bok, + Accent: charmtone.Zest, + + // Backgrounds (improved for accessibility) + BgBase: charmtone.Salt, // #F1EFEF - very light background + BgBaseLighter: lipgloss.Color("#FFFFFF"), // Pure white for areas needing more contrast + BgSubtle: charmtone.Ash, // #DFDBDD - light gray for subtle areas + BgOverlay: charmtone.Oyster, // #605F6B - darker overlay, better contrast + + // Foregrounds (darker for better accessibility on light backgrounds) + FgBase: charmtone.Pepper, // #201F26 - darkest, good contrast + FgMuted: charmtone.Charcoal, // #3A3943 - still quite dark + FgHalfMuted: charmtone.Iron, // #4D4C57 - medium dark + FgSubtle: charmtone.Oyster, // #605F6B - much better than Squid + FgSelected: charmtone.Pepper, // #201F26 - same as base for consistency + + // Borders (improved contrast) + Border: charmtone.Oyster, // #605F6B - better contrast than previous + BorderFocus: charmtone.Charple, // #6B50FF - same purple for focus + + // Status (same as dark, these work on both) + Success: charmtone.Turtle, + Error: charmtone.Cherry, + Warning: charmtone.Zest, + Info: charmtone.Sapphire, + + // Colors + White: charmtone.Salt, + + BlueLight: charmtone.Malibu, + BlueDark: charmtone.Sapphire, + Blue: charmtone.Guppy, + + Yellow: charmtone.Mustard, + Citron: charmtone.Citron, + + Green: charmtone.Turtle, + GreenDark: charmtone.Guac, + GreenLight: charmtone.Bok, + + Red: charmtone.Coral, + RedDark: charmtone.Cherry, + RedLight: charmtone.Salmon, + Cherry: charmtone.Cherry, + + // Markdown colors (light theme - accessible on Salt background). + MdText: charmtone.Pepper, // 14.26:1 contrast + MdHeading: lipgloss.Color("#0066CC"), // Dark blue, 4.86:1 + MdH6: lipgloss.Color("#006644"), // Forest green, 6.14:1 + MdHRule: charmtone.Oyster, // 5.48:1 + MdLink: lipgloss.Color("#007777"), // Dark teal, 4.69:1 + MdLinkText: lipgloss.Color("#006644"), // Forest green, 6.14:1 + MdImage: lipgloss.Color("#CC2255"), // Dark red, 4.67:1 + MdImageText: charmtone.Iron, // 7.37:1 + MdCodeFg: charmtone.Charcoal, // 9.93:1 on Ash + MdCodeBg: charmtone.Ash, // Light gray background + MdCodeBlockFg: charmtone.Pepper, // Dark text + MdCodeBlockBg: charmtone.Ash, // Light gray background + MdComment: charmtone.Oyster, // 5.48:1 + MdKeyword: lipgloss.Color("#0066CC"), // Dark blue, 4.86:1 + MdKeywordAlt: lipgloss.Color("#5533CC"), // Dark purple, 6.71:1 + MdKeywordType: charmtone.Sapphire, // 4.98:1 + MdOperator: lipgloss.Color("#CC2255"), // Dark red, 4.67:1 + MdPunctuation: charmtone.Charcoal, // 9.93:1 (can't use bright yellow) + MdName: charmtone.Charcoal, // 9.93:1 + MdNameBuiltin: lipgloss.Color("#CC2255"), // Dark red, 4.67:1 + MdNameTag: lipgloss.Color("#8B008B"), // Dark magenta, 7.42:1 + MdNameAttr: lipgloss.Color("#4B0082"), // Indigo, 11.31:1 + MdNameClass: charmtone.Pepper, // Darkest, bold + MdNameDecorator: lipgloss.Color("#B7410E"), // Rust, 4.86:1 + MdNameFunc: lipgloss.Color("#006644"), // Forest green, 6.14:1 + MdNumber: lipgloss.Color("#800080"), // Purple, 8.22:1 + MdString: lipgloss.Color("#8B4513"), // Saddle brown, 6.20:1 + MdStringEscape: lipgloss.Color("#006644"), // Forest green, 6.14:1 + MdDeleted: lipgloss.Color("#CC2255"), // Dark red, 4.67:1 + MdInserted: lipgloss.Color("#006644"), // Forest green, 6.14:1 + MdSubheading: charmtone.Iron, // 7.37:1 + MdError: charmtone.Pepper, // Dark text + MdErrorBg: lipgloss.Color("#FFCCCC"), // Light red background + + // Diff colors (light theme - light backgrounds with dark text). + DiffInsertFg: lipgloss.Color("#006644"), // Dark green text + DiffInsertBg: lipgloss.Color("#D4EDDA"), // Light green bg + DiffInsertSymBg: lipgloss.Color("#C3E6CB"), // Slightly darker green + DiffInsertCodeBg: lipgloss.Color("#D4EDDA"), // Light green bg + DiffDeleteFg: lipgloss.Color("#CC2255"), // Dark red text + DiffDeleteBg: lipgloss.Color("#F8D7DA"), // Light red bg + DiffDeleteSymBg: lipgloss.Color("#F5C6CB"), // Slightly darker red + DiffDeleteCodeBg: lipgloss.Color("#F8D7DA"), // Light red bg + } + + // Text selection. + t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) + + // LSP and MCP status (improved accessibility). + t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Oyster).SetString("●") + t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron) + t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Cherry) + t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Turtle) + + t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Pepper).Background(charmtone.Citron).Bold(true).SetString(" ! ") + t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Salt).Background(charmtone.Iron) + t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::") + t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Oyster) + + return t +} diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 01dd3a7192c787f3ecbf22513b0e79a07aa0752d..5ea1ae0da0517c6d87f1ae909798cad3773d3447 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -76,6 +76,51 @@ type Theme struct { RedLight color.Color Cherry color.Color + // Markdown colors (theme-aware for accessibility). + MdText color.Color // Document text + MdHeading color.Color // Heading color + MdH6 color.Color // H6 color + MdHRule color.Color // Horizontal rule + MdLink color.Color // Link URL + MdLinkText color.Color // Link text + MdImage color.Color // Image URL + MdImageText color.Color // Image alt text + MdCodeFg color.Color // Inline code foreground + MdCodeBg color.Color // Inline code background + MdCodeBlockFg color.Color // Code block text + MdCodeBlockBg color.Color // Code block background + MdComment color.Color // Code comment + MdKeyword color.Color // Keyword + MdKeywordAlt color.Color // Keyword reserved/namespace + MdKeywordType color.Color // Type keyword + MdOperator color.Color // Operator + MdPunctuation color.Color // Punctuation + MdName color.Color // Name/identifier + MdNameBuiltin color.Color // Builtin name + MdNameTag color.Color // Tag name + MdNameAttr color.Color // Attribute name + MdNameClass color.Color // Class name + MdNameDecorator color.Color // Decorator + MdNameFunc color.Color // Function name + MdNumber color.Color // Literal number + MdString color.Color // Literal string + MdStringEscape color.Color // String escape + MdDeleted color.Color // Deleted text + MdInserted color.Color // Inserted text + MdSubheading color.Color // Subheading + MdError color.Color // Error foreground + MdErrorBg color.Color // Error background + + // Diff colors (theme-aware). + DiffInsertFg color.Color // Insert line number fg + DiffInsertBg color.Color // Insert line number bg + DiffInsertSymBg color.Color // Insert symbol bg + DiffInsertCodeBg color.Color // Insert code bg + DiffDeleteFg color.Color // Delete line number fg + DiffDeleteBg color.Color // Delete line number bg + DiffDeleteSymBg color.Color // Delete symbol bg + DiffDeleteCodeBg color.Color // Delete code bg + // Text selection. TextSelection lipgloss.Style @@ -221,7 +266,7 @@ func (t *Theme) buildStyles() *Styles { StylePrimitive: ansi.StylePrimitive{ // BlockPrefix: "\n", // BlockSuffix: "\n", - Color: stringPtr(charmtone.Smoke.Hex()), + Color: stringPtr(ToHex(t.MdText)), }, // Margin: uintPtr(defaultMargin), }, @@ -236,7 +281,7 @@ func (t *Theme) buildStyles() *Styles { Heading: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BlockSuffix: "\n", - Color: stringPtr(charmtone.Malibu.Hex()), + Color: stringPtr(ToHex(t.MdHeading)), Bold: boolPtr(true), }, }, @@ -272,7 +317,7 @@ func (t *Theme) buildStyles() *Styles { H6: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "###### ", - Color: stringPtr(charmtone.Guac.Hex()), + Color: stringPtr(ToHex(t.MdH6)), Bold: boolPtr(false), }, }, @@ -286,7 +331,7 @@ func (t *Theme) buildStyles() *Styles { Bold: boolPtr(true), }, HorizontalRule: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), + Color: stringPtr(ToHex(t.MdHRule)), Format: "\n--------\n", }, Item: ansi.StylePrimitive{ @@ -301,117 +346,117 @@ func (t *Theme) buildStyles() *Styles { Unticked: "[ ] ", }, Link: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zinc.Hex()), + Color: stringPtr(ToHex(t.MdLink)), Underline: boolPtr(true), }, LinkText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), + Color: stringPtr(ToHex(t.MdLinkText)), Bold: boolPtr(true), }, Image: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), + Color: stringPtr(ToHex(t.MdImage)), Underline: boolPtr(true), }, ImageText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), + Color: stringPtr(ToHex(t.MdImageText)), Format: "Image: {{.text}} →", }, Code: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: " ", Suffix: " ", - Color: stringPtr(charmtone.Coral.Hex()), - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + Color: stringPtr(ToHex(t.MdCodeFg)), + BackgroundColor: stringPtr(ToHex(t.MdCodeBg)), }, }, CodeBlock: ansi.StyleCodeBlock{ StyleBlock: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), + Color: stringPtr(ToHex(t.MdCodeBlockFg)), }, Margin: uintPtr(defaultMargin), }, Chroma: &ansi.Chroma{ Text: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), + Color: stringPtr(ToHex(t.MdText)), }, Error: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Butter.Hex()), - BackgroundColor: stringPtr(charmtone.Sriracha.Hex()), + Color: stringPtr(ToHex(t.MdError)), + BackgroundColor: stringPtr(ToHex(t.MdErrorBg)), }, Comment: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Oyster.Hex()), + Color: stringPtr(ToHex(t.MdComment)), }, CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bengal.Hex()), + Color: stringPtr(ToHex(t.MdComment)), }, Keyword: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Malibu.Hex()), + Color: stringPtr(ToHex(t.MdKeyword)), }, KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), + Color: stringPtr(ToHex(t.MdKeywordAlt)), }, KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), + Color: stringPtr(ToHex(t.MdKeywordAlt)), }, KeywordType: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guppy.Hex()), + Color: stringPtr(ToHex(t.MdKeywordType)), }, Operator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salmon.Hex()), + Color: stringPtr(ToHex(t.MdOperator)), }, Punctuation: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zest.Hex()), + Color: stringPtr(ToHex(t.MdPunctuation)), }, Name: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), + Color: stringPtr(ToHex(t.MdName)), }, NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), + Color: stringPtr(ToHex(t.MdNameBuiltin)), }, NameTag: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Mauve.Hex()), + Color: stringPtr(ToHex(t.MdNameTag)), }, NameAttribute: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Hazy.Hex()), + Color: stringPtr(ToHex(t.MdNameAttr)), }, NameClass: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salt.Hex()), + Color: stringPtr(ToHex(t.MdNameClass)), Underline: boolPtr(true), Bold: boolPtr(true), }, NameDecorator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Citron.Hex()), + Color: stringPtr(ToHex(t.MdNameDecorator)), }, NameFunction: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), + Color: stringPtr(ToHex(t.MdNameFunc)), }, LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Julep.Hex()), + Color: stringPtr(ToHex(t.MdNumber)), }, LiteralString: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cumin.Hex()), + Color: stringPtr(ToHex(t.MdString)), }, LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bok.Hex()), + Color: stringPtr(ToHex(t.MdStringEscape)), }, GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Coral.Hex()), + Color: stringPtr(ToHex(t.MdDeleted)), }, GenericEmph: ansi.StylePrimitive{ Italic: boolPtr(true), }, GenericInserted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), + Color: stringPtr(ToHex(t.MdInserted)), }, GenericStrong: ansi.StylePrimitive{ Bold: boolPtr(true), }, GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), + Color: stringPtr(ToHex(t.MdSubheading)), }, Background: ansi.StylePrimitive{ - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + BackgroundColor: stringPtr(ToHex(t.MdCodeBlockBg)), }, }, }, @@ -460,23 +505,23 @@ func (t *Theme) buildStyles() *Styles { }, InsertLine: diffview.LineStyle{ LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#2b322a")), + Foreground(t.DiffInsertFg). + Background(t.DiffInsertBg), Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#323931")), + Foreground(t.DiffInsertFg). + Background(t.DiffInsertSymBg), Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#323931")), + Background(t.DiffInsertCodeBg), }, DeleteLine: diffview.LineStyle{ LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#312929")), + Foreground(t.DiffDeleteFg). + Background(t.DiffDeleteBg), Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#383030")), + Foreground(t.DiffDeleteFg). + Background(t.DiffDeleteSymBg), Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#383030")), + Background(t.DiffDeleteCodeBg), }, }, FilePicker: filepicker.Styles{ @@ -525,9 +570,11 @@ func NewManager() *Manager { themes: make(map[string]*Theme), } - t := NewCharmtoneTheme() // default theme - m.Register(t) - m.current = m.themes[t.Name] + dark := NewCharmtoneTheme() + light := NewLightTheme() + m.Register(dark) + m.Register(light) + m.current = dark // default to dark theme return m } @@ -563,6 +610,12 @@ func ParseHex(hex string) color.Color { return color.RGBA{R: r, G: g, B: b, A: 255} } +// ToHex converts a color.Color to hex string. +func ToHex(c color.Color) string { + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02X%02X%02X", r>>8, g>>8, b>>8) +} + // Alpha returns a color with transparency func Alpha(c color.Color, alpha uint8) color.Color { r, g, b, _ := c.RGBA()