From da21b17e47a210d6f96ecf49e649895c6448f97a Mon Sep 17 00:00:00 2001 From: Jan Smrcka Date: Tue, 24 Feb 2026 09:05:36 +0100 Subject: [PATCH] feat: switch themes from GitHub to Catppuccin, add CardBg and WCAG contrast tests --- internal/theme/theme.go | 155 ++++++++++++++++++----------------- internal/theme/theme_test.go | 113 ++++++++++++++++++++++++- internal/ui/diff.go | 4 +- internal/ui/log.go | 20 +++-- internal/ui/model.go | 141 +++++++++++++++++++------------ internal/ui/model_test.go | 70 ++++++++++++++-- internal/ui/styles.go | 20 ++--- 7 files changed, 366 insertions(+), 157 deletions(-) diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 0c67a79..ae15c6b 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -35,6 +35,9 @@ type Theme struct { RenamedFg string UntrackedFg string + // Card + CardBg string + // Chrome BorderFg string StatusBarBg string @@ -55,86 +58,90 @@ var Themes = map[string]Theme{ "light": LightTheme(), } -// DarkTheme returns a GitHub Dark-inspired theme. +// DarkTheme returns a Catppuccin Mocha-inspired pastel dark theme. func DarkTheme() Theme { return Theme{ - Bg: "#0d1117", - Fg: "#c9d1d9", - - AddedFg: "#3fb950", - AddedBg: "#12261e", - RemovedFg: "#f85149", - RemovedBg: "#2d1214", - HunkFg: "#58a6ff", - - LineNumFg: "#484f58", - LineNumAddedFg: "#2ea043", - LineNumRemovedFg: "#da3633", - - HeaderBg: "#161b22", - HeaderFg: "#58a6ff", - - HunkBg: "#161b22", - - SelectedBg: "#161b22", - SelectedFg: "#f0f6fc", - StagedFg: "#3fb950", - ModifiedFg: "#d29922", - AddedFileFg: "#3fb950", - DeletedFg: "#f85149", - RenamedFg: "#d2a8ff", - UntrackedFg: "#8b949e", - - BorderFg: "#30363d", - StatusBarBg: "#161b22", - StatusBarFg: "#8b949e", - HelpKeyFg: "#58a6ff", - HelpDescFg: "#8b949e", - - AccentFg: "#58a6ff", - - ChromaStyle: "github-dark", + Bg: "#1e1e2e", + Fg: "#e0e0f0", + + AddedFg: "#a6e3a1", + AddedBg: "#1e3a2c", + RemovedFg: "#f38ba8", + RemovedBg: "#3b1d2e", + HunkFg: "#6c5ce7", + + LineNumFg: "#585b70", + LineNumAddedFg: "#a6e3a1", + LineNumRemovedFg: "#f38ba8", + + HeaderBg: "#282a3a", + HeaderFg: "#c678dd", + + HunkBg: "#252636", + + CardBg: "#232336", + + SelectedBg: "#3d2b5a", + SelectedFg: "#c678dd", + StagedFg: "#50fa7b", + ModifiedFg: "#fab387", + AddedFileFg: "#a6e3a1", + DeletedFg: "#f38ba8", + RenamedFg: "#cba6f7", + UntrackedFg: "#7f849c", + + BorderFg: "#6c5ce7", + StatusBarBg: "#1a1a2e", + StatusBarFg: "#b4befe", + HelpKeyFg: "#c678dd", + HelpDescFg: "#9399b2", + + AccentFg: "#c678dd", + + ChromaStyle: "catppuccin-mocha", } } -// LightTheme returns a GitHub Light-inspired theme. +// LightTheme returns a Catppuccin Latte-inspired pastel light theme. func LightTheme() Theme { return Theme{ - Bg: "#ffffff", - Fg: "#1f2328", - - AddedFg: "#1a7f37", - AddedBg: "#dafbe1", - RemovedFg: "#cf222e", - RemovedBg: "#ffebe9", - HunkFg: "#0969da", - - LineNumFg: "#8c959f", - LineNumAddedFg: "#1a7f37", - LineNumRemovedFg: "#cf222e", - - HeaderBg: "#f6f8fa", - HeaderFg: "#0969da", - - HunkBg: "#f6f8fa", - - SelectedBg: "#f6f8fa", - SelectedFg: "#1f2328", - StagedFg: "#1a7f37", - ModifiedFg: "#9a6700", - AddedFileFg: "#1a7f37", - DeletedFg: "#cf222e", - RenamedFg: "#8250df", - UntrackedFg: "#656d76", - - BorderFg: "#d0d7de", - StatusBarBg: "#f6f8fa", - StatusBarFg: "#656d76", - HelpKeyFg: "#0969da", - HelpDescFg: "#656d76", - - AccentFg: "#0969da", - - ChromaStyle: "github", + Bg: "#eff1f5", + Fg: "#4c4f69", + + AddedFg: "#1a7f2a", + AddedBg: "#e6f5e4", + RemovedFg: "#d20f39", + RemovedBg: "#fde4e8", + HunkFg: "#1e66f5", + + LineNumFg: "#9ca0b0", + LineNumAddedFg: "#1a7f2a", + LineNumRemovedFg: "#d20f39", + + HeaderBg: "#e6e9ef", + HeaderFg: "#8839ef", + + HunkBg: "#e6e9ef", + + CardBg: "#e6e9ef", + + SelectedBg: "#d4c4f0", + SelectedFg: "#4c4f69", + StagedFg: "#087f23", + ModifiedFg: "#fe640b", + AddedFileFg: "#1a7f2a", + DeletedFg: "#d20f39", + RenamedFg: "#8839ef", + UntrackedFg: "#8c8fa1", + + BorderFg: "#8839ef", + StatusBarBg: "#e6e9ef", + StatusBarFg: "#6c6f85", + HelpKeyFg: "#8839ef", + HelpDescFg: "#8c8fa1", + + AccentFg: "#8839ef", + + ChromaStyle: "catppuccin-latte", } } diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go index b0f952d..2fce9e9 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -1,8 +1,10 @@ package theme import ( + "math" "reflect" - "strings" + "regexp" + "strconv" "testing" ) @@ -41,8 +43,8 @@ func TestLightTheme_NonEmpty(t *testing.T) { func TestDarkTheme_ChromaStyle(t *testing.T) { t.Parallel() th := DarkTheme() - if !strings.Contains(th.ChromaStyle, "dark") { - t.Errorf("dark theme ChromaStyle=%q, expected to contain 'dark'", th.ChromaStyle) + if th.ChromaStyle == "" { + t.Error("dark theme ChromaStyle should not be empty") } } @@ -54,6 +56,111 @@ func TestLightTheme_ChromaStyle(t *testing.T) { } } +var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +func checkValidHex(t *testing.T, th Theme, label string) { + t.Helper() + v := reflect.ValueOf(th) + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + name := typ.Field(i).Name + if field.Kind() != reflect.String || name == "ChromaStyle" { + continue + } + if !hexColorRe.MatchString(field.String()) { + t.Errorf("%s.%s = %q is not valid #RRGGBB", label, name, field.String()) + } + } +} + +func TestDarkTheme_ValidHex(t *testing.T) { + t.Parallel() + checkValidHex(t, DarkTheme(), "DarkTheme") +} + +func TestLightTheme_ValidHex(t *testing.T) { + t.Parallel() + checkValidHex(t, LightTheme(), "LightTheme") +} + +// relativeLuminance computes WCAG relative luminance from a hex color. +func relativeLuminance(hex string) float64 { + r, _ := strconv.ParseInt(hex[1:3], 16, 64) + g, _ := strconv.ParseInt(hex[3:5], 16, 64) + b, _ := strconv.ParseInt(hex[5:7], 16, 64) + linearize := func(c int64) float64 { + s := float64(c) / 255.0 + if s <= 0.04045 { + return s / 12.92 + } + return math.Pow((s+0.055)/1.055, 2.4) + } + return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b) +} + +// contrastRatio computes WCAG contrast ratio between two hex colors. +func contrastRatio(hex1, hex2 string) float64 { + l1 := relativeLuminance(hex1) + l2 := relativeLuminance(hex2) + if l1 < l2 { + l1, l2 = l2, l1 + } + return (l1 + 0.05) / (l2 + 0.05) +} + +type contrastPair struct { + fg, bg string + minRatio float64 + label string +} + +func checkContrast(t *testing.T, th Theme, label string) { + t.Helper() + pairs := []contrastPair{ + {th.Fg, th.Bg, 4.5, "Fg/Bg"}, + {th.AddedFg, th.AddedBg, 3.0, "AddedFg/AddedBg"}, + {th.RemovedFg, th.RemovedBg, 3.0, "RemovedFg/RemovedBg"}, + {th.HeaderFg, th.HeaderBg, 3.0, "HeaderFg/HeaderBg"}, + {th.SelectedFg, th.SelectedBg, 3.0, "SelectedFg/SelectedBg"}, + {th.StatusBarFg, th.StatusBarBg, 3.0, "StatusBarFg/StatusBarBg"}, + {th.Fg, th.CardBg, 4.5, "Fg/CardBg"}, + {th.HelpKeyFg, th.Bg, 3.0, "HelpKeyFg/Bg"}, + } + for _, p := range pairs { + ratio := contrastRatio(p.fg, p.bg) + if ratio < p.minRatio { + t.Errorf("%s %s: contrast %.2f < %.1f (fg=%s bg=%s)", + label, p.label, ratio, p.minRatio, p.fg, p.bg) + } + } +} + +func TestDarkTheme_ContrastRatios(t *testing.T) { + t.Parallel() + checkContrast(t, DarkTheme(), "DarkTheme") +} + +func TestLightTheme_ContrastRatios(t *testing.T) { + t.Parallel() + checkContrast(t, LightTheme(), "LightTheme") +} + +// TestContrastRatio_KnownValues verifies the formula against known WCAG values. +func TestContrastRatio_KnownValues(t *testing.T) { + t.Parallel() + // Black on white = 21:1 + ratio := contrastRatio("#ffffff", "#000000") + if math.Abs(ratio-21.0) > 0.1 { + t.Errorf("white/black contrast = %.2f, want ~21.0", ratio) + } + // Same color = 1:1 + ratio = contrastRatio("#888888", "#888888") + if math.Abs(ratio-1.0) > 0.01 { + t.Errorf("same color contrast = %.2f, want 1.0", ratio) + } +} + func TestThemes_DarkEqualsFunction(t *testing.T) { t.Parallel() if !reflect.DeepEqual(Themes["dark"], DarkTheme()) { diff --git a/internal/ui/diff.go b/internal/ui/diff.go index 112dc25..6efadfc 100644 --- a/internal/ui/diff.go +++ b/internal/ui/diff.go @@ -338,7 +338,7 @@ func RenderSplitDiff(parsed ParsedDiff, filename string, styles Styles, t theme. left := renderSplitSide(sl.Left, filename, styles, t, panelW, true) right := renderSplitSide(sl.Right, filename, styles, t, panelW, false) b.WriteString(left) - b.WriteString(styles.Border.Render("│")) + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(t.BorderFg)).Render("│")) b.WriteString(right) b.WriteByte('\n') } @@ -356,7 +356,7 @@ func RenderNewFileSplit(content, filename string, styles Styles, t theme.Theme, left := renderSplitSide(nil, filename, styles, t, panelW, true) right := renderSplitSide(&dl, filename, styles, t, panelW, false) b.WriteString(left) - b.WriteString(styles.Border.Render("│")) + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(t.BorderFg)).Render("│")) b.WriteString(right) b.WriteByte('\n') } diff --git a/internal/ui/log.go b/internal/ui/log.go index 91e10f0..3808199 100644 --- a/internal/ui/log.go +++ b/internal/ui/log.go @@ -59,7 +59,7 @@ func (m LogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.viewport = viewport.New(m.width, m.height-2) + m.viewport = viewport.New(m.width-2, m.height-4) m.ready = true case logLoadedMsg: m.commits = msg.commits @@ -189,10 +189,12 @@ func (m LogModel) View() string { } func (m LogModel) viewList() string { - mainH := m.height - 2 + contentH := m.height - 4 // card borders + status + help + cardW := m.width - 2 // inner width (card adds 2 for borders) + var b strings.Builder for i, c := range m.commits { - if i >= mainH { + if i >= contentH { break } line := m.renderCommitLine(c, i == m.cursor) @@ -202,11 +204,11 @@ func (m LogModel) viewList() string { } } - main := lipgloss.NewStyle().Width(m.width).Height(mainH).Render(b.String()) + card := renderCard(m.theme, "Commits", b.String(), true, cardW, contentH) status := m.styles.StatusBar.Width(m.width).Render( fmt.Sprintf(" %d commits", len(m.commits))) help := m.renderLogHelp(false) - return lipgloss.JoinVertical(lipgloss.Left, main, status, help) + return lipgloss.JoinVertical(lipgloss.Left, card, status, help) } func (m LogModel) renderCommitLine(c git.Commit, selected bool) string { @@ -220,14 +222,16 @@ func (m LogModel) renderCommitLine(c git.Commit, selected bool) string { } func (m LogModel) viewDiff() string { - mainH := m.height - 2 - diff := lipgloss.NewStyle().Width(m.width).Height(mainH).Render(m.viewport.View()) + contentH := m.height - 4 + cardW := m.width - 2 c := m.commits[m.cursor] + title := c.Short + " " + c.Subject + card := renderCard(m.theme, title, m.viewport.View(), true, cardW, contentH) status := m.styles.StatusBar.Width(m.width).Render( fmt.Sprintf(" %s %s — %s", c.Short, c.Subject, c.Author)) help := m.renderLogHelp(true) - return lipgloss.JoinVertical(lipgloss.Left, diff, status, help) + return lipgloss.JoinVertical(lipgloss.Left, card, status, help) } func (m LogModel) renderLogHelp(inDiff bool) string { diff --git a/internal/ui/model.go b/internal/ui/model.go index 4a86565..4ab996e 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -927,9 +927,9 @@ func (m Model) generateCommitMsgCmd() tea.Cmd { } } -// Layout: header(1) + content + status(1) + help(1) = height -func (m Model) contentHeight() int { return m.height - 3 } -func (m Model) diffWidth() int { return m.width - fileListWidth - 1 } +// Layout: cards(content+2 border) + status(1) + help(1) = height +func (m Model) contentHeight() int { return m.height - 4 } +func (m Model) diffWidth() int { return m.width - fileListWidth - 2 - 1 - 2 } const ( minWidth = 60 @@ -946,77 +946,108 @@ func (m Model) View() string { m.width, m.height, minWidth, minHeight) } - header := m.renderHeader() contentH := m.contentHeight() - var fileList string + var fileContent string if m.mode == modeBranchPicker { - fileList = m.renderBranchList(contentH) + fileContent = m.renderBranchList(contentH) } else { - fileList = m.renderFileList(contentH) + fileContent = m.renderFileList(contentH) } - filePanel := lipgloss.NewStyle(). - Width(fileListWidth).Height(contentH). - Render(fileList) + fileCard := m.renderCard( + m.fileCardTitle(), fileContent, + m.mode == modeFileList || m.mode == modeBranchPicker, + fileListWidth, contentH, + ) - border := m.renderBorder(contentH) - diffPanel := lipgloss.NewStyle(). - Width(m.diffWidth()).Height(contentH). - Render(m.viewport.View()) + diffCard := m.renderCard( + m.diffCardTitle(), m.viewport.View(), + m.mode == modeDiff, + m.diffWidth(), contentH, + ) - main := lipgloss.JoinHorizontal(lipgloss.Top, filePanel, border, diffPanel) + main := lipgloss.JoinHorizontal(lipgloss.Top, fileCard, " ", diffCard) statusBar := m.renderStatusBar() if m.mode == modeCommit { - return lipgloss.JoinVertical(lipgloss.Left, header, main, statusBar, m.renderCommitBar()) + return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, m.renderCommitBar()) } if m.mode == modeBranchPicker && m.branchCreating { - return lipgloss.JoinVertical(lipgloss.Left, header, main, statusBar, m.renderBranchCreateBar()) + return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, m.renderBranchCreateBar()) } helpBar := m.renderHelpBar() - return lipgloss.JoinVertical(lipgloss.Left, header, main, statusBar, helpBar) + return lipgloss.JoinVertical(lipgloss.Left, main, statusBar, helpBar) } -func (m Model) renderHeader() string { - branch := m.repo.BranchName() - left := m.styles.HeaderBar.Render(" " + branch) +func (m Model) renderCard(title, content string, focused bool, w, h int) string { + return renderCard(m.theme, title, content, focused, w, h) +} - mode := "" - if m.ref != "" { - mode = m.styles.Accent.Render(" ref:" + m.ref) - } else if m.stagedOnly { - mode = m.styles.Accent.Render(" staged only") +// renderCard builds a bordered card with Unicode box chars. +// Focused cards use AccentFg, unfocused use BorderFg. +func renderCard(t theme.Theme, title, content string, focused bool, w, h int) string { + borderColor := lipgloss.Color(t.BorderFg) + if focused { + borderColor = lipgloss.Color(t.AccentFg) } + bs := lipgloss.NewStyle().Foreground(borderColor) - right := "" - if len(m.files) > 0 && m.cursor < len(m.files) { - f := m.files[m.cursor] - name := f.change.Path - if f.change.Staged { - name += " [staged]" - } - right = name + // Top border: ╭─ Title ───────╮ + titleStr := "" + if title != "" { + titleStr = " " + title + " " + } + topFill := w - lipgloss.Width(titleStr) - 1 // -1: ╭─(2) + title + fill + ╮(1) = w+2 + if topFill < 0 { + topFill = 0 } + top := bs.Render("╭─" + titleStr + strings.Repeat("─", topFill) + "╮") - gap := m.width - lipgloss.Width(left) - lipgloss.Width(mode) - lipgloss.Width(right) - 1 - if gap < 0 { - gap = 0 + // Content lines: │line│ (pad/truncate to w) + lines := strings.Split(content, "\n") + for len(lines) < h { + lines = append(lines, "") + } + cardBg := lipgloss.Color(t.CardBg) + var rows []string + for i := 0; i < h; i++ { + line := lines[i] + pad := w - lipgloss.Width(line) + if pad > 0 { + line += lipgloss.NewStyle().Background(cardBg).Render(strings.Repeat(" ", pad)) + } + rows = append(rows, bs.Render("│")+line+bs.Render("│")) } - bar := left + mode + strings.Repeat(" ", gap) + right + " " - return m.styles.StatusBar.Width(m.width).Render(bar) + // Bottom border: ╰───────────╯ + bottom := bs.Render("╰" + strings.Repeat("─", w) + "╯") + + return lipgloss.JoinVertical(lipgloss.Left, top, strings.Join(rows, "\n"), bottom) } -func (m Model) renderBorder(height int) string { - border := strings.Repeat("│\n", height) - if len(border) > 0 { - border = border[:len(border)-1] +func (m Model) fileCardTitle() string { + if m.mode == modeBranchPicker { + return "Branches" } - style := m.styles.Border - if m.mode == modeDiff { - style = m.styles.BorderFocus + title := m.repo.BranchName() + if m.ref != "" { + title += " ref:" + m.ref + } else if m.stagedOnly { + title += " staged" } - return style.Render(border) + return title +} + +func (m Model) diffCardTitle() string { + if len(m.files) == 0 || m.cursor >= len(m.files) { + return "" + } + f := m.files[m.cursor] + name := f.change.Path + if f.change.Staged { + name += " [staged]" + } + return name } func (m Model) renderFileList(height int) string { @@ -1035,27 +1066,33 @@ func (m Model) renderFileList(height int) string { func (m Model) renderFileItem(f fileItem, selected bool) string { status := string(f.change.Status) - staged := " " + stagedRaw := " " if f.change.Staged { - staged = m.styles.StagedIcon.Render("● ") + stagedRaw = "● " } - statusStyled := m.styleStatus(status, f.change.Status) stats := fmt.Sprintf("+%d -%d", f.change.AddedLines, f.change.DeletedLines) name := filepath.Base(f.change.Path) if f.change.OldPath != "" { name = filepath.Base(f.change.OldPath) + " → " + filepath.Base(f.change.Path) } - nameMaxW := fileListWidth - lipgloss.Width(staged) - lipgloss.Width(status) - 1 - lipgloss.Width(stats) - 1 + nameMaxW := fileListWidth - lipgloss.Width(stagedRaw) - lipgloss.Width(status) - 1 - lipgloss.Width(stats) - 1 if nameMaxW < 1 { nameMaxW = 1 } name = truncatePath(name, nameMaxW) - line := fmt.Sprintf("%s%s %s %s", staged, statusStyled, name, stats) if selected { + // Plain text only — no inner ANSI, so FileSelected color applies uniformly + line := fmt.Sprintf("%s%s %s %s", stagedRaw, status, name, stats) return m.styles.FileSelected.Width(fileListWidth).Render(line) } + staged := stagedRaw + if f.change.Staged { + staged = m.styles.StagedIcon.Render("● ") + } + statusStyled := m.styleStatus(status, f.change.Status) + line := fmt.Sprintf("%s%s %s %s", staged, statusStyled, name, stats) return m.styles.FileItem.Width(fileListWidth).Render(line) } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 6ca5bdf..a0616bd 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -118,20 +118,78 @@ func TestFilesEqual_OneEmpty(t *testing.T) { func TestContentHeight(t *testing.T) { t.Parallel() m := Model{height: 30} - if got := m.contentHeight(); got != 27 { - t.Errorf("contentHeight()=%d, want 27", got) + // height - 4: cards add top+bottom border (+2), header removed (-1), net +1 + if got := m.contentHeight(); got != 26 { + t.Errorf("contentHeight()=%d, want 26", got) } } func TestDiffWidth(t *testing.T) { t.Parallel() m := Model{width: 120} - want := 120 - fileListWidth - 1 + // width - fileListWidth(35) - 2(file card borders) - 1(gap) - 2(diff card borders) + want := 120 - 40 if got := m.diffWidth(); got != want { t.Errorf("diffWidth()=%d, want %d", got, want) } } +func TestRenderCard_Dimensions(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + content := "line1\nline2\nline3" + card := m.renderCard("Title", content, true, 20, 5) + lines := strings.Split(card, "\n") + // h=5 content lines + 2 border lines (top + bottom) = 7 + if len(lines) != 7 { + t.Errorf("card line count=%d, want 7", len(lines)) + } +} + +func TestRenderCard_Title(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + card := m.renderCard("MyTitle", "content", false, 20, 3) + firstLine := strings.Split(card, "\n")[0] + if !strings.Contains(firstLine, "MyTitle") { + t.Errorf("first line should contain title, got %q", firstLine) + } +} + +func TestRenderCard_BorderChars(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + card := m.renderCard("T", "x", false, 10, 2) + for _, ch := range []string{"╭", "╮", "╰", "╯", "│"} { + if !strings.Contains(card, ch) { + t.Errorf("card missing border char %q", ch) + } + } +} + +func TestRenderCard_FocusedVsUnfocused(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + // Both should render without panic and contain border chars + focused := m.renderCard("T", "x", true, 10, 2) + unfocused := m.renderCard("T", "x", false, 10, 2) + for _, card := range []string{focused, unfocused} { + if !strings.Contains(card, "╭") { + t.Error("card should contain border chars") + } + } +} + +func TestRenderCard_EmptyTitle(t *testing.T) { + t.Parallel() + m := newTestModel(t, nil) + card := m.renderCard("", "content", false, 15, 2) + firstLine := strings.Split(card, "\n")[0] + if !strings.Contains(firstLine, "╭") || !strings.Contains(firstLine, "╮") { + t.Error("card with empty title should still have border corners") + } +} + func newTestModel(t *testing.T, files []fileItem) Model { t.Helper() th := theme.Themes["dark"] @@ -378,14 +436,14 @@ func TestBranchListScroll(t *testing.T) { t.Parallel() m := newTestModel(t, nil) m.mode = modeBranchPicker - // height=30, contentHeight=27, itemH=26 (minus filter bar). + // height=30, contentHeight=26, itemH=25 (minus filter bar). branches := make([]string, 40) for i := range branches { branches[i] = fmt.Sprintf("branch-%02d", i) } m.branches = branches m.branchCursor = 35 - m.branchOffset = 35 - 26 + 1 // 10 + m.branchOffset = 35 - 25 + 1 // 11 out := m.renderBranchList(m.contentHeight()) if !strings.Contains(out, "branch-35") { @@ -739,7 +797,7 @@ func TestRenderBranchCreateBar(t *testing.T) { func TestView_BranchCreating_ShowsCreateBar(t *testing.T) { t.Parallel() - // View() calls renderHeader() which needs a real repo for BranchName() + // View() calls fileCardTitle() which needs a real repo for BranchName() // Use renderBranchCreateBar() directly to test view integration m := newTestModel(t, nil) m.mode = modeBranchPicker diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 0664669..b642f86 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -33,10 +33,9 @@ type Styles struct { // Chrome HeaderBar lipgloss.Style StatusBar lipgloss.Style - HelpKey lipgloss.Style - HelpDesc lipgloss.Style - Border lipgloss.Style - BorderFocus lipgloss.Style + HelpKey lipgloss.Style + HelpDesc lipgloss.Style + CardBg lipgloss.Style // Commit input CommitInput lipgloss.Style @@ -53,7 +52,6 @@ func NewStyles(t theme.Theme) Styles { PaddingLeft(1), FileSelected: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.SelectedFg)). - Background(lipgloss.Color(t.SelectedBg)). Bold(true). PaddingLeft(1), StagedIcon: lipgloss.NewStyle(). @@ -84,8 +82,7 @@ func NewStyles(t theme.Theme) Styles { DiffContext: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Fg)), DiffHunkHeader: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.HunkFg)). - Faint(true), + Foreground(lipgloss.Color(t.HunkFg)), DiffLineNum: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.LineNumFg)), DiffLineNumAdded: lipgloss.NewStyle(). @@ -106,13 +103,12 @@ func NewStyles(t theme.Theme) Styles { Foreground(lipgloss.Color(t.StatusBarFg)), HelpKey: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.HelpKeyFg)). - Bold(true), + Bold(true). + Underline(true), HelpDesc: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.HelpDescFg)), - Border: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.BorderFg)), - BorderFocus: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.AccentFg)), + CardBg: lipgloss.NewStyle(). + Background(lipgloss.Color(t.CardBg)), CommitInput: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Fg)),